Skip to content

Commit 6ec107b

Browse files
content: Add KaTeX spans parser, initial rendering; w/o styles
With this, if the new experimental flag is enabled, the result will be really basic rendering of each text character in KaTeX spans.
1 parent 1fca686 commit 6ec107b

File tree

9 files changed

+1415
-109
lines changed

9 files changed

+1415
-109
lines changed

lib/model/content.dart

Lines changed: 61 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:html/parser.dart';
66
import '../api/model/model.dart';
77
import '../api/model/submessage.dart';
88
import 'code_block.dart';
9+
import 'katex.dart';
910

1011
/// A node in a parse tree for Zulip message-style content.
1112
///
@@ -341,22 +342,52 @@ class CodeBlockSpanNode extends ContentNode {
341342
}
342343

343344
class MathBlockNode extends BlockContentNode {
344-
const MathBlockNode({super.debugHtmlNode, required this.texSource});
345+
const MathBlockNode({
346+
super.debugHtmlNode,
347+
required this.texSource,
348+
required this.nodes,
349+
});
345350

346351
final String texSource;
352+
final List<KatexNode>? nodes;
347353

348354
@override
349-
bool operator ==(Object other) {
350-
return other is MathBlockNode && other.texSource == texSource;
355+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
356+
super.debugFillProperties(properties);
357+
properties.add(StringProperty('texSource', texSource));
351358
}
352359

353360
@override
354-
int get hashCode => Object.hash('MathBlockNode', texSource);
361+
List<DiagnosticsNode> debugDescribeChildren() {
362+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
363+
}
364+
}
365+
366+
class KatexNode extends ContentNode {
367+
const KatexNode({
368+
required this.text,
369+
required this.nodes,
370+
super.debugHtmlNode,
371+
}) : assert((text != null) ^ (nodes != null));
372+
373+
/// The text or a single character this KaTeX span contains, generally
374+
/// observed to be the leaf node in the KaTeX HTML tree.
375+
/// It will be null if this span has child nodes.
376+
final String? text;
377+
378+
/// The child nodes of this span in the KaTeX HTML tree.
379+
/// It will be null if this span is a text node.
380+
final List<KatexNode>? nodes;
355381

356382
@override
357383
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
358384
super.debugFillProperties(properties);
359-
properties.add(StringProperty('texSource', texSource));
385+
properties.add(StringProperty('text', text));
386+
}
387+
388+
@override
389+
List<DiagnosticsNode> debugDescribeChildren() {
390+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
360391
}
361392
}
362393

@@ -822,23 +853,25 @@ class ImageEmojiNode extends EmojiNode {
822853
}
823854

824855
class MathInlineNode extends InlineContentNode {
825-
const MathInlineNode({super.debugHtmlNode, required this.texSource});
856+
const MathInlineNode({
857+
super.debugHtmlNode,
858+
required this.texSource,
859+
required this.nodes,
860+
});
826861

827862
final String texSource;
828-
829-
@override
830-
bool operator ==(Object other) {
831-
return other is MathInlineNode && other.texSource == texSource;
832-
}
833-
834-
@override
835-
int get hashCode => Object.hash('MathInlineNode', texSource);
863+
final List<KatexNode>? nodes;
836864

837865
@override
838866
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
839867
super.debugFillProperties(properties);
840868
properties.add(StringProperty('texSource', texSource));
841869
}
870+
871+
@override
872+
List<DiagnosticsNode> debugDescribeChildren() {
873+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
874+
}
842875
}
843876

844877
class GlobalTimeNode extends InlineContentNode {
@@ -864,59 +897,6 @@ class GlobalTimeNode extends InlineContentNode {
864897

865898
////////////////////////////////////////////////////////////////
866899
867-
String? _parseMath(dom.Element element, {required bool block}) {
868-
final dom.Element katexElement;
869-
if (!block) {
870-
assert(element.localName == 'span' && element.className == 'katex');
871-
872-
katexElement = element;
873-
} else {
874-
assert(element.localName == 'span' && element.className == 'katex-display');
875-
876-
if (element.nodes case [
877-
dom.Element(localName: 'span', className: 'katex') && final child,
878-
]) {
879-
katexElement = child;
880-
} else {
881-
return null;
882-
}
883-
}
884-
885-
// Expect two children span.katex-mathml, span.katex-html .
886-
// For now we only care about the .katex-mathml .
887-
if (katexElement.nodes case [
888-
dom.Element(localName: 'span', className: 'katex-mathml', nodes: [
889-
dom.Element(
890-
localName: 'math',
891-
namespaceUri: 'http://www.w3.org/1998/Math/MathML')
892-
&& final mathElement,
893-
]),
894-
...
895-
]) {
896-
if (mathElement.attributes['display'] != (block ? 'block' : null)) {
897-
return null;
898-
}
899-
900-
final String texSource;
901-
if (mathElement.nodes case [
902-
dom.Element(localName: 'semantics', nodes: [
903-
...,
904-
dom.Element(
905-
localName: 'annotation',
906-
attributes: {'encoding': 'application/x-tex'},
907-
:final text),
908-
]),
909-
]) {
910-
texSource = text.trim();
911-
} else {
912-
return null;
913-
}
914-
return texSource;
915-
} else {
916-
return null;
917-
}
918-
}
919-
920900
/// Parser for the inline-content subtrees within Zulip content HTML.
921901
///
922902
/// The only entry point to this class is [parseBlockInline].
@@ -927,9 +907,12 @@ String? _parseMath(dom.Element element, {required bool block}) {
927907
class _ZulipInlineContentParser {
928908
InlineContentNode? parseInlineMath(dom.Element element) {
929909
final debugHtmlNode = kDebugMode ? element : null;
930-
final texSource = _parseMath(element, block: false);
931-
if (texSource == null) return null;
932-
return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode);
910+
final parsed = parseMath(element, block: false);
911+
if (parsed == null) return null;
912+
return MathInlineNode(
913+
texSource: parsed.texSource,
914+
nodes: parsed.nodes,
915+
debugHtmlNode: debugHtmlNode);
933916
}
934917

935918
UserMentionNode? parseUserMention(dom.Element element) {
@@ -1631,10 +1614,11 @@ class _ZulipContentParser {
16311614
})());
16321615

16331616
final firstChild = nodes.first as dom.Element;
1634-
final texSource = _parseMath(firstChild, block: true);
1635-
if (texSource != null) {
1617+
final parsed = parseMath(firstChild, block: true);
1618+
if (parsed != null) {
16361619
result.add(MathBlockNode(
1637-
texSource: texSource,
1620+
texSource: parsed.texSource,
1621+
nodes: parsed.nodes,
16381622
debugHtmlNode: kDebugMode ? firstChild : null));
16391623
} else {
16401624
result.add(UnimplementedBlockContentNode(htmlNode: firstChild));
@@ -1666,10 +1650,11 @@ class _ZulipContentParser {
16661650
if (child case dom.Text(text: '\n\n')) continue;
16671651

16681652
if (child case dom.Element(localName: 'span', className: 'katex-display')) {
1669-
final texSource = _parseMath(child, block: true);
1670-
if (texSource != null) {
1653+
final parsed = parseMath(child, block: true);
1654+
if (parsed != null) {
16711655
result.add(MathBlockNode(
1672-
texSource: texSource,
1656+
texSource: parsed.texSource,
1657+
nodes: parsed.nodes,
16731658
debugHtmlNode: debugHtmlNode));
16741659
continue;
16751660
}

lib/model/katex.dart

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import 'package:html/dom.dart' as dom;
2+
3+
import '../log.dart';
4+
import 'binding.dart';
5+
import 'content.dart';
6+
import 'settings.dart';
7+
8+
class MathParserResult {
9+
const MathParserResult({
10+
required this.nodes,
11+
required this.texSource,
12+
});
13+
14+
final List<KatexNode>? nodes;
15+
final String texSource;
16+
}
17+
18+
MathParserResult? parseMath(dom.Element element, { required bool block }) {
19+
final dom.Element katexElement;
20+
if (!block) {
21+
assert(element.localName == 'span' && element.className == 'katex');
22+
23+
katexElement = element;
24+
} else {
25+
assert(element.localName == 'span' && element.className == 'katex-display');
26+
27+
if (element.nodes case [
28+
dom.Element(localName: 'span', className: 'katex') && final child,
29+
]) {
30+
katexElement = child;
31+
} else {
32+
return null;
33+
}
34+
}
35+
36+
if (katexElement.nodes case [
37+
dom.Element(localName: 'span', className: 'katex-mathml', nodes: [
38+
dom.Element(
39+
localName: 'math',
40+
namespaceUri: 'http://www.w3.org/1998/Math/MathML')
41+
&& final mathElement,
42+
]),
43+
dom.Element(localName: 'span', className: 'katex-html', nodes: [...])
44+
&& final katexHtmlElement,
45+
]) {
46+
if (mathElement.attributes['display'] != (block ? 'block' : null)) {
47+
return null;
48+
}
49+
50+
final String texSource;
51+
if (mathElement.nodes case [
52+
dom.Element(localName: 'semantics', nodes: [
53+
...,
54+
dom.Element(
55+
localName: 'annotation',
56+
attributes: {'encoding': 'application/x-tex'},
57+
:final text),
58+
]),
59+
]) {
60+
texSource = text.trim();
61+
} else {
62+
return null;
63+
}
64+
65+
// The GlobalStore should be ready well before we reach the
66+
// content parsing stage here, thus the `!` here.
67+
final globalStore = ZulipBinding.instance.getGlobalStoreSync()!;
68+
final globalSettings = globalStore.settings;
69+
final flagRenderKatex =
70+
globalSettings.getBool(BoolGlobalSetting.renderKatex);
71+
72+
List<KatexNode>? nodes;
73+
if (flagRenderKatex) {
74+
try {
75+
nodes = KatexParser().parseKatexHTML(katexHtmlElement);
76+
} on KatexHtmlParseError catch (e, st) {
77+
assert(debugLog('$e\n$st'));
78+
}
79+
}
80+
81+
return MathParserResult(nodes: nodes, texSource: texSource);
82+
} else {
83+
return null;
84+
}
85+
}
86+
87+
class KatexParser {
88+
List<KatexNode> parseKatexHTML(dom.Element element) {
89+
assert(element.localName == 'span');
90+
assert(element.className == 'katex-html');
91+
return _parseChildSpans(element);
92+
}
93+
94+
List<KatexNode> _parseChildSpans(dom.Element element) {
95+
return List.unmodifiable(element.nodes.map((node) {
96+
if (node case dom.Element(localName: 'span')) {
97+
return _parseSpan(node);
98+
} else {
99+
throw KatexHtmlParseError();
100+
}
101+
}));
102+
}
103+
104+
KatexNode _parseSpan(dom.Element element) {
105+
String? text;
106+
List<KatexNode>? spans;
107+
if (element.nodes case [dom.Text(:final data)]) {
108+
text = data;
109+
} else {
110+
spans = _parseChildSpans(element);
111+
}
112+
if (text == null && spans == null) throw KatexHtmlParseError();
113+
114+
return KatexNode(
115+
text: text,
116+
nodes: spans);
117+
}
118+
}
119+
120+
class KatexHtmlParseError extends Error {
121+
final String? message;
122+
KatexHtmlParseError([this.message]);
123+
124+
@override
125+
String toString() {
126+
if (message != null) {
127+
return 'Katex HTML parse error: $message';
128+
}
129+
return 'Katex HTML parse error';
130+
}
131+
}

lib/model/settings.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ enum BoolGlobalSetting {
110110
/// (Having one stable value in this enum is also handy for tests.)
111111
placeholderIgnore(GlobalSettingType.placeholder, false),
112112

113+
/// An experimental flag to toggle rendering KaTeX content in messages.
114+
renderKatex(GlobalSettingType.experimentalFeatureFlag, false),
115+
113116
// Former settings which might exist in the database,
114117
// whose names should therefore not be reused:
115118
// (this list is empty so far)

0 commit comments

Comments
 (0)