Skip to content

Commit 8a12a57

Browse files
content: Add KaTeX spans parser, widgets for 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 f03ac2b commit 8a12a57

File tree

6 files changed

+307
-47
lines changed

6 files changed

+307
-47
lines changed

lib/model/content.dart

+77-28
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import 'package:html/parser.dart';
55

66
import '../api/model/model.dart';
77
import '../api/model/submessage.dart';
8+
import '../log.dart';
89
import 'code_block.dart';
10+
import 'katex.dart';
911

1012
/// A node in a parse tree for Zulip message-style content.
1113
///
@@ -341,22 +343,52 @@ class CodeBlockSpanNode extends ContentNode {
341343
}
342344

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

346352
final String texSource;
353+
final List<KatexNode>? nodes;
347354

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

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

356383
@override
357384
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
358385
super.debugFillProperties(properties);
359-
properties.add(StringProperty('texSource', texSource));
386+
properties.add(StringProperty('text', text));
387+
}
388+
389+
@override
390+
List<DiagnosticsNode> debugDescribeChildren() {
391+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
360392
}
361393
}
362394

@@ -822,23 +854,25 @@ class ImageEmojiNode extends EmojiNode {
822854
}
823855

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

827863
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);
864+
final List<KatexNode>? nodes;
836865

837866
@override
838867
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
839868
super.debugFillProperties(properties);
840869
properties.add(StringProperty('texSource', texSource));
841870
}
871+
872+
@override
873+
List<DiagnosticsNode> debugDescribeChildren() {
874+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
875+
}
842876
}
843877

