Ocideck/lib/services/markdown_service.dart
Brenno de Winter ee9e2bfc58 Add logger; replace silent catch(_) with logged fallbacks (#2)
Introduce lib/utils/log.dart (logError / logWarning over dart:developer) and
route all 53 previously-bare `catch (_)` blocks through it. Behaviour is
unchanged: every fallback still fails soft (a broken sidecar, unreadable file
or unsupported platform must never crash a presentation) but the cause is now
observable. logError is used for unexpected parse/IO failures, logWarning for
expected best-effort fallbacks; no deck or file contents are ever logged.

Note: file_service, markdown_service, marp_html_service, fullscreen_presenter,
image_carousel_picker and url_launcher_util also carried pre-existing local
changes, bundled here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:39 +02:00

1195 lines
39 KiB
Dart

import 'dart:convert';
import 'package:characters/characters.dart';
import 'package:uuid/uuid.dart';
import '../models/chart.dart';
import '../models/deck.dart';
import '../models/settings.dart';
import '../models/slide.dart';
import '../utils/log.dart';
const _uuid = Uuid();
class MarkdownService {
// ── Generation ──────────────────────────────────────────────────────────────
/// Serialise a deck to Marp markdown.
///
/// The styling (the [ThemeProfile]) is deliberately NOT written to the file:
/// a saved `.md` holds only the content (the "base"), and the app applies the
/// active style profile when it opens the deck. [inlineStyleProfile] re-adds
/// the profile for transient, non-file payloads — currently only the markdown
/// streamed to the audience (beamer) window, which has no other way to learn
/// the styling. It must stay false for anything written to disk.
String generateDeck(
Deck deck, {
bool inlineChartData = false,
bool inlineStyleProfile = false,
}) {
final buf = StringBuffer();
buf.writeln('---');
buf.writeln('marp: true');
buf.writeln('theme: ${deck.theme}');
if (deck.paginate) buf.writeln('paginate: true');
// General presentation metadata (also picked up by Marp where applicable).
if (deck.title.isNotEmpty) {
buf.writeln('title: ${_yamlScalar(deck.title)}');
}
if (deck.author.isNotEmpty) {
buf.writeln('author: ${_yamlScalar(deck.author)}');
}
if (deck.organization.isNotEmpty) {
buf.writeln('organization: ${_yamlScalar(deck.organization)}');
}
if (deck.version.isNotEmpty) {
buf.writeln('version: ${_yamlScalar(deck.version)}');
}
if (deck.date.isNotEmpty) {
buf.writeln('date: ${_yamlScalar(deck.date)}');
}
if (deck.description.isNotEmpty) {
buf.writeln('description: ${_yamlScalar(deck.description)}');
}
if (deck.keywords.isNotEmpty) {
buf.writeln('keywords: ${_yamlScalar(deck.keywords)}');
}
if (deck.tlp != TlpLevel.none) {
buf.writeln('tlp: ${deck.tlp.key}');
}
if (inlineStyleProfile) {
buf.writeln(
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
);
}
buf.writeln('---');
buf.writeln();
for (int i = 0; i < deck.slides.length; i++) {
if (i > 0) {
buf.writeln('---');
buf.writeln();
}
buf.write(
generateSlide(
deck.slides[i],
themeProfile: deck.themeProfile,
inlineChartData: inlineChartData,
),
);
}
return buf.toString();
}
/// Render a string as a YAML scalar, quoting/escaping only when needed so the
/// front matter stays readable.
String _yamlScalar(String v) {
final needsQuote =
v.isEmpty ||
v != v.trim() ||
RegExp(r'[:#"\n]').hasMatch(v) ||
RegExp(r'''^[\[\]{}>|*&!%@`,?-]''').hasMatch(v);
if (!needsQuote) return v;
final escaped = v
.replaceAll('\\', r'\\')
.replaceAll('"', r'\"')
.replaceAll('\n', r'\n');
return '"$escaped"';
}
/// Inverse of [_yamlScalar] for the simple line-based front matter parser.
String _parseScalar(String raw) {
final s = raw.trim();
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return _unescape(s.substring(1, s.length - 1));
}
return s;
}
String _unescape(String s) {
final out = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (s[i] == r'\'[0] && i + 1 < s.length) {
final next = s[i + 1];
if (next == 'n') {
out.write('\n');
i++;
} else if (next == '"') {
out.write('"');
i++;
} else if (next == r'\'[0]) {
out.write(r'\');
i++;
} else {
out.write(s[i]);
}
} else {
out.write(s[i]);
}
}
return out.toString();
}
/// Write [rows] as a GitHub-flavoured markdown table (first row = header).
void _writeTable(StringBuffer buf, List<List<String>> rows) {
if (rows.isEmpty) return;
final colCount = rows.fold<int>(0, (m, r) => r.length > m ? r.length : m);
if (colCount == 0) return;
String cell(List<String> row, int c) {
final v = c < row.length ? row[c] : '';
return v
.replaceAll('\\', r'\\')
.replaceAll('|', r'\|')
.replaceAll('\n', '<br>');
}
String renderRow(List<String> row) =>
'| ${List.generate(colCount, (c) => cell(row, c)).join(' | ')} |';
buf.writeln(renderRow(rows.first));
buf.writeln('| ${List.generate(colCount, (_) => '---').join(' | ')} |');
for (var i = 1; i < rows.length; i++) {
buf.writeln(renderRow(rows[i]));
}
}
List<String> _splitTableRow(String line) {
var s = line.trim();
if (s.startsWith('|')) s = s.substring(1);
if (s.endsWith('|')) s = s.substring(0, s.length - 1);
return s
.split(RegExp(r'(?<!\\)\|'))
.map((c) => _unescapeCell(c.trim()))
.toList();
}
String _unescapeCell(String s) {
final out = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (s[i] == r'\'[0] && i + 1 < s.length) {
final n = s[i + 1];
if (n == '|') {
out.write('|');
i++;
continue;
}
if (n == r'\'[0]) {
out.write(r'\');
i++;
continue;
}
}
out.write(s[i]);
}
return out.toString().replaceAll('<br>', '\n');
}
String generateSlide(
Slide slide, {
ThemeProfile? themeProfile,
bool inlineChartData = false,
}) {
final buf = StringBuffer();
final cssClass = slide.cssClass.isNotEmpty
? slide.cssClass
: slide.type.marpClass;
final hasLogo = themeProfile?.logoPath?.isNotEmpty == true;
final classes = [
if (cssClass.isNotEmpty) cssClass,
// Reserve logo space only when the logo is actually shown on this slide.
if (hasLogo && slide.showLogo) 'logo-safe',
// Mark slides that opt out of the logo so the theme can hide it.
if (hasLogo && !slide.showLogo) 'no-logo',
// Mark slides that opt out of the footer. Older presentations lack this
// token and therefore keep the existing default: footer shown.
if (!slide.showFooter) 'no-footer',
];
if (classes.isNotEmpty) {
buf.writeln('<!-- _class: ${classes.join(' ')} -->');
buf.writeln();
}
switch (slide.type) {
case SlideType.title:
// Background image before headings so Marp treats it as a bg directive
if (slide.imagePath.isNotEmpty) {
final sizeSpec = slide.imageSize > 0 ? '${slide.imageSize}% ' : '';
buf.writeln('![bg ${sizeSpec}opacity:.45](${slide.imagePath})');
_writeImageCaption(buf, slide.imageCaption);
buf.writeln();
}
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}');
case SlideType.section:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.subtitle.isNotEmpty) {
buf.writeln();
buf.writeln(slide.subtitle);
}
case SlideType.bullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}');
if (slide.listStyle != ListStyle.bullets) {
buf.writeln('<!-- ocideck_list_style: ${slide.listStyle.name} -->');
}
_writeChecklistProgress(buf, slide);
buf.writeln();
_writeList(buf, slide.bullets, slide.listStyle);
case SlideType.twoBullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
buf.writeln();
_writeTwoBulletColumns(
buf,
slide.bullets,
slide.bullets2,
slide.columnTitle1,
slide.columnTitle2,
slide.listStyle,
slide.showChecklistProgress,
themeProfile ?? const ThemeProfile(),
);
case SlideType.bulletsImage:
if (slide.imagePath.isNotEmpty) {
final pct = (slide.imageSize > 0 ? slide.imageSize : 40).clamp(
20,
70,
);
final textScale = _splitTextScale(slide);
buf.writeln(
'<!-- _style: --image-width: $pct%; --split-text-scale: ${textScale.toStringAsFixed(2)}; -->',
);
buf.writeln();
buf.writeln(
'<div class="split-text" style="font-size: ${textScale.toStringAsFixed(2)}em">',
);
buf.writeln();
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.listStyle != ListStyle.bullets) {
buf.writeln('<!-- ocideck_list_style: ${slide.listStyle.name} -->');
}
_writeChecklistProgress(buf, slide);
buf.writeln();
_writeList(buf, slide.bullets, slide.listStyle);
buf.writeln();
buf.writeln('</div>');
buf.writeln();
buf.writeln('<div class="split-image">');
buf.writeln();
buf.writeln('![](${slide.imagePath})');
_writeImageCaption(buf, slide.imageCaption);
buf.writeln();
buf.writeln('</div>');
} else {
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.listStyle != ListStyle.bullets) {
buf.writeln('<!-- ocideck_list_style: ${slide.listStyle.name} -->');
}
_writeChecklistProgress(buf, slide);
buf.writeln();
_writeList(buf, slide.bullets, slide.listStyle);
}
case SlideType.twoImages:
final splitPct = slide.imageSize > 0 ? slide.imageSize : 50;
if (slide.imagePath.isNotEmpty) {
buf.writeln('![bg left:$splitPct%](${slide.imagePath})');
}
if (slide.imagePath2.isNotEmpty) {
buf.writeln('![bg right:${100 - splitPct}%](${slide.imagePath2})');
}
_writeImageCaption(
buf,
[
slide.imageCaption,
slide.imageCaption2,
].where((caption) => caption.trim().isNotEmpty).join(' | '),
);
if (slide.title.isNotEmpty) {
buf.writeln();
buf.writeln('# ${slide.title}');
}
case SlideType.image:
if (slide.imagePath.isNotEmpty) {
final sizeSpec = slide.imageSize > 0 ? ' ${slide.imageSize}%' : '';
buf.writeln('![bg$sizeSpec](${slide.imagePath})');
_writeImageCaption(buf, slide.imageCaption);
}
if (slide.title.isNotEmpty) {
buf.writeln();
buf.writeln('# ${slide.title}');
}
case SlideType.video:
if (slide.title.isNotEmpty) {
buf.writeln('# ${slide.title}');
buf.writeln();
}
if (slide.videoPath.isNotEmpty) {
final autoplay = slide.videoAutoplay ? ' autoplay muted loop' : '';
buf.writeln(
'<video src="${slide.videoPath}" controls$autoplay style="width:100%; max-height:72vh;"></video>',
);
}
case SlideType.quote:
if (slide.imagePath.isNotEmpty) {
final sizeSpec = slide.imageSize > 0 ? '${slide.imageSize}% ' : '';
buf.writeln('![bg ${sizeSpec}opacity:.45](${slide.imagePath})');
_writeImageCaption(buf, slide.imageCaption);
buf.writeln();
}
if (slide.quote.isNotEmpty) buf.writeln('> ${slide.quote}');
if (slide.quoteAuthor.isNotEmpty) {
buf.writeln();
buf.writeln('${slide.quoteAuthor}');
}
case SlideType.table:
if (slide.title.isNotEmpty) {
buf.writeln('# ${slide.title}');
buf.writeln();
}
_writeTable(buf, slide.tableRows);
case SlideType.freeMarkdown:
buf.write(slide.customMarkdown);
if (slide.customMarkdown.isNotEmpty &&
!slide.customMarkdown.endsWith('\n')) {
buf.writeln();
}
case SlideType.code:
if (slide.title.isNotEmpty) {
buf.writeln('# ${slide.title}');
buf.writeln();
}
buf.writeln('```${slide.codeLanguage.trim()}');
buf.write(slide.customMarkdown);
if (slide.customMarkdown.isNotEmpty &&
!slide.customMarkdown.endsWith('\n')) {
buf.writeln();
}
buf.writeln('```');
case SlideType.chart:
// Re-serialize so inline data is dropped when the chart links a CSV
// (the .md keeps only the spec + source; the CSV stays the source).
final spec = ChartSpec.parse(slide.customMarkdown);
buf.writeln('```chart');
buf.writeln(spec.toBlock(forStorage: !inlineChartData));
buf.writeln('```');
}
if (slide.audioPath.isNotEmpty) {
final autoplay = slide.audioAutoplay ? ' autoplay' : '';
buf.writeln();
buf.writeln(
'<audio src="${slide.audioPath}" controls$autoplay style="width:100%;"></audio>',
);
}
if (slide.advanceDuration > 0) {
buf.writeln();
buf.writeln(
'<!-- advance: ${slide.advanceDuration.toStringAsFixed(1)} -->',
);
}
// Slides marked to be skipped during presenting/exporting. Persisted so the
// skip state survives save/load round-trips.
if (slide.skipped) {
buf.writeln();
buf.writeln('<!-- skip -->');
}
// Per-slide TLP classification (used to withhold the slide when sharing at
// a lower level). Persisted so it survives save/load round-trips.
if (slide.tlp != TlpLevel.none) {
buf.writeln();
buf.writeln('<!-- tlp: ${slide.tlp.key} -->');
}
if (slide.notes.isNotEmpty) {
buf.writeln();
buf.writeln('<!--');
buf.writeln(slide.notes);
buf.writeln('-->');
}
buf.writeln();
return buf.toString();
}
static void _writeList(
StringBuffer buf,
List<String> items,
ListStyle style,
) {
final counters = <int>[];
for (final item in items) {
int level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
final text = item.substring(level);
if (text.isEmpty) continue;
while (counters.length <= level) {
counters.add(0);
}
counters[level]++;
if (counters.length > level + 1) {
counters.removeRange(level + 1, counters.length);
}
final marker = switch (style) {
ListStyle.numbered => '${counters[level]}.',
ListStyle.bullets || ListStyle.checklist => '-',
};
final body = style == ListStyle.checklist
? '[${checklistItemChecked(item) ? 'x' : ' '}] '
'${checklistItemText(item)}'
: text;
buf.writeln('${' ' * level}$marker $body');
}
}
static void _writeTwoBulletColumns(
StringBuffer buf,
List<String> left,
List<String> right,
String leftTitle,
String rightTitle,
ListStyle listStyle,
bool showChecklistProgress,
ThemeProfile themeProfile,
) {
buf.writeln('<!-- ocideck_two_bullets_left: ${_encodeBullets(left)} -->');
buf.writeln('<!-- ocideck_two_bullets_right: ${_encodeBullets(right)} -->');
if (listStyle != ListStyle.bullets) {
buf.writeln('<!-- ocideck_list_style: ${listStyle.name} -->');
}
if (showChecklistProgress) {
buf.writeln('<!-- ocideck_checklist_progress: true -->');
}
if (leftTitle.isNotEmpty) {
buf.writeln(
'<!-- ocideck_two_bullets_left_title: ${_encodeText(leftTitle)} -->',
);
}
if (rightTitle.isNotEmpty) {
buf.writeln(
'<!-- ocideck_two_bullets_right_title: ${_encodeText(rightTitle)} -->',
);
}
buf.writeln(
'<div class="ocideck-two-bullets" style="display:grid; grid-template-columns:1fr 1fr; gap:3rem; align-items:start;">',
);
_writeBulletColumn(buf, left, leftTitle, listStyle, themeProfile);
_writeBulletColumn(buf, right, rightTitle, listStyle, themeProfile);
buf.writeln('</div>');
}
static void _writeChecklistProgress(StringBuffer buf, Slide slide) {
if (slide.showChecklistProgress) {
buf.writeln('<!-- ocideck_checklist_progress: true -->');
}
}
static void _writeBulletColumn(
StringBuffer buf,
List<String> bullets,
String columnTitle,
ListStyle listStyle,
ThemeProfile themeProfile,
) {
buf.writeln('<div>');
if (columnTitle.isNotEmpty) {
buf.writeln(
'<h3 style="margin:0 0 .5rem;">${_escapeHtml(columnTitle)}</h3>',
);
}
final tag = listStyle == ListStyle.numbered ? 'ol' : 'ul';
buf.writeln('<$tag style="margin:0; padding-left:1.3em;">');
_writeHtmlBulletItems(buf, bullets, listStyle, themeProfile);
buf.writeln('</$tag>');
buf.writeln('</div>');
}
static String _encodeBullets(List<String> bullets) {
return base64Url.encode(utf8.encode(jsonEncode(bullets)));
}
static String _encodeText(String value) =>
base64Url.encode(utf8.encode(value));
static String _decodeText(String encoded) {
try {
return utf8.decode(base64Url.decode(encoded.trim()));
} catch (e, s) {
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
return '';
}
}
static List<String> _decodeBullets(String encoded) {
try {
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
final raw = jsonDecode(decoded);
if (raw is List) return raw.map((v) => v.toString()).toList();
} catch (e, s) {
logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s);
}
return const [];
}
static void _writeHtmlBulletItems(
StringBuffer buf,
List<String> bullets,
ListStyle listStyle,
ThemeProfile themeProfile,
) {
final counters = <int>[];
for (final b in bullets) {
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
final text = listStyle == ListStyle.checklist
? checklistItemText(b).trim()
: b.substring(level).trim();
if (text.isEmpty) continue;
while (counters.length <= level) {
counters.add(0);
}
counters[level]++;
if (counters.length > level + 1) {
counters.removeRange(level + 1, counters.length);
}
final style = level == 0 ? '' : ' style="margin-left:${level * 1.4}em;"';
final value = listStyle == ListStyle.numbered
? ' value="${counters[level]}"'
: '';
final checkbox = listStyle == ListStyle.checklist
? '${checklistItemChecked(b) ? '' : ''} '
: '';
final decoration = listStyle == ListStyle.checklist
? ' style="${level == 0 ? '' : 'margin-left:${level * 1.4}em;'}'
'${checklistItemChecked(b) && themeProfile.checklistStrikeThrough ? 'text-decoration:line-through;opacity:.7;' : ''}"'
: style;
buf.writeln('<li$value$decoration>${_escapeHtml(checkbox + text)}</li>');
}
}
static String _escapeHtml(String value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
static double _splitTextScale(Slide slide) {
final bullets = slide.bullets
.map((b) => b.trimLeft())
.where((b) => b.isNotEmpty)
.toList();
if (bullets.isEmpty) return 1.2;
final maxChars = bullets.fold<int>(
0,
(max, bullet) => bullet.length > max ? bullet.length : max,
);
final count = bullets.length;
var scale = 1.0;
if (count <= 4) {
scale = 2.35;
} else if (count <= 5) {
scale = 2.10;
} else if (count <= 7) {
scale = 1.85;
} else if (count <= 9) {
scale = 1.55;
} else if (count <= 12) {
scale = 1.30;
}
if (maxChars > 115) {
scale -= 0.16;
} else if (maxChars > 90) {
scale -= 0.08;
}
return scale.clamp(1.0, 2.45);
}
static void _writeImageCaption(StringBuffer buf, String caption) {
final text = caption.trim();
if (text.isEmpty) return;
buf.writeln(
'<div class="image-caption">${const HtmlEscape().convert(text)}</div>',
);
}
static String _decodeImageCaption(String line) {
return line
.replaceFirst('<div class="image-caption">', '')
.replaceFirst('</div>', '')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&')
.trim();
}
// ── Parsing ─────────────────────────────────────────────────────────────────
/// Best-effort parse of Marp markdown into a Deck. Returns null if the
/// content cannot be parsed at all.
Deck? parseDeck(String markdown, {String? filePath}) {
try {
return _doParse(markdown, filePath: filePath);
} catch (e, s) {
logError('MarkdownService.parseDeck: parse markdown', e, s);
return null;
}
}
Deck _doParse(String markdown, {String? filePath}) {
String content = markdown;
String theme = 'ocideck';
bool paginate = true;
ThemeProfile themeProfile = const ThemeProfile();
String? presentationTitle;
String author = '';
String organization = '';
String version = '';
String date = '';
String description = '';
String keywords = '';
TlpLevel tlp = TlpLevel.none;
// Strip front matter
if (content.startsWith('---\n')) {
final end = content.indexOf('\n---\n', 4);
if (end != -1) {
final frontMatter = content.substring(4, end);
for (final line in frontMatter.split('\n')) {
if (line.startsWith('theme:')) {
theme = line.substring(6).trim();
} else if (line.startsWith('paginate:')) {
paginate = line.substring(9).trim() == 'true';
} else if (line.startsWith('title:')) {
presentationTitle = _parseScalar(line.substring(6));
} else if (line.startsWith('author:')) {
author = _parseScalar(line.substring(7));
} else if (line.startsWith('organization:')) {
organization = _parseScalar(line.substring(13));
} else if (line.startsWith('version:')) {
version = _parseScalar(line.substring(8));
} else if (line.startsWith('date:')) {
date = _parseScalar(line.substring(5));
} else if (line.startsWith('description:')) {
description = _parseScalar(line.substring(12));
} else if (line.startsWith('keywords:')) {
keywords = _parseScalar(line.substring(9));
} else if (line.startsWith('tlp:')) {
tlp = TlpLevelX.fromKey(line.substring(4));
} else if (line.startsWith('ocideck_style_profile:')) {
// Best-effort: a corrupt profile token must not fail the whole
// parse (which would blank the audience window). Keep the default.
try {
final encoded = line.substring(22).trim();
final decoded = utf8.decode(base64Url.decode(encoded));
themeProfile = ThemeProfile.fromJson(
Map<String, Object?>.from(jsonDecode(decoded) as Map),
);
} catch (e, s) {
logError(
'MarkdownService._doParse: decode ocideck_style_profile',
e,
s,
);
// Leave themeProfile at its default.
}
}
}
content = content.substring(end + 5).trim();
}
}
final blocks = content.split(RegExp(r'\n---\n'));
final slides = <Slide>[];
for (final block in blocks) {
final slide = _parseBlock(block.trim());
if (slide != null) slides.add(slide);
}
final title =
presentationTitle ??
(slides.isNotEmpty && slides.first.title.isNotEmpty
? slides.first.title
: 'Presentatie');
String? projectPath;
if (filePath != null) {
final sep = filePath.contains('/') ? '/' : '\\';
final parts = filePath.split(sep);
if (parts.length > 1) {
projectPath = parts.sublist(0, parts.length - 1).join(sep);
}
}
return Deck(
title: title,
theme: theme,
paginate: paginate,
slides: slides.isEmpty ? [Slide.create(SlideType.title)] : slides,
projectPath: projectPath,
themeProfile: themeProfile,
author: author,
organization: organization,
version: version,
date: date,
description: description,
keywords: keywords,
tlp: tlp,
);
}
Slide? _parseBlock(String block) {
if (block.isEmpty) return null;
String cssClass = '';
String remaining = block;
final classMatch = RegExp(
r'<!--\s*_class:\s*([^>]+?)\s*-->',
).firstMatch(block);
if (classMatch != null) {
cssClass = classMatch.group(1) ?? '';
remaining = block.replaceFirst(classMatch.group(0)!, '').trim();
}
// Extract presenter notes and advance timing from HTML comments
final notesBuffer = StringBuffer();
double advanceDuration = 0;
bool skipped = false;
TlpLevel slideTlp = TlpLevel.none;
final bullets = <String>[];
var bullets2 = <String>[];
var listStyle = ListStyle.bullets;
var showChecklistProgress = false;
var columnTitle1 = '';
var columnTitle2 = '';
// bulletsImage slides store their panel width in `<!-- _style:
// --image-width: N%; -->`; capture it before the comment is stripped.
int styleImageWidth = 0;
remaining = remaining.replaceAllMapped(
RegExp(r'<!--([\s\S]*?)-->', multiLine: true),
(m) {
final content = m.group(1)!.trim();
if (content.startsWith('advance:')) {
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
} else if (content == 'skip') {
skipped = true;
} else if (content.startsWith('tlp:')) {
slideTlp = TlpLevelX.fromKey(content.substring(4));
} else if (content.startsWith('_style:')) {
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
} else if (content.startsWith('ocideck_two_bullets_left:')) {
bullets
..clear()
..addAll(_decodeBullets(content.substring(25)));
} else if (content.startsWith('ocideck_two_bullets_left_title:')) {
columnTitle1 = _decodeText(content.substring(31));
} else if (content.startsWith('ocideck_two_bullets_right_title:')) {
columnTitle2 = _decodeText(content.substring(32));
} else if (content.startsWith('ocideck_two_bullets_right:')) {
bullets2 = _decodeBullets(content.substring(26));
} else if (content.startsWith('ocideck_list_style:')) {
final name = content.substring(19).trim();
listStyle = ListStyle.values.firstWhere(
(style) => style.name == name,
orElse: () => ListStyle.bullets,
);
} else if (content.startsWith('ocideck_checklist_progress:')) {
showChecklistProgress =
content.substring('ocideck_checklist_progress:'.length).trim() ==
'true';
} else if (!content.startsWith('_')) {
notesBuffer.write(notesBuffer.isEmpty ? content : '\n$content');
}
return '';
},
).trim();
final notes = notesBuffer.toString().trim();
// Code slides carry a fenced block that the generic line parser below would
// mangle (the body lines aren't markdown). Handle them up front.
if (cssClass.split(RegExp(r'\s+')).contains('code')) {
return _parseCodeBlock(
remaining: remaining,
cssClass: cssClass,
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
tlp: slideTlp,
);
}
// Chart slides carry a fenced ```chart JSON block; handle up front too.
if (cssClass.split(RegExp(r'\s+')).contains('chart')) {
return _parseChartBlock(
remaining: remaining,
cssClass: cssClass,
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
tlp: slideTlp,
);
}
final lines = remaining.split('\n');
String h1 = '';
String h2 = '';
String paragraph = '';
String imagePath = '';
String imagePath2 = '';
String imageCaption = '';
String imageCaption2 = '';
int imageSize = 0;
String videoPath = '';
bool videoAutoplay = false;
String audioPath = '';
bool audioAutoplay = false;
String quote = '';
String quoteAuthor = '';
final tableLines = <String>[];
for (final line in lines) {
final t = line.trim();
if (t.startsWith('|')) {
tableLines.add(t);
} else if (t.startsWith('# ')) {
h1 = t.substring(2);
} else if (t.startsWith('## ')) {
h2 = t.substring(3);
} else if (t.startsWith('- ')) {
// Count leading spaces (2 per level)
int spaces = 0;
for (final ch in line.characters) {
if (ch == ' ') {
spaces++;
} else {
break;
}
}
final level = spaces ~/ 2;
final body = t.substring(2);
bullets.add('\t' * level + body);
if (RegExp(r'^\[[ xX]\]\s*').hasMatch(body)) {
listStyle = ListStyle.checklist;
}
} else if (RegExp(r'^\d+\.\s+').hasMatch(t)) {
int spaces = 0;
for (final ch in line.characters) {
if (ch == ' ') {
spaces++;
} else {
break;
}
}
final level = spaces ~/ 2;
bullets.add('\t' * level + t.replaceFirst(RegExp(r'^\d+\.\s+'), ''));
listStyle = ListStyle.numbered;
} else if (t.startsWith('> ')) {
quote = t.substring(2);
} else if (t.startsWith('')) {
quoteAuthor = t.substring(2);
} else if (RegExp(r'!\[bg').hasMatch(t)) {
final m = RegExp(r'!\[bg[^\]]*\]\(([^)]+)\)').firstMatch(t);
if (m != null) {
if (imagePath.isEmpty) {
imagePath = m.group(1) ?? '';
} else {
imagePath2 = m.group(1) ?? ''; // tweede afbeelding
}
}
// Parse size: ![bg 50%](...) or ![bg left:42%](...)
final sizeMatch = RegExp(r'!\[bg[^\]]*?(\d+)%[^\]]*\]').firstMatch(t);
if (sizeMatch != null && imageSize == 0) {
imageSize = int.tryParse(sizeMatch.group(1)!) ?? 0;
}
} else if (cssClass.split(RegExp(r'\s+')).contains('split') &&
RegExp(r'!\[[^\]]*\]\(([^)]+)\)').hasMatch(t)) {
// Plain markdown image, e.g. the `![](path)` used inside a
// bulletsImage `split-image` panel. Restricted to split slides so a
// plain image inside free markdown is not mistaken for an image slide.
final m = RegExp(r'!\[[^\]]*\]\(([^)]+)\)').firstMatch(t);
if (m != null) {
if (imagePath.isEmpty) {
imagePath = m.group(1) ?? '';
} else {
imagePath2 = m.group(1) ?? '';
}
}
} else if (t.startsWith('<div class="image-caption">')) {
final captionParts = _decodeImageCaption(t).split(' | ');
imageCaption = captionParts.isNotEmpty ? captionParts.first : '';
imageCaption2 = captionParts.length > 1
? captionParts.sublist(1).join(' | ')
: '';
} else if (t.startsWith('<video')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) videoPath = m.group(1) ?? '';
videoAutoplay = t.contains('autoplay');
} else if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
} else if (t.isNotEmpty && h1.isNotEmpty && paragraph.isEmpty) {
paragraph = t;
}
}
if (imageSize == 0 && styleImageWidth > 0) imageSize = styleImageWidth;
final tableRows = <List<String>>[];
for (final line in tableLines) {
final cells = _splitTableRow(line);
// Skip the GFM separator row (e.g. | --- | :---: |).
if (cells.isNotEmpty &&
cells.every((c) => RegExp(r'^:?-+:?$').hasMatch(c.trim()))) {
continue;
}
tableRows.add(cells);
}
SlideType type;
switch (cssClass) {
case final c when c.split(RegExp(r'\s+')).contains('title'):
type = SlideType.title;
case final c when c.split(RegExp(r'\s+')).contains('section'):
type = SlideType.section;
case final c when c.split(RegExp(r'\s+')).contains('two-bullets'):
type = SlideType.twoBullets;
case final c when c.split(RegExp(r'\s+')).contains('split'):
type = SlideType.bulletsImage;
case final c when c.split(RegExp(r'\s+')).contains('quote'):
type = SlideType.quote;
case final c when c.split(RegExp(r'\s+')).contains('video'):
type = SlideType.video;
case final c when c.split(RegExp(r'\s+')).contains('table'):
type = SlideType.table;
default:
if (quote.isNotEmpty) {
type = SlideType.quote;
} else if (imagePath.isNotEmpty && imagePath2.isNotEmpty) {
type = SlideType.twoImages;
} else if (bullets.isNotEmpty && imagePath.isNotEmpty) {
type = SlideType.bulletsImage;
} else if (bullets.isNotEmpty) {
type = SlideType.bullets;
} else if (videoPath.isNotEmpty) {
type = SlideType.video;
} else if (imagePath.isNotEmpty) {
type = SlideType.image;
} else if (tableRows.isNotEmpty &&
bullets.isEmpty &&
h2.isEmpty &&
paragraph.isEmpty) {
type = SlideType.table;
} else if (h1.isEmpty && h2.isEmpty && bullets.isEmpty) {
type = SlideType.freeMarkdown;
} else {
type = SlideType.bullets;
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final showLogo = !classTokens.contains('no-logo');
final showFooter = !classTokens.contains('no-footer');
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != type.marpClass &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: type,
title: h1,
subtitle: type == SlideType.section ? paragraph : h2,
bullets: bullets,
bullets2: bullets2,
listStyle: listStyle,
showChecklistProgress: showChecklistProgress,
columnTitle1: columnTitle1,
columnTitle2: columnTitle2,
imagePath: imagePath,
imagePath2: imagePath2,
imageCaption: imageCaption,
imageCaption2: imageCaption2,
imageSize: imageSize,
videoPath: videoPath,
videoAutoplay: videoAutoplay,
audioPath: audioPath,
audioAutoplay: audioAutoplay,
quote: quote,
quoteAuthor: quoteAuthor,
customMarkdown: type == SlideType.freeMarkdown ? remaining : '',
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: showLogo,
showFooter: showFooter,
skipped: skipped,
tlp: slideTlp,
tableRows: type == SlideType.table ? tableRows : const [],
);
}
/// Parse a `<!-- _class: code -->` slide: an optional `# title`, the fenced
/// code block (its info string is the language) and an optional `<audio>`.
Slide _parseCodeBlock({
required String remaining,
required String cssClass,
required String notes,
required double advanceDuration,
required bool skipped,
TlpLevel tlp = TlpLevel.none,
}) {
final lines = remaining.split('\n');
String title = '';
String language = '';
String audioPath = '';
bool audioAutoplay = false;
final code = <String>[];
bool inFence = false;
for (final line in lines) {
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
if (!inFence) {
inFence = true;
language = fence.group(1)!.trim();
} else {
inFence = false;
}
continue;
}
if (inFence) {
code.add(line);
continue;
}
final t = line.trim();
if (t.startsWith('# ') && title.isEmpty) {
title = t.substring(2);
} else if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != 'code' &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: SlideType.code,
title: title,
customMarkdown: code.join('\n'),
codeLanguage: language,
audioPath: audioPath,
audioAutoplay: audioAutoplay,
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
tlp: tlp,
);
}
/// Parse a `<!-- _class: chart -->` slide: the fenced ```chart JSON block and
/// an optional `<audio>`. The JSON is kept verbatim in [Slide.customMarkdown].
Slide _parseChartBlock({
required String remaining,
required String cssClass,
required String notes,
required double advanceDuration,
required bool skipped,
TlpLevel tlp = TlpLevel.none,
}) {
final lines = remaining.split('\n');
final json = <String>[];
String audioPath = '';
bool audioAutoplay = false;
bool inFence = false;
for (final line in lines) {
final fence = RegExp(r'^\s*```').hasMatch(line);
if (fence) {
inFence = !inFence;
continue;
}
if (inFence) {
json.add(line);
continue;
}
final t = line.trim();
if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != 'chart' &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: SlideType.chart,
customMarkdown: json.join('\n').trim(),
audioPath: audioPath,
audioAutoplay: audioAutoplay,
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
tlp: tlp,
);
}
}