diff --git a/lib/model/content.dart b/lib/model/content.dart index 28486f634d..78d7af24bc 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -341,6 +341,11 @@ class CodeBlockSpanNode extends ContentNode { } } +/// A complete KaTeX math expression within Zulip content, +/// whether block or inline. +/// +/// The content nodes that are descendants of this node +/// will all be of KaTeX-specific types, such as [KatexNode]. sealed class MathNode extends ContentNode { const MathNode({ super.debugHtmlNode, @@ -374,15 +379,20 @@ sealed class MathNode extends ContentNode { } } +/// A content node that expects a generic KaTeX context from its parent. +/// +/// Each of these will have a [MathNode] as an ancestor. sealed class KatexNode extends ContentNode { const KatexNode({super.debugHtmlNode}); } +/// A generic KaTeX content node, corresponding to any span in KaTeX HTML +/// that we don't otherwise specially handle. class KatexSpanNode extends KatexNode { const KatexSpanNode({ - required this.styles, - required this.text, - required this.nodes, + this.styles = const KatexSpanStyles(), + this.text, + this.nodes, super.debugHtmlNode, }) : assert((text != null) ^ (nodes != null)); @@ -411,6 +421,7 @@ class KatexSpanNode extends KatexNode { } } +/// A KaTeX strut, corresponding to a `span.strut` node in KaTeX HTML. class KatexStrutNode extends KatexNode { const KatexStrutNode({ required this.heightEm, @@ -429,6 +440,12 @@ class KatexStrutNode extends KatexNode { } } +/// A KaTeX "vertical list", corresponding to a `span.vlist-t` in KaTeX HTML. +/// +/// These nodes in KaTeX HTML have a very specific structure. +/// The children of these nodes in our tree correspond in the HTML to +/// certain great-grandchildren (certain `> .vlist-r > .vlist > span`) +/// of the `.vlist-t` node. class KatexVlistNode extends KatexNode { const KatexVlistNode({ required this.rows, @@ -443,6 +460,11 @@ class KatexVlistNode extends KatexNode { } } +/// An element of a KaTeX "vertical list"; a child of a [KatexVlistNode]. +/// +/// These correspond to certain `.vlist-t > .vlist-r > .vlist > span` nodes +/// in KaTeX HTML. The [KatexVlistNode] parent in our tree +/// corresponds to the `.vlist-t` great-grandparent in the HTML. class KatexVlistRowNode extends ContentNode { const KatexVlistRowNode({ required this.verticalOffsetEm, @@ -465,6 +487,11 @@ class KatexVlistRowNode extends ContentNode { } } +/// A KaTeX node corresponding to negative values for `margin-left` +/// or `margin-right` in the inline CSS style of a KaTeX HTML node. +/// +/// The parser synthesizes these as additional nodes, not corresponding +/// directly to any node in the HTML. class KatexNegativeMarginNode extends KatexNode { const KatexNegativeMarginNode({ required this.leftOffsetEm, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 6eab8473c7..d7d09d5ea2 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -341,13 +341,10 @@ class _KatexParser { KatexSpanNode child = KatexSpanNode( styles: styles, - text: null, nodes: _parseChildSpans(otherSpans)); if (marginLeftIsNegative) { child = KatexSpanNode( - styles: KatexSpanStyles(), - text: null, nodes: [KatexNegativeMarginNode( leftOffsetEm: marginLeftEm!, nodes: [child])]); diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 2263b74f8b..551956966e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1,7 +1,6 @@ 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'; @@ -16,7 +15,6 @@ import '../model/binding.dart'; import '../model/content.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; -import '../model/katex.dart'; import '../model/presence.dart'; import 'actions.dart'; import 'code_block.dart'; @@ -834,231 +832,6 @@ class MathBlock extends StatelessWidget { } } -/// Creates a base text style for rendering KaTeX content. -/// -/// This applies the CSS styles defined in .katex class in katex.scss : -/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 -/// -/// Requires the [style.fontSize] to be non-null. -TextStyle mkBaseKatexTextStyle(TextStyle style) { - return style.copyWith( - fontSize: style.fontSize! * 1.21, - fontFamily: 'KaTeX_Main', - height: 1.2, - fontWeight: FontWeight.normal, - fontStyle: FontStyle.normal, - textBaseline: TextBaseline.alphabetic, - leadingDistribution: TextLeadingDistribution.even, - decoration: TextDecoration.none, - fontFamilyFallback: const []); -} - -@visibleForTesting -class KatexWidget extends StatelessWidget { - const KatexWidget({ - super.key, - required this.textStyle, - required this.nodes, - }); - - final TextStyle textStyle; - final List nodes; - - @override - Widget build(BuildContext context) { - Widget widget = _KatexNodeList(nodes: nodes); - - return Directionality( - textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: mkBaseKatexTextStyle(textStyle).copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), - child: widget)); - } -} - -class _KatexNodeList extends StatelessWidget { - const _KatexNodeList({required this.nodes}); - - final List nodes; - - @override - Widget build(BuildContext context) { - return Text.rich(TextSpan( - children: List.unmodifiable(nodes.map((e) { - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - // Work around a bug where text inside a WidgetSpan could be scaled - // multiple times incorrectly, if the system font scale is larger - // than 1x. - // See: https://github.com/flutter/flutter/issues/126962 - child: MediaQuery( - data: MediaQueryData(textScaler: TextScaler.noScaling), - child: switch (e) { - KatexSpanNode() => _KatexSpan(e), - KatexStrutNode() => _KatexStrut(e), - KatexVlistNode() => _KatexVlist(e), - KatexNegativeMarginNode() => _KatexNegativeMargin(e), - })); - })))); - } -} - -class _KatexSpan extends StatelessWidget { - const _KatexSpan(this.node); - - final KatexSpanNode node; - - @override - Widget build(BuildContext context) { - var em = DefaultTextStyle.of(context).style.fontSize!; - - Widget widget = const SizedBox.shrink(); - if (node.text != null) { - widget = Text(node.text!); - } else if (node.nodes != null && node.nodes!.isNotEmpty) { - widget = _KatexNodeList(nodes: node.nodes!); - } - - final styles = node.styles; - - // Currently, we expect `top` to be only present with the - // vlist inner row span, and parser handles that explicitly. - assert(styles.topEm == null); - - final fontFamily = styles.fontFamily; - final fontSize = switch (styles.fontSizeEm) { - double fontSizeEm => fontSizeEm * em, - null => null, - }; - if (fontSize != null) em = fontSize; - - 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); - } - - widget = SizedBox( - height: styles.heightEm != null - ? styles.heightEm! * em - : null, - child: widget); - - final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { - (null, null) => null, - (null, final marginRightEm?) => - EdgeInsets.only(right: marginRightEm * em), - (final marginLeftEm?, null) => - EdgeInsets.only(left: marginLeftEm * em), - (final marginLeftEm?, final marginRightEm?) => - EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), - }; - - if (margin != null) { - assert(margin.isNonNegative); - widget = Padding(padding: margin, child: widget); - } - - return widget; - } -} - -class _KatexStrut extends StatelessWidget { - const _KatexStrut(this.node); - - final KatexStrutNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - final verticalAlignEm = node.verticalAlignEm; - if (verticalAlignEm == null) { - return SizedBox(height: node.heightEm * em); - } - - return SizedBox( - height: node.heightEm * em, - child: Baseline( - baseline: (verticalAlignEm + node.heightEm) * em, - baselineType: TextBaseline.alphabetic, - child: const Text('')), - ); - } -} - -class _KatexVlist extends StatelessWidget { - const _KatexVlist(this.node); - - final KatexVlistNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - return Stack(children: List.unmodifiable(node.rows.map((row) { - return Transform.translate( - offset: Offset(0, row.verticalOffsetEm * em), - child: _KatexSpan(row.node)); - }))); - } -} - -class _KatexNegativeMargin extends StatelessWidget { - const _KatexNegativeMargin(this.node); - - final KatexNegativeMarginNode node; - - @override - Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; - - return NegativeLeftOffset( - leftOffset: node.leftOffsetEm * em, - child: _KatexNodeList(nodes: node.nodes)); - } -} - class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 9b89270c8b..9d439ffdd3 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -4,6 +4,235 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; +import '../model/content.dart'; +import '../model/katex.dart'; +import 'content.dart'; + +/// Creates a base text style for rendering KaTeX content. +/// +/// This applies the CSS styles defined in .katex class in katex.scss : +/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +/// +/// Requires the [style.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle style) { + return style.copyWith( + fontSize: style.fontSize! * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: const []); +} + +@visibleForTesting +class KatexWidget extends StatelessWidget { + const KatexWidget({ + super.key, + required this.textStyle, + required this.nodes, + }); + + final TextStyle textStyle; + final List nodes; + + @override + Widget build(BuildContext context) { + Widget widget = _KatexNodeList(nodes: nodes); + + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: mkBaseKatexTextStyle(textStyle).copyWith( + color: ContentTheme.of(context).textStylePlainParagraph.color), + child: widget)); + } +} + +class _KatexNodeList extends StatelessWidget { + const _KatexNodeList({required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + return Text.rich(TextSpan( + children: List.unmodifiable(nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + // Work around a bug where text inside a WidgetSpan could be scaled + // multiple times incorrectly, if the system font scale is larger + // than 1x. + // See: https://github.com/flutter/flutter/issues/126962 + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + })); + })))); + } +} + +class _KatexSpan extends StatelessWidget { + const _KatexSpan(this.node); + + final KatexSpanNode node; + + @override + Widget build(BuildContext context) { + var em = DefaultTextStyle.of(context).style.fontSize!; + + Widget widget = const SizedBox.shrink(); + if (node.text != null) { + widget = Text(node.text!); + } else if (node.nodes != null && node.nodes!.isNotEmpty) { + widget = _KatexNodeList(nodes: node.nodes!); + } + + final styles = node.styles; + + // Currently, we expect `top` to be only present with the + // vlist inner row span, and parser handles that explicitly. + assert(styles.topEm == null); + + final fontFamily = styles.fontFamily; + final fontSize = switch (styles.fontSizeEm) { + double fontSizeEm => fontSizeEm * em, + null => null, + }; + if (fontSize != null) em = fontSize; + + 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); + } + + widget = SizedBox( + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget); + + final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) { + (null, null) => null, + (null, final marginRightEm?) => + EdgeInsets.only(right: marginRightEm * em), + (final marginLeftEm?, null) => + EdgeInsets.only(left: marginLeftEm * em), + (final marginLeftEm?, final marginRightEm?) => + EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em), + }; + + if (margin != null) { + assert(margin.isNonNegative); + widget = Padding(padding: margin, child: widget); + } + + return widget; + } +} + +class _KatexStrut extends StatelessWidget { + const _KatexStrut(this.node); + + final KatexStrutNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + final verticalAlignEm = node.verticalAlignEm; + if (verticalAlignEm == null) { + return SizedBox(height: node.heightEm * em); + } + + return SizedBox( + height: node.heightEm * em, + child: Baseline( + baseline: (verticalAlignEm + node.heightEm) * em, + baselineType: TextBaseline.alphabetic, + child: const Text('')), + ); + } +} + +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node)); + }))); + } +} + +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + class NegativeLeftOffset extends SingleChildRenderObjectWidget { NegativeLeftOffset({super.key, required this.leftOffset, super.child}) : assert(leftOffset.isNegative), diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 88bd11c66c..5eaf5500fa 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,14 +518,13 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ])); @@ -538,14 +537,13 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ])]); @@ -563,25 +561,23 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', - nodes: null), + text: 'a'), ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', - nodes: null), + text: 'b'), ]), ]), ]); @@ -602,14 +598,13 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'λ', - nodes: null), + text: 'λ'), ]), ]), ])]); @@ -631,25 +626,23 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', - nodes: null), + text: 'a'), ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', - nodes: null), + text: 'b'), ]), ]), ])]); @@ -680,13 +673,13 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(nodes: [ KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), + text: 'a'), ]), ]), ImageNodeList([ @@ -699,647 +692,6 @@ 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 const 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: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 - text: '2', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 - text: '3', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 - text: '4', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 - text: '5', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 - text: '6', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 - text: '7', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 - text: '8', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: '9', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 - text: '0', - nodes: null), - ]), - ]), - ]); - - static const 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: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 - text: '2', - nodes: null), - ]), - ]), - ]), - ]); - - static const 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: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '⟨', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), - text: '(', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), - text: '[', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), - text: '⌈', - nodes: null), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), - text: '⌊', - nodes: null), - ]), - ]), - ]), - ]); - - static const mathBlockKatexSpace = ContentExample( - 'math block; KaTeX space', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 - '```math\n1:2\n```', - '

