From 16bec6a7ea7731af22dafc83772cc1642842c79b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:54:24 +0530 Subject: [PATCH 1/4] 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 | 107 +++++++++++++++- lib/widgets/content.dart | 22 ++++ test/model/content_test.dart | 226 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 17 +++ 5 files changed, 402 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..501955cff7 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -209,11 +209,99 @@ 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(); + } + } + + // TODO(#1716) Handle styling for .vlist-t2 spans + 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 +312,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 +582,7 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? topEm; double? marginRightEm; double? marginLeftEm; @@ -510,6 +601,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 +632,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, @@ -578,6 +674,8 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? topEm; + final double? marginRightEm; final double? marginLeftEm; @@ -590,6 +688,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.topEm, this.marginRightEm, this.marginLeftEm, this.fontFamily, @@ -604,6 +703,7 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + topEm, marginRightEm, marginLeftEm, fontFamily, @@ -618,6 +718,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 +733,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 +755,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 +769,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 +781,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 967a498f3ad4c60e4e67f345a109c651bb9b2869 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 20 Jun 2025 21:04:39 +0530 Subject: [PATCH 2/4] 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 501955cff7..2652e89523 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 74b08db3aec351b8b74d59b2919e6c0a95c6c9c3 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 05:34:26 +0530 Subject: [PATCH 3/4] 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 c4503b492adbb9c69f0957fd8c277d5e878f444e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 17 Jul 2025 19:49:19 +0530 Subject: [PATCH 4/4] 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 2652e89523..8fe55dde77 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) {