Skip to content

Commit 52b7b96

Browse files
committed
content: Handle KaTeX math, with a rough preview
Fixes: #359
1 parent f3de9a0 commit 52b7b96

File tree

4 files changed

+198
-11
lines changed

4 files changed

+198
-11
lines changed

lib/model/content.dart

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,26 @@ class CodeBlockSpanNode extends InlineContentNode {
289289
}
290290
}
291291

292+
class MathBlockNode extends BlockContentNode {
293+
const MathBlockNode({super.debugHtmlNode, required this.texSource});
294+
295+
final String texSource;
296+
297+
@override
298+
bool operator ==(Object other) {
299+
return other is MathBlockNode && other.texSource == texSource;
300+
}
301+
302+
@override
303+
int get hashCode => Object.hash('MathBlockNode', texSource);
304+
305+
@override
306+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
307+
super.debugFillProperties(properties);
308+
properties.add(StringProperty('texSource', texSource));
309+
}
310+
}
311+
292312
class ImageNode extends BlockContentNode {
293313
const ImageNode({super.debugHtmlNode, required this.srcUrl});
294314

@@ -493,6 +513,26 @@ class ImageEmojiNode extends EmojiNode {
493513
}
494514
}
495515

516+
class MathInlineNode extends InlineContentNode {
517+
const MathInlineNode({super.debugHtmlNode, required this.texSource});
518+
519+
final String texSource;
520+
521+
@override
522+
bool operator ==(Object other) {
523+
return other is MathInlineNode && other.texSource == texSource;
524+
}
525+
526+
@override
527+
int get hashCode => Object.hash('MathInlineNode', texSource);
528+
529+
@override
530+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
531+
super.debugFillProperties(properties);
532+
properties.add(StringProperty('texSource', texSource));
533+
}
534+
}
535+
496536
////////////////////////////////////////////////////////////////
497537
498538
// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
@@ -532,6 +572,60 @@ class _ZulipContentParser {
532572
/// and should be read or updated only inside an assertion.
533573
_ParserContext _debugParserContext = _ParserContext.block;
534574

575+
String? parseMath(dom.Element element, {required bool block}) {
576+
assert(block == (_debugParserContext == _ParserContext.block));
577+
578+
final dom.Element katexElement;
579+
if (!block) {
580+
assert(element.localName == 'span'
581+
&& element.classes.length == 1
582+
&& element.classes.contains('katex'));
583+
584+
katexElement = element;
585+
} else {
586+
assert(element.localName == 'span'
587+
&& element.classes.length == 1
588+
&& element.classes.contains('katex-display'));
589+
590+
if (element.nodes.length != 1) return null;
591+
final child = element.nodes.single;
592+
if (child is! dom.Element) return null;
593+
if (child.localName != 'span') return null;
594+
if (child.classes.length != 1) return null;
595+
if (!child.classes.contains('katex')) return null;
596+
katexElement = child;
597+
}
598+
599+
// Expect two children span.katex-mathml, span.katex-html .
600+
// For now we only care about the .katex-mathml .
601+
if (katexElement.nodes.isEmpty) return null;
602+
final child = katexElement.nodes.first;
603+
if (child is! dom.Element) return null;
604+
if (child.localName != 'span') return null;
605+
if (child.classes.length != 1) return null;
606+
if (!child.classes.contains('katex-mathml')) return null;
607+
608+
if (child.nodes.length != 1) return null;
609+
final grandchild = child.nodes.single;
610+
if (grandchild is! dom.Element) return null;
611+
if (grandchild.localName != 'math') return null;
612+
if (grandchild.attributes['display'] != (block ? 'block' : null)) return null;
613+
if (grandchild.namespaceUri != 'http://www.w3.org/1998/Math/MathML') return null;
614+
615+
if (grandchild.nodes.length != 1) return null;
616+
final greatgrand = grandchild.nodes.single;
617+
if (greatgrand is! dom.Element) return null;
618+
if (greatgrand.localName != 'semantics') return null;
619+
620+
if (greatgrand.nodes.isEmpty) return null;
621+
final child4 = greatgrand.nodes.last;
622+
if (child4 is! dom.Element) return null;
623+
if (child4.localName != 'annotation') return null;
624+
if (child4.attributes['encoding'] != 'application/x-tex') return null;
625+
626+
return child4.text.trim();
627+
}
628+
535629
/// The links found so far in the current block inline container.
536630
///
537631
/// Empty is represented as null.
@@ -623,6 +717,14 @@ class _ZulipContentParser {
623717
return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode);
624718
}
625719

