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:
parent
169a7a8bff
commit
3e664193ce
13 changed files with 3557 additions and 22 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
assets/web_export/highlight.css
Normal file
10
assets/web_export/highlight.css
Normal 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
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
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
2024
assets/web_export/mermaid.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/web_export/tex-svg.js
Normal file
1
assets/web_export/tex-svg.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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" '
|
||||
|
|
|
|||
128
lib/services/marp_html_service.dart
Normal file
128
lib/services/marp_html_service.dart
Normal 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();}
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
|
@ -928,6 +928,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
exportService: widget.exportService,
|
||||
tlp: deck.tlp,
|
||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||
markdown: deckNotifier.generateMarkdown(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ flutter:
|
|||
assets:
|
||||
- assets/images/de-winter-wittegeheel.png
|
||||
- assets/themes/ocideck.css
|
||||
- assets/web_export/
|
||||
fonts:
|
||||
- family: EB Garamond
|
||||
fonts:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
76
test/marp_html_service_test.dart
Normal file
76
test/marp_html_service_test.dart
Normal 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'));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue