Add self-contained Marp HTML export

New export target: a single offline .html rendered from the deck's Marp
Markdown. Bundles (inlines) marked, highlight.js, MathJax (tex-svg, no font
files) and mermaid, so code highlighting, LaTeX math and mermaid diagrams all
render in any browser with no network access.

- MarpHtmlService splits the deck on `---`, strips front-matter, and inlines
  the vendored libraries (assets/web_export/) with a </script> breakout guard.
  The asset loader is injectable for testing.
- ExportFormat.html wired through ExportService (no rasterization needed),
  the export dialog (new button, skips slide rendering) and app_shell
  (passes the generated Markdown). Export dialog is now scrollable.

Note: rendered with marked, not Marp Core, so theme fidelity differs from the
in-app preview / PDF / PPTX; the win is a portable, dependency-free deck.

Tests: slide splitting, library inlining, breakout escaping, and an
end-to-end .html export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-04 01:37:46 +02:00
parent 169a7a8bff
commit 3e664193ce
13 changed files with 3557 additions and 22 deletions

View file

@ -7,13 +7,13 @@ Built with Flutter for macOS, Windows, and Linux.
## Features
- **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, and free-form Markdown.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, and a slide-grid overview.
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata.
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF and PPTX. Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, and tabbed multi-deck editing.
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — a bundled Marp CSS theme (`assets/themes/ocideck.css`) and Google Fonts.
- **Theming** — a bundled Marp CSS theme (`assets/themes/ocideck.css`) and a bundled EB Garamond font (no network fetch).
## Requirements

View file

@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}

