Skip to content

Commit 6157fc0

Browse files
gnpricechrisbobbe
authored andcommitted
content: Handle KaTeX math, with a rough preview
Fixes: #359
1 parent 241518c commit 6157fc0

File tree

4 files changed

+226
-12
lines changed

4 files changed

+226
-12
lines changed

lib/model/content.dart

Lines changed: 120 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 descendant4 = greatgrand.nodes.last;
622+
if (descendant4 is! dom.Element) return null;
623+
if (descendant4.localName != 'annotation') return null;
624+
if (descendant4.attributes['encoding'] != 'application/x-tex') return null;
625+
626+
return descendant4.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,24 @@ 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+
if (element.nodes case [_]
902+
|| [_, dom.Element(localName: 'br'),
903+
dom.Text(text: "\n")]) {
904+
// This might be too specific; we'll find out when we do #190.
905+
// The case with the `<br>\n` can happen when at the end of a quote;
906+
// it seems like a glitch in the server's Markdown processing,
907+
// so hopefully there just aren't any further such glitches.
908+
final texSource = parseMath(child, block: true);
909+
if (texSource == null) return UnimplementedBlockContentNode(htmlNode: node);
910+
return MathBlockNode(texSource: texSource, debugHtmlNode: debugHtmlNode);
911+
}
912+
}
913+
}
914+
795915
final parsed = parseBlockInline(element.nodes);
796916
return ParagraphNode(debugHtmlNode: debugHtmlNode,
797917
links: parsed.links,

lib/widgets/content.dart

Lines changed: 53 additions & 12 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,20 +267,13 @@ class CodeBlock extends StatelessWidget {
265267

266268
final CodeBlockNode node;
267269

270+
static final _borderColor = const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor();
271+
268272
@override
269273
Widget build(BuildContext context) {
270-
return Container(
271-
decoration: BoxDecoration(
272-
color: Colors.white,
273-
border: Border.all(
274-
width: 1,
275-
color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor()),
276-
borderRadius: BorderRadius.circular(4)),
277-
child: SingleChildScrollViewWithScrollbar(
278-
scrollDirection: Axis.horizontal,
279-
child: Padding(
280-
padding: const EdgeInsets.fromLTRB(7, 5, 7, 3),
281-
child: Text.rich(_buildNodes(node.spans)))));
274+
return _CodeBlockContainer(
275+
borderColor: _borderColor,
276+
child: Text.rich(_buildNodes(node.spans)));
282277
}
283278

284279
InlineSpan _buildNodes(List<CodeBlockSpanNode> nodes) {
@@ -292,6 +287,29 @@ class CodeBlock extends StatelessWidget {
292287
}
293288
}
294289

290+
class _CodeBlockContainer extends StatelessWidget {
291+
const _CodeBlockContainer({required this.borderColor, required this.child});
292+
293+
final Color borderColor;
294+
final Widget child;
295+
296+
@override
297+
Widget build(BuildContext context) {
298+
return Container(
299+
decoration: BoxDecoration(
300+
color: Colors.white,
301+
border: Border.all(
302+
width: 1,
303+
color: borderColor),
304+
borderRadius: BorderRadius.circular(4)),
305+
child: SingleChildScrollViewWithScrollbar(
306+
scrollDirection: Axis.horizontal,
307+
child: Padding(
308+
padding: const EdgeInsets.fromLTRB(7, 5, 7, 3),
309+
child: child)));
310+
}
311+
}
312+
295313
class SingleChildScrollViewWithScrollbar extends StatefulWidget {
296314
const SingleChildScrollViewWithScrollbar(
297315
{super.key, required this.scrollDirection, required this.child});
@@ -319,6 +337,23 @@ class _SingleChildScrollViewWithScrollbarState
319337
}
320338
}
321339

340+
class MathBlock extends StatelessWidget {
341+
const MathBlock({super.key, required this.node});
342+
343+
final MathBlockNode node;
344+
345+
static final _borderColor = const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor();
346+
347+
@override
348+
Widget build(BuildContext context) {
349+
return _CodeBlockContainer(
350+
borderColor: _borderColor,
351+
child: Text.rich(TextSpan(
352+
style: _kCodeBlockStyle,
353+
children: [TextSpan(text: node.texSource)])));
354+
}
355+
}
356+
322357
//
323358
// Inline layout.
324359
//
@@ -475,6 +510,9 @@ class _InlineContentBuilder {
475510
} else if (node is ImageEmojiNode) {
476511
return WidgetSpan(alignment: PlaceholderAlignment.middle,
477512
child: MessageImageEmoji(node: node));
513+
} else if (node is MathInlineNode) {
514+
return TextSpan(style: _kInlineMathStyle,
515+
children: [TextSpan(text: node.texSource)]);
478516
} else if (node is UnimplementedInlineContentNode) {
479517
return _errorUnimplemented(node);
480518
} else {
@@ -544,6 +582,9 @@ class _InlineContentBuilder {
544582
}
545583
}
546584

585+
final _kInlineMathStyle = _kInlineCodeStyle.merge(TextStyle(
586+
backgroundColor: const HSLColor.fromAHSL(1, 240, 0.4, 0.93).toColor()));
587+
547588
final _kInlineCodeStyle = kMonospaceTextStyle
548589
.merge(const TextStyle(
549590
backgroundColor: Color(0xffeeeeee),

test/model/content_test.dart

Lines changed: 31 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,29 @@ 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+
409+
testParse('parse math block in quote',
410+
// There's sometimes a quirky extra `<br>\n` at the end of the `<p>` that
411+
// encloses the math block. In particular this happens when the math block
412+
// is the last thing in the quote; though not in a doubly-nested quote;
413+
// and there might be further wrinkles yet to be found. Some experiments:
414+
// https://chat.zulip.org/#narrow/stream/7-test-here/topic/content/near/1715732
415+
// "````quote\n```math\n\\lambda\n```\n````"
416+
'<blockquote>\n<p>'
417+
'<span class="katex-display"><span class="katex">'
418+
'<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>λ</mi></mrow>'
419+
'<annotation encoding="application/x-tex">\\lambda</annotation></semantics></math></span>'
420+
'<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>'
421+
'<br>\n</p>\n</blockquote>',
422+
[const QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
423+
393424
testParse('parse image',
394425
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
395426
'<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
Future<void> tapText(WidgetTester tester, Finder textFinder) async {
6677
final height = tester.getSize(textFinder).height;
6778
final target = tester.getTopLeft(textFinder)
@@ -240,6 +251,17 @@ void main() {
240251
});
241252
});
242253

254+
testWidgets('MathInlineNode', (tester) async {
255+
// "$$ \\lambda $$"
256+
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(
257+
'<p><span class="katex">'
258+
'<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
259+
'<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math></span>'
260+
'<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>',
261+
).nodes)));
262+
tester.widget(find.text(r'\lambda'));
263+
});
264+
243265
group('RealmContentNetworkImage', () {
244266
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
245267

0 commit comments

Comments
 (0)