Render fenced code (syntax highlight) and LaTeX math in free Markdown

The free-Markdown slide preview now parses block-level content:
- ```lang fenced code blocks render with highlight.js colouring
  (flutter_highlight); unknown languages fall back to plain monospace
  instead of throwing.
- $$…$$ display math renders via flutter_math_fork (KaTeX), with a plain
  fallback on parse errors.

Because the preview feeds the export rasterizer, code and math now also
appear in PDF/PPTX output. Adds flutter_highlight, flutter_math_fork and
highlight dependencies.

Tests: code block highlighted, math rendered, unknown language safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-04 00:59:14 +02:00
parent 4f2f5fea7c
commit 169a7a8bff
4 changed files with 306 additions and 47 deletions

View file

@ -1,5 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../models/deck.dart'; import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
@ -1849,7 +1854,6 @@ class _MarkdownPreview extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pad = w * 0.07; final pad = w * 0.07;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final lines = slide.customMarkdown.split('\n');
return Container( return Container(
color: Colors.white, color: Colors.white,
@ -1869,8 +1873,69 @@ class _MarkdownPreview extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: lines.take(20).map((line) { children: _buildBlocks(context),
),
),
),
),
),
);
}
/// Parse the free Markdown into block widgets: fenced ```code``` (syntax
/// highlighted), `$$$$` display math, and ordinary heading/bullet/text lines.
List<Widget> _buildBlocks(BuildContext context) {
final link = _hexColor(profile.accentColor); final link = _hexColor(profile.accentColor);
final lines = slide.customMarkdown.split('\n');
final widgets = <Widget>[];
var i = 0;
// Cap rendered blocks so a huge slide can't blow up layout (the preview is a
// thumbnail; FittedBox scales the rest down).
while (i < lines.length && widgets.length < 24) {
final line = lines[i];
// Fenced code block: ``` or ```language ```
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
final language = fence.group(1)!.trim();
final code = <String>[];
i++;
while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) {
code.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing fence
widgets.add(_codeBlock(code.join('\n'), language));
continue;
}
// Display math fenced by lines containing only `$$`.
if (line.trim() == r'$$') {
final tex = <String>[];
i++;
while (i < lines.length && lines[i].trim() != r'$$') {
tex.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing $$
widgets.add(_mathBlock(tex.join('\n')));
continue;
}
// Single-line display math: $$ $$
final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line);
if (oneLine != null) {
widgets.add(_mathBlock(oneLine.group(1)!.trim()));
i++;
continue;
}
widgets.add(_textLine(context, line, link));
i++;
}
return widgets;
}
Widget _textLine(BuildContext context, String line, Color link) {
if (line.startsWith('# ')) { if (line.startsWith('# ')) {
return _md( return _md(
context, context,
@ -1891,10 +1956,7 @@ class _MarkdownPreview extends StatelessWidget {
line.substring(3), line.substring(3),
_applyFont( _applyFont(
font, font,
TextStyle( TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600),
fontSize: w * 0.03,
fontWeight: FontWeight.w600,
),
), ),
linkColor: link, linkColor: link,
); );
@ -1914,9 +1976,53 @@ class _MarkdownPreview extends StatelessWidget {
_applyFont(font, TextStyle(fontSize: w * 0.024)), _applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link, linkColor: link,
); );
}).toList(), }
),
Widget _codeBlock(String code, String language) {
_ensureHighlightLanguages();
final mono = TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.02,
height: 1.3,
color: const Color(0xFF24292E),
);
// HighlightView throws on an unregistered language, so only use it for ones
// we actually know; otherwise fall back to plain monospace.
final known = language.isNotEmpty && allLanguages.containsKey(language);
final Widget content = known
? HighlightView(
code,
language: language,
theme: githubTheme,
padding: EdgeInsets.zero,
textStyle: mono,
)
: Text(code, style: mono);
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: w * 0.008),
padding: EdgeInsets.all(w * 0.018),
decoration: BoxDecoration(
color: const Color(0xFFF6F8FA),
borderRadius: BorderRadius.circular(w * 0.008),
border: Border.all(color: const Color(0xFFE1E4E8)),
), ),
child: content,
);
}
Widget _mathBlock(String tex) {
return Padding(
padding: EdgeInsets.symmetric(vertical: w * 0.012),
child: Math.tex(
tex,
textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)),
onErrorFallback: (err) => Text(
'\$\$$tex\$\$',
style: TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.022,
color: Colors.red,
), ),
), ),
), ),
@ -1924,6 +2030,15 @@ class _MarkdownPreview extends StatelessWidget {
} }
} }
/// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing.
bool _highlightReady = false;
void _ensureHighlightLanguages() {
if (_highlightReady) return;
allLanguages.forEach(highlight.registerLanguage);
_highlightReady = true;
}
// Shared helper // Shared helper
/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain. /// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain.

View file

@ -214,6 +214,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_highlight:
dependency: "direct main"
description:
name: flutter_highlight
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -222,6 +230,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_math_fork:
dependency: "direct main"
description:
name: flutter_math_fork
sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -238,6 +254,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.3.1"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -264,6 +288,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
highlight:
dependency: "direct main"
description:
name: highlight
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@ -280,6 +312,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.15.6" version: "0.15.6"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -408,6 +448,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:
@ -552,6 +600,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.5.0"
provider:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -805,6 +861,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.17" version: "0.6.17"
tuple:
dependency: transitive
description:
name: tuple
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
url: "https://pub.dev"
source: hosted
version: "2.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -893,6 +957,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" version: "4.5.3"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
url: "https://pub.dev"
source: hosted
version: "1.2.5"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View file

@ -25,6 +25,9 @@ dependencies:
url_launcher: ^6.3.0 url_launcher: ^6.3.0
desktop_drop: ^0.7.1 desktop_drop: ^0.7.1
image: ^4.8.0 image: ^4.8.0
flutter_highlight: ^0.7.0
flutter_math_fork: ^0.7.4
highlight: ^0.7.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
Widget _host(Slide slide) {
return Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(slide: slide),
),
),
);
}
void main() {
testWidgets('free Markdown renders a highlighted code block', (tester) async {
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
customMarkdown: '# Demo\n\n```dart\nvoid main() => print(42);\n```\n',
);
await tester.pumpWidget(_host(slide));
expect(find.byType(HighlightView), findsOneWidget);
});
testWidgets('free Markdown renders display math', (tester) async {
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
customMarkdown: 'Stelling:\n\n\$\$E = mc^2\$\$\n',
);
await tester.pumpWidget(_host(slide));
expect(find.byType(Math), findsOneWidget);
});
testWidgets('an unknown code language falls back without throwing', (
tester,
) async {
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
customMarkdown: '```nonexistentlang\nsome code\n```\n',
);
await tester.pumpWidget(_host(slide));
// No HighlightView (unknown language) and no exception during build.
expect(find.byType(HighlightView), findsNothing);
expect(tester.takeException(), isNull);
expect(find.text('some code'), findsOneWidget);
});
}