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/binding.dart b/lib/model/binding.dart index 9c66346bec..94435631ac 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -83,6 +83,13 @@ abstract class ZulipBinding { /// a widget tree may not exist. Future getGlobalStore(); + /// Get the app's singleton [GlobalStore] if already loaded, else null. + /// + /// Where possible, use [GlobalStoreWidget.of] to get access to a [GlobalStore]. + /// Use this method only in contexts where getting access to a [BuildContext] + /// is inconvenient. + GlobalStore? getGlobalStoreSync(); + /// Like [getGlobalStore], but assert this method was not previously called. /// /// This is used by the implementation of [GlobalStoreWidget], @@ -333,8 +340,17 @@ class LiveZulipBinding extends ZulipBinding { } @override - Future getGlobalStore() => _globalStore ??= LiveGlobalStore.load(); - Future? _globalStore; + Future getGlobalStore() { + return _globalStoreFuture ??= LiveGlobalStore.load().then((store) { + return _globalStore = store; + }); + } + + @override + GlobalStore? getGlobalStoreSync() => _globalStore; + + Future? _globalStoreFuture; + GlobalStore? _globalStore; @override Future getGlobalStoreUniquely() { diff --git a/lib/model/content.dart b/lib/model/content.dart index dce6e45207..72c8240133 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -6,6 +6,7 @@ import 'package:html/parser.dart'; import '../api/model/model.dart'; import '../api/model/submessage.dart'; import 'code_block.dart'; +import 'katex.dart'; /// A node in a parse tree for Zulip message-style content. /// @@ -341,22 +342,63 @@ class CodeBlockSpanNode extends ContentNode { } class MathBlockNode extends BlockContentNode { - const MathBlockNode({super.debugHtmlNode, required this.texSource}); + const MathBlockNode({ + super.debugHtmlNode, + required this.texSource, + required this.nodes, + }); final String texSource; + /// Parsed KaTeX node tree to be used for rendering the KaTeX content. + /// + /// It will be null if the parser encounters an unsupported HTML element or + /// CSS style, indicating that the widget should render the [texSource] as a + /// fallback instead. + 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 this KaTeX node contains. + /// + /// It will be null if [nodes] is non-null. + final String? text; + + /// The child nodes of this node in the KaTeX HTML tree. + /// + /// It will be null if [text] is non-null. + final List? nodes; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(StringProperty('texSource', texSource)); + properties.add(DiagnosticsProperty('styles', styles)); + properties.add(StringProperty('text', text)); + } + + @override + List debugDescribeChildren() { + return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const []; } } @@ -822,23 +864,25 @@ class ImageEmojiNode extends EmojiNode { } class MathInlineNode extends InlineContentNode { - const MathInlineNode({super.debugHtmlNode, required this.texSource}); + const MathInlineNode({ + super.debugHtmlNode, + required this.texSource, + required this.nodes, + }); 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,52 +908,6 @@ class GlobalTimeNode extends InlineContentNode { //////////////////////////////////////////////////////////////// -String? _parseMath(dom.Element element, {required bool block}) { - final dom.Element katexElement; - if (!block) { - assert(element.localName == 'span' && element.className == 'katex'); - - katexElement = element; - } 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(); -} - /// Parser for the inline-content subtrees within Zulip content HTML. /// /// The only entry point to this class is [parseBlockInline]. @@ -920,9 +918,12 @@ 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.nodes, + debugHtmlNode: debugHtmlNode); } UserMentionNode? parseUserMention(dom.Element element) { @@ -1624,10 +1625,11 @@ 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.nodes, debugHtmlNode: kDebugMode ? firstChild : null)); } else { result.add(UnimplementedBlockContentNode(htmlNode: firstChild)); @@ -1659,10 +1661,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.nodes, debugHtmlNode: debugHtmlNode)); continue; } diff --git a/lib/model/katex.dart b/lib/model/katex.dart new file mode 100644 index 0000000000..fc71025b59 --- /dev/null +++ b/lib/model/katex.dart @@ -0,0 +1,485 @@ +import 'package:flutter/foundation.dart'; +import 'package:html/dom.dart' as dom; + +import '../log.dart'; +import 'binding.dart'; +import 'content.dart'; +import 'settings.dart'; + +class MathParserResult { + const MathParserResult({ + required this.texSource, + required this.nodes, + }); + + final String texSource; + + /// Parsed KaTeX node tree to be used for rendering the KaTeX content. + /// + /// It will be null if the parser encounters an unsupported HTML element or + /// CSS style, indicating that the widget should render the [texSource] as a + /// fallback instead. + final List? nodes; +} + +/// Parses the HTML spans containing KaTeX HTML tree. +/// +/// The element should be either `` if parsing +/// inline content, otherwise `` when +/// parsing block content. +/// +/// Returns null if it encounters an unexpected root KaTeX HTML element. +MathParserResult? parseMath(dom.Element element, { required bool block }) { + final dom.Element katexElement; + if (!block) { + assert(element.localName == 'span' && element.className == 'katex'); + + katexElement = element; + } else { + assert(element.localName == 'span' && element.className == 'katex-display'); + + 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; + } + + // The GlobalStore should be ready well before we reach the + // content parsing stage here, thus the `!` here. + final globalStore = ZulipBinding.instance.getGlobalStoreSync()!; + final globalSettings = globalStore.settings; + final flagRenderKatex = + globalSettings.getBool(BoolGlobalSetting.renderKatex); + final flagForceRenderKatex = + globalSettings.getBool(BoolGlobalSetting.forceRenderKatex); + + List? nodes; + if (flagRenderKatex) { + final parser = _KatexParser(); + try { + nodes = parser.parseKatexHtml(katexHtmlElement); + } on KatexHtmlParseError catch (e, st) { + assert(debugLog('$e\n$st')); + } + + if (parser.hasError && !flagForceRenderKatex) { + nodes = null; + } + } + + return MathParserResult(nodes: nodes, texSource: texSource); + } else { + return null; + } +} + +class _KatexParser { + bool get hasError => _hasError; + bool _hasError = false; + + void _logError(String message) { + assert(debugLog(message)); + _hasError = 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/2fe1941b/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; + } + + // We can't add the case for the next class (.mathsf, .textsf) in the + // above switch block, because there is already a case for .textsf above. + // So start a new block, to keep the order of the cases here same as the + // CSS class 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; + + // TODO handle skipped class declarations between .mainrm and + // .sizing . + + case 'sizing': + case 'fontsize-ensurer': + // .sizing, + // .fontsize-ensurer { ... } + if (index + 2 < spanClasses.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[sizeIdx - 1] / sizes[resetSizeIdx - 1]; + index += 3; + continue; + } + } + } + + 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'; + + case 'mult': + // TODO handle nested spans with `.delim-size{1,4}` class. + break; + } + + if (styles.fontFamily == null) throw KatexHtmlParseError(); + + index += 2; + continue; + } + + throw KatexHtmlParseError(); + + // TODO handle .nulldelimiter and .delimcenter . + + 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; + } + + throw KatexHtmlParseError(); + + // TODO handle more classes from katex.scss + } + + // Ignore these classes because they don't have a CSS definition + // in katex.scss, but we encounter them in the generated HTML. + switch (spanClass) { + case 'mord': + case 'mopen': + classFound = true; + } + + if (!classFound) _logError('KaTeX: Unsupported CSS class: $spanClass'); + + index++; + } + + String? text; + List? spans; + if (element.nodes case [dom.Text(:final data)]) { + text = data; + } else { + spans = _parseChildSpans(element); + } + if (text == null && spans == null) throw KatexHtmlParseError(); + + return KatexNode( + styles: styles, + text: text, + nodes: spans); + } +} + +enum KatexSpanFontWeight { + bold, +} + +enum KatexSpanFontStyle { + normal, + italic, +} + +enum KatexSpanTextAlign { + left, + center, + right, +} + +class KatexSpanStyles { + String? fontFamily; + double? fontSizeEm; + KatexSpanFontWeight? fontWeight; + KatexSpanFontStyle? fontStyle; + KatexSpanTextAlign? textAlign; + + KatexSpanStyles({ + this.fontFamily, + this.fontSizeEm, + this.fontWeight, + this.fontStyle, + this.textAlign, + }); + + @override + int get hashCode => Object.hash( + 'KatexSpanStyles', + fontFamily, + fontSizeEm, + fontWeight, + fontStyle, + textAlign, + ); + + @override + bool operator ==(Object other) { + return other is KatexSpanStyles && + other.fontFamily == fontFamily && + other.fontSizeEm == fontSizeEm && + other.fontWeight == fontWeight && + other.fontStyle == fontStyle && + other.textAlign == textAlign; + } + + @override + String toString() { + final args = []; + if (fontFamily != null) args.add('fontFamily: $fontFamily'); + if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); + if (fontWeight != null) args.add('fontWeight: $fontWeight'); + if (fontStyle != null) args.add('fontStyle: $fontStyle'); + if (textAlign != null) args.add('textAlign: $textAlign'); + return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; + } +} + +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..4bf6f8adae 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; 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 +14,7 @@ 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 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; @@ -804,11 +806,135 @@ 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 nodes = node.nodes; + if (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); + } +} + +// Base text style from .katex class in katex.scss : +// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +const kBaseKatexTextStyle = TextStyle( + fontSize: kBaseFontSize * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2); + +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 = Text.rich(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: kBaseKatexTextStyle.copyWith( + color: ContentTheme.of(context).textStylePlainParagraph.color), + 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!); + } else if (span.nodes != null && span.nodes!.isNotEmpty) { + widget = Text.rich(TextSpan( + children: List.unmodifiable(span.nodes!.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _KatexSpan(e)); + })))); + } + + final styles = span.styles; + + final fontFamily = styles.fontFamily; + final fontSize = switch (styles.fontSizeEm) { + double fontSizeEm => fontSizeEm * em, + null => null, + }; + final fontWeight = switch (styles.fontWeight) { + KatexSpanFontWeight.bold => FontWeight.bold, + null => null, + }; + var fontStyle = switch (styles.fontStyle) { + KatexSpanFontStyle.normal => FontStyle.normal, + KatexSpanFontStyle.italic => FontStyle.italic, + null => null, + }; + + TextStyle? textStyle; + if (fontFamily != null || + fontSize != null || + fontWeight != null || + fontStyle != null) { + // TODO(upstream) remove this workaround when upstream fixes the broken + // rendering of KaTeX_Math font with italic font style on Android: + // https://github.com/flutter/flutter/issues/167474 + if (defaultTargetPlatform == TargetPlatform.android && + fontFamily == 'KaTeX_Math') { + fontStyle = FontStyle.normal; + } + + textStyle = TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + fontStyle: fontStyle, + ); + } + final textAlign = switch (styles.textAlign) { + KatexSpanTextAlign.left => TextAlign.left, + KatexSpanTextAlign.center => TextAlign.center, + KatexSpanTextAlign.right => TextAlign.right, + null => null, + }; + + if (textStyle != null || textAlign != null) { + widget = DefaultTextStyle.merge( + style: textStyle, + textAlign: textAlign, + child: widget); + } + return widget; } } @@ -1120,11 +1246,17 @@ 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 nodes = node.nodes; + return 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 +1415,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 d9b10dad5d..191c48a987 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -235,7 +235,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 9f5f308d77..59117b3ab8 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: ^10.1.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-Regular.ttf + - asset: assets/KaTeX/KaTeX_Caligraphic-Bold.ttf + weight: 700 + + - family: KaTeX_Fraktur + fonts: + - asset: assets/KaTeX/KaTeX_Fraktur-Regular.ttf + - asset: assets/KaTeX/KaTeX_Fraktur-Bold.ttf + weight: 700 + + - family: KaTeX_Main + fonts: + - asset: assets/KaTeX/KaTeX_Main-Regular.ttf + - asset: assets/KaTeX/KaTeX_Main-Bold.ttf + weight: 700 + - asset: assets/KaTeX/KaTeX_Main-Italic.ttf + style: italic + - asset: assets/KaTeX/KaTeX_Main-BoldItalic.ttf + weight: 700 + style: italic + + - family: KaTeX_Math + fonts: + - asset: assets/KaTeX/KaTeX_Math-Italic.ttf + style: italic + - asset: assets/KaTeX/KaTeX_Math-BoldItalic.ttf + weight: 700 + style: italic + + - family: KaTeX_SansSerif + fonts: + - asset: assets/KaTeX/KaTeX_SansSerif-Regular.ttf + - asset: assets/KaTeX/KaTeX_SansSerif-Bold.ttf + weight: 700 + - asset: assets/KaTeX/KaTeX_SansSerif-Italic.ttf + style: italic + + - 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/binding.dart b/test/model/binding.dart index 73b4ed5513..ced2a4d4b3 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -104,6 +104,9 @@ class TestZulipBinding extends ZulipBinding { @override Future getGlobalStore() => Future.value(globalStore); + @override + GlobalStore? getGlobalStoreSync() => globalStore; + @override Future getGlobalStoreUniquely() { assert(() { diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5a6a55698e..5ab60c8e7e 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -6,7 +6,10 @@ 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/settings.dart'; +import 'package:zulip/model/katex.dart'; +import 'binding.dart'; import 'content_checks.dart'; /// An example of Zulip content for test cases. @@ -514,9 +517,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 +537,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 +562,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 +600,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 +630,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 +679,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', @@ -615,6 +699,200 @@ class ContentExample { ]), ]); + // The font sizes can be compared using the katex.css generated + // from katex.scss : + // https://unpkg.com/katex@0.16.21/dist/katex.css + static final mathBlockKatexSizing = ContentExample( + 'math block; KaTeX different sizing', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 + '```math\n\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0\n```', + '

' + '' + '1234567890' + '\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0' + '

', + [ + MathBlockNode( + texSource: "\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0", + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: []), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 + text: '1', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 + text: '2', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 + text: '3', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 + text: '4', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 + text: '5', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 + text: '6', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 + text: '7', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 + text: '8', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: '9', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 + text: '0', + nodes: null), + ]), + ]), + ]); + + static final mathBlockKatexNestedSizing = ContentExample( + 'math block; KaTeX nested sizing', + '```math\n\\tiny {1 \\Huge 2}\n```', + '

' + '' + '12' + '\\tiny {1 \\Huge 2}' + '

', + [ + MathBlockNode( + texSource: '\\tiny {1 \\Huge 2}', + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: []), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: '1', + nodes: null), + KatexNode( + styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 + text: '2', + nodes: null), + ]), + ]), + ]), + ]); + + static final mathBlockKatexDelimSizing = ContentExample( + 'math block; KaTeX delimiter sizing', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 + '```math\n⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊\n```', + '

' + '' + '([' + '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊' + '

', + [ + MathBlockNode( + texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: []), + KatexNode( + styles: KatexSpanStyles(), + text: '⟨', + nodes: null), + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), + text: '(', + nodes: null), + ]), + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), + text: '[', + nodes: null), + ]), + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), + text: '⌈', + nodes: null), + ]), + KatexNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), + text: '⌊', + nodes: null), + ]), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -1375,7 +1653,7 @@ void testParseExample(ContentExample example) { testParse('parse ${example.description}', example.html, example.expectedNodes); } -void main() { +void main() async { // When writing test cases in this file: // // * Prefer to add a [ContentExample] static and use [testParseExample]. @@ -1384,6 +1662,12 @@ void main() { // // * To write the example, see comment at top of [ContentExample]. + TestZulipBinding.ensureInitialized(); + + // We need this to be able to test the currently experimental KaTeX code. + await testBinding.globalStore.settings.setBool( + BoolGlobalSetting.renderKatex, true); + // // Inline content. // @@ -1674,6 +1958,9 @@ void main() { testParseExample(ContentExample.mathBlockInQuote); testParseExample(ContentExample.mathBlocksMultipleInQuote); testParseExample(ContentExample.mathBlockBetweenImages); + testParseExample(ContentExample.mathBlockKatexSizing); + testParseExample(ContentExample.mathBlockKatexNestedSizing); + testParseExample(ContentExample.mathBlockKatexDelimSizing); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f7941bc6df..a788225aac 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -22,6 +22,7 @@ import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/content_test.dart'; +import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; @@ -553,7 +554,113 @@ void main() { styleFinder: (tester) => mergedStyleOf(tester, 'A')!); }); - testContentSmoke(ContentExample.mathBlock); + group('MathBlock', () { + testContentSmoke(ContentExample.mathBlock); + + testWidgets('displays KaTeX source; experimental flag default', (tester) async { + await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); + tester.widget(find.text(r'\lambda', findRichText: true)); + }); + + testWidgets('displays KaTeX content; experimental flag enabled', (tester) async { + addTearDown(testBinding.reset); + 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)); + }); + + void checkKatexText( + WidgetTester tester, + String text, { + required String fontFamily, + required double fontSize, + required double fontHeight, + }) { + check(mergedStyleOf(tester, text)).isNotNull() + ..fontFamily.equals(fontFamily) + ..fontSize.equals(fontSize); + check(tester.getSize(find.text(text))) + .height.isCloseTo(fontSize * fontHeight, 0.5); + } + + testWidgets('displays KaTeX content with different sizing', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + final content = ContentExample.mathBlockKatexSizing; + await prepareContent(tester, plainContent(content.html)); + + final mathBlockNode = content.expectedNodes.single as MathBlockNode; + final baseNode = mathBlockNode.nodes!.single; + final nodes = baseNode.nodes!.skip(1); // Skip .strut node. + for (final katexNode in nodes) { + final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; + checkKatexText(tester, katexNode.text!, + fontFamily: 'KaTeX_Main', + fontSize: fontSize, + fontHeight: kBaseKatexTextStyle.height!); + } + }); + + testWidgets('displays KaTeX content with nested sizing', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + final content = ContentExample.mathBlockKatexNestedSizing; + await prepareContent(tester, plainContent(content.html)); + + var fontSize = 0.5 * kBaseKatexTextStyle.fontSize!; + checkKatexText(tester, '1', + fontFamily: 'KaTeX_Main', + fontSize: fontSize, + fontHeight: kBaseKatexTextStyle.height!); + + fontSize = 4.976 * fontSize; + checkKatexText(tester, '2', + fontFamily: 'KaTeX_Main', + fontSize: fontSize, + fontHeight: kBaseKatexTextStyle.height!); + }); + + testWidgets('displays KaTeX content with different delimiter sizing', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + final content = ContentExample.mathBlockKatexDelimSizing; + await prepareContent(tester, plainContent(content.html)); + + final mathBlockNode = content.expectedNodes.single as MathBlockNode; + final baseNode = mathBlockNode.nodes!.single; + var nodes = baseNode.nodes!.skip(1); // Skip .strut node. + + final fontSize = kBaseKatexTextStyle.fontSize!; + + final firstNode = nodes.first; + checkKatexText(tester, firstNode.text!, + fontFamily: 'KaTeX_Main', + fontSize: fontSize, + fontHeight: kBaseKatexTextStyle.height!); + nodes = nodes.skip(1); + + for (var katexNode in nodes) { + katexNode = katexNode.nodes!.single; // Skip empty .mord parent. + final fontFamily = katexNode.styles.fontFamily!; + checkKatexText(tester, katexNode.text!, + fontFamily: fontFamily, + fontSize: fontSize, + fontHeight: kBaseKatexTextStyle.height!); + } + }); + }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], /// from a target [Pattern] (such as a string). @@ -969,6 +1076,21 @@ void main() { targetHtml: html, targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda')); }); + + testWidgets('displays KaTeX source; experimental flag default', (tester) async { + await prepareContent(tester, plainContent(ContentExample.mathInline.html)); + tester.widget(find.text(r'\lambda', findRichText: true)); + }); + + testWidgets('displays KaTeX content; experimental flag enabled', (tester) async { + addTearDown(testBinding.reset); + 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', () { diff --git a/tools/content/unimplemented_features_test.dart b/tools/content/unimplemented_features_test.dart index 5ef4a7493b..446cb962dd 100644 --- a/tools/content/unimplemented_features_test.dart +++ b/tools/content/unimplemented_features_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/content.dart'; +import '../../test/model/binding.dart'; import 'model.dart'; /// Check if there are unimplemented features from the given corpora of HTML @@ -33,6 +34,11 @@ import 'model.dart'; /// * lib/model/content.dart, which implements of the content parser. /// * tools/content/fetch_messages.dart, which produces the corpora. void main() async { + // Parsing the HTML content depends on `ZulipBinding` being initialized, + // specifically KaTeX content parser retrieves the `GlobalSettings` to + // for the experimental flag. + TestZulipBinding.ensureInitialized(); + Future checkForUnimplementedFeaturesInFile(File file) async { final messageIdsByFeature = >{}; final contentsByFeature = >{};