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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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, and tabbed multi-deck editing.
|
- **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.
|
- **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
|
## 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/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
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 {
|
extension ExportFormatExtension on ExportFormat {
|
||||||
String get label {
|
String get label {
|
||||||
|
|
@ -17,6 +19,8 @@ extension ExportFormatExtension on ExportFormat {
|
||||||
return 'PDF';
|
return 'PDF';
|
||||||
case ExportFormat.pptx:
|
case ExportFormat.pptx:
|
||||||
return 'PowerPoint (PPTX)';
|
return 'PowerPoint (PPTX)';
|
||||||
|
case ExportFormat.html:
|
||||||
|
return 'HTML (Marp, self-contained)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +30,8 @@ extension ExportFormatExtension on ExportFormat {
|
||||||
return '.pdf';
|
return '.pdf';
|
||||||
case ExportFormat.pptx:
|
case ExportFormat.pptx:
|
||||||
return '.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).
|
/// Builds PDF and PPTX files from pre-rendered slide images (WYSIWYG export).
|
||||||
/// Slides are expected to be 16:9 PNG bytes (see [SlideRasterizer]).
|
/// Slides are expected to be 16:9 PNG bytes (see [SlideRasterizer]).
|
||||||
class ExportService {
|
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".
|
// 16:9 widescreen slide size in EMU (English Metric Units): 13.333" x 7.5".
|
||||||
static const int _slideWidthEmu = 12192000;
|
static const int _slideWidthEmu = 12192000;
|
||||||
static const int _slideHeightEmu = 6858000;
|
static const int _slideHeightEmu = 6858000;
|
||||||
|
|
@ -88,8 +100,13 @@ class ExportService {
|
||||||
bool compress = false,
|
bool compress = false,
|
||||||
String? outputDirectory,
|
String? outputDirectory,
|
||||||
List<String>? notes,
|
List<String>? notes,
|
||||||
|
String? markdown,
|
||||||
}) async {
|
}) 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.');
|
return ExportResult.fail('Geen slides om te exporteren.');
|
||||||
}
|
}
|
||||||
final compactSuffix = compress && format == ExportFormat.pdf
|
final compactSuffix = compress && format == ExportFormat.pdf
|
||||||
|
|
@ -110,6 +127,8 @@ class ExportService {
|
||||||
bytes = await _buildPdf(images, compress: compress);
|
bytes = await _buildPdf(images, compress: compress);
|
||||||
case ExportFormat.pptx:
|
case ExportFormat.pptx:
|
||||||
bytes = _buildPptx(images, notes: notes);
|
bytes = _buildPptx(images, notes: notes);
|
||||||
|
case ExportFormat.html:
|
||||||
|
bytes = Uint8List.fromList(utf8.encode(await _html.build(markdown!)));
|
||||||
}
|
}
|
||||||
await File(outputPath).writeAsBytes(bytes, flush: true);
|
await File(outputPath).writeAsBytes(bytes, flush: true);
|
||||||
return ExportResult.ok(outputPath);
|
return ExportResult.ok(outputPath);
|
||||||
|
|
@ -204,7 +223,10 @@ class ExportService {
|
||||||
addText('ppt/slides/slide$n.xml', _slideXml());
|
addText('ppt/slides/slide$n.xml', _slideXml());
|
||||||
addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n, hasNote));
|
addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n, hasNote));
|
||||||
if (hasNote) {
|
if (hasNote) {
|
||||||
addText('ppt/notesSlides/notesSlide$n.xml', _notesSlideXml(noteFor[i]!));
|
addText(
|
||||||
|
'ppt/notesSlides/notesSlide$n.xml',
|
||||||
|
_notesSlideXml(noteFor[i]!),
|
||||||
|
);
|
||||||
addText(
|
addText(
|
||||||
'ppt/notesSlides/_rels/notesSlide$n.xml.rels',
|
'ppt/notesSlides/_rels/notesSlide$n.xml.rels',
|
||||||
_notesSlideRels(n),
|
_notesSlideRels(n),
|
||||||
|
|
@ -230,9 +252,7 @@ class ExportService {
|
||||||
String _notesSlideXml(String note) {
|
String _notesSlideXml(String note) {
|
||||||
final paras = StringBuffer();
|
final paras = StringBuffer();
|
||||||
for (final line in note.split('\n')) {
|
for (final line in note.split('\n')) {
|
||||||
paras.write(
|
paras.write('<a:p><a:r><a:t>${_xmlEscape(line)}</a:t></a:r></a:p>');
|
||||||
'<a:p><a:r><a:t>${_xmlEscape(line)}</a:t></a:r></a:p>',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
'<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,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||||
|
markdown: deckNotifier.generateMarkdown(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.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.
|
/// Folder all exports are written to. Null = next to the source deck.
|
||||||
final String? exportDirectory;
|
final String? exportDirectory;
|
||||||
|
|
||||||
|
/// The deck's Marp Markdown, used for the self-contained HTML export.
|
||||||
|
final String markdown;
|
||||||
|
|
||||||
const ExportDialog({
|
const ExportDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.deckPath,
|
required this.deckPath,
|
||||||
|
|
@ -27,6 +32,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required this.exportService,
|
required this.exportService,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
|
this.markdown = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
|
|
@ -38,6 +44,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required ExportService exportService,
|
required ExportService exportService,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
String? exportDirectory,
|
String? exportDirectory,
|
||||||
|
String markdown = '',
|
||||||
}) {
|
}) {
|
||||||
return showDialog(
|
return showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -50,6 +57,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
exportDirectory: exportDirectory,
|
exportDirectory: exportDirectory,
|
||||||
|
markdown: markdown,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -71,15 +79,18 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
bool _compress = false;
|
bool _compress = false;
|
||||||
|
|
||||||
Future<void> _export(ExportFormat format, {bool compress = false}) async {
|
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(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_result = null;
|
_result = null;
|
||||||
_phase = 'Slides renderen…';
|
_phase = needsRaster ? 'Slides renderen…' : 'HTML samenstellen…';
|
||||||
_done = 0;
|
_done = 0;
|
||||||
_total = widget.slides.length;
|
_total = needsRaster ? widget.slides.length : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
final images = await SlideRasterizer.rasterize(
|
final images = needsRaster
|
||||||
|
? await SlideRasterizer.rasterize(
|
||||||
context: context,
|
context: context,
|
||||||
slides: widget.slides,
|
slides: widget.slides,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
|
@ -88,7 +99,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
onProgress: (done, total) {
|
onProgress: (done, total) {
|
||||||
if (mounted) setState(() => _done = done);
|
if (mounted) setState(() => _done = done);
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
: const <Uint8List>[];
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _phase = '${format.label} samenstellen…');
|
setState(() => _phase = '${format.label} samenstellen…');
|
||||||
|
|
@ -101,6 +113,7 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
outputDirectory: widget.exportDirectory,
|
outputDirectory: widget.exportDirectory,
|
||||||
// Speaker notes travel 1:1 with the rendered slides (PPTX notes pane).
|
// Speaker notes travel 1:1 with the rendered slides (PPTX notes pane).
|
||||||
notes: [for (final s in widget.slides) s.notes],
|
notes: [for (final s in widget.slides) s.notes],
|
||||||
|
markdown: widget.markdown,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -114,6 +127,7 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
title: const Text('Exporteren'),
|
title: const Text('Exporteren'),
|
||||||
content: SizedBox(width: 380, child: _content()),
|
content: SizedBox(width: 380, child: _content()),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -238,6 +252,19 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
label: 'Exporteer als ${ExportFormat.pptx.label}',
|
label: 'Exporteer als ${ExportFormat.pptx.label}',
|
||||||
onPressed: () => _export(ExportFormat.pptx),
|
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;
|
return Icons.picture_as_pdf_outlined;
|
||||||
case ExportFormat.pptx:
|
case ExportFormat.pptx:
|
||||||
return Icons.slideshow_outlined;
|
return Icons.slideshow_outlined;
|
||||||
|
case ExportFormat.html:
|
||||||
|
return Icons.public_outlined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ flutter:
|
||||||
assets:
|
assets:
|
||||||
- assets/images/de-winter-wittegeheel.png
|
- assets/images/de-winter-wittegeheel.png
|
||||||
- assets/themes/ocideck.css
|
- assets/themes/ocideck.css
|
||||||
|
- assets/web_export/
|
||||||
fonts:
|
fonts:
|
||||||
- family: EB Garamond
|
- family: EB Garamond
|
||||||
fonts:
|
fonts:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:archive/archive.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:ocideck/services/export_service.dart';
|
import 'package:ocideck/services/export_service.dart';
|
||||||
|
import 'package:ocideck/services/marp_html_service.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
|
@ -226,4 +227,29 @@ void main() {
|
||||||
final r = await service.export(deckPath(), ExportFormat.pdf, const []);
|
final r = await service.export(deckPath(), ExportFormat.pdf, const []);
|
||||||
expect(r.success, isFalse);
|
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