Compare commits

...

2 commits

Author SHA1 Message Date
Brenno de Winter
169a7a8bff Render fenced code (syntax highlight) and LaTeX math in free Markdown
The free-Markdown slide preview now parses block-level content:
- ```lang fenced code blocks render with highlight.js colouring
  (flutter_highlight); unknown languages fall back to plain monospace
  instead of throwing.
- $$…$$ display math renders via flutter_math_fork (KaTeX), with a plain
  fallback on parse errors.

Because the preview feeds the export rasterizer, code and math now also
appear in PDF/PPTX output. Adds flutter_highlight, flutter_math_fork and
highlight dependencies.

Tests: code block highlighted, math rendered, unknown language safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:59:14 +02:00
Brenno de Winter
4f2f5fea7c Export speaker notes to PPTX; fix stale README CI reference
PPTX export now writes each slide's speaker notes into the PowerPoint notes
pane (notesSlide parts + a notesMaster, wired through content-types and
relationships). Slides without notes stay note-free, so the machinery is
omitted entirely when no slide has notes. Note text is XML-escaped and
multi-line notes become separate paragraphs.

Also drop the README line pointing at the removed ci.yml workflow.

Tests: notes embedded only for noted slides, text present and escaped,
slide links to its notesSlide, and all (including notes) XML well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:51:09 +02:00
8 changed files with 510 additions and 58 deletions

View file

@ -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

View file

@ -87,6 +87,7 @@ class ExportService {
List<Uint8List> images, {
bool compress = false,
String? outputDirectory,
List<String>? 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<Uint8List> images) {
Uint8List _buildPptx(List<Uint8List> images, {List<String>? 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 = <int, String>{
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 `<a:t>` run.
static String _xmlEscape(String s) {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
/// 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(
'<a:p><a:r><a:t>${_xmlEscape(line)}</a:t></a:r></a:p>',
);
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:cSld><p:spTree>'
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
'<p:sp>'
'<p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/>'
'<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
'<p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>'
'<p:spPr/>'
'<p:txBody><a:bodyPr/><a:lstStyle/>$paras</p:txBody>'
'</p:sp>'
'</p:spTree></p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
'</p:notes>';
}
String _notesSlideRels(int n) {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
'Target="../slides/slide$n.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" '
'Target="../notesMasters/notesMaster1.xml"/>'
'</Relationships>';
}
String _notesMaster() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:cSld><p:spTree>'
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
'</p:spTree></p:cSld>'
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" '
'accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" '
'accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
'</p:notesMaster>';
}
String _notesMasterRels() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
'Target="../theme/theme1.xml"/>'
'</Relationships>';
}
static List<int> utf8Bytes(String s) => utf8.encode(s);
String _contentTypes(int count) {
String _contentTypes(int count, Iterable<int> 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(
'<Override PartName="/ppt/notesMasters/notesMaster1.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>',
);
for (final i in notes) {
overrides.write(
'<Override PartName="/ppt/notesSlides/notesSlide${i + 1}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>',
);
}
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
@ -220,24 +335,31 @@ class ExportService {
'</Relationships>';
}
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('<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>');
}
// The notesMaster relationship is appended after the slides/presProps/theme
// rels (see _presentationRels): rId(count+4).
final notesMasterIdLst = hasNotes
? '<p:notesMasterIdLst><p:notesMasterId r:id="rId${count + 4}"/></p:notesMasterIdLst>'
: '';
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>'
// Schema order: notesMasterIdLst must precede sldIdLst.
'$notesMasterIdLst'
'<p:sldIdLst>$sldIds</p:sldIdLst>'
'<p:sldSz cx="$_slideWidthEmu" cy="$_slideHeightEmu" type="screen16x9"/>'
'<p:notesSz cx="6858000" cy="9144000"/>'
'</p:presentation>';
}
String _presentationRels(int count) {
String _presentationRels(int count, bool hasNotes) {
final rels = StringBuffer();
rels.write(
'<Relationship Id="rId1" '
@ -264,6 +386,14 @@ class ExportService {
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
'Target="theme/theme1.xml"/>',
);
if (hasNotes) {
// Must match the r:id used by notesMasterIdLst in _presentationXml.
rels.write(
'<Relationship Id="rId${count + 4}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" '
'Target="notesMasters/notesMaster1.xml"/>',
);
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'$rels'
@ -348,7 +478,12 @@ class ExportService {
'</p:sld>';
}
String _slideRels(int n) {
String _slideRels(int n, bool hasNote) {
final notesRel = hasNote
? '<Relationship Id="rId3" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" '
'Target="../notesSlides/notesSlide$n.xml"/>'
: '';
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
@ -357,6 +492,7 @@ class ExportService {
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
'Target="../media/image$n.png"/>'
'$notesRel'
'</Relationships>';
}

View file

@ -99,6 +99,8 @@ class _ExportDialogState extends State<ExportDialog> {
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;

View file

@ -1,5 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
@ -1849,7 +1854,6 @@ class _MarkdownPreview extends StatelessWidget {
Widget build(BuildContext context) {
final pad = w * 0.07;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final lines = slide.customMarkdown.split('\n');
return Container(
color: Colors.white,
@ -1869,52 +1873,7 @@ class _MarkdownPreview extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: lines.take(20).map((line) {
final link = _hexColor(profile.accentColor);
if (line.startsWith('# ')) {
return _md(
context,
line.substring(2),
_applyFont(
font,
TextStyle(
fontSize: w * 0.04,
fontWeight: FontWeight.bold,
color: AppTheme.navy,
),
),
linkColor: link,
);
} else if (line.startsWith('## ')) {
return _md(
context,
line.substring(3),
_applyFont(
font,
TextStyle(
fontSize: w * 0.03,
fontWeight: FontWeight.w600,
),
),
linkColor: link,
);
} else if (line.startsWith('- ')) {
return _md(
context,
'${line.substring(2)}',
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
} else if (line.isEmpty) {
return SizedBox(height: w * 0.01);
}
return _md(
context,
line,
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
}).toList(),
children: _buildBlocks(context),
),
),
),
@ -1922,6 +1881,162 @@ class _MarkdownPreview extends StatelessWidget {
),
);
}
/// Parse the free Markdown into block widgets: fenced ```code``` (syntax
/// highlighted), `$$$$` display math, and ordinary heading/bullet/text lines.
List<Widget> _buildBlocks(BuildContext context) {
final link = _hexColor(profile.accentColor);
final lines = slide.customMarkdown.split('\n');
final widgets = <Widget>[];
var i = 0;
// Cap rendered blocks so a huge slide can't blow up layout (the preview is a
// thumbnail; FittedBox scales the rest down).
while (i < lines.length && widgets.length < 24) {
final line = lines[i];
// Fenced code block: ``` or ```language ```
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
final language = fence.group(1)!.trim();
final code = <String>[];
i++;
while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) {
code.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing fence
widgets.add(_codeBlock(code.join('\n'), language));
continue;
}
// Display math fenced by lines containing only `$$`.
if (line.trim() == r'$$') {
final tex = <String>[];
i++;
while (i < lines.length && lines[i].trim() != r'$$') {
tex.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing $$
widgets.add(_mathBlock(tex.join('\n')));
continue;
}
// Single-line display math: $$ $$
final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line);
if (oneLine != null) {
widgets.add(_mathBlock(oneLine.group(1)!.trim()));
i++;
continue;
}
widgets.add(_textLine(context, line, link));
i++;
}
return widgets;
}
Widget _textLine(BuildContext context, String line, Color link) {
if (line.startsWith('# ')) {
return _md(
context,
line.substring(2),
_applyFont(
font,
TextStyle(
fontSize: w * 0.04,
fontWeight: FontWeight.bold,
color: AppTheme.navy,
),
),
linkColor: link,
);
} else if (line.startsWith('## ')) {
return _md(
context,
line.substring(3),
_applyFont(
font,
TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600),
),
linkColor: link,
);
} else if (line.startsWith('- ')) {
return _md(
context,
'${line.substring(2)}',
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
} else if (line.isEmpty) {
return SizedBox(height: w * 0.01);
}
return _md(
context,
line,
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
}
Widget _codeBlock(String code, String language) {
_ensureHighlightLanguages();
final mono = TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.02,
height: 1.3,
color: const Color(0xFF24292E),
);
// HighlightView throws on an unregistered language, so only use it for ones
// we actually know; otherwise fall back to plain monospace.
final known = language.isNotEmpty && allLanguages.containsKey(language);
final Widget content = known
? HighlightView(
code,
language: language,
theme: githubTheme,
padding: EdgeInsets.zero,
textStyle: mono,
)
: Text(code, style: mono);
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: w * 0.008),
padding: EdgeInsets.all(w * 0.018),
decoration: BoxDecoration(
color: const Color(0xFFF6F8FA),
borderRadius: BorderRadius.circular(w * 0.008),
border: Border.all(color: const Color(0xFFE1E4E8)),
),
child: content,
);
}
Widget _mathBlock(String tex) {
return Padding(
padding: EdgeInsets.symmetric(vertical: w * 0.012),
child: Math.tex(
tex,
textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)),
onErrorFallback: (err) => Text(
'\$\$$tex\$\$',
style: TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.022,
color: Colors.red,
),
),
),
);
}
}
/// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing.
bool _highlightReady = false;
void _ensureHighlightLanguages() {
if (_highlightReady) return;
allLanguages.forEach(highlight.registerLanguage);
_highlightReady = true;
}
// Shared helper

