From ad45212a666b4f32c01762b31de12f327bf0a817 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 2 Jan 2024 16:00:10 -0800 Subject: [PATCH] content: Handle KaTeX math, with a rough preview Fixes: #359 --- lib/model/content.dart | 120 +++++++++++++++++++++++++++++++++ lib/widgets/content.dart | 65 ++++++++++++++---- test/model/content_test.dart | 31 +++++++++ test/widgets/content_test.dart | 22 ++++++ 4 files changed, 226 insertions(+), 12 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 0200bcfb61..f27753327c 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -289,6 +289,26 @@ class CodeBlockSpanNode extends InlineContentNode { } } +class MathBlockNode extends BlockContentNode { + const MathBlockNode({super.debugHtmlNode, required this.texSource}); + + final String texSource; + + @override + bool operator ==(Object other) { + return other is MathBlockNode && other.texSource == texSource; + } + + @override + int get hashCode => Object.hash('MathBlockNode', texSource); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('texSource', texSource)); + } +} + class ImageNode extends BlockContentNode { const ImageNode({super.debugHtmlNode, required this.srcUrl}); @@ -493,6 +513,26 @@ class ImageEmojiNode extends EmojiNode { } } +class MathInlineNode extends InlineContentNode { + const MathInlineNode({super.debugHtmlNode, required this.texSource}); + + final String texSource; + + @override + bool operator ==(Object other) { + return other is MathInlineNode && other.texSource == texSource; + } + + @override + int get hashCode => Object.hash('MathInlineNode', texSource); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('texSource', texSource)); + } +} + //////////////////////////////////////////////////////////////// // Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112 @@ -532,6 +572,60 @@ class _ZulipContentParser { /// and should be read or updated only inside an assertion. _ParserContext _debugParserContext = _ParserContext.block; + String? parseMath(dom.Element element, {required bool block}) { + assert(block == (_debugParserContext == _ParserContext.block)); + + final dom.Element katexElement; + if (!block) { + assert(element.localName == 'span' + && element.classes.length == 1 + && element.classes.contains('katex')); + + katexElement = element; + } else { + assert(element.localName == 'span' + && element.classes.length == 1 + && element.classes.contains('katex-display')); + + if (element.nodes.length != 1) return null; + final child = element.nodes.single; + if (child is! dom.Element) return null; + if (child.localName != 'span') return null; + if (child.classes.length != 1) return null; + if (!child.classes.contains('katex')) return null; + katexElement = child; + } + + // Expect two children span.katex-mathml, span.katex-html . + // For now we only care about the .katex-mathml . + if (katexElement.nodes.isEmpty) return null; + final child = katexElement.nodes.first; + if (child is! dom.Element) return null; + if (child.localName != 'span') return null; + if (child.classes.length != 1) return null; + if (!child.classes.contains('katex-mathml')) return null; + + if (child.nodes.length != 1) return null; + final grandchild = child.nodes.single; + if (grandchild is! dom.Element) return null; + if (grandchild.localName != 'math') return null; + if (grandchild.attributes['display'] != (block ? 'block' : null)) return null; + if (grandchild.namespaceUri != 'http://www.w3.org/1998/Math/MathML') return null; + + if (grandchild.nodes.length != 1) return null; + final greatgrand = grandchild.nodes.single; + if (greatgrand is! dom.Element) return null; + if (greatgrand.localName != 'semantics') return null; + + if (greatgrand.nodes.isEmpty) return null; + final descendant4 = greatgrand.nodes.last; + if (descendant4 is! dom.Element) return null; + if (descendant4.localName != 'annotation') return null; + if (descendant4.attributes['encoding'] != 'application/x-tex') return null; + + return descendant4.text.trim(); + } + /// The links found so far in the current block inline container. /// /// Empty is represented as null. @@ -623,6 +717,14 @@ class _ZulipContentParser { return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode); } + if (localName == 'span' + && classes.length == 1 + && classes.contains('katex')) { + final texSource = parseMath(element, block: false); + if (texSource == null) return unimplemented(); + return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode); + } + // TODO more types of node return unimplemented(); } @@ -792,6 +894,24 @@ class _ZulipContentParser { } if (localName == 'p' && classes.isEmpty) { + // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. + if (element.nodes case [dom.Element(localName: 'span') && var child, ...]) { + if (child.classes.length == 1 + && child.classes.contains('katex-display')) { + if (element.nodes case [_] + || [_, dom.Element(localName: 'br'), + dom.Text(text: "\n")]) { + // This might be too specific; we'll find out when we do #190. + // The case with the `
\n` can happen when at the end of a quote; + // it seems like a glitch in the server's Markdown processing, + // so hopefully there just aren't any further such glitches. + final texSource = parseMath(child, block: true); + if (texSource == null) return UnimplementedBlockContentNode(htmlNode: node); + return MathBlockNode(texSource: texSource, debugHtmlNode: debugHtmlNode); + } + } + } + final parsed = parseBlockInline(element.nodes); return ParagraphNode(debugHtmlNode: debugHtmlNode, links: parsed.links, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index bfcbd87d05..3b83e21131 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -80,6 +80,8 @@ class BlockContentList extends StatelessWidget { return ListNodeWidget(node: node); } else if (node is CodeBlockNode) { return CodeBlock(node: node); + } else if (node is MathBlockNode) { + return MathBlock(node: node); } else if (node is ImageNode) { return MessageImage(node: node); } else if (node is UnimplementedBlockContentNode) { @@ -265,20 +267,13 @@ class CodeBlock extends StatelessWidget { final CodeBlockNode node; + static final _borderColor = const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor(); + @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - border: Border.all( - width: 1, - color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor()), - borderRadius: BorderRadius.circular(4)), - child: SingleChildScrollViewWithScrollbar( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), - child: Text.rich(_buildNodes(node.spans))))); + return _CodeBlockContainer( + borderColor: _borderColor, + child: Text.rich(_buildNodes(node.spans))); } InlineSpan _buildNodes(List nodes) { @@ -292,6 +287,29 @@ class CodeBlock extends StatelessWidget { } } +class _CodeBlockContainer extends StatelessWidget { + const _CodeBlockContainer({required this.borderColor, required this.child}); + + final Color borderColor; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + width: 1, + color: borderColor), + borderRadius: BorderRadius.circular(4)), + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), + child: child))); + } +} + class SingleChildScrollViewWithScrollbar extends StatefulWidget { const SingleChildScrollViewWithScrollbar( {super.key, required this.scrollDirection, required this.child}); @@ -319,6 +337,23 @@ class _SingleChildScrollViewWithScrollbarState } } +class MathBlock extends StatelessWidget { + const MathBlock({super.key, required this.node}); + + final MathBlockNode node; + + static final _borderColor = const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(); + + @override + Widget build(BuildContext context) { + return _CodeBlockContainer( + borderColor: _borderColor, + child: Text.rich(TextSpan( + style: _kCodeBlockStyle, + children: [TextSpan(text: node.texSource)]))); + } +} + // // Inline layout. // @@ -475,6 +510,9 @@ class _InlineContentBuilder { } else if (node is ImageEmojiNode) { return WidgetSpan(alignment: PlaceholderAlignment.middle, child: MessageImageEmoji(node: node)); + } else if (node is MathInlineNode) { + return TextSpan(style: _kInlineMathStyle, + children: [TextSpan(text: node.texSource)]); } else if (node is UnimplementedInlineContentNode) { return _errorUnimplemented(node); } else { @@ -544,6 +582,9 @@ class _InlineContentBuilder { } } +final _kInlineMathStyle = _kInlineCodeStyle.merge(TextStyle( + backgroundColor: const HSLColor.fromAHSL(1, 240, 0.4, 0.93).toColor())); + final _kInlineCodeStyle = kMonospaceTextStyle .merge(const TextStyle( backgroundColor: Color(0xffeeeeee), diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 50cea9da2b..e0ed1dc61c 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -168,6 +168,14 @@ void main() { const ImageEmojiNode( src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:')); + testParseInline('parse inline math', + // "$$ \\lambda $$" + '

' + 'λ' + ' \\lambda ' + '

', + const MathInlineNode(texSource: r'\lambda')); + // // Block content. // @@ -390,6 +398,29 @@ void main() { '\n'), ]); + testParse('parse math block', + // "```math\n\\lambda\n```" + '

' + 'λ' + '\\lambda' + '

', + [const MathBlockNode(texSource: r'\lambda')]); + + testParse('parse math block in quote', + // There's sometimes a quirky extra `
\n` at the end of the `

