diff --git a/assets/KaTeX/KaTeX_AMS-Regular.ttf b/assets/KaTeX/KaTeX_AMS-Regular.ttf
new file mode 100644
index 0000000000..c6f9a5e7c0
Binary files /dev/null and b/assets/KaTeX/KaTeX_AMS-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Caligraphic-Bold.ttf b/assets/KaTeX/KaTeX_Caligraphic-Bold.ttf
new file mode 100644
index 0000000000..9ff4a5e044
Binary files /dev/null and b/assets/KaTeX/KaTeX_Caligraphic-Bold.ttf differ
diff --git a/assets/KaTeX/KaTeX_Caligraphic-Regular.ttf b/assets/KaTeX/KaTeX_Caligraphic-Regular.ttf
new file mode 100644
index 0000000000..f522294ff0
Binary files /dev/null and b/assets/KaTeX/KaTeX_Caligraphic-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Fraktur-Bold.ttf b/assets/KaTeX/KaTeX_Fraktur-Bold.ttf
new file mode 100644
index 0000000000..4e98259c3b
Binary files /dev/null and b/assets/KaTeX/KaTeX_Fraktur-Bold.ttf differ
diff --git a/assets/KaTeX/KaTeX_Fraktur-Regular.ttf b/assets/KaTeX/KaTeX_Fraktur-Regular.ttf
new file mode 100644
index 0000000000..b8461b275f
Binary files /dev/null and b/assets/KaTeX/KaTeX_Fraktur-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Main-Bold.ttf b/assets/KaTeX/KaTeX_Main-Bold.ttf
new file mode 100644
index 0000000000..4060e627dc
Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-Bold.ttf differ
diff --git a/assets/KaTeX/KaTeX_Main-BoldItalic.ttf b/assets/KaTeX/KaTeX_Main-BoldItalic.ttf
new file mode 100644
index 0000000000..dc007977ee
Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-BoldItalic.ttf differ
diff --git a/assets/KaTeX/KaTeX_Main-Italic.ttf b/assets/KaTeX/KaTeX_Main-Italic.ttf
new file mode 100644
index 0000000000..0e9b0f354a
Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-Italic.ttf differ
diff --git a/assets/KaTeX/KaTeX_Main-Regular.ttf b/assets/KaTeX/KaTeX_Main-Regular.ttf
new file mode 100644
index 0000000000..dd45e1ed2e
Binary files /dev/null and b/assets/KaTeX/KaTeX_Main-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Math-BoldItalic.ttf b/assets/KaTeX/KaTeX_Math-BoldItalic.ttf
new file mode 100644
index 0000000000..728ce7a1e2
Binary files /dev/null and b/assets/KaTeX/KaTeX_Math-BoldItalic.ttf differ
diff --git a/assets/KaTeX/KaTeX_Math-Italic.ttf b/assets/KaTeX/KaTeX_Math-Italic.ttf
new file mode 100644
index 0000000000..70d559b4e9
Binary files /dev/null and b/assets/KaTeX/KaTeX_Math-Italic.ttf differ
diff --git a/assets/KaTeX/KaTeX_SansSerif-Bold.ttf b/assets/KaTeX/KaTeX_SansSerif-Bold.ttf
new file mode 100644
index 0000000000..2f65a8a3a6
Binary files /dev/null and b/assets/KaTeX/KaTeX_SansSerif-Bold.ttf differ
diff --git a/assets/KaTeX/KaTeX_SansSerif-Italic.ttf b/assets/KaTeX/KaTeX_SansSerif-Italic.ttf
new file mode 100644
index 0000000000..d5850df98e
Binary files /dev/null and b/assets/KaTeX/KaTeX_SansSerif-Italic.ttf differ
diff --git a/assets/KaTeX/KaTeX_SansSerif-Regular.ttf b/assets/KaTeX/KaTeX_SansSerif-Regular.ttf
new file mode 100644
index 0000000000..537279f6bd
Binary files /dev/null and b/assets/KaTeX/KaTeX_SansSerif-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Script-Regular.ttf b/assets/KaTeX/KaTeX_Script-Regular.ttf
new file mode 100644
index 0000000000..fd679bf374
Binary files /dev/null and b/assets/KaTeX/KaTeX_Script-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Size1-Regular.ttf b/assets/KaTeX/KaTeX_Size1-Regular.ttf
new file mode 100644
index 0000000000..871fd7d19d
Binary files /dev/null and b/assets/KaTeX/KaTeX_Size1-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Size2-Regular.ttf b/assets/KaTeX/KaTeX_Size2-Regular.ttf
new file mode 100644
index 0000000000..7a212caf91
Binary files /dev/null and b/assets/KaTeX/KaTeX_Size2-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Size3-Regular.ttf b/assets/KaTeX/KaTeX_Size3-Regular.ttf
new file mode 100644
index 0000000000..00bff3495f
Binary files /dev/null and b/assets/KaTeX/KaTeX_Size3-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Size4-Regular.ttf b/assets/KaTeX/KaTeX_Size4-Regular.ttf
new file mode 100644
index 0000000000..74f08921f0
Binary files /dev/null and b/assets/KaTeX/KaTeX_Size4-Regular.ttf differ
diff --git a/assets/KaTeX/KaTeX_Typewriter-Regular.ttf b/assets/KaTeX/KaTeX_Typewriter-Regular.ttf
new file mode 100644
index 0000000000..c83252c571
Binary files /dev/null and b/assets/KaTeX/KaTeX_Typewriter-Regular.ttf differ
diff --git a/assets/KaTeX/LICENSE b/assets/KaTeX/LICENSE
new file mode 100644
index 0000000000..37c6433e3b
--- /dev/null
+++ b/assets/KaTeX/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2013-2020 Khan Academy and other contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lib/licenses.dart b/lib/licenses.dart
index c23882bb83..6c873dbb49 100644
--- a/lib/licenses.dart
+++ b/lib/licenses.dart
@@ -12,6 +12,9 @@ import 'package:flutter/services.dart';
Stream additionalLicenses() async* {
// Alphabetic by path.
+ yield LicenseEntryWithLineBreaks(
+ ['KaTeX'],
+ await rootBundle.loadString('assets/KaTeX/LICENSE'));
yield LicenseEntryWithLineBreaks(
['Noto Color Emoji'],
await rootBundle.loadString('assets/Noto_Color_Emoji/LICENSE'));
diff --git a/lib/model/content.dart b/lib/model/content.dart
index dce6e45207..582512bc47 100644
--- a/lib/model/content.dart
+++ b/lib/model/content.dart
@@ -5,7 +5,9 @@ import 'package:html/parser.dart';
import '../api/model/model.dart';
import '../api/model/submessage.dart';
+import '../log.dart';
import 'code_block.dart';
+import 'katex.dart';
/// A node in a parse tree for Zulip message-style content.
///
@@ -341,22 +343,58 @@ class CodeBlockSpanNode extends ContentNode {
}
class MathBlockNode extends BlockContentNode {
- const MathBlockNode({super.debugHtmlNode, required this.texSource});
+ const MathBlockNode({
+ super.debugHtmlNode,
+ this.debugHasError = false,
+ required this.texSource,
+ required this.nodes,
+ });
+ final bool debugHasError;
final String texSource;
+ final List? nodes;
@override
- bool operator ==(Object other) {
- return other is MathBlockNode && other.texSource == texSource;
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(StringProperty('texSource', texSource));
}
@override
- int get hashCode => Object.hash('MathBlockNode', texSource);
+ List debugDescribeChildren() {
+ return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
+ }
+}
+
+class KatexNode extends ContentNode {
+ const KatexNode({
+ required this.styles,
+ required this.text,
+ required this.nodes,
+ super.debugHtmlNode,
+ }) : assert((text != null) ^ (nodes != null));
+
+ final KatexSpanStyles styles;
+
+ /// The text or a single character this KaTeX span contains, generally
+ /// observed to be the leaf node in the KaTeX HTML tree.
+ /// It will be null if this span has child nodes.
+ final String? text;
+
+ /// The child nodes of this span in the KaTeX HTML tree.
+ /// It will be null if this span is a text node.
+ final List? nodes;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
- properties.add(StringProperty('texSource', texSource));
+ properties.add(KatexSpanStylesProperty('styles', styles));
+ properties.add(StringProperty('text', text));
+ }
+
+ @override
+ List debugDescribeChildren() {
+ return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
}
}
@@ -822,23 +860,27 @@ class ImageEmojiNode extends EmojiNode {
}
class MathInlineNode extends InlineContentNode {
- const MathInlineNode({super.debugHtmlNode, required this.texSource});
+ const MathInlineNode({
+ super.debugHtmlNode,
+ this.debugHasError = false,
+ required this.texSource,
+ required this.nodes,
+ });
+ final bool debugHasError;
final String texSource;
-
- @override
- bool operator ==(Object other) {
- return other is MathInlineNode && other.texSource == texSource;
- }
-
- @override
- int get hashCode => Object.hash('MathInlineNode', texSource);
+ final List? nodes;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('texSource', texSource));
}
+
+ @override
+ List debugDescribeChildren() {
+ return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const [];
+ }
}
class GlobalTimeNode extends InlineContentNode {
@@ -864,7 +906,10 @@ class GlobalTimeNode extends InlineContentNode {
////////////////////////////////////////////////////////////////
-String? _parseMath(dom.Element element, {required bool block}) {
+({List? spans, bool debugHasError, String texSource})? _parseMath(
+ dom.Element element, {
+ required bool block,
+}) {
final dom.Element katexElement;
if (!block) {
assert(element.localName == 'span' && element.className == 'katex');
@@ -873,41 +918,60 @@ String? _parseMath(dom.Element element, {required bool block}) {
} else {
assert(element.localName == 'span' && element.className == 'katex-display');
- if (element.nodes.length != 1) return null;
- final child = element.nodes.single;
- if (child is! dom.Element) return null;
- if (child.localName != 'span') return null;
- if (child.className != 'katex') return null;
- katexElement = child;
- }
-
- // Expect two children span.katex-mathml, span.katex-html .
- // For now we only care about the .katex-mathml .
- if (katexElement.nodes.isEmpty) return null;
- final child = katexElement.nodes.first;
- if (child is! dom.Element) return null;
- if (child.localName != 'span') return null;
- if (child.className != 'katex-mathml') return null;
-
- if (child.nodes.length != 1) return null;
- final grandchild = child.nodes.single;
- if (grandchild is! dom.Element) return null;
- if (grandchild.localName != 'math') return null;
- if (grandchild.attributes['display'] != (block ? 'block' : null)) return null;
- if (grandchild.namespaceUri != 'http://www.w3.org/1998/Math/MathML') return null;
-
- if (grandchild.nodes.length != 1) return null;
- final greatgrand = grandchild.nodes.single;
- if (greatgrand is! dom.Element) return null;
- if (greatgrand.localName != 'semantics') return null;
-
- if (greatgrand.nodes.isEmpty) return null;
- final descendant4 = greatgrand.nodes.last;
- if (descendant4 is! dom.Element) return null;
- if (descendant4.localName != 'annotation') return null;
- if (descendant4.attributes['encoding'] != 'application/x-tex') return null;
-
- return descendant4.text.trim();
+ if (element.nodes case [
+ dom.Element(localName: 'span', className: 'katex') && final child,
+ ]) {
+ katexElement = child;
+ } else {
+ return null;
+ }
+ }
+
+ if (katexElement.nodes case [
+ dom.Element(localName: 'span', className: 'katex-mathml', nodes: [
+ dom.Element(
+ localName: 'math',
+ namespaceUri: 'http://www.w3.org/1998/Math/MathML')
+ && final mathElement,
+ ]),
+ dom.Element(localName: 'span', className: 'katex-html', nodes: [...])
+ && final katexHtmlElement,
+ ]) {
+ if (mathElement.attributes['display'] != (block ? 'block' : null)) {
+ return null;
+ }
+
+ final String texSource;
+ if (mathElement.nodes case [
+ dom.Element(localName: 'semantics', nodes: [
+ ...,
+ dom.Element(
+ localName: 'annotation',
+ attributes: {'encoding': 'application/x-tex'},
+ :final text),
+ ]),
+ ]) {
+ texSource = text.trim();
+ } else {
+ return null;
+ }
+
+ List? spans;
+ final parser = KatexParser();
+ try {
+ spans = parser.parseKatexHTML(katexHtmlElement);
+ } on KatexHtmlParseError catch (e, st) {
+ assert(debugLog('$e\n$st'));
+ }
+
+ return (
+ spans: spans,
+ debugHasError: parser.debugHasError,
+ texSource: texSource,
+ );
+ } else {
+ return null;
+ }
}
/// Parser for the inline-content subtrees within Zulip content HTML.
@@ -920,9 +984,13 @@ String? _parseMath(dom.Element element, {required bool block}) {
class _ZulipInlineContentParser {
InlineContentNode? parseInlineMath(dom.Element element) {
final debugHtmlNode = kDebugMode ? element : null;
- final texSource = _parseMath(element, block: false);
- if (texSource == null) return null;
- return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode);
+ final parsed = _parseMath(element, block: false);
+ if (parsed == null) return null;
+ return MathInlineNode(
+ texSource: parsed.texSource,
+ nodes: parsed.spans,
+ debugHasError: parsed.debugHasError,
+ debugHtmlNode: debugHtmlNode);
}
UserMentionNode? parseUserMention(dom.Element element) {
@@ -1624,10 +1692,12 @@ class _ZulipContentParser {
})());
final firstChild = nodes.first as dom.Element;
- final texSource = _parseMath(firstChild, block: true);
- if (texSource != null) {
+ final parsed = _parseMath(firstChild, block: true);
+ if (parsed != null) {
result.add(MathBlockNode(
- texSource: texSource,
+ texSource: parsed.texSource,
+ nodes: parsed.spans,
+ debugHasError: parsed.debugHasError,
debugHtmlNode: kDebugMode ? firstChild : null));
} else {
result.add(UnimplementedBlockContentNode(htmlNode: firstChild));
@@ -1659,10 +1729,11 @@ class _ZulipContentParser {
if (child case dom.Text(text: '\n\n')) continue;
if (child case dom.Element(localName: 'span', className: 'katex-display')) {
- final texSource = _parseMath(child, block: true);
- if (texSource != null) {
+ final parsed = _parseMath(child, block: true);
+ if (parsed != null) {
result.add(MathBlockNode(
- texSource: texSource,
+ texSource: parsed.texSource,
+ nodes: parsed.spans,
debugHtmlNode: debugHtmlNode));
continue;
}
diff --git a/lib/model/katex.dart b/lib/model/katex.dart
new file mode 100644
index 0000000000..a6288e2fd5
--- /dev/null
+++ b/lib/model/katex.dart
@@ -0,0 +1,481 @@
+import 'package:csslib/parser.dart' as css_parser;
+import 'package:csslib/visitor.dart' as css_visitor;
+import 'package:flutter/foundation.dart';
+import 'package:html/dom.dart' as dom;
+
+import '../log.dart';
+import 'content.dart';
+
+class KatexParser {
+ bool get debugHasError => _debugHasError;
+ bool _debugHasError = false;
+
+ void _logError(String message) {
+ assert(debugLog(message));
+ _debugHasError = true;
+ }
+
+ List parseKatexHTML(dom.Element element) {
+ assert(element.localName == 'span');
+ assert(element.className == 'katex-html');
+ return _parseChildSpans(element);
+ }
+
+ List _parseChildSpans(dom.Element element) {
+ return List.unmodifiable(element.nodes.map((node) {
+ if (node case dom.Element(localName: 'span')) {
+ return _parseSpan(node);
+ } else {
+ throw KatexHtmlParseError();
+ }
+ }));
+ }
+
+ static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$');
+ static final _sizeClassRegExp = RegExp(r'^size(\d\d?)$');
+
+ KatexNode _parseSpan(dom.Element element) {
+ // TODO maybe check if the sequence of ancestors matter for spans.
+
+ final spanClasses = List.unmodifiable(element.className.split(' '));
+
+ // Aggregate the CSS styles that apply, in the same order as the CSS
+ // classes specified for this span, mimicking the behaviour on web.
+ //
+ // Each case in the switch blocks below is a separate CSS class definition
+ // in the same order as in katex.scss :
+ // https://github.com/KaTeX/KaTeX/blob/2fe1941b7e6c0603680ef6edd799bd8a8b46871a/src/styles/katex.scss
+ // A copy of class definition (where possible) is accompanied in a comment
+ // with each case statement to keep track of updates.
+ var styles = KatexSpanStyles();
+ var index = 0;
+ while (index < spanClasses.length) {
+ var classFound = false;
+
+ final spanClass = spanClasses[index];
+ switch (spanClass) {
+ case 'base':
+ // .base { ... }
+ // Do nothing, it has properties that don't need special handling.
+ classFound = true;
+
+ case 'strut':
+ // .strut { ... }
+ // Do nothing, it has properties that don't need special handling.
+ classFound = true;
+
+ case 'textbf':
+ // .textbf { font-weight: bold; }
+ styles.fontWeight = KatexSpanFontWeight.bold;
+ classFound = true;
+
+ case 'textit':
+ // .textit { font-style: italic; }
+ styles.fontStyle = KatexSpanFontStyle.italic;
+ classFound = true;
+
+ case 'textrm':
+ // .textrm { font-family: KaTeX_Main; }
+ styles.fontFamily = 'KaTeX_Main';
+ classFound = true;
+
+ case 'textsf':
+ // .textsf { font-family: KaTeX_SansSerif; }
+ styles.fontFamily = 'KaTeX_SansSerif';
+ classFound = true;
+
+ case 'texttt':
+ // .texttt { font-family: KaTeX_Typewriter; }
+ styles.fontFamily = 'KaTeX_Typewriter';
+ classFound = true;
+
+ case 'mathnormal':
+ // .mathnormal { font-family: KaTeX_Math; font-style: italic; }
+ styles.fontFamily = 'KaTeX_Math';
+ styles.fontStyle = KatexSpanFontStyle.italic;
+ classFound = true;
+
+ case 'mathit':
+ // .mathit { font-family: KaTeX_Main; font-style: italic; }
+ styles.fontFamily = 'KaTeX_Main';
+ styles.fontStyle = KatexSpanFontStyle.italic;
+ classFound = true;
+
+ case 'mathrm':
+ // .mathrm { font-style: normal; }
+ styles.fontStyle = KatexSpanFontStyle.normal;
+ classFound = true;
+
+ case 'mathbf':
+ // .mathbf { font-family: KaTeX_Main; font-weight: bold; }
+ styles.fontFamily = 'KaTeX_Main';
+ styles.fontWeight = KatexSpanFontWeight.bold;
+ classFound = true;
+
+ case 'boldsymbol':
+ // .boldsymbol { font-family: KaTeX_Math; font-weight: bold; font-style: italic; }
+ styles.fontFamily = 'KaTeX_Math';
+ styles.fontWeight = KatexSpanFontWeight.bold;
+ styles.fontStyle = KatexSpanFontStyle.italic;
+ classFound = true;
+
+ case 'amsrm':
+ // .amsrm { font-family: KaTeX_AMS; }
+ styles.fontFamily = 'KaTeX_AMS';
+ classFound = true;
+
+ case 'mathbb':
+ case 'textbb':
+ // .mathbb,
+ // .textbb { font-family: KaTeX_AMS; }
+ styles.fontFamily = 'KaTeX_AMS';
+ classFound = true;
+
+ case 'mathcal':
+ // .mathcal { font-family: KaTeX_Caligraphic; }
+ styles.fontFamily = 'KaTeX_Caligraphic';
+ classFound = true;
+
+ case 'mathfrak':
+ case 'textfrak':
+ // .mathfrak,
+ // .textfrak { font-family: KaTeX_Fraktur; }
+ styles.fontFamily = 'KaTeX_Fraktur';
+ classFound = true;
+
+ case 'mathboldfrak':
+ case 'textboldfrak':
+ // .mathboldfrak,
+ // .textboldfrak { font-family: KaTeX_Fraktur; font-weight: bold; }
+ styles.fontFamily = 'KaTeX_Fraktur';
+ styles.fontWeight = KatexSpanFontWeight.bold;
+ classFound = true;
+
+ case 'mathtt':
+ // .mathtt { font-family: KaTeX_Typewriter; }
+ styles.fontFamily = 'KaTeX_Typewriter';
+ classFound = true;
+
+ case 'mathscr':
+ case 'textscr':
+ // .mathscr,
+ // .textscr { font-family: KaTeX_Script; }
+ styles.fontFamily = 'KaTeX_Script';
+ classFound = true;
+ }
+
+ // Work around the duplicated case statement with a new switch block,
+ // to preserve the same order and to keep the cases mirroring the CSS
+ // definitions in katex.scss .
+ switch (spanClass) {
+ case 'mathsf':
+ case 'textsf':
+ // .mathsf,
+ // .textsf { font-family: KaTeX_SansSerif; }
+ styles.fontFamily = 'KaTeX_SansSerif';
+ classFound = true;
+
+ case 'mathboldsf':
+ case 'textboldsf':
+ // .mathboldsf,
+ // .textboldsf { font-family: KaTeX_SansSerif; font-weight: bold; }
+ styles.fontFamily = 'KaTeX_SansSerif';
+ styles.fontWeight = KatexSpanFontWeight.bold;
+ classFound = true;
+
+ case 'mathsfit':
+ case 'mathitsf':
+ case 'textitsf':
+ // .mathsfit,
+ // .mathitsf,
+ // .textitsf { font-family: KaTeX_SansSerif; font-style: italic; }
+ styles.fontFamily = 'KaTeX_SansSerif';
+ styles.fontStyle = KatexSpanFontStyle.italic;
+ classFound = true;
+
+ case 'mainrm':
+ // .mainrm { font-family: KaTeX_Main; font-style: normal; }
+ styles.fontFamily = 'KaTeX_Main';
+ styles.fontStyle = KatexSpanFontStyle.normal;
+ classFound = true;
+
+ case 'sizing':
+ case 'fontsize-ensurer':
+ // .sizing,
+ // .fontsize-ensurer { ... }
+ if (index + 2 < spanClass.length) {
+ final resetSizeClass = spanClasses[index + 1];
+ final sizeClass = spanClasses[index + 2];
+
+ final resetSizeClassSuffix =_resetSizeClassRegExp.firstMatch(resetSizeClass)?.group(1);
+ final sizeClassSuffix = _sizeClassRegExp.firstMatch(sizeClass)?.group(1);
+
+ if (resetSizeClassSuffix != null && sizeClassSuffix != null) {
+ const sizes = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488];
+
+ final resetSizeIdx = int.parse(resetSizeClassSuffix, radix: 10);
+ final sizeIdx = int.parse(sizeClassSuffix, radix: 10);
+
+ // These indexes start at 1.
+ if (resetSizeIdx <= sizes.length && sizeIdx <= sizes.length) {
+ styles.fontSizeEm = sizes[resetSizeIdx - 1] * sizes[sizeIdx - 1];
+ index += 3;
+ continue;
+ }
+ }
+ }
+
+ // Should be unreachable.
+ throw KatexHtmlParseError();
+
+ case 'delimsizing':
+ // .delimsizing { ... }
+ if (index + 1 < spanClasses.length) {
+ final nextClass = spanClasses[index + 1];
+ switch (nextClass) {
+ case 'size1':
+ styles.fontFamily = 'KaTeX_Size1';
+ case 'size2':
+ styles.fontFamily = 'KaTeX_Size2';
+ case 'size3':
+ styles.fontFamily = 'KaTeX_Size3';
+ case 'size4':
+ styles.fontFamily = 'KaTeX_Size4';
+ }
+ if (styles.fontFamily == null) throw KatexHtmlParseError();
+
+ index += 2;
+ continue;
+ }
+
+ // Should be unreachable.
+ throw KatexHtmlParseError();
+
+ case 'op-symbol':
+ // .op-symbol { ... }
+ if (index + 1 < spanClasses.length) {
+ final nextClass = spanClasses[index + 1];
+ switch (nextClass) {
+ case 'small-op':
+ styles.fontFamily = 'KaTeX_Size1';
+ case 'large-op':
+ styles.fontFamily = 'KaTeX_Size2';
+ }
+ if (styles.fontFamily == null) throw KatexHtmlParseError();
+
+ index += 2;
+ continue;
+ }
+
+ // Should be unreachable.
+ throw KatexHtmlParseError();
+
+ // TODO handle more classes from katex.scss
+ }
+
+ // Ignore these classes because they don't have a CSS definition
+ // in katex.scss .
+ switch (spanClass) {
+ case 'mord':
+ classFound = true;
+ }
+
+ if (!classFound) _logError('KaTeX: Unsupported CSS class: $spanClass');
+
+ index++;
+ }
+
+ String? text;
+ List? spans;
+ if (element.nodes case [dom.Text(data: final data)]) {
+ text = data;
+ } else {
+ spans = _parseChildSpans(element);
+ }
+ if (text == null && spans == null) throw KatexHtmlParseError();
+
+ final inlineStyles = _parseSpanInlineStyles(element);
+
+ return KatexNode(
+ text: text,
+ styles: inlineStyles != null
+ ? styles.merge(inlineStyles)
+ : styles,
+ nodes: spans);
+ }
+
+ KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) {
+ if (element.attributes case {'style': final styleStr}) {
+ final stylesheet = css_parser.parse('*{$styleStr}');
+ final topLevels = stylesheet.topLevels;
+ if (topLevels.length != 1) throw KatexHtmlParseError();
+ final topLevel = topLevels.single;
+ if (topLevel is! css_visitor.RuleSet) throw KatexHtmlParseError();
+ final rule = topLevel;
+
+ double? marginLeftEm;
+ double? marginRightEm;
+ double? paddingLeftEm;
+
+ for (final declaration in rule.declarationGroup.declarations) {
+ if (declaration is! css_visitor.Declaration) throw KatexHtmlParseError();
+ final property = declaration.property;
+
+ final expressions = declaration.expression;
+ if (expressions is! css_visitor.Expressions) throw KatexHtmlParseError();
+ if (expressions.expressions.length != 1) throw KatexHtmlParseError();
+ final expression = expressions.expressions.single;
+
+ switch (property) {
+ case 'margin-left':
+ marginLeftEm = _getEm(expression);
+ if (marginLeftEm != null) continue;
+
+ case 'margin-right':
+ marginRightEm = _getEm(expression);
+ if (marginRightEm != null) continue;
+
+ case 'padding-left':
+ paddingLeftEm = _getEm(expression);
+ if (paddingLeftEm != null) continue;
+
+ default:
+ // TODO handle more CSS properties
+ assert(debugLog('Unsupported CSS property: $property of type ${expression.runtimeType}'));
+ }
+ }
+
+ return KatexSpanStyles(
+ marginLeftEm: marginLeftEm,
+ marginRightEm: marginRightEm,
+ paddingLeftEm: paddingLeftEm,
+ );
+ }
+ return null;
+ }
+
+ double? _getEm(css_visitor.Expression expression) {
+ if (expression is css_visitor.EmTerm && expression.value is num) {
+ return (expression.value as num).toDouble();
+ }
+ return null;
+ }
+}
+
+enum KatexSpanFontWeight {
+ bold,
+}
+
+enum KatexSpanFontStyle {
+ normal,
+ italic,
+}
+
+enum KatexSpanTextAlign {
+ left,
+ center,
+ right,
+}
+
+class KatexSpanStyles {
+ double? heightEm;
+ double? marginLeftEm;
+ double? marginRightEm;
+ double? paddingLeftEm;
+
+ String? fontFamily;
+ double? fontSizeEm;
+ KatexSpanFontStyle? fontStyle;
+ KatexSpanFontWeight? fontWeight;
+ KatexSpanTextAlign? textAlign;
+
+ KatexSpanStyles({
+ this.heightEm,
+ this.marginLeftEm,
+ this.marginRightEm,
+ this.paddingLeftEm,
+ this.fontFamily,
+ this.fontSizeEm,
+ this.fontStyle,
+ this.fontWeight,
+ this.textAlign,
+ });
+
+ @override
+ int get hashCode => Object.hash(
+ 'KatexSpanStyles',
+ heightEm,
+ marginLeftEm,
+ marginRightEm,
+ paddingLeftEm,
+ fontFamily,
+ fontSizeEm,
+ fontStyle,
+ fontWeight,
+ textAlign,
+ );
+
+ @override
+ bool operator ==(Object other) {
+ return other is KatexSpanStyles &&
+ other.heightEm == heightEm &&
+ other.marginLeftEm == marginLeftEm &&
+ other.marginRightEm == marginRightEm &&
+ other.paddingLeftEm == paddingLeftEm &&
+ other.fontFamily == fontFamily &&
+ other.fontSizeEm == fontSizeEm &&
+ other.fontStyle == fontStyle &&
+ other.fontWeight == fontWeight &&
+ other.textAlign == textAlign;
+ }
+
+ static final _zero = KatexSpanStyles();
+
+ @override
+ String toString() {
+ if (this == _zero) return '${objectRuntimeType(this, 'KatexSpanStyles')}()';
+
+ final args = [];
+ if (heightEm != null) args.add('heightEm: $heightEm');
+ if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm');
+ if (marginRightEm != null) args.add('marginRightEm: $marginRightEm');
+ if (paddingLeftEm != null) args.add('paddingLeftEm: $paddingLeftEm');
+ if (fontFamily != null) args.add('fontFamily: $fontFamily');
+ if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm');
+ if (fontStyle != null) args.add('fontStyle: $fontStyle');
+ if (fontWeight != null) args.add('fontWeight: $fontWeight');
+ if (textAlign != null) args.add('textAlign: $textAlign');
+ return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})';
+ }
+
+ KatexSpanStyles merge(KatexSpanStyles other) {
+ return KatexSpanStyles(
+ heightEm: other.heightEm ?? heightEm,
+ marginLeftEm: other.marginLeftEm ?? marginLeftEm,
+ marginRightEm: other.marginRightEm ?? marginRightEm,
+ paddingLeftEm: other.paddingLeftEm ?? paddingLeftEm,
+ fontFamily: other.fontFamily ?? fontFamily,
+ fontSizeEm: other.fontSizeEm ?? fontSizeEm,
+ fontStyle: other.fontStyle ?? fontStyle,
+ fontWeight: other.fontWeight ?? fontWeight,
+ textAlign: other.textAlign ?? textAlign,
+ );
+ }
+}
+
+class KatexSpanStylesProperty extends DiagnosticsProperty {
+ KatexSpanStylesProperty(super.name, super.value);
+}
+
+class KatexHtmlParseError extends Error {
+ final String? message;
+ KatexHtmlParseError([this.message]);
+
+ @override
+ String toString() {
+ if (message != null) {
+ return 'Katex HTML parse error: $message';
+ }
+ return 'Katex HTML parse error';
+ }
+}
diff --git a/lib/model/settings.dart b/lib/model/settings.dart
index dc1bdc524a..5fd2fec9f8 100644
--- a/lib/model/settings.dart
+++ b/lib/model/settings.dart
@@ -110,6 +110,13 @@ enum BoolGlobalSetting {
/// (Having one stable value in this enum is also handy for tests.)
placeholderIgnore(GlobalSettingType.placeholder, false),
+ /// An experimental flag to toggle rendering KaTeX content in messages.
+ renderKatex(GlobalSettingType.experimentalFeatureFlag, false),
+
+ /// An experimental flag to enable rendering KaTeX even when some
+ /// errors are encountered.
+ forceRenderKatex(GlobalSettingType.experimentalFeatureFlag, false),
+
// Former settings which might exist in the database,
// whose names should therefore not be reused:
// (this list is empty so far)
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index f89872dd1d..3552a9623e 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -5,7 +5,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:html/dom.dart' as dom;
-import 'package:intl/intl.dart';
+import 'package:intl/intl.dart' as intl;
import '../api/core.dart';
import '../api/model/model.dart';
@@ -13,6 +13,8 @@ import '../generated/l10n/zulip_localizations.dart';
import '../model/avatar_url.dart';
import '../model/content.dart';
import '../model/internal_link.dart';
+import '../model/katex.dart';
+import '../model/settings.dart';
import 'actions.dart';
import 'code_block.dart';
import 'dialog.dart';
@@ -804,11 +806,152 @@ class MathBlock extends StatelessWidget {
@override
Widget build(BuildContext context) {
final contentTheme = ContentTheme.of(context);
- return _CodeBlockContainer(
- borderColor: contentTheme.colorMathBlockBorder,
- child: Text.rich(TextSpan(
- style: contentTheme.codeBlockTextStyles.plain,
- children: [TextSpan(text: node.texSource)])));
+ final globalSettings = GlobalStoreWidget.settingsOf(context);
+ final flagRenderKatex =
+ globalSettings.getBool(BoolGlobalSetting.renderKatex);
+ final flagForceRenderKatex =
+ globalSettings.getBool(BoolGlobalSetting.forceRenderKatex);
+
+ final renderKatex =
+ switch ((flagRenderKatex, flagForceRenderKatex, node.debugHasError)) {
+ (true, true, _) => true,
+ (true, false, false) => true,
+ (_, _, _) => false,
+ };
+ final nodes = node.nodes;
+ if (!renderKatex || nodes == null) {
+ return _CodeBlockContainer(
+ borderColor: contentTheme.colorMathBlockBorder,
+ child: Text.rich(TextSpan(
+ style: contentTheme.codeBlockTextStyles.plain,
+ children: [TextSpan(text: node.texSource)])));
+ }
+
+ return _Katex(inline: false, nodes: nodes);
+ }
+}
+
+class _Katex extends StatelessWidget {
+ const _Katex({
+ required this.inline,
+ required this.nodes,
+ });
+
+ final bool inline;
+ final List nodes;
+
+ @override
+ Widget build(BuildContext context) {
+ Widget widget = RichText(
+ text: TextSpan(
+ children: List.unmodifiable(nodes.map((e) {
+ return WidgetSpan(
+ alignment: PlaceholderAlignment.baseline,
+ baseline: TextBaseline.alphabetic,
+ child: _KatexSpan(e));
+ }))));
+
+ if (!inline) {
+ widget = Center(
+ child: SingleChildScrollViewWithScrollbar(
+ scrollDirection: Axis.horizontal,
+ child: widget));
+ }
+
+ return Directionality(
+ textDirection: TextDirection.ltr,
+ child: DefaultTextStyle(
+ style: TextStyle(
+ color: ContentTheme.of(context).textStylePlainParagraph.color,
+ fontSize: kBaseFontSize * 1.21,
+ fontFamily: 'KaTeX_Main',
+ height: 1.2),
+ child: widget));
+ }
+}
+
+class _KatexSpan extends StatelessWidget {
+ const _KatexSpan(this.span);
+
+ final KatexNode span;
+
+ @override
+ Widget build(BuildContext context) {
+ final em = DefaultTextStyle.of(context).style.fontSize!;
+
+ Widget widget = const SizedBox.shrink();
+ if (span.text != null) widget = Text(span.text!);
+ if (span.nodes != null && span.nodes!.isNotEmpty) {
+ widget = RichText(
+ text: TextSpan(
+ children: List.unmodifiable(span.nodes!.map((e) {
+ return WidgetSpan(
+ alignment: PlaceholderAlignment.baseline,
+ baseline: TextBaseline.alphabetic,
+ child: _KatexSpan(e));
+ }))));
+ }
+
+ final styles = span.styles;
+ TextStyle? textStyle;
+ TextAlign? textAlign;
+
+ if (styles.fontFamily != null) {
+ textStyle ??= TextStyle();
+ textStyle = textStyle.copyWith(fontFamily: styles.fontFamily);
+ }
+ if (styles.fontSizeEm != null) {
+ textStyle ??= TextStyle();
+ textStyle = textStyle.copyWith(fontSize: styles.fontSizeEm! * em);
+ }
+ if (styles.fontStyle != null) {
+ textStyle ??= TextStyle();
+ textStyle = textStyle.copyWith(fontStyle: switch (styles.fontStyle!) {
+ KatexSpanFontStyle.normal => FontStyle.normal,
+ KatexSpanFontStyle.italic => FontStyle.italic,
+ });
+ }
+ if (styles.fontWeight != null) {
+ textStyle ??= TextStyle();
+ textStyle = textStyle.copyWith(fontWeight: switch (styles.fontWeight!) {
+ KatexSpanFontWeight.bold => FontWeight.bold,
+ });
+ }
+ if (styles.textAlign != null) {
+ textAlign = switch (styles.textAlign!) {
+ KatexSpanTextAlign.left => TextAlign.left,
+ KatexSpanTextAlign.center => TextAlign.center,
+ KatexSpanTextAlign.right => TextAlign.right,
+ };
+ }
+
+ var margin = EdgeInsets.zero;
+ final marginRightEm = styles.marginRightEm;
+ final marginLeftEm = styles.marginLeftEm;
+ if (marginRightEm != null && !marginRightEm.isNegative) {
+ margin += EdgeInsets.only(right: marginRightEm * em);
+ }
+ if (marginLeftEm != null && !marginLeftEm.isNegative) {
+ margin += EdgeInsets.only(left: marginLeftEm * em);
+ }
+
+ var padding = EdgeInsets.zero;
+ final paddingLeftEm = styles.paddingLeftEm;
+ if (paddingLeftEm != null && !paddingLeftEm.isNegative) {
+ padding += EdgeInsets.only(right: paddingLeftEm * em);
+ }
+
+ if (textStyle != null || textAlign != null) {
+ widget = DefaultTextStyle.merge(
+ style: textStyle,
+ textAlign: textAlign,
+ child: widget);
+ }
+ return Container(
+ margin: margin != EdgeInsets.zero ? margin : null,
+ padding: padding != EdgeInsets.zero ? padding : null,
+ child: widget,
+ );
}
}
@@ -1120,11 +1263,29 @@ class _InlineContentBuilder {
child: MessageImageEmoji(node: node));
case MathInlineNode():
- return TextSpan(
- style: widget.style
- .merge(ContentTheme.of(_context!).textStyleInlineMath)
- .apply(fontSizeFactor: kInlineCodeFontSizeFactor),
- children: [TextSpan(text: node.texSource)]);
+ final globalSettings = GlobalStoreWidget.settingsOf(_context!);
+ final nodes = node.nodes;
+ final flagRenderKatex =
+ globalSettings.getBool(BoolGlobalSetting.renderKatex);
+ final flagForceRenderKatex =
+ globalSettings.getBool(BoolGlobalSetting.forceRenderKatex);
+
+ final renderKatex =
+ switch ((flagRenderKatex, flagForceRenderKatex, node.debugHasError)) {
+ (true, true, _) => true,
+ (true, false, false) => true,
+ (_, _, _) => false,
+ };
+ return !renderKatex || nodes == null
+ ? TextSpan(
+ style: widget.style
+ .merge(ContentTheme.of(_context!).textStyleInlineMath)
+ .apply(fontSizeFactor: kInlineCodeFontSizeFactor),
+ children: [TextSpan(text: node.texSource)])
+ : WidgetSpan(
+ alignment: PlaceholderAlignment.baseline,
+ baseline: TextBaseline.alphabetic,
+ child: _Katex(inline: true, nodes: nodes));
case GlobalTimeNode():
return WidgetSpan(alignment: PlaceholderAlignment.middle,
@@ -1283,7 +1444,7 @@ class GlobalTime extends StatelessWidget {
final GlobalTimeNode node;
final TextStyle ambientTextStyle;
- static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date
+ static final _dateFormat = intl.DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date
@override
Widget build(BuildContext context) {
diff --git a/pubspec.lock b/pubspec.lock
index 19a072d370..cb7b0d2b65 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -227,7 +227,7 @@ packages:
source: hosted
version: "3.0.6"
csslib:
- dependency: transitive
+ dependency: "direct main"
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
diff --git a/pubspec.yaml b/pubspec.yaml
index c95e6fb353..582d9452e6 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -39,6 +39,7 @@ dependencies:
collection: ^1.17.2
convert: ^3.1.1
crypto: ^3.0.3
+ csslib: ^1.0.2
device_info_plus: ^11.2.0
drift: ^2.23.0
file_picker: ^9.0.2
@@ -121,6 +122,74 @@ flutter:
- assets/Source_Sans_3/LICENSE.md
fonts:
+ # KaTeX's custom fonts.
+ - family: KaTeX_AMS
+ fonts:
+ - asset: assets/KaTeX/KaTeX_AMS-Regular.ttf
+
+ - family: KaTeX_Caligraphic
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Caligraphic-Bold.ttf
+ weight: 700
+ - asset: assets/KaTeX/KaTeX_Caligraphic-Regular.ttf
+
+ - family: KaTeX_Fraktur
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Fraktur-Bold.ttf
+ weight: 700
+ - asset: assets/KaTeX/KaTeX_Fraktur-Regular.ttf
+
+ - family: KaTeX_Main
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Main-Bold.ttf
+ weight: 700
+ - asset: assets/KaTeX/KaTeX_Main-BoldItalic.ttf
+ weight: 700
+ style: italic
+ - asset: assets/KaTeX/KaTeX_Main-Italic.ttf
+ style: italic
+ - asset: assets/KaTeX/KaTeX_Main-Regular.ttf
+
+ - family: KaTeX_Math
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Math-BoldItalic.ttf
+ weight: 700
+ style: italic
+ - asset: assets/KaTeX/KaTeX_Math-Italic.ttf
+ style: italic
+
+ - family: KaTeX_SansSerif
+ fonts:
+ - asset: assets/KaTeX/KaTeX_SansSerif-Bold.ttf
+ weight: 700
+ - asset: assets/KaTeX/KaTeX_SansSerif-Italic.ttf
+ style: italic
+ - asset: assets/KaTeX/KaTeX_SansSerif-Regular.ttf
+
+ - family: KaTeX_Script
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Script-Regular.ttf
+
+ - family: KaTeX_Size1
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Size1-Regular.ttf
+
+ - family: KaTeX_Size2
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Size2-Regular.ttf
+
+ - family: KaTeX_Size3
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Size3-Regular.ttf
+
+ - family: KaTeX_Size4
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Size4-Regular.ttf
+
+ - family: KaTeX_Typewriter
+ fonts:
+ - asset: assets/KaTeX/KaTeX_Typewriter-Regular.ttf
+
# Google's emoji font. (Web uses these emoji for the "Google" emojiset.)
#
# This should not be used on iOS.
diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index 5a6a55698e..42611bb334 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -6,6 +6,7 @@ import 'package:stack_trace/stack_trace.dart';
import 'package:test/scaffolding.dart';
import 'package:zulip/model/code_block.dart';
import 'package:zulip/model/content.dart';
+import 'package:zulip/model/katex.dart';
import 'content_checks.dart';
@@ -514,9 +515,19 @@ class ContentExample {
''
'λ
',
- const MathInlineNode(texSource: r'\lambda'));
+ MathInlineNode(texSource: r'\lambda', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'λ',
+ nodes: null),
+ ]),
+ ]));
- static const mathBlock = ContentExample(
+ static final mathBlock = ContentExample(
'math block',
"```math\n\\lambda\n```",
expectedText: r'\lambda',
@@ -524,9 +535,19 @@ class ContentExample {
''
'λ',
- [MathBlockNode(texSource: r'\lambda')]);
+ [MathBlockNode(texSource: r'\lambda', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'λ',
+ nodes: null),
+ ]),
+ ])]);
- static const mathBlocksMultipleInParagraph = ContentExample(
+ static final mathBlocksMultipleInParagraph = ContentExample(
'math blocks, multiple in paragraph',
'```math\na\n\nb\n```',
// https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2001490
@@ -539,11 +560,31 @@ class ContentExample {
''
'b', [
- MathBlockNode(texSource: 'a'),
- MathBlockNode(texSource: 'b'),
+ MathBlockNode(texSource: 'a', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'a',
+ nodes: null),
+ ]),
+ ]),
+ MathBlockNode(texSource: 'b', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'b',
+ nodes: null),
+ ]),
+ ]),
]);
- static const mathBlockInQuote = ContentExample(
+ static final mathBlockInQuote = ContentExample(
'math block in quote',
// There's sometimes a quirky extra `
\n` at the end of the `` that
// encloses the math block. In particular this happens when the math block
@@ -557,9 +598,21 @@ class ContentExample {
'\\lambda'
'λ'
'
\n
\n',
- [QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
+ [QuotationNode([
+ MathBlockNode(texSource: r'\lambda', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'λ',
+ nodes: null),
+ ]),
+ ]),
+ ])]);
- static const mathBlocksMultipleInQuote = ContentExample(
+ static final mathBlocksMultipleInQuote = ContentExample(
'math blocks, multiple in quote',
"````quote\n```math\na\n\nb\n```\n````",
// https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2029236
@@ -575,11 +628,31 @@ class ContentExample {
'b'
'
\n\n',
[QuotationNode([
- MathBlockNode(texSource: 'a'),
- MathBlockNode(texSource: 'b'),
+ MathBlockNode(texSource: 'a', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'a',
+ nodes: null),
+ ]),
+ ]),
+ MathBlockNode(texSource: 'b', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'b',
+ nodes: null),
+ ]),
+ ]),
])]);
- static const mathBlockBetweenImages = ContentExample(
+ static final mathBlockBetweenImages = ContentExample(
'math block between images',
// https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/2035891
'https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg\n```math\na\n```\nhttps://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg/1280px-Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg',
@@ -604,7 +677,16 @@ class ContentExample {
originalWidth: null,
originalHeight: null),
]),
- MathBlockNode(texSource: 'a'),
+ MathBlockNode(texSource: 'a', nodes: [
+ KatexNode(styles: KatexSpanStyles(), text: null, nodes: [
+ KatexNode(styles: KatexSpanStyles(),text: null, nodes: []),
+ KatexNode(
+ styles: KatexSpanStyles(
+ fontFamily: 'KaTeX_Math',
+ fontStyle: KatexSpanFontStyle.italic),
+ text: 'a', nodes: null),
+ ]),
+ ]),
ImageNodeList([
ImageNode(
srcUrl: '/external_content/58b0ef9a06d7bb24faec2b11df2f57f476e6f6bb/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f372f37312f5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a70672f3132383070782d5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a7067',
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index f7941bc6df..92345f7487 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -553,7 +553,27 @@ void main() {
styleFinder: (tester) => mergedStyleOf(tester, 'A')!);
});
- testContentSmoke(ContentExample.mathBlock);
+ group('MathBlock', () {
+ testContentSmoke(ContentExample.mathBlock);
+
+ testWidgets('displays TeX source; experimental flag default', (tester) async {
+ final globalSettings = testBinding.globalStore.settings;
+ await globalSettings.setBool(BoolGlobalSetting.renderKatex, null);
+ check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isFalse();
+
+ await prepareContent(tester, plainContent(ContentExample.mathBlock.html));
+ tester.widget(find.text(r'\lambda', findRichText: true));
+ });
+
+ testWidgets('displays TeX source; experimental flag enabled', (tester) async {
+ final globalSettings = testBinding.globalStore.settings;
+ await globalSettings.setBool(BoolGlobalSetting.renderKatex, true);
+ check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isTrue();
+
+ await prepareContent(tester, plainContent(ContentExample.mathBlock.html));
+ tester.widget(find.text('λ', findRichText: true));
+ });
+ });
/// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio],
/// from a target [Pattern] (such as a string).
@@ -969,6 +989,24 @@ void main() {
targetHtml: html,
targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda'));
});
+
+ testWidgets('displays TeX source; experimental flag default', (tester) async {
+ final globalSettings = testBinding.globalStore.settings;
+ await globalSettings.setBool(BoolGlobalSetting.renderKatex, null);
+ check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isFalse();
+
+ await prepareContent(tester, plainContent(ContentExample.mathInline.html));
+ tester.widget(find.text(r'\lambda', findRichText: true));
+ });
+
+ testWidgets('displays TeX source; experimental flag enabled', (tester) async {
+ final globalSettings = testBinding.globalStore.settings;
+ await globalSettings.setBool(BoolGlobalSetting.renderKatex, true);
+ check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isTrue();
+
+ await prepareContent(tester, plainContent(ContentExample.mathInline.html));
+ tester.widget(find.text('λ', findRichText: true));
+ });
});
group('GlobalTime', () {