720+
if (localName == 'span'
721+
&& classes.length == 1
722+
&& classes.contains('katex')) {
723+
final texSource = parseMath(element, block: false);
724+
if (texSource == null) return unimplemented();
725+
return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode);
726+
}
727+
626728
// TODO more types of node
627729
return unimplemented();
628730
}
@@ -792,6 +894,16 @@ class _ZulipContentParser {
792894
}
793895

794896
if (localName == 'p' && classes.isEmpty) {
897+
// Oddly, the way a math block gets encoded in Zulip HTML is inside a <p>.
898+
if (element.nodes case [dom.Element(localName: 'span') && var child]) {
899+
if (child.classes.length == 1
900+
&& child.classes.contains('katex-display')) {
901+
final texSource = parseMath(child, block: true);
902+
if (texSource == null) return UnimplementedBlockContentNode(htmlNode: node);
903+
return MathBlockNode(texSource: texSource, debugHtmlNode: debugHtmlNode);
904+
}
905+
}
906+
795907
final parsed = parseBlockInline(element.nodes);
796908
return ParagraphNode(debugHtmlNode: debugHtmlNode,
797909
links: parsed.links,

lib/widgets/content.dart

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ class BlockContentList extends StatelessWidget {
8080
return ListNodeWidget(node: node);
8181
} else if (node is CodeBlockNode) {
8282
return CodeBlock(node: node);
83+
} else if (node is MathBlockNode) {
84+
return MathBlock(node: node);
8385
} else if (node is ImageNode) {
8486
return MessageImage(node: node);
8587
} else if (node is UnimplementedBlockContentNode) {
@@ -265,6 +267,27 @@ class CodeBlock extends StatelessWidget {
265267

266268
final CodeBlockNode node;
267269

270+
@override
271+
Widget build(BuildContext context) {
272+
return _CodeBlockContainer(child: Text.rich(_buildNodes(node.spans)));
273+
}
274+
275+
InlineSpan _buildNodes(List<CodeBlockSpanNode> nodes) {
276+
return TextSpan(
277+
style: _kCodeBlockStyle,
278+
children: nodes.map(_buildNode).toList(growable: false));
279+
}
280+
281+
InlineSpan _buildNode(CodeBlockSpanNode node) {
282+
return TextSpan(text: node.text, style: codeBlockTextStyle(node.type));
283+
}
284+
}
285+
286+
class _CodeBlockContainer extends StatelessWidget {
287+
const _CodeBlockContainer({required this.child});
288+
289+
final Widget child;
290+
268291
@override
269292
Widget build(BuildContext context) {
270293
return Container(
@@ -278,17 +301,7 @@ class CodeBlock extends StatelessWidget {
278301
scrollDirection: Axis.horizontal,
279302
child: Padding(
280303
padding: const EdgeInsets.fromLTRB(7, 5, 7, 3),
281-
child: Text.rich(_buildNodes(node.spans)))));
282-
}
283-
284-
InlineSpan _buildNodes(List<CodeBlockSpanNode> nodes) {
285-
return TextSpan(
286-
style: _kCodeBlockStyle,
287-
children: nodes.map(_buildNode).toList(growable: false));
288-
}
289-
290-
InlineSpan _buildNode(CodeBlockSpanNode node) {
291-
return TextSpan(text: node.text, style: codeBlockTextStyle(node.type));
304+
child: child)));
292305
}
293306
}
294307