` that + // encloses the math block. In particular this happens when the math block + // is the last thing in the quote; though not in a doubly-nested quote; + // and there might be further wrinkles yet to be found. Some experiments: + // https://chat.zulip.org/#narrow/stream/7-test-here/topic/content/near/1715732 + // "````quote\n```math\n\\lambda\n```\n````" + '

\n

' + '' + 'λ' + '\\lambda' + '' + '
\n

\n
', + [const QuotationNode([MathBlockNode(texSource: r'\lambda')])]); + testParse('parse image', // "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3" '
' diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index c4c19b75ff..82bc600c2c 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -62,6 +62,17 @@ void main() { }); }); + testWidgets('MathBlock', (tester) async { + // "```math\n\\lambda\n```" + await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent( + '

' + 'λ' + '\\lambda' + '

', + ).nodes))); + tester.widget(find.text(r'\lambda')); + }); + Future tapText(WidgetTester tester, Finder textFinder) async { final height = tester.getSize(textFinder).height; final target = tester.getTopLeft(textFinder) @@ -240,6 +251,17 @@ void main() { }); }); + testWidgets('MathInlineNode', (tester) async { + // "$$ \\lambda $$" + await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent( + '

' + 'λ' + ' \\lambda ' + '

', + ).nodes))); + tester.widget(find.text(r'\lambda')); + }); + group('RealmContentNetworkImage', () { final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);