Skip to content

Commit 4a26227

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 8ab0adc commit 4a26227

File tree

7 files changed

+401
-96
lines changed

7 files changed

+401
-96
lines changed

lib/model/content.dart

+68-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,59 @@ 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;
347352

353+
/// Parsed KaTeX node tree to be used for rendering the KaTeX content.
354+
///
355+
/// It will be null if the parser encounters an unsupported HTML element or
356+
/// CSS style, indicating that the widget should render the [texSource] as a
357+
/// fallback instead.
358+
final List<KatexNode>? nodes;
359+
348360
@override
349-
bool operator ==(Object other) {
350-
return other is MathBlockNode && other.texSource == texSource;
361+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
362+
super.debugFillProperties(properties);
363+
properties.add(StringProperty('texSource', texSource));
351364
}
352365

353366
@override
354-
int get hashCode => Object.hash('MathBlockNode', texSource);
367+
List<DiagnosticsNode> debugDescribeChildren() {
368+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
369+
}
370+
}
371+
372+
class KatexNode extends ContentNode {
373+
const KatexNode({
374+
required this.text,
375+
required this.nodes,
376+
super.debugHtmlNode,
377+
}) : assert((text != null) ^ (nodes != null));
378+
379+
/// The text this KaTeX node contains.
380+
///
381+
/// It will be null if [nodes] is non-null.
382+
final String? text;
383+
384+
/// The child nodes of this node in the KaTeX HTML tree.
385+
///
386+
/// It will be null if [text] is non-null.
387+
final List<KatexNode>? nodes;
355388

356389
@override
357390
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
358391
super.debugFillProperties(properties);
359-
properties.add(StringProperty('texSource', texSource));
392+
properties.add(StringProperty('text', text));
393+
}
394+
395+
@override
396+
List<DiagnosticsNode> debugDescribeChildren() {
397+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
360398
}
361399
}
362400

@@ -822,23 +860,25 @@ class ImageEmojiNode extends EmojiNode {
822860
}
823861

824862
class MathInlineNode extends InlineContentNode {
825-
const MathInlineNode({super.debugHtmlNode, required this.texSource});
863+
const MathInlineNode({
864+
super.debugHtmlNode,
865+
required this.texSource,
866+
required this.nodes,
867+
});
826868

827869
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);
870+
final List<KatexNode>? nodes;
836871

837872
@override
838873
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
839874
super.debugFillProperties(properties);
840875
properties.add(StringProperty('texSource', texSource));
841876
}
877+
878+
@override
879+
List<DiagnosticsNode> debugDescribeChildren() {
880+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
881+
}
842882
}
843883

844884
class GlobalTimeNode extends InlineContentNode {
@@ -864,59 +904,6 @@ class GlobalTimeNode extends InlineContentNode {
864904

865905
////////////////////////////////////////////////////////////////
866906
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-
920907
/// Parser for the inline-content subtrees within Zulip content HTML.
921908
///
922909
/// The only entry point to this class is [parseBlockInline].
@@ -927,9 +914,12 @@ String? _parseMath(dom.Element element, {required bool block}) {
927914
class _ZulipInlineContentParser {
928915
InlineContentNode? parseInlineMath(dom.Element element) {
929916
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);
917+
final parsed = parseMath(element, block: false);
918+
if (parsed == null) return null;
919+
return MathInlineNode(
920+
texSource: parsed.texSource,
921+
nodes: parsed.nodes,
922+
debugHtmlNode: debugHtmlNode);
933923
}
934924

935925
UserMentionNode? parseUserMention(dom.Element element) {
@@ -1631,10 +1621,11 @@ class _ZulipContentParser {
16311621
})());
16321622

16331623
final firstChild = nodes.first as dom.Element;
1634-
final texSource = _parseMath(firstChild, block: true);
1635-
if (texSource != null) {
1624+
final parsed = parseMath(firstChild, block: true);
1625+
if (parsed != null) {
16361626
result.add(MathBlockNode(
1637-
texSource: texSource,
1627+
texSource: parsed.texSource,
1628+
nodes: parsed.nodes,
16381629
debugHtmlNode: kDebugMode ? firstChild : null));
16391630
} else {
16401631
result.add(UnimplementedBlockContentNode(htmlNode: firstChild));
@@ -1666,10 +1657,11 @@ class _ZulipContentParser {
16661657
if (child case dom.Text(text: '\n\n')) continue;
16671658

16681659
if (child case dom.Element(localName: 'span', className: 'katex-display')) {
1669-
final texSource = _parseMath(child, block: true);
1670-
if (texSource != null) {
1660+
final parsed = parseMath(child, block: true);
1661+
if (parsed != null) {
16711662
result.add(MathBlockNode(
1672-
texSource: texSource,
1663+
texSource: parsed.texSource,
1664+
nodes: parsed.nodes,
16731665
debugHtmlNode: debugHtmlNode));
16741666
continue;
16751667
}

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.texSource,
11+
required this.nodes,
12+
});
13+
14+
final String texSource;
15+
16+
/// Parsed KaTeX node tree to be used for rendering the KaTeX content.
17+
///
18+
/// It will be null if the parser encounters an unsupported HTML element or
19+
/// CSS style, indicating that the widget should render the [texSource] as a
20+
/// fallback instead.
21+
final List<KatexNode>? nodes;
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)