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:
parent
4f2f5fea7c
commit
169a7a8bff
4 changed files with 306 additions and 47 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
88
pubspec.lock
88
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
53
test/free_markdown_preview_test.dart
Normal file
53
test/free_markdown_preview_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue