Skip to content

Commit 7609852

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 7609852

File tree

7 files changed

+403
-96
lines changed

7 files changed

+403
-96
lines changed

lib/model/content.dart

+61-76
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 node contains, generally
374+
/// observed to be the leaf node in the KaTeX HTML tree.
375+
/// It will be null if [nodes] is non-null.
376+
final String? text;
377+
378+
/// The child nodes of this node in the KaTeX HTML tree.
379+
/// It will be null if [text] is non-null.
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

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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+
/// Parsed KaTeX node tree to be used for rendering the KaTeX content.
15+
///
16+
/// It will be null if the parser encounters an unsupported HTML element or
17+
/// CSS style, indicating that the widget should render the [texSource] as a
18+
/// fallback instead.
19+
final List<KatexNode>? nodes;
20+
21+
final String texSource;
22+
}
23+
24+
/// Parses the HTML spans containing KaTeX HTML tree.
25+
///
26+
/// The element should be either `<span class="katex">` if parsing
27+
/// inline content, otherwise `<span class="katex-display">` when
28+
/// parsing block content.
29+
///
30+
/// Returns null if it encounters an unexpected root KaTeX HTML element.
31+
MathParserResult? parseMath(dom.Element element, { required bool block }) {
32+
final dom.Element katexElement;
33+
if (!block) {
34+
assert(element.localName == 'span' && element.className == 'katex');
35+
36+
katexElement = element;
37+
} else {
38+
assert(element.localName == 'span' && element.className == 'katex-display');
39+
40+
if (element.nodes case [
41+
dom.Element(localName: 'span', className: 'katex') && final child,
42+
]) {
43+
katexElement = child;
44+
} else {
45+
return null;
46+
}
47+
}
48+
49+
if (katexElement.nodes case [
50+
dom.Element(localName: 'span', className: 'katex-mathml', nodes: [
51+
dom.Element(
52+
localName: 'math',
53+
namespaceUri: 'http://www.w3.org/1998/Math/MathML')
54+
&& final mathElement,
55+
]),
56+
dom.Element(localName: 'span', className: 'katex-html', nodes: [...])
57+
&& final katexHtmlElement,
58+
]) {
59+
if (mathElement.attributes['display'] != (block ? 'block' : null)) {
60+
return null;
61+
}
62+
63+
final String texSource;
64+
if (mathElement.nodes case [
65+
dom.Element(localName: 'semantics', nodes: [
66+
...,
67+
dom.Element(
68+
localName: 'annotation',
69+
attributes: {'encoding': 'application/x-tex'},
70+
:final text),
71+
]),
72+
]) {
73+
texSource = text.trim();
74+
} else {
75+
return null;
76+
}
77+
78+
// The GlobalStore should be ready well before we reach the
79+
// content parsing stage here, thus the `!` here.
80+
final globalStore = ZulipBinding.instance.getGlobalStoreSync()!;
81+
final globalSettings = globalStore.settings;
82+
final flagRenderKatex =
83+
globalSettings.getBool(BoolGlobalSetting.renderKatex);
84+
85+
List<KatexNode>? nodes;
86+
if (flagRenderKatex) {
87+
try {
88+
nodes = _KatexParser().parseKatexHTML(katexHtmlElement);
89+
} on KatexHtmlParseError catch (e, st) {
90+
assert(debugLog('$e\n$st'));
91+
}
92+
}
93+
94+
return MathParserResult(nodes: nodes, texSource: texSource);
95+
} else {
96+
return null;
97+
}
98+
}
99+
100+
class _KatexParser {
101+
List<KatexNode> parseKatexHTML(dom.Element element) {
102+
assert(element.localName == 'span');
103+
assert(element.className == 'katex-html');
104+
return _parseChildSpans(element);
105+
}
106+
107+
List<KatexNode> _parseChildSpans(dom.Element element) {
108+
return List.unmodifiable(element.nodes.map((node) {
109+
if (node case dom.Element(localName: 'span')) {
110+
return _parseSpan(node);
111+
} else {
112+
throw KatexHtmlParseError();
113+
}
114+
}));
115+
}
116+
117+
KatexNode _parseSpan(dom.Element element) {
118+
String? text;
119+
List<KatexNode>? spans;
120+
if (element.nodes case [dom.Text(:final data)]) {
121+
text = data;
122+
} else {
123+
spans = _parseChildSpans(element);
124+
}
125+
if (text == null && spans == null) throw KatexHtmlParseError();
126+
127+
return KatexNode(
128+
text: text,
129+
nodes: spans);
130+
}
131+
}
132+
133+
class KatexHtmlParseError extends Error {
134+
final String? message;
135+
KatexHtmlParseError([this.message]);
136+
137+
@override
138+
String toString() {
139+
if (message != null) {
140+
return 'Katex HTML parse error: $message';
141+
}
142+
return 'Katex HTML parse error';
143+
}
144+
}

lib/model/settings.dart

+3
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)