@@ -319,6 +332,24 @@ class _SingleChildScrollViewWithScrollbarState
319332
}
320333
}
321334

335+
class MathBlock extends StatelessWidget {
336+
const MathBlock({super.key, required this.node});
337+
338+
final MathBlockNode node;
339+
340+
static final _markerStyle = TextStyle(color: Colors.black.withOpacity(0.4));
341+
342+
@override
343+
Widget build(BuildContext context) {
344+
return _CodeBlockContainer(
345+
child: Text.rich(TextSpan(style: _kCodeBlockStyle, children: [
346+
TextSpan(text: r'\[ ', style: _markerStyle),
347+
TextSpan(text: node.texSource.replaceAll("\n", "\n ")),
348+
TextSpan(text: r' \]', style: _markerStyle),
349+
])));
350+
}
351+
}
352+
322353
//
323354
// Inline layout.
324355
//
@@ -475,6 +506,12 @@ class _InlineContentBuilder {
475506
} else if (node is ImageEmojiNode) {
476507
return WidgetSpan(alignment: PlaceholderAlignment.middle,
477508
child: MessageImageEmoji(node: node));
509+
} else if (node is MathInlineNode) {
510+
return TextSpan(style: _kInlineCodeStyle, children: [
511+
TextSpan(text: r'$', style: MathBlock._markerStyle),
512+
TextSpan(text: node.texSource),
513+
TextSpan(text: r'$', style: MathBlock._markerStyle),
514+
]);
478515
} else if (node is UnimplementedInlineContentNode) {
479516
return _errorUnimplemented(node);
480517
} else {

test/model/content_test.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ void main() {
168168
const ImageEmojiNode(
169169
src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:'));
170170

171+
testParseInline('parse inline math',
172+
// "$$ \\lambda $$"
173+
'<p><span class="katex">'
174+
'<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
175+
'<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math></span>'
176+
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></p>',
177+
const MathInlineNode(texSource: r'\lambda'));
178+
171179
//
172180
// Block content.
173181
//
@@ -390,6 +398,14 @@ void main() {
390398
'\n</code></pre></div>'),
391399
]);
392400

401+
testParse('parse math block',
402+
// "```math\n\\lambda\n```"
403+
'<p><span class="katex-display"><span class="katex">'
404+
'<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>λ</mi></mrow>'
405+
'<annotation encoding="application/x-tex">\\lambda</annotation></semantics></math></span>'
406+
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></span></p>',
407+
[const MathBlockNode(texSource: r'\lambda')]);
408+
393409
testParse('parse image',
394410
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
395411
'<div class="message_inline_image">'

test/widgets/content_test.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ void main() {
6262
});
6363
});
6464

65+
testWidgets('MathBlock', (tester) async {
66+
// "```math\n\\lambda\n```"
67+
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(
68+
'<p><span class="katex-display"><span class="katex">'
69+
'<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>λ</mi></mrow>'
70+
'<annotation encoding="application/x-tex">\\lambda</annotation></semantics></math></span>'
71+
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></span></p>',
72+
).nodes)));
73+
tester.widget(find.text(r'\[ \lambda \]'));
74+
});
75+
6576
group('LinkNode interactions', () {
6677
// The Flutter test font uses square glyphs, so width equals height:
6778
// https://github.com/flutter/flutter/wiki/Flutter-Test-Fonts
@@ -233,6 +244,17 @@ void main() {
233244
});
234245
});
235246

247+
testWidgets('MathInlineNode', (tester) async {
248+
// "$$ \\lambda $$"
249+
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(
250+
'<p><span class="katex">'
251+
'<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
252+
'<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math></span>'
253+
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></p>',
254+
).nodes)));
255+
tester.widget(find.text(r'$\lambda$'));
256+
});
257+
236258
group('RealmContentNetworkImage', () {
237259
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
238260

0 commit comments

Comments
 (0)