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 { 'λ' ' \\lambda ' '

', - 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 { 'λ' '\\lambda' '

', - [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' '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 { '' '
\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', () {