844878
class GlobalTimeNode extends InlineContentNode {
@@ -864,7 +898,10 @@ class GlobalTimeNode extends InlineContentNode {
864898

865899
////////////////////////////////////////////////////////////////
866900
867-
String? _parseMath(dom.Element element, {required bool block}) {
901+
({List<KatexNode>? spans, String texSource})? _parseMath(
902+
dom.Element element, {
903+
required bool block,
904+
}) {
868905
final dom.Element katexElement;
869906
if (!block) {
870907
assert(element.localName == 'span' && element.className == 'katex');
@@ -882,16 +919,15 @@ String? _parseMath(dom.Element element, {required bool block}) {
882919
}
883920
}
884921

885-
// Expect two children span.katex-mathml, span.katex-html .
886-
// For now we only care about the .katex-mathml .
887922
if (katexElement.nodes case [
888923
dom.Element(localName: 'span', className: 'katex-mathml', nodes: [
889924
dom.Element(
890925
localName: 'math',
891926
namespaceUri: 'http://www.w3.org/1998/Math/MathML')
892927
&& final mathElement,
893928
]),
894-
...
929+
dom.Element(localName: 'span', className: 'katex-html', nodes: [...])
930+
&& final katexHtmlElement,
895931
]) {
896932
if (mathElement.attributes['display'] != (block ? 'block' : null)) {
897933
return null;
@@ -911,7 +947,15 @@ String? _parseMath(dom.Element element, {required bool block}) {
911947
} else {
912948
return null;
913949
}
914-
return texSource;
950+
951+
List<KatexNode>? spans;
952+
try {
953+
spans = KatexParser().parseKatexHTML(katexHtmlElement);
954+
} on KatexHtmlParseError catch (e, st) {
955+
assert(debugLog('$e\n$st'));
956+
}
957+
958+
return (spans: spans, texSource: texSource);
915959
} else {
916960
return null;
917961
}
@@ -927,9 +971,12 @@ String? _parseMath(dom.Element element, {required bool block}) {
927971
class _ZulipInlineContentParser {
928972
InlineContentNode? parseInlineMath(dom.Element element) {
929973
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);
974+
final parsed = _parseMath(element, block: false);
975+
if (parsed == null) return null;
976+
return MathInlineNode(
977+
texSource: parsed.texSource,
978+
nodes: parsed.spans,
979+
debugHtmlNode: debugHtmlNode);
933980
}
934981

935982
UserMentionNode? parseUserMention(dom.Element element) {
@@ -1631,10 +1678,11 @@ class _ZulipContentParser {
16311678
})());
16321679

16331680
final firstChild = nodes.first as dom.Element;
1634-
final texSource = _parseMath(firstChild, block: true);
1635-
if (texSource != null) {
1681+
final parsed = _parseMath(firstChild, block: true);
1682+
if (parsed != null) {
16361683
result.add(MathBlockNode(
1637-
texSource: texSource,
1684+
texSource: parsed.texSource,
1685+
nodes: parsed.spans,
16381686
debugHtmlNode: kDebugMode ? firstChild : null));
16391687
} else {
16401688
result.add(UnimplementedBlockContentNode(htmlNode: firstChild));
@@ -1666,10 +1714,11 @@ class _ZulipContentParser {
16661714
if (child case dom.Text(text: '\n\n')) continue;
16671715

16681716
if (child case dom.Element(localName: 'span', className: 'katex-display')) {
1669-
final texSource = _parseMath(child, block: true);
1670-
if (texSource != null) {
1717+
final parsed = _parseMath(child, block: true);
1718+
if (parsed != null) {
16711719
result.add(MathBlockNode(
1672-
texSource: texSource,
1720+
texSource: parsed.texSource,
1721+
nodes: parsed.spans,
16731722
debugHtmlNode: debugHtmlNode));
16741723
continue;
16751724
}

lib/model/katex.dart

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import 'package:html/dom.dart' as dom;
2+
3+
import 'content.dart';
4+
5+
class KatexParser {
6+
List<KatexNode> parseKatexHTML(dom.Element element) {
7+
assert(element.localName == 'span');
8+
assert(element.className == 'katex-html');
9+
return _parseChildSpans(element);
10+
}
11+
12+
List<KatexNode> _parseChildSpans(dom.Element element) {
13+
return List.unmodifiable(element.nodes.map((node) {
14+
if (node case dom.Element(localName: 'span')) {
15+
return _parseSpan(node);
16+
} else {
17+
throw KatexHtmlParseError();
18+
}
19+
}));
20+
}
21+
22+
KatexNode _parseSpan(dom.Element element) {
23+
String? text;
24+
List<KatexNode>? spans;
25+
if (element.nodes case [dom.Text(data: final data)]) {
26+
text = data;
27+
} else {
28+
spans = _parseChildSpans(element);
29+
}
30+
if (text == null && spans == null) throw KatexHtmlParseError();
31+
32+
return KatexNode(
33+
text: text,
34+
nodes: spans);
35+
}
36+
}
37+
38+
class KatexHtmlParseError extends Error {
39+
final String? message;
40+
KatexHtmlParseError([this.message]);
41+
42+
@override
43+
String toString() {
44+
if (message != null) {
45+
return 'Katex HTML parse error: $message';
46+
}
47+
return 'Katex HTML parse error';
48+
}
49+
}

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)

lib/widgets/content.dart

+89-10
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import '../generated/l10n/zulip_localizations.dart';
1313
import '../model/avatar_url.dart';
1414
import '../model/content.dart';
1515
import '../model/internal_link.dart';
16+
import '../model/settings.dart';
1617
import 'actions.dart';
1718
import 'code_block.dart';
1819
import 'dialog.dart';
@@ -804,11 +805,80 @@ class MathBlock extends StatelessWidget {
804805
@override
805806
Widget build(BuildContext context) {
806807
final contentTheme = ContentTheme.of(context);
807-
return _CodeBlockContainer(
808-
borderColor: contentTheme.colorMathBlockBorder,
809-
child: Text.rich(TextSpan(
810-
style: contentTheme.codeBlockTextStyles.plain,
811-
children: [TextSpan(text: node.texSource)])));
808+
final globalSettings = GlobalStoreWidget.settingsOf(context);
809+
final renderKatex = globalSettings.getBool(BoolGlobalSetting.renderKatex);
810+
811+
final nodes = node.nodes;
812+
if (!renderKatex || nodes == null) {
813+
return _CodeBlockContainer(
814+
borderColor: contentTheme.colorMathBlockBorder,
815+
child: Text.rich(TextSpan(
816+
style: contentTheme.codeBlockTextStyles.plain,
817+
children: [TextSpan(text: node.texSource)])));
818+
}
819+
820+
return _Katex(inline: false, nodes: nodes);
821+
}
822+
}
823+
824+
class _Katex extends StatelessWidget {
825+
const _Katex({
826+
required this.inline,
827+
required this.nodes,
828+
});
829+
830+
final bool inline;
831+
final List<KatexNode> nodes;
832+
833+
@override
834+
Widget build(BuildContext context) {
835+
Widget widget = RichText(
836+
text: TextSpan(
837+
children: List.unmodifiable(nodes.map((e) {
838+
return WidgetSpan(
839+
alignment: PlaceholderAlignment.baseline,
840+
baseline: TextBaseline.alphabetic,
841+
child: _KatexSpan(e));
842+
}))));
843+
844+
if (!inline) {
845+
widget = Center(
846+
child: SingleChildScrollViewWithScrollbar(
847+
scrollDirection: Axis.horizontal,
848+
child: widget));
849+
}
850+
851+
return Directionality(
852+
textDirection: TextDirection.ltr,
853+
child: DefaultTextStyle(
854+
style: TextStyle(
855+
fontSize: kBaseFontSize * 1.21,
856+
fontFamily: 'KaTeX_Main',
857+
height: 1.2),
858+
child: widget));
859+
}
860+
}
861+
862+
class _KatexSpan extends StatelessWidget {
863+
const _KatexSpan(this.span);
864+
865+
final KatexNode span;
866+
867+
@override
868+
Widget build(BuildContext context) {
869+
Widget widget = const SizedBox.shrink();
870+
if (span.text != null) widget = Text(span.text!);
871+
if (span.nodes != null && span.nodes!.isNotEmpty) {
872+
widget = RichText(
873+
text: TextSpan(
874+
children: List.unmodifiable(span.nodes!.map((e) {
875+
return WidgetSpan(
876+
alignment: PlaceholderAlignment.baseline,
877+
baseline: TextBaseline.alphabetic,
878+
child: _KatexSpan(e));
879+
}))));
880+
}
881+
return widget;
812882
}
813883
}
814884

@@ -1120,11 +1190,20 @@ class _InlineContentBuilder {
11201190
child: MessageImageEmoji(node: node));
11211191

11221192
case MathInlineNode():
1123-
return TextSpan(
1124-
style: widget.style
1125-
.merge(ContentTheme.of(_context!).textStyleInlineMath)
1126-
.apply(fontSizeFactor: kInlineCodeFontSizeFactor),
1127-
children: [TextSpan(text: node.texSource)]);
1193+
final globalSettings = GlobalStoreWidget.settingsOf(_context!);
1194+
final nodes = node.nodes;
1195+
final renderKatex =
1196+
globalSettings.getBool(BoolGlobalSetting.renderKatex);
1197+
return !renderKatex || nodes == null
1198+
? TextSpan(
1199+
style: widget.style
1200+
.merge(ContentTheme.of(_context!).textStyleInlineMath)
1201+
.apply(fontSizeFactor: kInlineCodeFontSizeFactor),
1202+
children: [TextSpan(text: node.texSource)])
1203+
: WidgetSpan(
1204+
alignment: PlaceholderAlignment.baseline,
1205+
baseline: TextBaseline.alphabetic,
1206+
child: _Katex(inline: true, nodes: nodes));
11281207

11291208
case GlobalTimeNode():
11301209
return WidgetSpan(alignment: PlaceholderAlignment.middle,

0 commit comments

Comments
 (0)