import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import '../models/deck.dart'; import '../models/settings.dart'; import 'classification_policy.dart'; import 'marp_html_service.dart'; enum ExportFormat { pdf, pptx, html } extension ExportFormatExtension on ExportFormat { String get label { switch (this) { case ExportFormat.pdf: return 'PDF'; case ExportFormat.pptx: return 'PowerPoint (PPTX)'; case ExportFormat.html: return 'HTML (Marp, self-contained)'; } } String get extension { switch (this) { case ExportFormat.pdf: return '.pdf'; case ExportFormat.pptx: return '.pptx'; case ExportFormat.html: return '.html'; } } } class ExportResult { final bool success; final String? outputPath; final String? error; const ExportResult._({required this.success, this.outputPath, this.error}); factory ExportResult.ok(String path) => ExportResult._(success: true, outputPath: path); factory ExportResult.fail(String error) => ExportResult._(success: false, error: error); } /// Builds PDF and PPTX files from pre-rendered slide images (WYSIWYG export). /// Slides are expected to be 16:9 PNG bytes (see [SlideRasterizer]). class ExportService { /// Renders the self-contained Marp HTML export. Injectable for testing. final MarpHtmlService _html; ExportService({MarpHtmlService? htmlService}) : _html = htmlService ?? MarpHtmlService(); // 16:9 widescreen slide size in EMU (English Metric Units): 13.333" x 7.5". static const int _slideWidthEmu = 12192000; static const int _slideHeightEmu = 6858000; /// JPEG quality (0–100) used when a PDF is exported in compressed mode. /// Low enough to shrink photo-heavy decks dramatically while keeping slides /// legible. static const int _compressedJpegQuality = 60; /// Slides are downscaled to this width (px) in compressed mode. The compressed /// PDF is meant as a screen handout, so 720p is plenty and shrinks the file /// further on top of JPEG encoding. Wider slides are never upscaled. static const int _compressedMaxWidth = 1280; /// Timestamp for [time] in UTC, formatted `YYYYMMDDHHMMSS` — /// e.g. `20260603124547`. Used as a filename prefix so exports sort /// chronologically and never overwrite each other. static String natoDtg(DateTime time) { final t = time.toUtc(); String two(int n) => n.toString().padLeft(2, '0'); return '${t.year.toString().padLeft(4, '0')}${two(t.month)}${two(t.day)}' '${two(t.hour)}${two(t.minute)}${two(t.second)}'; } /// Write [images] to a file named after [deckPath] in the requested [format]. /// /// The file name is prefixed with a UTC timestamp (see [natoDtg]), /// e.g. `20260603124547 deck.pdf`. /// /// The file is written to [outputDirectory] when given (created if missing); /// otherwise it lands next to the source deck (legacy behaviour). /// /// When [compress] is set (PDF only), each slide is re-encoded as JPEG instead /// of being embedded as lossless PNG, and `-compact` is appended to the file /// name so it never overwrites a full-quality export. Future export( String deckPath, ExportFormat format, List images, { bool compress = false, String? outputDirectory, List? notes, String? markdown, ThemeProfile? themeProfile, TlpLevel tlp = TlpLevel.none, ClassificationPolicy policy = const ClassificationPolicy(), }) async { // Classificatie-gate. Dit is het enige chokepoint waar elk formaat // (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de // UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een // weigering wordt er niets gebouwd of weggeschreven. final decision = policy.evaluate(tlp); if (!decision.allowed) { return ExportResult.fail(decision.reason!); } if (format == ExportFormat.html) { if (markdown == null || markdown.trim().isEmpty) { return ExportResult.fail('Geen inhoud om te exporteren.'); } } else if (images.isEmpty) { return ExportResult.fail('Geen slides om te exporteren.'); } final compactSuffix = compress && format == ExportFormat.pdf ? '-compact' : ''; final dir = (outputDirectory != null && outputDirectory.isNotEmpty) ? outputDirectory : p.dirname(deckPath); final prefix = '${natoDtg(DateTime.now())} '; final fileName = '$prefix${p.basenameWithoutExtension(deckPath)}$compactSuffix${format.extension}'; final outputPath = p.join(dir, fileName); try { await Directory(dir).create(recursive: true); final Uint8List bytes; switch (format) { case ExportFormat.pdf: bytes = await _buildPdf(images, compress: compress); case ExportFormat.pptx: bytes = _buildPptx(images, notes: notes); case ExportFormat.html: bytes = Uint8List.fromList( utf8.encode(await _html.build(markdown!, theme: themeProfile)), ); } await File(outputPath).writeAsBytes(bytes, flush: true); return ExportResult.ok(outputPath); } catch (e) { return ExportResult.fail('Export fout: $e'); } } // ── PDF ─────────────────────────────────────────────────────────────────── Future _buildPdf( List images, { bool compress = false, }) async { final doc = pw.Document(); // Page size in points; only the ratio matters for a full-bleed image. const format = PdfPageFormat(1280, 720, marginAll: 0); for (final png in images) { // MemoryImage auto-detects PNG vs JPEG from the byte header, so a // compressed (JPEG) slide embeds just like the lossless one. final image = pw.MemoryImage(compress ? _toJpeg(png) : png); doc.addPage( pw.Page( pageFormat: format, build: (_) => pw.Image(image, fit: pw.BoxFit.fill), ), ); } return doc.save(); } /// Downscale a rendered slide PNG to [_compressedMaxWidth] and re-encode it as /// JPEG at [_compressedJpegQuality]. Slides are full-bleed (no transparency), /// so dropping the alpha channel is safe. Uint8List _toJpeg(Uint8List png) { final decoded = img.decodePng(png); if (decoded == null) return png; // Unexpected; keep the original bytes. final resized = decoded.width > _compressedMaxWidth ? img.copyResize( decoded, width: _compressedMaxWidth, interpolation: img.Interpolation.average, ) : decoded; return img.encodeJpg(resized, quality: _compressedJpegQuality); } // ── PPTX (Office Open XML) ───────────────────────────────────────────────── Uint8List _buildPptx(List images, {List? notes}) { final archive = Archive(); void addText(String name, String content) { final data = utf8Bytes(content); archive.add(ArchiveFile(name, data.length, data)); } final slideCount = images.length; // Which slides carry speaker notes. When none do, the whole notes machinery // (notesMaster + notesSlides) is omitted to keep the file minimal. final noteFor = { for (var i = 0; i < slideCount; i++) if (notes != null && i < notes.length && notes[i].trim().isNotEmpty) i: notes[i].trim(), }; final hasNotes = noteFor.isNotEmpty; addText('[Content_Types].xml', _contentTypes(slideCount, noteFor.keys)); addText('_rels/.rels', _rootRels()); addText('ppt/presentation.xml', _presentationXml(slideCount, hasNotes)); addText( 'ppt/_rels/presentation.xml.rels', _presentationRels(slideCount, hasNotes), ); addText('ppt/presProps.xml', _presProps()); addText('ppt/theme/theme1.xml', _theme1()); addText('ppt/slideMasters/slideMaster1.xml', _slideMaster()); addText('ppt/slideMasters/_rels/slideMaster1.xml.rels', _slideMasterRels()); addText('ppt/slideLayouts/slideLayout1.xml', _slideLayout()); addText('ppt/slideLayouts/_rels/slideLayout1.xml.rels', _slideLayoutRels()); if (hasNotes) { addText('ppt/notesMasters/notesMaster1.xml', _notesMaster()); addText( 'ppt/notesMasters/_rels/notesMaster1.xml.rels', _notesMasterRels(), ); } for (var i = 0; i < slideCount; i++) { final n = i + 1; final hasNote = noteFor.containsKey(i); addText('ppt/slides/slide$n.xml', _slideXml()); addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n, hasNote)); if (hasNote) { addText( 'ppt/notesSlides/notesSlide$n.xml', _notesSlideXml(noteFor[i]!), ); addText( 'ppt/notesSlides/_rels/notesSlide$n.xml.rels', _notesSlideRels(n), ); } final png = images[i]; archive.add(ArchiveFile('ppt/media/image$n.png', png.length, png)); } return ZipEncoder().encodeBytes(archive); } /// XML-escape free text destined for an `` run. static String _xmlEscape(String s) { return s .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>'); } /// A notesSlide whose body placeholder carries the speaker notes. Newlines in /// [note] become separate paragraphs. String _notesSlideXml(String note) { final paras = StringBuffer(); for (final line in note.split('\n')) { paras.write('${_xmlEscape(line)}'); } return '' '' '' '' '' '' '' '' '' '' '' '$paras' '' '' '' ''; } String _notesSlideRels(int n) { return '' '' '' '' ''; } String _notesMaster() { return '' '' '' '' '' '' '' '' ''; } String _notesMasterRels() { return '' '' '' ''; } static List utf8Bytes(String s) => utf8.encode(s); String _contentTypes(int count, Iterable noteIndices) { final overrides = StringBuffer(); for (var i = 1; i <= count; i++) { overrides.write( '', ); } final notes = noteIndices.toList(); if (notes.isNotEmpty) { overrides.write( '', ); for (final i in notes) { overrides.write( '', ); } } return '' '' '' '' '' '' '' '' '' '' '$overrides' ''; } String _rootRels() { return '' '' '' ''; } String _presentationXml(int count, bool hasNotes) { final sldIds = StringBuffer(); for (var i = 0; i < count; i++) { // Slide relationship ids start at rId2 (rId1 = master). sldIds.write(''); } // The notesMaster relationship is appended after the slides/presProps/theme // rels (see _presentationRels): rId(count+4). final notesMasterIdLst = hasNotes ? '' : ''; return '' '' '' // Schema order: notesMasterIdLst must precede sldIdLst. '$notesMasterIdLst' '$sldIds' '' '' ''; } String _presentationRels(int count, bool hasNotes) { final rels = StringBuffer(); rels.write( '', ); for (var i = 0; i < count; i++) { final n = i + 1; rels.write( '', ); } final presPropsId = 'rId${count + 2}'; final themeId = 'rId${count + 3}'; rels.write( '', ); rels.write( '', ); if (hasNotes) { // Must match the r:id used by notesMasterIdLst in _presentationXml. rels.write( '', ); } return '' '' '$rels' ''; } String _presProps() { return '' ''; } String _slideMaster() { return '' '' '' '' '${_emptySpTree()}' '' '' '' '' ''; } String _slideMasterRels() { return '' '' '' '' ''; } String _slideLayout() { return '' '' '${_emptySpTree()}' '' ''; } String _slideLayoutRels() { return '' '' '' ''; } String _slideXml() { return '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' ''; } String _slideRels(int n, bool hasNote) { final notesRel = hasNote ? '' : ''; return '' '' '' '' '$notesRel' ''; } String _emptySpTree() { return '' '' '' '' ''; } String _theme1() { return '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' '' ''; } }