diff --git a/README.md b/README.md index 933bf2d..45d0304 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ Targeted test groups speed up focused work: | `make test-services` | Image, caption, and description sidecar services | | `make test-presenter` | Fullscreen presenter navigation and keyboard shortcuts | -CI runs `make check` on every push and pull request (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)). +Run `make check` before pushing — it is the same quality gate (format check, +static analysis, full test suite) you would wire into CI. ## Project layout diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index db62b42..d5729ad 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -87,6 +87,7 @@ class ExportService { List images, { bool compress = false, String? outputDirectory, + List? notes, }) async { if (images.isEmpty) { return ExportResult.fail('Geen slides om te exporteren.'); @@ -108,7 +109,7 @@ class ExportService { case ExportFormat.pdf: bytes = await _buildPdf(images, compress: compress); case ExportFormat.pptx: - bytes = _buildPptx(images); + bytes = _buildPptx(images, notes: notes); } await File(outputPath).writeAsBytes(bytes, flush: true); return ExportResult.ok(outputPath); @@ -158,7 +159,7 @@ class ExportService { // ── PPTX (Office Open XML) ───────────────────────────────────────────────── - Uint8List _buildPptx(List images) { + Uint8List _buildPptx(List images, {List? notes}) { final archive = Archive(); void addText(String name, String content) { final data = utf8Bytes(content); @@ -166,11 +167,22 @@ class ExportService { } 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)); + addText('[Content_Types].xml', _contentTypes(slideCount, noteFor.keys)); addText('_rels/.rels', _rootRels()); - addText('ppt/presentation.xml', _presentationXml(slideCount)); - addText('ppt/_rels/presentation.xml.rels', _presentationRels(slideCount)); + 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()); @@ -178,10 +190,26 @@ class ExportService { 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)); + 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)); } @@ -189,9 +217,83 @@ class ExportService { 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) { + String _contentTypes(int count, Iterable noteIndices) { final overrides = StringBuffer(); for (var i = 1; i <= count; i++) { overrides.write( @@ -199,6 +301,19 @@ class ExportService { 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>', ); } + final notes = noteIndices.toList(); + if (notes.isNotEmpty) { + overrides.write( + '', + ); + for (final i in notes) { + overrides.write( + '', + ); + } + } return '' '' '' @@ -220,24 +335,31 @@ class ExportService { ''; } - String _presentationXml(int count) { + 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) { + String _presentationRels(int count, bool hasNotes) { final rels = StringBuffer(); rels.write( '', ); + if (hasNotes) { + // Must match the r:id used by notesMasterIdLst in _presentationXml. + rels.write( + '', + ); + } return '' '' '$rels' @@ -348,7 +478,12 @@ class ExportService { ''; } - String _slideRels(int n) { + String _slideRels(int n, bool hasNote) { + final notesRel = hasNote + ? '' + : ''; return '' '' '' + '$notesRel' ''; } diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart index cb6d6ce..cf83ef9 100644 --- a/lib/widgets/dialogs/export_dialog.dart +++ b/lib/widgets/dialogs/export_dialog.dart @@ -99,6 +99,8 @@ class _ExportDialogState extends State { images, compress: compress, outputDirectory: widget.exportDirectory, + // Speaker notes travel 1:1 with the rendered slides (PPTX notes pane). + notes: [for (final s in widget.slides) s.notes], ); if (!mounted) return; diff --git a/test/export_service_test.dart b/test/export_service_test.dart index a38e04f..e54b0f7 100644 --- a/test/export_service_test.dart +++ b/test/export_service_test.dart @@ -99,6 +99,60 @@ void main() { } }); + test('PPTX without notes has no notesSlide/notesMaster parts', () async { + final r = await service.export(deckPath(), ExportFormat.pptx, [_png()]); + final archive = ZipDecoder().decodeBytes( + await File(r.outputPath!).readAsBytes(), + ); + final names = archive.files.map((f) => f.name).toSet(); + expect(names.any((n) => n.startsWith('ppt/notesSlides/')), isFalse); + expect(names.any((n) => n.startsWith('ppt/notesMasters/')), isFalse); + }); + + test('PPTX embeds speaker notes only for slides that have them', () async { + final r = await service.export( + deckPath(), + ExportFormat.pptx, + [_png(), _png()], + notes: ['', 'Vergeet de cijfers niet <3 & co'], + ); + expect(r.success, isTrue, reason: r.error); + + final archive = ZipDecoder().decodeBytes( + await File(r.outputPath!).readAsBytes(), + ); + final names = archive.files.map((f) => f.name).toSet(); + + // Notes machinery is present for the noted slide only. + expect(names, contains('ppt/notesMasters/notesMaster1.xml')); + expect(names, contains('ppt/notesSlides/notesSlide2.xml')); + expect(names, isNot(contains('ppt/notesSlides/notesSlide1.xml'))); + + String partText(String name) => utf8.decode( + archive.files.firstWhere((f) => f.name == name).content as List, + ); + + // The note text is present and XML-escaped. + final notes2 = partText('ppt/notesSlides/notesSlide2.xml'); + expect(notes2, contains('Vergeet de cijfers niet <3 & co')); + // The slide links to its notesSlide. + expect( + partText('ppt/slides/_rels/slide2.xml.rels'), + contains('notesSlide2.xml'), + ); + + // Every XML part (including the new notes parts) must be well-formed. + for (final file in archive.files) { + if (file.name.endsWith('.xml') || file.name.endsWith('.rels')) { + expect( + () => XmlDocument.parse(utf8.decode(file.content as List)), + returnsNormally, + reason: '${file.name} is not well-formed XML', + ); + } + } + }); + test('compressed PDF is written as a separate -compact file', () async { final images = [_png(), _png()]; final r = await service.export(