From b350875bdbb055190a8cf360143394c7fd91a7c8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:54:24 +0530 Subject: [PATCH 1/5] content: Handle vertical offset spans in KaTeX content Implement handling most common types of `vlist` spans. --- lib/model/content.dart | 31 +++++ lib/model/katex.dart | 106 +++++++++++++++- lib/widgets/content.dart | 22 ++++ test/model/content_test.dart | 226 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 17 +++ 5 files changed, 401 insertions(+), 1 deletion(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 78fc961c00..ddb4a785a2 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -429,6 +429,37 @@ class KatexStrutNode extends KatexNode { } } +class KatexVlistNode extends KatexNode { + const KatexVlistNode({ + required this.rows, + super.debugHtmlNode, + }); + + final List rows; + + @override + List debugDescribeChildren() { + return rows.map((row) => row.toDiagnosticsNode()).toList(); + } +} + +class KatexVlistRowNode extends ContentNode { + const KatexVlistRowNode({ + required this.verticalOffsetEm, + required this.node, + super.debugHtmlNode, + }); + + final double verticalOffsetEm; + final KatexSpanNode node; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 057f7076bc..25bb5a6426 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -209,11 +209,98 @@ class _KatexParser { debugHtmlNode: debugHtmlNode); } + if (element.className == 'vlist-t' + || element.className == 'vlist-t vlist-t2') { + final vlistT = element; + if (vlistT.nodes.isEmpty) throw _KatexHtmlParseError(); + if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + final hasTwoVlistR = vlistT.className == 'vlist-t vlist-t2'; + if (!hasTwoVlistR && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); + + if (hasTwoVlistR) { + if (vlistT.nodes case [ + _, + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]), + ]), + ]) { + // Do nothing. + } else { + throw _KatexHtmlParseError(); + } + } + + if (vlistT.nodes.first + case dom.Element(localName: 'span', className: 'vlist-r') && + final vlistR) { + if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + if (vlistR.nodes.first + case dom.Element(localName: 'span', className: 'vlist') && + final vlist) { + final rows = []; + + for (final innerSpan in vlist.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + className: '', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ...final otherSpans, + ], + )) { + var styles = _parseSpanInlineStyles(innerSpan); + if (styles == null) throw _KatexHtmlParseError(); + if (styles.verticalAlignEm != null) throw _KatexHtmlParseError(); + final topEm = styles.topEm ?? 0; + + styles = styles.filter(topEm: false); + + final pstrutStyles = _parseSpanInlineStyles(pstrutSpan); + if (pstrutStyles == null) throw _KatexHtmlParseError(); + if (pstrutStyles.filter(heightEm: false) + != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + final pstrutHeight = pstrutStyles.heightEm ?? 0; + + rows.add(KatexVlistRowNode( + verticalOffsetEm: topEm + pstrutHeight, + debugHtmlNode: kDebugMode ? innerSpan : null, + node: KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)))); + } else { + throw _KatexHtmlParseError(); + } + } + + return KatexVlistNode( + rows: rows, + debugHtmlNode: debugHtmlNode, + ); + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + final inlineStyles = _parseSpanInlineStyles(element); if (inlineStyles != null) { // We expect `vertical-align` inline style to be only present on a // `strut` span, for which we emit `KatexStrutNode` separately. if (inlineStyles.verticalAlignEm != null) throw _KatexHtmlParseError(); + + // Currently, we expect `top` to only be inside a vlist, and + // we handle that case separately above. + if (inlineStyles.topEm != null) throw _KatexHtmlParseError(); } // Aggregate the CSS styles that apply, in the same order as the CSS @@ -224,7 +311,9 @@ class _KatexParser { // 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. - final spanClasses = List.unmodifiable(element.className.split(' ')); + final spanClasses = element.className != '' + ? List.unmodifiable(element.className.split(' ')) + : const []; String? fontFamily; double? fontSizeEm; KatexSpanFontWeight? fontWeight; @@ -492,6 +581,7 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? topEm; double? marginRightEm; double? marginLeftEm; @@ -510,6 +600,10 @@ class _KatexParser { verticalAlignEm = _getEm(expression); if (verticalAlignEm != null) continue; + case 'top': + topEm = _getEm(expression); + if (topEm != null) continue; + case 'margin-right': marginRightEm = _getEm(expression); if (marginRightEm != null) { @@ -537,6 +631,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, @@ -578,6 +673,8 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? topEm; + final double? marginRightEm; final double? marginLeftEm; @@ -590,6 +687,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.topEm, this.marginRightEm, this.marginLeftEm, this.fontFamily, @@ -604,6 +702,7 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + topEm, marginRightEm, marginLeftEm, fontFamily, @@ -618,6 +717,7 @@ class KatexSpanStyles { return other is KatexSpanStyles && other.heightEm == heightEm && other.verticalAlignEm == verticalAlignEm && + other.topEm == topEm && other.marginRightEm == marginRightEm && other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && @@ -632,6 +732,7 @@ class KatexSpanStyles { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); + if (topEm != null) args.add('topEm: $topEm'); if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); @@ -653,6 +754,7 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, + topEm: other.topEm ?? topEm, marginRightEm: other.marginRightEm ?? marginRightEm, marginLeftEm: other.marginLeftEm ?? marginLeftEm, fontFamily: other.fontFamily ?? fontFamily, @@ -666,6 +768,7 @@ class KatexSpanStyles { KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool topEm = true, bool marginRightEm = true, bool marginLeftEm = true, bool fontFamily = true, @@ -677,6 +780,7 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + topEm: topEm ? this.topEm : null, marginRightEm: marginRightEm ? this.marginRightEm : null, marginLeftEm: marginLeftEm ? this.marginLeftEm : null, fontFamily: fontFamily ? this.fontFamily : null, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 52ab7008b8..ba05f5205e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -897,6 +897,7 @@ class _KatexNodeList extends StatelessWidget { child: switch (e) { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), })); })))); } @@ -924,6 +925,10 @@ class _KatexSpan extends StatelessWidget { // So, this should always be null for non `strut` spans. assert(styles.verticalAlignEm == null); + // 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, @@ -1024,6 +1029,23 @@ class _KatexStrut extends StatelessWidget { } } +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 WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index c19e1c02a5..93e94b5f85 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -943,6 +943,228 @@ class ContentExample { ]), ]); + 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 imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2033,6 +2255,10 @@ void main() async { testParseExample(ContentExample.mathBlockKatexNestedSizing); testParseExample(ContentExample.mathBlockKatexDelimSizing); testParseExample(ContentExample.mathBlockKatexSpace); + testParseExample(ContentExample.mathBlockKatexSuperscript); + testParseExample(ContentExample.mathBlockKatexSubscript); + testParseExample(ContentExample.mathBlockKatexSubSuperScript); + testParseExample(ContentExample.mathBlockKatexRaisebox); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f6366a9215..7cc249d79b 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -602,6 +602,23 @@ void main() { (':', 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)), + ]), ]; for (final testCase in testCases) { From 0ec184e192940489720012d483b794fba9d2fa4b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 20 Jun 2025 21:04:39 +0530 Subject: [PATCH 2/5] content: Error message for unexpected CSS class in vlist inner span --- lib/model/katex.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 25bb5a6426..155511d27e 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -246,13 +246,17 @@ class _KatexParser { for (final innerSpan in vlist.nodes) { if (innerSpan case dom.Element( localName: 'span', - className: '', nodes: [ dom.Element(localName: 'span', className: 'pstrut') && final pstrutSpan, ...final otherSpans, ], )) { + if (innerSpan.className != '') { + throw _KatexHtmlParseError('unexpected CSS class for ' + 'vlist inner span: ${innerSpan.className}'); + } + var styles = _parseSpanInlineStyles(innerSpan); if (styles == null) throw _KatexHtmlParseError(); if (styles.verticalAlignEm != null) throw _KatexHtmlParseError(); From b0ca8948a8896c1841ef7ff78fa270ea20631bf7 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 05:34:26 +0530 Subject: [PATCH 3/5] content: Implement debugDescribeChildren for KatexVlistRowNode Turns out that anything under KatexVlistRowNode wasn't being tested by content tests, fix that by implementing this method. Fortunately there were no fixes needed in the tests. --- lib/model/content.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/model/content.dart b/lib/model/content.dart index ddb4a785a2..9f906d1c4c 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -458,6 +458,11 @@ class KatexVlistRowNode extends ContentNode { super.debugFillProperties(properties); properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); } + + @override + List debugDescribeChildren() { + return [node.toDiagnosticsNode()]; + } } class MathBlockNode extends MathNode implements BlockContentNode { From 280bf3c706ad8416811c3e4eb9f3d108756122ee Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 19:49:19 +0530 Subject: [PATCH 4/5] content: Make sure there aren't any unexpected styles on `.vlist` span Also add a comment explaining the reason of ignoring the `height` inline styles value. --- lib/model/katex.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 155511d27e..5762c60a8b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -224,10 +224,18 @@ class _KatexParser { dom.Element(localName: 'span', className: 'vlist-r', nodes: [ dom.Element(localName: 'span', className: 'vlist', nodes: [ dom.Element(localName: 'span', className: '', nodes: []), - ]), + ]) && final vlist, ]), ]) { - // Do nothing. + // In the generated HTML the .vlist in second .vlist-r span will have + // a "height" inline style which we ignore, because it doesn't seem + // to have any effect in rendering on the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseSpanInlineStyles(vlist); + if (vlistStyles != null + && vlistStyles.filter(heightEm: false) != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } } else { throw _KatexHtmlParseError(); } @@ -241,6 +249,17 @@ class _KatexParser { if (vlistR.nodes.first case dom.Element(localName: 'span', className: 'vlist') && final vlist) { + // Same as above for the second .vlist-r span, .vlist span in first + // .vlist-r span will have "height" inline style which we ignore, + // because it doesn't seem to have any effect in rendering on + // the web. + // But also make sure there aren't any other inline styles present. + final vlistStyles = _parseSpanInlineStyles(vlist); + if (vlistStyles != null + && vlistStyles.filter(heightEm: false) != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + final rows = []; for (final innerSpan in vlist.nodes) { From e5454768158058ea695814676f7d8f022d04b921 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 18 Jul 2025 01:51:55 +0530 Subject: [PATCH 5/5] content: Handle 'position' & 'top' property in KaTeX span inline style Allowing support for handling KaTeX HTML for big operators. Fixes: #1671 --- lib/model/katex.dart | 56 +++++++++++++++++++++++++++++----- lib/widgets/content.dart | 11 ++++--- test/model/content_test.dart | 25 +++++++++++++++ test/widgets/content_test.dart | 3 ++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 5762c60a8b..28b4417e93 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -320,10 +320,6 @@ class _KatexParser { // We expect `vertical-align` inline style to be only present on a // `strut` span, for which we emit `KatexStrutNode` separately. if (inlineStyles.verticalAlignEm != null) throw _KatexHtmlParseError(); - - // Currently, we expect `top` to only be inside a vlist, and - // we handle that case separately above. - if (inlineStyles.topEm != null) throw _KatexHtmlParseError(); } // Aggregate the CSS styles that apply, in the same order as the CSS @@ -586,10 +582,21 @@ class _KatexParser { } if (text == null && spans == null) throw _KatexHtmlParseError(); + final mergedStyles = inlineStyles != null + ? styles.merge(inlineStyles) + : styles; + + // We expect `top` style to be only present if `position: relative` + // is also present. As both are non-inherited CSS attributes and + // should only ever be present together. + // TODO account for other sides (left, right, bottom). + if (mergedStyles.topEm != null + && mergedStyles.position != KatexSpanPosition.relative) { + throw _KatexHtmlParseError(); + } + return KatexSpanNode( - styles: inlineStyles != null - ? styles.merge(inlineStyles) - : styles, + styles: mergedStyles, text: text, nodes: spans, debugHtmlNode: debugHtmlNode); @@ -607,6 +614,7 @@ class _KatexParser { double? topEm; double? marginRightEm; double? marginLeftEm; + KatexSpanPosition? position; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -640,6 +648,13 @@ class _KatexParser { if (marginLeftEm < 0) throw _KatexHtmlParseError(); continue; } + + case 'position': + position = switch (_getLiteral(expression)) { + 'relative' => KatexSpanPosition.relative, + _ => null, + }; + if (position != null) continue; } // TODO handle more CSS properties @@ -658,6 +673,7 @@ class _KatexParser { verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, + position: position, ); } else { throw _KatexHtmlParseError(); @@ -674,6 +690,17 @@ class _KatexParser { } return null; } + + /// Returns the CSS literal string value if the given [expression] is + /// actually a literal expression, else returns null. + String? _getLiteral(css_visitor.Expression expression) { + if (expression case css_visitor.LiteralTerm(:final value)) { + if (value case css_visitor.Identifier(:final name)) { + return name; + } + } + return null; + } } enum KatexSpanFontWeight { @@ -691,6 +718,10 @@ enum KatexSpanTextAlign { right, } +enum KatexSpanPosition { + relative, +} + @immutable class KatexSpanStyles { final double? heightEm; @@ -707,6 +738,8 @@ class KatexSpanStyles { final KatexSpanFontStyle? fontStyle; final KatexSpanTextAlign? textAlign; + final KatexSpanPosition? position; + const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, @@ -718,6 +751,7 @@ class KatexSpanStyles { this.fontWeight, this.fontStyle, this.textAlign, + this.position, }); @override @@ -733,6 +767,7 @@ class KatexSpanStyles { fontWeight, fontStyle, textAlign, + position, ); @override @@ -747,7 +782,8 @@ class KatexSpanStyles { other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && other.fontStyle == fontStyle && - other.textAlign == textAlign; + other.textAlign == textAlign && + other.position == position; } @override @@ -763,6 +799,7 @@ class KatexSpanStyles { if (fontWeight != null) args.add('fontWeight: $fontWeight'); if (fontStyle != null) args.add('fontStyle: $fontStyle'); if (textAlign != null) args.add('textAlign: $textAlign'); + if (position != null) args.add('position: $position'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } @@ -785,6 +822,7 @@ class KatexSpanStyles { fontStyle: other.fontStyle ?? fontStyle, fontWeight: other.fontWeight ?? fontWeight, textAlign: other.textAlign ?? textAlign, + position: other.position ?? position, ); } @@ -799,6 +837,7 @@ class KatexSpanStyles { bool fontWeight = true, bool fontStyle = true, bool textAlign = true, + bool position = true, }) { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, @@ -811,6 +850,7 @@ class KatexSpanStyles { fontWeight: fontWeight ? this.fontWeight : null, fontStyle: fontStyle ? this.fontStyle : null, textAlign: textAlign ? this.textAlign : null, + position: position ? this.position : null, ); } } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index ba05f5205e..01d41a9108 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -925,10 +925,6 @@ class _KatexSpan extends StatelessWidget { // So, this should always be null for non `strut` spans. assert(styles.verticalAlignEm == null); - // 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, @@ -1001,6 +997,13 @@ class _KatexSpan extends StatelessWidget { widget = Padding(padding: margin, child: widget); } + if (styles.topEm != null) { + assert(styles.position == KatexSpanPosition.relative); + widget = Transform.translate( + offset: Offset(0, styles.topEm! * em), + child: widget); + } + return widget; } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 93e94b5f85..7c372f5485 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1165,6 +1165,30 @@ class ContentExample { ]), ]); + static const mathBlockKatexBigOperators = ContentExample( + 'math block katex big operators', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2203220 + '```math\n\\bigsqcup\n```', + '

' + '' + '\\bigsqcup' + '

', [ + MathBlockNode(texSource: '\\bigsqcup', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 1.6, verticalAlignEm: -0.55), + KatexSpanNode( + styles: KatexSpanStyles( + topEm: 0.0, + fontFamily: 'KaTeX_Size2', + position: KatexSpanPosition.relative), + text: '⨆', nodes: null), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2259,6 +2283,7 @@ void main() async { testParseExample(ContentExample.mathBlockKatexSubscript); testParseExample(ContentExample.mathBlockKatexSubSuperScript); testParseExample(ContentExample.mathBlockKatexRaisebox); + testParseExample(ContentExample.mathBlockKatexBigOperators); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7cc249d79b..78fe5691f2 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -619,6 +619,9 @@ void main() { ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), ]), + (ContentExample.mathBlockKatexBigOperators, skip: false, [ + ('⨆', Offset(0.00, 6.46), Size(22.84, 25.00)), + ]), ]; for (final testCase in testCases) {