1213
assets/web_export/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
assets/web_export/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2024
assets/web_export/mermaid.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,9 @@ import 'package:path/path.dart' as p;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
enum ExportFormat { pdf, pptx }
import 'marp_html_service.dart';
enum ExportFormat { pdf, pptx, html }
extension ExportFormatExtension on ExportFormat {
String get label {
@ -17,6 +19,8 @@ extension ExportFormatExtension on ExportFormat {
return 'PDF';
case ExportFormat.pptx:
return 'PowerPoint (PPTX)';
case ExportFormat.html:
return 'HTML (Marp, self-contained)';
}
}
@ -26,6 +30,8 @@ extension ExportFormatExtension on ExportFormat {
return '.pdf';
case ExportFormat.pptx:
return '.pptx';
case ExportFormat.html:
return '.html';
}
}
}
@ -46,6 +52,12 @@ class ExportResult {
/// 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;
@ -88,8 +100,13 @@ class ExportService {
bool compress = false,
String? outputDirectory,
List<String>? notes,
String? markdown,
}) async {
if (images.isEmpty) {
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
@ -110,6 +127,8 @@ class ExportService {
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!)));
}
await File(outputPath).writeAsBytes(bytes, flush: true);
return ExportResult.ok(outputPath);
@ -204,7 +223,10 @@ class ExportService {
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/notesSlide$n.xml',
_notesSlideXml(noteFor[i]!),
);
addText(
'ppt/notesSlides/_rels/notesSlide$n.xml.rels',
_notesSlideRels(n),
@ -230,9 +252,7 @@ class ExportService {
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>',
);
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" '

View file

@ -0,0 +1,128 @@
import 'package:flutter/services.dart' show rootBundle;
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
///
/// The output embeds (inlines) `marked` for Markdown, `highlight.js` for code,
/// `MathJax` (tex-svg, so no external font files are needed) for math, and
/// `mermaid` for diagrams so the resulting `.html` renders fully offline.
///
/// Note: this is a Marp-*flavoured* deck rendered with `marked`, not Marp Core,
/// so theme fidelity differs from the in-app preview / PDF / PPTX. The strength
/// here is a portable, dependency-free presentation that opens in any browser.
class MarpHtmlService {
/// Reads a bundled asset (defaults to the Flutter asset bundle). Injectable so
/// the builder can be unit-tested against the on-disk asset files.
final Future<String> Function(String asset) loadAsset;
MarpHtmlService({Future<String> Function(String asset)? loadAsset})
: loadAsset = loadAsset ?? rootBundle.loadString;
static const _assetDir = 'assets/web_export';
Future<String> build(String deckMarkdown) async {
final marked = await loadAsset('$_assetDir/marked.min.js');
final hljs = await loadAsset('$_assetDir/highlight.min.js');
final hljsCss = await loadAsset('$_assetDir/highlight.css');
final mathjax = await loadAsset('$_assetDir/tex-svg.js');
final mermaid = await loadAsset('$_assetDir/mermaid.min.js');
final sections = StringBuffer();
for (final slide in marpSlides(deckMarkdown)) {
sections
..write('<section class="slide"><script type="text/markdown">')
..write(_guard(slide))
..write('</script></section>');
}
String inline(String code) => '<script>${_guard(code)}</script>';
return '<!doctype html>\n'
'<html lang="nl"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>OciDeck export</title>'
'<style>$_baseCss\n$hljsCss</style>'
'<script>$_mathjaxConfig</script>'
'${inline(marked)}'
'${inline(hljs)}'
'${inline(mathjax)}'
'${inline(mermaid)}'
'</head><body>'
'$sections'
'${inline(_renderScript)}'
'</body></html>';
}
/// Split Marp Markdown into per-slide Markdown chunks: drop the leading YAML
/// front-matter, then break on lines that contain only `---`.
static List<String> marpSlides(String markdown) {
var text = markdown.replaceAll('\r\n', '\n');
// Strip a leading YAML front-matter block: ---\n ... \n---\n
if (text.startsWith('---\n')) {
final close = text.indexOf('\n---', 4);
if (close != -1) {
final nl = text.indexOf('\n', close + 1);
text = nl == -1 ? '' : text.substring(nl + 1);
}
}
final slides = <String>[];
final buf = StringBuffer();
for (final line in text.split('\n')) {
if (line.trim() == '---') {
slides.add(buf.toString().trim());
buf.clear();
} else {
buf.writeln(line);
}
}
slides.add(buf.toString().trim());
return slides.where((s) => s.isNotEmpty).toList();
}
/// Neutralise any `</script` inside inlined content so it can't break out of
/// the surrounding <script> element. Safe for both JS (string contexts) and
/// the embedded Markdown payloads.
static String _guard(String s) => s
.replaceAll('</script', r'<\/script')
.replaceAll('</SCRIPT', r'<\/SCRIPT');
static const _mathjaxConfig =
r'''window.MathJax={tex:{inlineMath:[['$','$']],displayMath:[['$$','$$']]},svg:{fontCache:'global'},startup:{typeset:false}};''';
static const _baseCss = r'''
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a}
.slide{position:relative;width:1280px;min-height:720px;margin:24px auto;background:#fff;padding:60px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.4);border-radius:4px}
.slide h1{font-size:48px;margin:.15em 0}
.slide h2{font-size:34px;margin:.15em 0}
.slide p,.slide li{font-size:24px;line-height:1.45}
.slide pre{background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;padding:16px;overflow:auto;font-size:18px}
.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}
.slide pre.mermaid{background:transparent;border:0;text-align:center}
.slide img{max-width:100%}
.slide blockquote{border-left:4px solid #ccc;margin:.5em 0;padding-left:16px;color:#555}
.slide table{border-collapse:collapse}.slide th,.slide td{border:1px solid #ccc;padding:6px 12px;font-size:20px}
@media print{body{background:#fff}.slide{margin:0;box-shadow:none;border-radius:0;page-break-after:always;width:100%;min-height:100vh}}
''';
static const _renderScript = r'''
(function(){
if(window.marked&&marked.setOptions){marked.setOptions({gfm:true,breaks:false});}
document.querySelectorAll('section.slide').forEach(function(sec){
var holder=sec.querySelector('script[type="text/markdown"]');
var src=holder?holder.textContent:'';
var div=document.createElement('div');div.className='content';
div.innerHTML=window.marked?marked.parse(src):src;
sec.innerHTML='';sec.appendChild(div);
});
document.querySelectorAll('code.language-mermaid').forEach(function(code){
var pre=code.closest('pre');if(!pre)return;
var holder=document.createElement('pre');holder.className='mermaid';
holder.textContent=code.textContent;pre.replaceWith(holder);
});
if(window.hljs){document.querySelectorAll('pre code').forEach(function(el){try{hljs.highlightElement(el);}catch(e){}});}
if(window.mermaid){try{mermaid.initialize({startOnLoad:false});mermaid.run();}catch(e){}}
if(window.MathJax&&MathJax.typesetPromise){MathJax.typesetPromise();}
})();
''';
}

View file

@ -928,6 +928,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
exportService: widget.exportService,
tlp: deck.tlp,
exportDirectory: ref.read(settingsProvider).exportDirectory,
markdown: deckNotifier.generateMarkdown(),
);
}

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
@ -18,6 +20,9 @@ class ExportDialog extends StatefulWidget {
/// Folder all exports are written to. Null = next to the source deck.
final String? exportDirectory;
/// The deck's Marp Markdown, used for the self-contained HTML export.
final String markdown;
const ExportDialog({
super.key,
required this.deckPath,
@ -27,6 +32,7 @@ class ExportDialog extends StatefulWidget {
required this.exportService,
this.tlp = TlpLevel.none,
this.exportDirectory,
this.markdown = '',
});
static Future<void> show(
@ -38,6 +44,7 @@ class ExportDialog extends StatefulWidget {
required ExportService exportService,
TlpLevel tlp = TlpLevel.none,
String? exportDirectory,
String markdown = '',
}) {
return showDialog(
context: context,
@ -50,6 +57,7 @@ class ExportDialog extends StatefulWidget {
exportService: exportService,
tlp: tlp,
exportDirectory: exportDirectory,
markdown: markdown,
),
);
}
@ -71,24 +79,28 @@ class _ExportDialogState extends State<ExportDialog> {
bool _compress = false;
Future<void> _export(ExportFormat format, {bool compress = false}) async {
// HTML renders from Markdown in the browser, so it needs no slide raster.
final needsRaster = format != ExportFormat.html;
setState(() {
_loading = true;
_result = null;
_phase = 'Slides renderen…';
_phase = needsRaster ? 'Slides renderen…' : 'HTML samenstellen…';
_done = 0;
_total = widget.slides.length;
_total = needsRaster ? widget.slides.length : 0;
});
final images = await SlideRasterizer.rasterize(
context: context,
slides: widget.slides,
themeProfile: widget.themeProfile,
projectPath: widget.projectPath,
tlp: widget.tlp,
onProgress: (done, total) {
if (mounted) setState(() => _done = done);
},
);
final images = needsRaster
? await SlideRasterizer.rasterize(
context: context,
slides: widget.slides,
themeProfile: widget.themeProfile,
projectPath: widget.projectPath,
tlp: widget.tlp,
onProgress: (done, total) {
if (mounted) setState(() => _done = done);
},
)
: const <Uint8List>[];
if (!mounted) return;
setState(() => _phase = '${format.label} samenstellen…');
@ -101,6 +113,7 @@ class _ExportDialogState extends State<ExportDialog> {
outputDirectory: widget.exportDirectory,
// Speaker notes travel 1:1 with the rendered slides (PPTX notes pane).
notes: [for (final s in widget.slides) s.notes],
markdown: widget.markdown,
);
if (!mounted) return;
@ -114,6 +127,7 @@ class _ExportDialogState extends State<ExportDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Exporteren'),
content: SizedBox(width: 380, child: _content()),
actions: [
@ -238,6 +252,19 @@ class _ExportDialogState extends State<ExportDialog> {
label: 'Exporteer als ${ExportFormat.pptx.label}',
onPressed: () => _export(ExportFormat.pptx),
),
_exportButton(
icon: _formatIcon(ExportFormat.html),
label: 'Exporteer als HTML (Marp, offline)',
onPressed: () => _export(ExportFormat.html),
),
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text(
'HTML opent in elke browser zonder internet en rendert codeblokken, '
'wiskunde en mermaid-diagrammen.',
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
],
);
}
@ -263,6 +290,8 @@ class _ExportDialogState extends State<ExportDialog> {
return Icons.picture_as_pdf_outlined;
case ExportFormat.pptx:
return Icons.slideshow_outlined;
case ExportFormat.html:
return Icons.public_outlined;
}
}
}

View file

@ -40,6 +40,7 @@ flutter:
assets:
- assets/images/de-winter-wittegeheel.png
- assets/themes/ocideck.css
- assets/web_export/
fonts:
- family: EB Garamond
fonts:

View file

@ -6,6 +6,7 @@ import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:ocideck/services/export_service.dart';
import 'package:ocideck/services/marp_html_service.dart';
import 'package:path/path.dart' as p;
import 'package:xml/xml.dart';
@ -226,4 +227,29 @@ void main() {
final r = await service.export(deckPath(), ExportFormat.pdf, const []);
expect(r.success, isFalse);
});
test('HTML export writes a self-contained .html from Markdown', () async {
final htmlService = ExportService(
htmlService: MarpHtmlService(loadAsset: (a) => File(a).readAsString()),
);
final r = await htmlService.export(
deckPath(),
ExportFormat.html,
const [], // HTML needs no rasterized slides
markdown: '# Titel\n\n---\n\n# Tweede',
);
expect(r.success, isTrue, reason: r.error);
expect(p.extension(r.outputPath!), '.html');
expect(p.basename(r.outputPath!), matches(_dtgPrefix));
final html = await File(r.outputPath!).readAsString();
expect(html, startsWith('<!doctype html>'));
expect(html, contains('# Titel'));
});
test('HTML export fails without Markdown', () async {
final r = await service.export(deckPath(), ExportFormat.html, const []);
expect(r.success, isFalse);
});
}