View file

@ -214,6 +214,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_highlight:
dependency: "direct main"
description:
name: flutter_highlight
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_lints:
dependency: "direct dev"
description:
@ -222,6 +230,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_math_fork:
dependency: "direct main"
description:
name: flutter_math_fork
sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -238,6 +254,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.1"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@ -264,6 +288,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
highlight:
dependency: "direct main"
description:
name: highlight
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
hooks:
dependency: transitive
description:
@ -280,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@ -408,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
node_preamble:
dependency: transitive
description:
@ -552,6 +600,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.0"
provider:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
@ -805,6 +861,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.17"
tuple:
dependency: transitive
description:
name: tuple
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
url: "https://pub.dev"
source: hosted
version: "2.0.2"
typed_data:
dependency: transitive
description:
@ -893,6 +957,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
url: "https://pub.dev"
source: hosted
version: "1.2.5"
vector_math:
dependency: transitive
description:

View file

@ -25,6 +25,9 @@ dependencies:
url_launcher: ^6.3.0
desktop_drop: ^0.7.1
image: ^4.8.0
flutter_highlight: ^0.7.0
flutter_math_fork: ^0.7.4
highlight: ^0.7.0
dev_dependencies:
flutter_test:

View file

@ -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<int>,
);
// The note text is present and XML-escaped.
final notes2 = partText('ppt/notesSlides/notesSlide2.xml');
expect(notes2, contains('Vergeet de cijfers niet &lt;3 &amp; 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<int>)),
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(

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
Widget _host(Slide slide) {
return Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(slide: slide),
),
),
);
}
void main() {
testWidgets('free Markdown renders a highlighted code block', (tester) async {
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
customMarkdown: '# Demo\n\n```dart\nvoid main() => print(42);\n```\n',
);
await tester.pumpWidget(_host(slide));
expect(find.byType(HighlightView), findsOneWidget);
});
testWidgets('free Markdown renders display math', (tester) async {
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
customMarkdown: 'Stelling:\n\n\$\$E = mc^2\$\$\n',
);
await tester.pumpWidget(_host(slide));
expect(find.byType(Math), findsOneWidget);
});
testWidgets('an unknown code language falls back without throwing', (
tester,
) async {
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
customMarkdown: '```nonexistentlang\nsome code\n```\n',
);
await tester.pumpWidget(_host(slide));
// No HighlightView (unknown language) and no exception during build.
expect(find.byType(HighlightView), findsNothing);
expect(tester.takeException(), isNull);
expect(find.text('some code'), findsOneWidget);
});
}