From 32ef54e037711474289749d6e8adaaba5cec3bf2 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Sun, 7 Jun 2026 11:42:44 +0200 Subject: [PATCH] Add chart slides (bar/line/pie) with hybrid CSV storage New "Grafiek" slide type rendering bar, line and pie charts. Storage fits Marp: a ```chart fenced block holds the spec as JSON. Small charts keep their data inline (the .md stays self-contained); data-driven charts link an external CSV via "source": "data/.csv" kept in a separate data/ directory and packaged into .ocideck like images. On save the inline data is stripped for linked charts (the CSV is the source of truth); on open it is re-hydrated from the CSV. - lib/models/chart.dart: ChartSpec/ChartSeries JSON parse/serialize, inline-vs-source handling, and a CSV parser. - In-app rendering (preview/presenter/PDF/PPTX) via fl_chart. - HTML export renders charts as self-contained inline SVG generated in Dart (no JS chart library); export inlines linked data so the page is standalone. - Editor: type picker, title, a CSV-style data field, and CSV import that can inline the data or link it as data/.csv (with unlink). - Markdown round-trip + .ocideck packaging of linked CSVs; translations for all supported languages. flutter analyze is clean, all tests pass (new chart/CSV/round-trip tests), and the macOS debug build compiles. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_localizations.dart | 91 ++++++ lib/models/chart.dart | 147 +++++++++ lib/models/slide.dart | 5 + lib/services/file_service.dart | 84 +++++- lib/services/markdown_service.dart | 99 +++++- lib/services/marp_html_service.dart | 204 ++++++++++++- lib/widgets/app_shell.dart | 4 +- lib/widgets/dialogs/add_slide_dialog.dart | 1 + lib/widgets/editors/chart_editor.dart | 265 ++++++++++++++++ lib/widgets/panels/editor_panel.dart | 10 + lib/widgets/slides/slide_preview.dart | 352 ++++++++++++++++++++++ pubspec.lock | 16 + pubspec.yaml | 1 + test/chart_test.dart | 74 +++++ test/markdown_round_trip_test.dart | 33 ++ 15 files changed, 1379 insertions(+), 7 deletions(-) create mode 100644 lib/models/chart.dart create mode 100644 lib/widgets/editors/chart_editor.dart create mode 100644 test/chart_test.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9eebff7..2384af9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1179,6 +1179,19 @@ const _dutchSourceStrings = { 'Stoppen (Esc)': 'Interrompi (Esc)', 'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma', 'Laser · annotaties wissen': 'Laser · cancella annotazioni', + 'Grafiek': 'Grafico', + 'Type grafiek': 'Tipo di grafico', + 'Staaf': 'Barre', + 'Lijn': 'Linee', + 'Cirkel': 'Torta', + 'CSV importeren': 'Importa CSV', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Dati (CSV: prima riga = nomi serie, prima colonna = etichette)', + 'Gekoppeld aan': 'Collegato a', + 'Ontkoppelen': 'Scollega', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Salvare i dati nella slide o tenerli come file CSV separato accanto alla presentazione?', + 'In de slide': 'Nella slide', + 'Als CSV-bestand': 'Come file CSV', + 'Geen grafiekgegevens': 'Nessun dato del grafico', 'Plak of typ hier je broncode...': 'Incolla o digita qui il tuo codice sorgente...', 'Overgeslagen': 'Saltata', @@ -1355,6 +1368,19 @@ const _dutchSourceStrings = { 'Stoppen (Esc)': 'Beenden (Esc)', 'Pen · markeerstift · gum': 'Stift · Marker · Radierer', 'Laser · annotaties wissen': 'Laser · Anmerkungen löschen', + 'Grafiek': 'Diagramm', + 'Type grafiek': 'Diagrammtyp', + 'Staaf': 'Balken', + 'Lijn': 'Linie', + 'Cirkel': 'Kreis', + 'CSV importeren': 'CSV importieren', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Daten (CSV: erste Zeile = Reihennamen, erste Spalte = Beschriftungen)', + 'Gekoppeld aan': 'Verknüpft mit', + 'Ontkoppelen': 'Trennen', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Daten in der Folie speichern oder als separate CSV-Datei neben der Präsentation behalten?', + 'In de slide': 'In der Folie', + 'Als CSV-bestand': 'Als CSV-Datei', + 'Geen grafiekgegevens': 'Keine Diagrammdaten', 'Plak of typ hier je broncode...': 'Quellcode hier einfügen oder eingeben...', 'Overgeslagen': 'Übersprungen', @@ -1532,6 +1558,19 @@ const _dutchSourceStrings = { 'Stoppen (Esc)': 'Arrêter (Esc)', 'Pen · markeerstift · gum': 'Stylo · surligneur · gomme', 'Laser · annotaties wissen': 'Laser · effacer les annotations', + 'Grafiek': 'Graphique', + 'Type grafiek': 'Type de graphique', + 'Staaf': 'Barres', + 'Lijn': 'Lignes', + 'Cirkel': 'Secteurs', + 'CSV importeren': 'Importer un CSV', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Données (CSV : 1re ligne = noms de séries, 1re colonne = libellés)', + 'Gekoppeld aan': 'Lié à', + 'Ontkoppelen': 'Dissocier', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Enregistrer les données dans la diapositive, ou les conserver dans un fichier CSV séparé à côté de la présentation ?', + 'In de slide': 'Dans la diapositive', + 'Als CSV-bestand': 'Comme fichier CSV', + 'Geen grafiekgegevens': 'Aucune donnée de graphique', 'Plak of typ hier je broncode...': 'Collez ou tapez votre code source ici...', 'Overgeslagen': 'Ignorée', @@ -1708,6 +1747,19 @@ const _dutchSourceStrings = { 'Stoppen (Esc)': 'Detener (Esc)', 'Pen · markeerstift · gum': 'Lápiz · marcador · goma', 'Laser · annotaties wissen': 'Láser · borrar anotaciones', + 'Grafiek': 'Gráfico', + 'Type grafiek': 'Tipo de gráfico', + 'Staaf': 'Barras', + 'Lijn': 'Líneas', + 'Cirkel': 'Circular', + 'CSV importeren': 'Importar CSV', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Datos (CSV: primera fila = nombres de series, primera columna = etiquetas)', + 'Gekoppeld aan': 'Vinculado a', + 'Ontkoppelen': 'Desvincular', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': '¿Guardar los datos en la diapositiva o mantenerlos como archivo CSV separado junto a la presentación?', + 'In de slide': 'En la diapositiva', + 'Als CSV-bestand': 'Como archivo CSV', + 'Geen grafiekgegevens': 'Sin datos de gráfico', 'Plak of typ hier je broncode...': 'Pega o escribe aquí tu código fuente...', 'Overgeslagen': 'Omitida', @@ -1885,6 +1937,19 @@ const _dutchSourceStrings = { 'Stoppen (Esc)': 'Stopje (Esc)', 'Pen · markeerstift · gum': 'Pen · markearstift · gom', 'Laser · annotaties wissen': 'Laser · annotaasjes wiskje', + 'Grafiek': 'Grafyk', + 'Type grafiek': 'Grafyktype', + 'Staaf': 'Steaf', + 'Lijn': 'Line', + 'Cirkel': 'Sirkel', + 'CSV importeren': 'CSV ymportearje', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: earste rige = rige-nammen, earste kolom = labels)', + 'Gekoppeld aan': 'Keppele oan', + 'Ontkoppelen': 'Ûntkeppelje', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Data yn de slide bewarje, of as los CSV-bestân neist de presintaasje hâlde?', + 'In de slide': 'Yn de slide', + 'Als CSV-bestand': 'As CSV-bestân', + 'Geen grafiekgegevens': 'Gjin grafykgegevens', 'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...', 'Overgeslagen': 'Oerslein', 'Kopiëren': 'Kopiearje', @@ -2062,6 +2127,19 @@ const _dutchSourceStrings = { 'Stoppen (Esc)': 'Stòp (Esc)', 'Pen · markeerstift · gum': 'Pèn · marker · gòm', 'Laser · annotaties wissen': 'Laser · kita anotashonnan', + 'Grafiek': 'Gráfiko', + 'Type grafiek': 'Tipo di gráfiko', + 'Staaf': 'Bara', + 'Lijn': 'Liña', + 'Cirkel': 'Sirkel', + 'CSV importeren': 'Importá CSV', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Dato (CSV: promé fila = nòmber di serie, promé kolom = etiketnan)', + 'Gekoppeld aan': 'Konektá na', + 'Ontkoppelen': 'Deskonektá', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Warda e dato den e slide, òf keda komo un archivo CSV separá banda di e presentashon?', + 'In de slide': 'Den e slide', + 'Als CSV-bestand': 'Komo archivo CSV', + 'Geen grafiekgegevens': 'Sin dato di gráfiko', 'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...', 'Overgeslagen': 'Saltá', 'Kopiëren': 'Kopia', @@ -2228,6 +2306,19 @@ const _dutchSourceStringAdditions = { 'Stoppen (Esc)': 'Stop (Esc)', 'Pen · markeerstift · gum': 'Pen · highlighter · eraser', 'Laser · annotaties wissen': 'Laser · clear annotations', + 'Grafiek': 'Chart', + 'Type grafiek': 'Chart type', + 'Staaf': 'Bar', + 'Lijn': 'Line', + 'Cirkel': 'Pie', + 'CSV importeren': 'Import CSV', + 'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)': 'Data (CSV: first row = series names, first column = labels)', + 'Gekoppeld aan': 'Linked to', + 'Ontkoppelen': 'Unlink', + 'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?': 'Store the data in the slide, or keep it as a separate CSV file next to the presentation?', + 'In de slide': 'In the slide', + 'Als CSV-bestand': 'As a CSV file', + 'Geen grafiekgegevens': 'No chart data', 'Platte tekst': 'Plain text', 'Titel (optioneel)': 'Title (optional)', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': diff --git a/lib/models/chart.dart b/lib/models/chart.dart new file mode 100644 index 0000000..b94dd97 --- /dev/null +++ b/lib/models/chart.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +/// Supported chart kinds for a chart slide. +enum ChartType { bar, line, pie } + +ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere( + (t) => t.name == name, + orElse: () => ChartType.bar, +); + +/// One named data series (a row of values aligned to the x labels). +class ChartSeries { + final String name; + final List data; + const ChartSeries({required this.name, required this.data}); + + Map toJson() => {'name': name, 'data': data}; + + factory ChartSeries.fromJson(Map json) => ChartSeries( + name: (json['name'] ?? '').toString(), + data: [ + for (final v in (json['data'] as List? ?? const [])) + (v as num?)?.toDouble() ?? 0, + ], + ); +} + +/// The full chart specification, stored as JSON inside a ```chart fenced block. +/// +/// Small charts keep their data inline; data-driven charts instead point at an +/// external CSV via [source] (kept as the living source of truth and packaged +/// alongside the deck like images). When a [source] is set the inline data is +/// stripped from the markdown on save and re-hydrated from the CSV on load. +class ChartSpec { + final ChartType type; + final String title; + final String? source; + final List x; + final List series; + + const ChartSpec({ + this.type = ChartType.bar, + this.title = '', + this.source, + this.x = const [], + this.series = const [], + }); + + bool get hasInlineData => x.isNotEmpty && series.isNotEmpty; + + ChartSpec copyWith({ + ChartType? type, + String? title, + String? source, + bool clearSource = false, + List? x, + List? series, + }) => ChartSpec( + type: type ?? this.type, + title: title ?? this.title, + source: clearSource ? null : (source ?? this.source), + x: x ?? this.x, + series: series ?? this.series, + ); + + /// Parse the JSON content of a ```chart block. Tolerant: returns a default + /// spec on any error so a malformed block never crashes rendering. + factory ChartSpec.parse(String raw) { + try { + final data = jsonDecode(raw.trim()); + if (data is! Map) return const ChartSpec(); + final src = (data['source'] as String?)?.trim(); + return ChartSpec( + type: _chartTypeFromName(data['type'] as String?), + title: (data['title'] ?? '').toString(), + source: (src == null || src.isEmpty) ? null : src, + x: [for (final v in (data['x'] as List? ?? const [])) v.toString()], + series: [ + for (final s in (data['series'] as List? ?? const [])) + ChartSeries.fromJson(Map.from(s as Map)), + ], + ); + } catch (_) { + return const ChartSpec(); + } + } + + /// Serialize back to the pretty JSON that lives in the markdown block. + /// When [forStorage] is true and a [source] is set, the (re-hydratable) + /// inline data is omitted so the .md stays lean and the CSV stays the source. + String toBlock({bool forStorage = false}) { + final map = {'type': type.name}; + if (title.isNotEmpty) map['title'] = title; + if (source != null) map['source'] = source; + final dropData = forStorage && source != null; + if (!dropData) { + if (x.isNotEmpty) map['x'] = x; + if (series.isNotEmpty) { + map['series'] = [for (final s in series) s.toJson()]; + } + } + return const JsonEncoder.withIndent(' ').convert(map); + } + + /// Return a copy with x/series taken from [csv]; keeps [source]. + ChartSpec withCsv(String csv) { + final parsed = parseCsv(csv); + return copyWith(x: parsed.$1, series: parsed.$2); + } +} + +/// Parse CSV text into (x labels, series). The first row is a header whose +/// first cell is ignored (the label column) and whose remaining cells are the +/// series names; each later row is `label, v1, v2, …`. +(List, List) parseCsv(String csv) { + final lines = csv + .replaceAll('\r\n', '\n') + .split('\n') + .where((l) => l.trim().isNotEmpty) + .toList(); + if (lines.isEmpty) return (const [], const []); + + List cells(String line) => line.split(',').map((c) => c.trim()).toList(); + + final header = cells(lines.first); + final seriesNames = header.length > 1 ? header.sublist(1) : []; + final x = []; + final seriesData = [for (final _ in seriesNames) []]; + + for (final line in lines.skip(1)) { + final row = cells(line); + if (row.isEmpty) continue; + x.add(row.first); + for (var i = 0; i < seriesNames.length; i++) { + final raw = (i + 1) < row.length ? row[i + 1] : ''; + seriesData[i].add(double.tryParse(raw) ?? 0); + } + } + + return ( + x, + [ + for (var i = 0; i < seriesNames.length; i++) + ChartSeries(name: seriesNames[i], data: seriesData[i]), + ], + ); +} diff --git a/lib/models/slide.dart b/lib/models/slide.dart index dfd2b53..2a84970 100644 --- a/lib/models/slide.dart +++ b/lib/models/slide.dart @@ -16,6 +16,7 @@ enum SlideType { table, freeMarkdown, code, + chart, } extension SlideTypeExtension on SlideType { @@ -45,6 +46,8 @@ extension SlideTypeExtension on SlideType { return 'Vrije Markdown'; case SlideType.code: return 'Broncode'; + case SlideType.chart: + return 'Grafiek'; } } @@ -74,6 +77,8 @@ extension SlideTypeExtension on SlideType { return ''; case SlideType.code: return 'code'; + case SlideType.chart: + return 'chart'; } } } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 29147dc..ac2de63 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart' show rootBundle; import '../models/deck.dart'; import '../l10n/app_localizations.dart'; import '../models/settings.dart'; +import '../models/chart.dart'; import '../models/slide.dart'; import 'annotation_codec.dart'; import 'caption_service.dart'; @@ -146,7 +147,7 @@ class FileService { } final deck = _md.parseDeck(raw, filePath: filePath); if (deck == null) return null; - final hydrated = await _hydrateImageCaptions(deck); + final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck)); // Re-attach the separate annotation layer from its sidecar, if present. if (content == null) { final sidecar = File(_sidecarPath(filePath)); @@ -179,6 +180,75 @@ class FileService { } } + /// Load the external CSV of any chart slide that links one, inlining the data + /// into the in-memory spec so the renderer has it. The markdown on disk keeps + /// only the `source` reference (data is stripped again on save). + Future _hydrateCharts(Deck deck) async { + if (deck.projectPath == null) return deck; + var changed = false; + final slides = []; + for (final s in deck.slides) { + if (s.type != SlideType.chart) { + slides.add(s); + continue; + } + final spec = ChartSpec.parse(s.customMarkdown); + if (spec.source == null || spec.hasInlineData) { + slides.add(s); + continue; + } + final abs = p.isAbsolute(spec.source!) + ? spec.source! + : p.join(deck.projectPath!, spec.source!); + final file = File(abs); + if (!await file.exists()) { + slides.add(s); + continue; + } + try { + final csv = await file.readAsString(); + slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock())); + changed = true; + } catch (_) { + slides.add(s); + } + } + return changed ? deck.copyWith(slides: slides) : deck; + } + + /// For packaging: add a chart's linked CSV under data/ and rewrite its source + /// path; if the CSV is missing, fall back to keeping the data inline. + Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) { + final spec = ChartSpec.parse(s.customMarkdown); + final src = spec.source; + if (src == null) return s; + final rel = addAsset(src, 'data'); + if (rel == null) { + return s.copyWith( + customMarkdown: spec.copyWith(clearSource: true).toBlock(), + ); + } + return s.copyWith( + customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true), + ); + } + + /// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new + /// location). A normal save is a no-op because source and dest coincide. + Future _copyChartData(Deck deck, String destDir) async { + for (final s in deck.slides) { + if (s.type != SlideType.chart) continue; + final src = ChartSpec.parse(s.customMarkdown).source; + if (src == null || p.isAbsolute(src) || deck.projectPath == null) continue; + final from = File(p.join(deck.projectPath!, src)); + final toPath = p.join(destDir, src); + if (from.path == toPath || !from.existsSync()) continue; + final out = File(toPath); + await out.parent.create(recursive: true); + await out.writeAsBytes(await from.readAsBytes(), flush: true); + } + } + Future saveDeckAs(Deck deck, {String? initialDirectory}) async { final safeName = deck.title .replaceAll(RegExp(r'[^\w\s-]'), '') @@ -245,12 +315,19 @@ class FileService { ), ]; + // Chart slides link their data via a CSV path inside the JSON block; bring + // the file along under data/ and rewrite the path to match. + final packedSlides = [ + for (final s in slides) + if (s.type == SlideType.chart) _packChartSlide(s, addAsset) else s, + ]; + final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos'); final profile = logoRel != null ? deck.themeProfile.copyWith(logoPath: logoRel) : deck.themeProfile; - final packDeck = deck.copyWith(slides: slides, themeProfile: profile); + final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile); // Markdown. final markdown = _md.generateDeck(packDeck); @@ -453,6 +530,9 @@ class FileService { logoAsset.cssUrl, ); + // Bring linked chart CSVs along when saving to a new location. + await _copyChartData(deck, dir); + final markdown = _md.generateDeck(updatedDeck); await File(filePath).writeAsString(markdown); // Annotations live in a separate sidecar so the Marp .md stays pure. diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart index 81a5d16..e384949 100644 --- a/lib/services/markdown_service.dart +++ b/lib/services/markdown_service.dart @@ -1,6 +1,7 @@ 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'; @@ -10,7 +11,7 @@ const _uuid = Uuid(); class MarkdownService { // ── Generation ────────────────────────────────────────────────────────────── - String generateDeck(Deck deck) { + String generateDeck(Deck deck, {bool inlineChartData = false}) { final buf = StringBuffer(); buf.writeln('---'); buf.writeln('marp: true'); @@ -49,7 +50,13 @@ class MarkdownService { buf.writeln('---'); buf.writeln(); } - buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile)); + buf.write( + generateSlide( + deck.slides[i], + themeProfile: deck.themeProfile, + inlineChartData: inlineChartData, + ), + ); } return buf.toString(); } @@ -158,7 +165,11 @@ class MarkdownService { return out.toString().replaceAll('
', '\n'); } - String generateSlide(Slide slide, {ThemeProfile? themeProfile}) { + String generateSlide( + Slide slide, { + ThemeProfile? themeProfile, + bool inlineChartData = false, + }) { final buf = StringBuffer(); final cssClass = slide.cssClass.isNotEmpty ? slide.cssClass @@ -330,6 +341,14 @@ class MarkdownService { 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) { @@ -650,6 +669,18 @@ class MarkdownService { ); } + // 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 = ''; @@ -908,4 +939,66 @@ class MarkdownService { tlp: tlp, ); } + + /// Parse a `` slide: the fenced ```chart JSON block and + /// an optional `