Skip to content

Commit 8fd716e

Browse files
content: Add KaTeX spans parser, widgets for initial rendering; w/o styles
1 parent 9b5de0e commit 8fd716e

File tree

5 files changed

+295
-47
lines changed

5 files changed

+295
-47
lines changed

lib/model/content.dart

Lines changed: 71 additions & 28 deletions
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,46 @@ 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<KatexSpanNode>? 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 KatexSpanNode extends ContentNode {
368+
const KatexSpanNode({
369+
required this.text,
370+
required this.nodes,
371+
super.debugHtmlNode,
372+
});
373+
374+
final String? text;
375+
final List<KatexSpanNode> nodes;
355376

356377
@override
357378
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
358379
super.debugFillProperties(properties);
359-
properties.add(StringProperty('texSource', texSource));
380+
properties.add(StringProperty('text', text));
381+
}
382+
383+
@override
384+
List<DiagnosticsNode> debugDescribeChildren() {
385+
return nodes.map((node) => node.toDiagnosticsNode()).toList();
360386
}
361387
}
362388

@@ -822,23 +848,25 @@ class ImageEmojiNode extends EmojiNode {
822848
}
823849

824850
class MathInlineNode extends InlineContentNode {
825-
const MathInlineNode({super.debugHtmlNode, required this.texSource});
851+
const MathInlineNode({
852+
super.debugHtmlNode,
853+
required this.texSource,
854+
required this.nodes,
855+
});
826856

827857
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);
858+
final List<KatexSpanNode>? nodes;
836859

837860
@override
838861
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
839862
super.debugFillProperties(properties);
840863
properties.add(StringProperty('texSource', texSource));
841864
}
865+
866+
@override
867+
List<DiagnosticsNode> debugDescribeChildren() {
868+
return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
869+
}
842870
}
843871

844872
class GlobalTimeNode extends InlineContentNode {
@@ -864,7 +892,10 @@ class GlobalTimeNode extends InlineContentNode {
864892

865893
////////////////////////////////////////////////////////////////
866894
867-
String? _parseMath(dom.Element element, {required bool block}) {
895+
({List<KatexSpanNode>? spans, String texSource})? _parseMath(
896+
dom.Element element, {
897+
required bool block,
898+
}) {
868899
final dom.Element katexElement;
869900
if (!block) {
870901
assert(element.localName == 'span' && element.className == 'katex');
@@ -882,16 +913,15 @@ String? _parseMath(dom.Element element, {required bool block}) {
882913
}
883914
}
884915

885-
// Expect two children span.katex-mathml, span.katex-html .
886-
// For now we only care about the .katex-mathml .
887916
if (katexElement.nodes case [
888917
dom.Element(localName: 'span', className: 'katex-mathml', nodes: [
889918
dom.Element(
890919
localName: 'math',
891920
namespaceUri: 'http://www.w3.org/1998/Math/MathML')
892921
&& final mathElement,
893922
]),
894-
...
923+
dom.Element(localName: 'span', className: 'katex-html', nodes: [...])
924+
&& final katexHtmlElement,
895925
]) {
896926
if (mathElement.attributes['display'] != (block ? 'block' : null)) {
897927
return null;
@@ -911,7 +941,15 @@ String? _parseMath(dom.Element element, {required bool block}) {
911941
} else {
912942
return null;
913943
}
914-
return texSource;
944+
945+
List<KatexSpanNode>? spans;
946+
try {
947+
spans = KatexParser().parseKatexHTML(katexHtmlElement);
948+
} on KatexHtmlParseError catch (e, st) {
949+
assert(debugLog('$e\n$st'));
950+
}
951+
952+
return (spans: spans, texSource: texSource);
915953
} else {
916954
return null;
917955
}
@@ -927,9 +965,12 @@ String? _parseMath(dom.Element element, {required bool block}) {
927965
class _ZulipInlineContentParser {
928966
InlineContentNode? parseInlineMath(dom.Element element) {
929967
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);
968+
final parsed = _parseMath(element, block: false);
969+
if (parsed == null) return null;
970+
return MathInlineNode(
971+
texSource: parsed.texSource,
972+
nodes: parsed.spans,
973+
debugHtmlNode: debugHtmlNode);
933974
}
934975

935976
UserMentionNode? parseUserMention(dom.Element element) {
@@ -1631,10 +1672,11 @@ class _ZulipContentParser {
16311672
})());
16321673

16331674
final firstChild = nodes.first as dom.Element;
1634-
final texSource = _parseMath(firstChild, block: true);
1635-
if (texSource != null) {
1675+
final parsed = _parseMath(firstChild, block: true);
1676+
if (parsed != null) {
16361677
result.add(MathBlockNode(
1637-
texSource: texSource,
1678+
texSource: parsed.texSource,
1679+
nodes: parsed.spans,
16381680
debugHtmlNode: kDebugMode ? firstChild : null));
16391681
} else {
16401682
result.add(UnimplementedBlockContentNode(htmlNode: firstChild));
@@ -1666,10 +1708,11 @@ class _ZulipContentParser {
16661708
if (child case dom.Text(text: '\n\n')) continue;
16671709

16681710
if (child case dom.Element(localName: 'span', className: 'katex-display')) {
1669-
final texSource = _parseMath(child, block: true);
1670-
if (texSource != null) {
1711+
final parsed = _parseMath(child, block: true);
1712+
if (parsed != null) {
16711713
result.add(MathBlockNode(
1672-
texSource: texSource,
1714+
texSource: parsed.texSource,
1715+
nodes: parsed.spans,
16731716
debugHtmlNode: debugHtmlNode));
16741717
continue;
16751718
}

lib/model/katex.dart

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

lib/widgets/content.dart

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

@@ -1121,11 +1191,20 @@ class _InlineContentBuilder {
11211191
child: MessageImageEmoji(node: node));
11221192

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

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

0 commit comments

Comments
 (0)