import 'dart:convert'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/services.dart' show rootBundle; import '../models/chart.dart'; import '../models/settings.dart'; /// 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 text asset (defaults to the Flutter asset bundle). /// Injectable so the builder can be unit-tested against the on-disk files. final Future Function(String asset) loadAsset; /// Reads a bundled binary asset (used to embed the EB Garamond font). final Future Function(String asset) loadBytes; MarpHtmlService({ Future Function(String asset)? loadAsset, Future Function(String asset)? loadBytes, }) : loadAsset = loadAsset ?? rootBundle.loadString, loadBytes = loadBytes ?? ((a) async => (await rootBundle.load(a)).buffer.asUint8List()); static const _assetDir = 'assets/web_export'; /// Builds the HTML. When [theme] is given, the slides take that profile's /// colours and font so the export matches the in-app / PDF look. Future build(String deckMarkdown, {ThemeProfile? theme}) 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 css = theme == null ? _baseCss : await _themedCss(theme); final sections = StringBuffer(); for (final slide in marpSlides(deckMarkdown)) { sections ..write('
'); } String inline(String code) => ''; return '\n' '' '' 'OciDeck export' '' '' '${inline(marked)}' '${inline(hljs)}' '${inline(mathjax)}' '${inline(mermaid)}' '' '$sections' '${inline(_renderScript)}' ''; } /// Split Marp Markdown into per-slide Markdown chunks: drop the leading YAML /// front-matter, then break on lines that contain only `---`. static List 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 = []; 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 ` element. Safe for both JS (string contexts) and /// the embedded Markdown payloads. static String _guard(String s) => s .replaceAll('${_chartSvg(spec, theme)}\n'; }); } static String _esc(String s) => s .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>'); static String _color(ChartSpec spec, int i, ThemeProfile? theme) { final series = spec.series[i]; if (series.color == null && i == 0 && theme != null) { return theme.accentColor; } return chartSeriesColor(series, i); } static String _chartSvg(ChartSpec spec, ThemeProfile? theme) { if (!spec.hasInlineData) { return ''; } final textColor = theme?.textColor ?? '#111827'; final titleBackground = theme?.titleBackgroundColor ?? '#F8FAFC'; final titleColor = theme?.titleTextColor ?? textColor; final accent = theme?.accentColor ?? '#2563EB'; final b = StringBuffer() ..write( '', ); if (spec.title.isNotEmpty) { final title = spec.title.length > 52 ? '${spec.title.substring(0, 51)}…' : spec.title; b ..write( '', ) ..write( '', ) ..write( '${_esc(title)}', ); } final plotTop = spec.title.isNotEmpty ? 68.0 : 20.0; switch (spec.type) { case ChartType.bar: _barSvg(b, spec, plotTop, theme); case ChartType.line: _lineSvg(b, spec, plotTop, theme); case ChartType.pie: final legendRows = (spec.x.length / 6).ceil().clamp(1, 3); _pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28); case ChartType.radar: _radarSvg(b, spec, plotTop, theme, textColor); } if (spec.type == ChartType.pie) { _pieLegendSvg(b, spec, textColor); } else { _legendSvg(b, spec, theme, textColor); } b.write(''); return b.toString(); } static void _legendSvg( StringBuffer b, ChartSpec spec, ThemeProfile? theme, String textColor, ) { final count = math.min(spec.series.length, 6); final cellWidth = 720.0 / count; for (var i = 0; i < count; i++) { final rawName = spec.series[i].name.isEmpty ? 'Reeks ${i + 1}' : spec.series[i].name; final name = rawName.length > 12 ? '${rawName.substring(0, 11)}…' : rawName; final x = 40 + i * cellWidth; b ..write( '', ) ..write( '', ) ..write( '${_esc(name)}', ); } } static void _pieLegendSvg(StringBuffer b, ChartSpec spec, String textColor) { const maxColumns = 6; final columns = math.min(spec.x.length, maxColumns); final rows = (spec.x.length / maxColumns).ceil().clamp(1, 3); final cellWidth = 720.0 / columns; final startY = 414.0 - (rows - 1) * 28; for (var i = 0; i < spec.x.length; i++) { final row = i ~/ maxColumns; if (row >= rows) break; final column = i % maxColumns; final x = 40 + column * cellWidth; final y = startY + row * 28; final label = spec.x[i].length > 12 ? '${spec.x[i].substring(0, 11)}…' : spec.x[i]; b ..write( '', ) ..write( '', ) ..write( '${_esc(label)}', ); } } static double _maxY(ChartSpec spec) { var m = 0.0; for (final s in spec.series) { for (final v in s.data) { if (v > m) m = v; } } if (spec.supportsBounds) { for (final b in [spec.minBound, spec.maxBound]) { if (b != null && b > m) m = b; } } return m <= 0 ? 1 : m * 1.15; } /// Draw the optional min/max threshold lines (bar/line only) as dashed rules. static void _boundLinesSvg( StringBuffer b, ChartSpec spec, double left, double top, double right, double bottom, double maxY, ) { if (!spec.supportsBoundLines) return; void draw(double? value, String color, String prefix) { if (value == null || value < 0 || value > maxY) return; final y = bottom - (bottom - top) * (value / maxY); b ..write( '', ) ..write( '' '$prefix ${_num(value)}', ); } draw(spec.minBound, '#F59E0B', 'min'); draw(spec.maxBound, '#EF4444', 'max'); } static String _num(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1); static void _axes( StringBuffer b, ChartSpec spec, double left, double top, double right, double bottom, double maxY, ) { // Horizontal gridlines + y labels. for (var i = 0; i <= 4; i++) { final y = bottom - (bottom - top) * i / 4; final val = maxY * i / 4; b ..write( '', ) ..write( '${_num(val)}', ); } // X labels. final n = spec.x.length; final step = math.max(1, (n / 8).ceil()); for (var i = 0; i < n; i++) { if (i != n - 1 && i % step != 0) continue; final x = left + (right - left) * (i + 0.5) / n; final label = spec.x[i].length > 10 ? '${spec.x[i].substring(0, 9)}…' : spec.x[i]; b.write( '${_esc(label)}', ); } } static void _barSvg( StringBuffer b, ChartSpec spec, double top, ThemeProfile? theme, ) { const left = 60.0, right = 770.0, bottom = 382.0; final maxY = _maxY(spec); _axes(b, spec, left, top, right, bottom, maxY); final n = spec.x.length; final groupW = (right - left) / n; final sCount = spec.series.length; final barW = (groupW * 0.7) / sCount; for (var xi = 0; xi < n; xi++) { final gx = left + groupW * xi + groupW * 0.15; for (var si = 0; si < sCount; si++) { if (xi >= spec.series[si].data.length) continue; final v = spec.series[si].data[xi]; final h = (bottom - top) * (v / maxY); final x = gx + barW * si; b.write( '', ); } } _boundLinesSvg(b, spec, left, top, right, bottom, maxY); } static void _lineSvg( StringBuffer b, ChartSpec spec, double top, ThemeProfile? theme, ) { const left = 60.0, right = 770.0, bottom = 382.0; final maxY = _maxY(spec); _axes(b, spec, left, top, right, bottom, maxY); final n = spec.x.length; double px(int i) => left + (right - left) * (i + 0.5) / n; double py(double v) => bottom - (bottom - top) * (v / maxY); for (var si = 0; si < spec.series.length; si++) { final data = spec.series[si].data; final pts = [ for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}', ].join(' '); b.write( '', ); for (var i = 0; i < data.length; i++) { b.write( '', ); } } _boundLinesSvg(b, spec, left, top, right, bottom, maxY); } static void _pieSvg( StringBuffer b, ChartSpec spec, double top, ThemeProfile? theme, { required double bottom, }) { final count = math.min(spec.series.length, 2); final columns = count; final rows = (count / columns).ceil(); final cellWidth = 720.0 / columns; final cellHeight = (bottom - top) / rows; final radius = math.min(cellWidth * 0.25, cellHeight * 0.42); for (var xi = 0; xi < count; xi++) { final col = xi % columns; final row = xi ~/ columns; final cellLeft = 40 + cellWidth * col; final cx = cellLeft + cellWidth * 0.36; final cy = top + cellHeight * (row + 0.5); final series = spec.series[xi]; final values = [ for (var labelIndex = 0; labelIndex < spec.x.length; labelIndex++) labelIndex < series.data.length && series.data[labelIndex] > 0 ? series.data[labelIndex] : 0.0, ]; final total = values.fold(0, (a, v) => a + v); var angle = -90.0; for (var labelIndex = 0; labelIndex < values.length; labelIndex++) { final frac = total > 0 ? values[labelIndex] / total : 0; final sweep = frac * 360; if (sweep <= 0) continue; final a0 = angle * math.pi / 180; final a1 = (angle + sweep) * math.pi / 180; final x0 = cx + radius * math.cos(a0); final y0 = cy + radius * math.sin(a0); final x1 = cx + radius * math.cos(a1); final y1 = cy + radius * math.sin(a1); final large = sweep > 180 ? 1 : 0; b.write( '', ); angle += sweep; } b ..write('') ..write( '' '${_esc(_shortChartLabel(series.name.isEmpty ? 'Reeks ${xi + 1}' : series.name))}', ); } } static void _radarSvg( StringBuffer b, ChartSpec spec, double top, ThemeProfile? theme, String textColor, ) { final n = spec.x.length; if (n < 3 || spec.series.isEmpty) return; const bottom = 382.0; const cx = 400.0; final cy = (top + bottom) / 2; final radius = math.min(170.0, (bottom - top) / 2 - 34); final (lo, hi, ticks) = _radarScale(spec); final span = (hi - lo) == 0 ? 1.0 : (hi - lo); double angle(int i) => -math.pi / 2 + 2 * math.pi * i / n; // Concentric grid rings, evenly spaced across the [lo, hi] scale. for (var ring = 1; ring <= ticks; ring++) { final rr = radius * ring / ticks; final pts = [ for (var i = 0; i < n; i++) '${cx + rr * math.cos(angle(i))},${cy + rr * math.sin(angle(i))}', ].join(' '); b.write( '', ); } // Scale legend on the right: hi at the top down to lo at the bottom, so the // figure itself stays clean. final legendX = cx + radius + 30; for (var k = ticks; k >= 0; k--) { final value = lo + span * k / ticks; final y = (cy - radius) + (2 * radius) * (ticks - k) / ticks; b ..write( '', ) ..write( '${_num(value)}', ); } // Spokes and axis labels. for (var i = 0; i < n; i++) { final c = math.cos(angle(i)); final s = math.sin(angle(i)); b.write( '', ); final anchor = c > 0.3 ? 'start' : (c < -0.3 ? 'end' : 'middle'); final label = spec.x[i].length > 12 ? '${spec.x[i].substring(0, 11)}…' : spec.x[i]; b.write( '' '${_esc(label)}', ); } // One filled polygon per series. for (var si = 0; si < spec.series.length; si++) { final data = spec.series[si].data; final pts = [ for (var i = 0; i < n; i++) () { final v = i < data.length ? data[i] : 0.0; final rr = radius * ((v - lo) / span).clamp(0.0, 1.0); return '${cx + rr * math.cos(angle(i))},' '${cy + rr * math.sin(angle(i))}'; }(), ].join(' '); final color = _color(spec, si, theme); b.write( '', ); } } /// Radar scale shared with the live preview: honour optional min/max bounds, /// otherwise round the data range to a tidy scale with an even tick count. static (double, double, int) _radarScale(ChartSpec spec) { var dataMin = 0.0; var dataMax = 0.0; var seen = false; for (final s in spec.series) { for (final v in s.data) { if (!seen) { dataMin = v; dataMax = v; seen = true; } else { if (v < dataMin) dataMin = v; if (v > dataMax) dataMax = v; } } } if (!seen) { dataMin = 0; dataMax = 1; } final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); final rawHi = spec.maxBound ?? dataMax; final range = (rawHi - rawLo).abs(); final r = range <= 0 ? 1.0 : range; final rawStep = r / 4; final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble(); final norm = rawStep / mag; final niceNorm = norm < 1.5 ? 1.0 : norm < 3 ? 2.0 : norm < 7 ? 5.0 : 10.0; final step = niceNorm * mag; final lo = spec.minBound ?? (rawLo / step).floorToDouble() * step; var hi = spec.maxBound ?? (rawHi / step).ceilToDouble() * step; if (hi <= lo) hi = lo + step; final ticks = math.max(2, ((hi - lo) / step).round()); return (lo, hi, ticks); } static String _shortChartLabel(String value) => value.length > 13 ? '${value.substring(0, 12)}…' : value; /// CSS that mirrors the deck's [ThemeProfile]: slide background, text and /// accent colours, table colours and font. The EB Garamond font is embedded /// (base64) so it renders offline; other fonts resolve to system families. Future _themedCss(ThemeProfile t) async { final fontFace = await _ebGaramondFontFace(t.fontFamily); final family = _cssFontStack(t.fontFamily); return '$fontFace\n' '*{box-sizing:border-box}' 'html,body{margin:0;padding:0}' 'body{background:#1e1e1e;font-family:$family;color:${t.textColor}}' '.slide{position:relative;width:1280px;min-height:720px;margin:24px auto;' 'background:${t.slideBackgroundColor};color:${t.textColor};padding:60px;' 'overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.4);border-radius:4px;' 'font-family:$family}' '.slide h1{font-size:48px;margin:.15em 0;color:${t.textColor}}' '.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}' '.slide a{color:${t.accentColor}}' '.slide p,.slide li{font-size:24px;line-height:1.45}' '.slide pre{background:${t.codeBackgroundColor};color:${t.codeTextColor};' 'border:1px solid ${t.codeTextColor}38;border-radius:6px;' 'padding:16px;overflow:auto;font-size:18px}' '.slide pre code{color:${t.codeTextColor};background:transparent}' '.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 ${t.accentColor};margin:.5em 0;' 'padding-left:16px;opacity:.85}' '.slide table{border-collapse:collapse}' '.slide th{background:${t.sectionBackgroundColor};color:${t.tableHeaderTextColor};' 'border:1px solid #ccc;padding:6px 12px;font-size:20px}' '.slide td{color:${t.tableTextColor};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}}'; } String _cssFontStack(String font) { if (font == 'EB Garamond') return "'EB Garamond', Georgia, serif"; const serif = {'Georgia', 'Times New Roman'}; final generic = serif.contains(font) ? 'serif' : 'sans-serif'; return "'$font', $generic"; } /// Embed the bundled EB Garamond variable font as base64 so it works offline. /// Returns an empty string for any other (system) font. Future _ebGaramondFontFace(String font) async { if (font != 'EB Garamond') return ''; try { final bytes = await loadBytes('assets/fonts/EBGaramond-Variable.ttf'); final b64 = base64Encode(bytes); return "@font-face{font-family:'EB Garamond';font-weight:400 800;" "font-style:normal;src:url(data:font/ttf;base64,$b64) " "format('truetype');}"; } catch (_) { return ''; // Fall back to the CSS font stack if the asset is missing. } } 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();} })(); '''; }