View file

@ -0,0 +1,76 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/services/marp_html_service.dart';
/// Reads the vendored libraries straight from the repo (tests run at the root).
Future<String> _diskLoader(String asset) => File(asset).readAsString();
void main() {
group('marpSlides', () {
test('drops the YAML front-matter and splits on --- separators', () {
const md = '''
---
marp: true
theme: ocideck
---
# Slide one
---
## Slide two
''';
final slides = MarpHtmlService.marpSlides(md);
expect(slides, hasLength(2));
expect(slides[0], contains('# Slide one'));
expect(slides[0], isNot(contains('marp: true')));
expect(slides[1], contains('## Slide two'));
});
test('a deck without front-matter keeps every slide', () {
final slides = MarpHtmlService.marpSlides(
'# A\n\n---\n\n# B\n\n---\n\n# C',
);
expect(slides, hasLength(3));
});
});
test('build() inlines the libraries and the slide content', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
const md = '''
---
marp: true
---
# Titel
\$\$E=mc^2\$\$
```dart
void main() {}
```
''';
final html = await service.build(md);
expect(html, startsWith('<!doctype html>'));
// Slide payload is embedded for the in-browser renderer.
expect(html, contains('# Titel'));
expect(html, contains(r'E=mc^2'));
// Each engine is inlined (offline): marked, highlight.js, MathJax, mermaid.
expect(html, contains('marked'));
expect(html, contains('hljs'));
expect(html, contains('MathJax'));
expect(html, contains('mermaid'));
// Everything is inlined: there must be no external <script src=...> tags.
expect(html, isNot(contains('<script src')));
});
test('build() neutralises a closing-script breakout in content', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X\n\nfoo </script> bar');
// The literal breakout must be escaped so it cannot terminate the payload.
expect(html, isNot(contains('foo </script> bar')));
expect(html, contains(r'<\/script'));
});
}