' - '' - '1:21:2' - '

', [ - MathBlockNode( - texSource: '1:2', - nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode( - heightEm: 0.6444, - verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '1', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, - nodes: []), - KatexSpanNode( - styles: KatexSpanStyles(), - text: ':', - nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.2778), - text: null, - nodes: []), - ]), - KatexSpanNode( - styles: KatexSpanStyles(), - text: null, - nodes: [ - KatexStrutNode( - heightEm: 0.6444, - verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(), - text: '2', - nodes: null), - ]), - ]), - ]); - - static const mathBlockKatexSuperscript = ContentExample( - 'math block, KaTeX superscript; single vlist-r, single vertical offset row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 - '```math\na\'\n```', - '

' - '' - 'a' - 'a'' - '

', [ - MathBlockNode(texSource: 'a\'', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -3.113 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '′', nodes: null), - ]), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexSubscript = ContentExample( - 'math block, KaTeX subscript; two vlist-r, single vertical offset row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 - '```math\nx_n\n```', - '

' - '' - 'xn' - 'x_n' - '

', [ - MathBlockNode(texSource: 'x_n', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'x', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexSubSuperScript = ContentExample( - 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 - '```math\n_u^o\n```', - '

' - '' - 'uo' - '_u^o' - '

', [ - MathBlockNode(texSource: "_u^o", nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.453 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'u', nodes: null), - ]), - ])), - KatexVlistRowNode( - verticalOffsetEm: -3.113 + 2.7, - node: KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'o', nodes: null), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexRaisebox = ContentExample( - 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 - '```math\na\\raisebox{0.25em}{\$b\$}c\n```', - '

' - '' - 'abc' - 'a\\raisebox{0.25em}{\$b\$}c' - '

', [ - MathBlockNode(texSource: 'a\\raisebox{0.25em}{\$b\$}c', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'a', nodes: null), - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -3.25 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'b', nodes: null), - ]), - ])), - ]), - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'c', nodes: null), - ]), - ]), - ]); - - static const mathBlockKatexNegativeMargin = ContentExample( - 'math block, KaTeX negative margin', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 - '```math\n1 \\! 2\n```', - '

' - '' - '1 ⁣21 \\! 2' - '

', [ - MathBlockNode(texSource: '1 \\! 2', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), - KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), - ]), - ]), - ]), - ]); - - static const mathBlockKatexLogo = ContentExample( - 'math block, KaTeX logo', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 - '```math\n\\KaTeX\n```', - '

' - '' - 'KaTeX' - '\\KaTeX' - '

', [ - MathBlockNode(texSource: '\\KaTeX', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'K', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.905 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 - text: 'A', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'T', nodes: null), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.7845 + 3, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'E', nodes: null), - ]), - ])), - ]), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), - text: 'X', nodes: null), - ]), - ]), - ]), - ]), - ]), - ]), - ]), - ]), - ]); - - static const mathBlockKatexNegativeMarginsOnVlistRow = ContentExample( - 'math block, KaTeX negative margins on a vlist row', - // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 - '```math\nX_n\n```', - '

' - '' - 'XnX_n' - '

', [ - MathBlockNode(texSource: 'X_n', nodes: [ - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), - KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles( - marginRightEm: 0.07847, - fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'X', nodes: null), - KatexSpanNode( - styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), - text: null, nodes: [ - KatexVlistNode(rows: [ - KatexVlistRowNode( - verticalOffsetEm: -2.55 + 2.7, - node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(marginRightEm: 0.05), - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 - text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), - text: 'n', nodes: null), - ]), - ]), - ]), - ])), - ]), - ]), - ]), - ]), - ]), - ]); - static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2421,22 +1773,14 @@ void main() async { testParseExample(ContentExample.codeBlockWithUnknownSpanType); testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); + // The math examples in this file are about how math blocks and spans fit + // into the context of a Zulip message. + // For tests going deeper inside KaTeX content, see katex_test.dart. testParseExample(ContentExample.mathBlock); testParseExample(ContentExample.mathBlocksMultipleInParagraph); testParseExample(ContentExample.mathBlockInQuote); testParseExample(ContentExample.mathBlocksMultipleInQuote); testParseExample(ContentExample.mathBlockBetweenImages); - testParseExample(ContentExample.mathBlockKatexSizing); - testParseExample(ContentExample.mathBlockKatexNestedSizing); - testParseExample(ContentExample.mathBlockKatexDelimSizing); - testParseExample(ContentExample.mathBlockKatexSpace); - testParseExample(ContentExample.mathBlockKatexSuperscript); - testParseExample(ContentExample.mathBlockKatexSubscript); - testParseExample(ContentExample.mathBlockKatexSubSuperScript); - testParseExample(ContentExample.mathBlockKatexRaisebox); - testParseExample(ContentExample.mathBlockKatexNegativeMargin); - testParseExample(ContentExample.mathBlockKatexLogo); - testParseExample(ContentExample.mathBlockKatexNegativeMarginsOnVlistRow); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart new file mode 100644 index 0000000000..3b0916d3c0 --- /dev/null +++ b/test/model/katex_test.dart @@ -0,0 +1,611 @@ +import 'dart:io'; + +import 'package:zulip/model/settings.dart'; +import 'package:checks/checks.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test_api/scaffolding.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/katex.dart'; + +import 'binding.dart'; +import 'content_test.dart'; + +/// An example of KaTeX Zulip content for test cases. +/// +/// For guidance on writing examples, see comments on [ContentExample]. +class KatexExample extends ContentExample { + KatexExample.inline(String description, String texSource, String html, + List? expectedNodes) + : super.inline(description, '\$\$ $texSource \$\$', html, + MathInlineNode(texSource: texSource, nodes: expectedNodes)); + + KatexExample.block(String description, String texSource, String html, + List? expectedNodes) + : super(description, '```math\n$texSource\n```', html, + [MathBlockNode(texSource: texSource, nodes: expectedNodes)]); + + // 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 = KatexExample.block( + 'math block; KaTeX different sizing', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 + '\\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', + '

' + '' + '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' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 + text: '1'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 + text: '2'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 + text: '3'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 + text: '4'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 + text: '5'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 + text: '6'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 + text: '7'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 + text: '8'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: '9'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 + text: '0'), + ]), + ]); + + static final mathBlockKatexNestedSizing = KatexExample.block( + 'math block; KaTeX nested sizing', + r'\tiny {1 \Huge 2}', + '

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

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 + nodes: [ + KatexSpanNode(text: '1'), + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 + text: '2'), + ]), + ]), + ]); + + static final mathBlockKatexDelimSizing = KatexExample.block( + 'math block; KaTeX delimiter sizing', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 + r'⟨ \big( \Big[ \bigg⌈ \Bigg⌊', + '

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

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), + KatexSpanNode(text: '⟨'), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), + text: '('), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), + text: '['), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), + text: '⌈'), + ]), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), + text: '⌊'), + ]), + ]), + ]); + + static final mathBlockKatexSpace = KatexExample.block( + 'math block; KaTeX space', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2214883 + '1:2', + '

' + '' + '1:21:2' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(text: '1'), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + nodes: []), + KatexSpanNode(text: ':'), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.2778), + nodes: []), + ]), + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(text: '2'), + ]), + ]); + + static final mathBlockKatexSuperscript = KatexExample.block( + 'math block, KaTeX superscript; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 + "a'", + '

' + '' + 'a' + 'a'' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode(styles: KatexSpanStyles(fontSizeEm: 0.7), nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode(text: '′'), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexSubscript = KatexExample.block( + 'math block, KaTeX subscript; two vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 + 'x_n', + '

' + '' + 'xn' + 'x_n' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'x'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginLeftEm: 0, marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n'), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexSubSuperScript = KatexExample.block( + 'math block, KaTeX subsup script; two vlist-r, multiple vertical offset rows', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 + '_u^o', + '

' + '' + 'uo' + '_u^o' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), + KatexSpanNode(nodes: [ + KatexSpanNode(nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.453 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'u'), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'o'), + ]), + ])), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexRaisebox = KatexExample.block( + 'math block, KaTeX raisebox; single vlist-r, single vertical offset row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 + r'a\raisebox{0.25em}{$b$}c', + '

' + '' + 'abc' + 'a\\raisebox{0.25em}{\$b\$}c' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'a'), + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.25 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'b'), + ]), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'c'), + ]), + ]); + + static final mathBlockKatexNegativeMargin = KatexExample.block( + 'math block, KaTeX negative margin', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 + r'1 \! 2', + '

' + '' + '1 ⁣21 \\! 2' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(text: '1'), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexSpanNode(text: '2'), + ]), + ]), + ]); + + static final mathBlockKatexLogo = KatexExample.block( + 'math block, KaTeX logo', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 + r'\KaTeX', + '

' + '' + 'KaTeX' + '\\KaTeX' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K'), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A'), + ]), + ])), + ]), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T'), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E'), + ]), + ])), + ]), + KatexSpanNode(nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X'), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + + static final mathBlockKatexNegativeMarginsOnVlistRow = KatexExample.block( + 'math block, KaTeX negative margins on a vlist row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 + 'X_n', + '

' + '' + 'XnX_n' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'X'), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode(nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n'), + ]), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]); +} + +void main() async { + TestZulipBinding.ensureInitialized(); + + await testBinding.globalStore.settings.setBool( + BoolGlobalSetting.renderKatex, true); + + testParseExample(KatexExample.mathBlockKatexSizing); + testParseExample(KatexExample.mathBlockKatexNestedSizing); + testParseExample(KatexExample.mathBlockKatexDelimSizing); + testParseExample(KatexExample.mathBlockKatexSpace); + testParseExample(KatexExample.mathBlockKatexSuperscript); + testParseExample(KatexExample.mathBlockKatexSubscript); + testParseExample(KatexExample.mathBlockKatexSubSuperScript); + testParseExample(KatexExample.mathBlockKatexRaisebox); + testParseExample(KatexExample.mathBlockKatexNegativeMargin); + testParseExample(KatexExample.mathBlockKatexLogo); + testParseExample(KatexExample.mathBlockKatexNegativeMarginsOnVlistRow); + + test('all KaTeX content examples are tested', () { + // Check that every KatexExample defined above has a corresponding + // actual test case that runs on it. If you've added a new example + // and this test breaks, remember to add a `testParseExample` call for it. + + // This implementation is a bit of a hack; it'd be cleaner to get the + // actual Dart parse tree using package:analyzer. Unfortunately that + // approach takes several seconds just to load the parser library, enough + // to add noticeably to the runtime of our whole test suite. + final thisFilename = Trace.current().frames[0].uri.path; + final source = File(thisFilename).readAsStringSync(); + final declaredExamples = RegExp(multiLine: true, + r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*KatexExample\s*(?:\.\s*(?:inline|block)\s*)?\(', + ).allMatches(source).map((m) => m.group(1)); + final testedExamples = RegExp(multiLine: true, + r'^\s*testParseExample\s*\(\s*KatexExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', + ).allMatches(source).map((m) => m.group(1)); + check(testedExamples).unorderedEquals(declaredExamples); + }, skip: Platform.isWindows, // [intended] purely analyzes source, so + // any one platform is enough; avoid dealing with Windows file paths + ); +} diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 46ed10079e..e615dc7b81 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -14,6 +13,7 @@ import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/katex.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; @@ -107,6 +107,40 @@ TextStyle? mergedStyleOf(WidgetTester tester, Pattern spanPattern, { /// and reports the target's font size. typedef TargetFontSizeFinder = double Function(InlineSpan rootSpan); +Widget plainContent(String html) { + return Builder(builder: (context) => + DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: parseContent(html).nodes))); +} + +// TODO(#488) For content that we need to show outside a per-message context +// or a context without a full PerAccountStore, make sure to include tests +// that don't provide such context. +Future prepareContent(WidgetTester tester, Widget child, { + List navObservers = const [], + bool wrapWithPerAccountStoreWidget = false, +}) async { + if (wrapWithPerAccountStoreWidget) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + } + + addTearDown(testBinding.reset); + + prepareBoringImageHttpClient(); + + await tester.pumpWidget(TestZulipApp( + accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, + navigatorObservers: navObservers, + child: child)); + await tester.pump(); // global store + if (wrapWithPerAccountStoreWidget) { + await tester.pump(); + } + + debugNetworkImageHttpClientProvider = null; +} + void main() { // For testing a new content feature: // @@ -121,45 +155,11 @@ void main() { TestZulipBinding.ensureInitialized(); - Widget plainContent(String html) { - return Builder(builder: (context) => - DefaultTextStyle( - style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: parseContent(html).nodes))); - } - Widget messageContent(String html) { return MessageContent(message: eg.streamMessage(content: html), content: parseContent(html)); } - // TODO(#488) For content that we need to show outside a per-message context - // or a context without a full PerAccountStore, make sure to include tests - // that don't provide such context. - Future prepareContent(WidgetTester tester, Widget child, { - List navObservers = const [], - bool wrapWithPerAccountStoreWidget = false, - }) async { - if (wrapWithPerAccountStoreWidget) { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - } - - addTearDown(testBinding.reset); - - prepareBoringImageHttpClient(); - - await tester.pumpWidget(TestZulipApp( - accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, - navigatorObservers: navObservers, - child: child)); - await tester.pump(); // global store - if (wrapWithPerAccountStoreWidget) { - await tester.pump(); - } - - debugNetworkImageHttpClientProvider = null; - } - /// Test that the given content example renders without throwing an exception. /// /// This requires [ContentExample.expectedText] to be non-null in order to @@ -556,6 +556,10 @@ void main() { }); group('MathBlock', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math block. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathBlock); testWidgets('displays KaTeX source; experimental flag disabled', (tester) async { @@ -576,100 +580,6 @@ void main() { await prepareContent(tester, plainContent(ContentExample.mathBlock.html)); tester.widget(find.text('λ', findRichText: true)); }); - - group('characters render at specific offsets with specific size', () { - const testCases = <(ContentExample, List<(String, Offset, Size)>, {bool? skip})>[ - (ContentExample.mathBlockKatexSizing, skip: false, [ - ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), - ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), - ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), - ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), - ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), - ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), - ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), - ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), - ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), - ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), - ]), - (ContentExample.mathBlockKatexNestedSizing, skip: false, [ - ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), - ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), - ]), - (ContentExample.mathBlockKatexDelimSizing, skip: false, [ - ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), - ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), - ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), - ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), - ]), - (ContentExample.mathBlockKatexSpace, skip: false, [ - ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), - (':', Offset(16.00, 2.24), Size(5.72, 25.00)), - ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), - ]), - (ContentExample.mathBlockKatexSuperscript, skip: false, [ - ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), - ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), - ]), - (ContentExample.mathBlockKatexSubscript, skip: false, [ - ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), - ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), - ]), - (ContentExample.mathBlockKatexSubSuperScript, skip: false, [ - ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), - ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), - ]), - (ContentExample.mathBlockKatexRaisebox, skip: false, [ - ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), - ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), - ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), - ]), - (ContentExample.mathBlockKatexNegativeMargin, skip: false, [ - ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), - ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), - ]), - (ContentExample.mathBlockKatexLogo, skip: false, [ - ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), - ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), - ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), - ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), - ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), - ]), - (ContentExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ - ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), - ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), - ]), - ]; - - for (final testCase in testCases) { - testWidgets(testCase.$1.description, (tester) async { - await _loadKatexFonts(); - - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - await prepareContent(tester, plainContent(testCase.$1.html)); - - final baseRect = tester.getRect(find.byType(KatexWidget)); - - for (final characterData in testCase.$2) { - final character = characterData.$1; - final expectedTopLeftOffset = characterData.$2; - final expectedSize = characterData.$3; - - final rect = tester.getRect(find.text(character)); - final topLeftOffset = rect.topLeft - baseRect.topLeft; - final size = rect.size; - - check(topLeftOffset) - .within(distance: 0.05, from: expectedTopLeftOffset); - check(size) - .within(distance: 0.05, from: expectedSize); - } - }, skip: testCase.skip); - } - }); }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -1077,6 +987,10 @@ void main() { }); group('inline math', () { + // See also katex_test.dart for detailed tests of + // how we render the inside of a math span. + // These tests check how it relates to the enclosing Zulip message. + testContentSmoke(ContentExample.mathInline); testWidgets('maintains font-size ratio with surrounding text', (tester) async { @@ -1485,45 +1399,3 @@ void main() { }); }); } - -Future _loadKatexFonts() async { - const fonts = { - 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], - 'KaTeX_Caligraphic': [ - 'KaTeX_Caligraphic-Regular.ttf', - 'KaTeX_Caligraphic-Bold.ttf', - ], - 'KaTeX_Fraktur': [ - 'KaTeX_Fraktur-Regular.ttf', - 'KaTeX_Fraktur-Bold.ttf', - ], - 'KaTeX_Main': [ - 'KaTeX_Main-Regular.ttf', - 'KaTeX_Main-Bold.ttf', - 'KaTeX_Main-Italic.ttf', - 'KaTeX_Main-BoldItalic.ttf', - ], - 'KaTeX_Math': [ - 'KaTeX_Math-Italic.ttf', - 'KaTeX_Math-BoldItalic.ttf', - ], - 'KaTeX_SansSerif': [ - 'KaTeX_SansSerif-Regular.ttf', - 'KaTeX_SansSerif-Bold.ttf', - 'KaTeX_SansSerif-Italic.ttf', - ], - 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], - 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], - 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], - 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], - 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], - 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], - }; - for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { - final fontLoader = FontLoader(fontFamily); - for (final fontFile in fontFiles) { - fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); - } - await fontLoader.load(); - } -} diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart new file mode 100644 index 0000000000..325bcf0b6a --- /dev/null +++ b/test/widgets/katex_test.dart @@ -0,0 +1,153 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/katex.dart'; + +import '../model/binding.dart'; +import '../model/katex_test.dart'; +import '../model/store_checks.dart'; +import 'content_test.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('MathBlock', () { + group('characters render at specific offsets with specific size', () { + final testCases = <(KatexExample, List<(String, Offset, Size)>, {bool? skip})>[ + (KatexExample.mathBlockKatexSizing, skip: false, [ + ('1', Offset(0.00, 2.24), Size(25.59, 61.00)), + ('2', Offset(25.59, 10.04), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.55), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.98), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.50), Size(12.34, 30.00)), + ('6', Offset(91.82, 30.26), Size(10.28, 25.00)), + ('7', Offset(102.10, 32.15), Size(9.25, 22.00)), + ('8', Offset(111.35, 34.03), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.91), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.68), Size(5.14, 12.00)), + ]), + (KatexExample.mathBlockKatexNestedSizing, skip: false, [ + ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), + ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), + ]), + (KatexExample.mathBlockKatexDelimSizing, skip: false, [ + ('(', Offset(8.00, 20.14), Size(9.42, 25.00)), + ('[', Offset(17.42, 20.14), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 20.14), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 20.14), Size(13.14, 25.00)), + ]), + (KatexExample.mathBlockKatexSpace, skip: false, [ + ('1', Offset(0.00, 2.24), Size(10.28, 25.00)), + (':', Offset(16.00, 2.24), Size(5.72, 25.00)), + ('2', Offset(27.43, 2.24), Size(10.28, 25.00)), + ]), + (KatexExample.mathBlockKatexSuperscript, skip: false, [ + ('a', Offset(0.00, 5.28), Size(10.88, 25.00)), + ('′', Offset(10.88, 1.13), Size(3.96, 17.00)), + ]), + (KatexExample.mathBlockKatexSubscript, skip: false, [ + ('x', Offset(0.00, 5.28), Size(11.76, 25.00)), + ('n', Offset(11.76, 13.65), Size(8.63, 17.00)), + ]), + (KatexExample.mathBlockKatexSubSuperScript, skip: false, [ + ('u', Offset(0.00, 15.65), Size(8.23, 17.00)), + ('o', Offset(0.00, 2.07), Size(6.98, 17.00)), + ]), + (KatexExample.mathBlockKatexRaisebox, skip: false, [ + ('a', Offset(0.00, 4.16), Size(10.88, 25.00)), + ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), + ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), + ]), + (KatexExample.mathBlockKatexNegativeMargin, skip: false, [ + ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), + ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), + ]), + (KatexExample.mathBlockKatexLogo, skip: false, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), + (KatexExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ + ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), + ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), + ]), + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await _loadKatexFonts(); + + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + await prepareContent(tester, plainContent(testCase.$1.html)); + + final baseRect = tester.getRect(find.byType(KatexWidget)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final expectedTopLeftOffset = characterData.$2; + final expectedSize = characterData.$3; + + final rect = tester.getRect(find.text(character)); + final topLeftOffset = rect.topLeft - baseRect.topLeft; + final size = rect.size; + + check(topLeftOffset) + .within(distance: 0.05, from: expectedTopLeftOffset); + check(size) + .within(distance: 0.05, from: expectedSize); + } + }, skip: testCase.skip); + } + }); + }); +} + +Future _loadKatexFonts() async { + const fonts = { + 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], + 'KaTeX_Caligraphic': [ + 'KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf', + ], + 'KaTeX_Fraktur': [ + 'KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf', + ], + 'KaTeX_Main': [ + 'KaTeX_Main-Regular.ttf', + 'KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf', + 'KaTeX_Main-BoldItalic.ttf', + ], + 'KaTeX_Math': [ + 'KaTeX_Math-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf', + ], + 'KaTeX_SansSerif': [ + 'KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf', + ], + 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], + 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], + 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], + 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], + 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], + 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], + }; + for (final MapEntry(key: fontFamily, value: fontFiles) in fonts.entries) { + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +}