diff --git a/lib/model/content.dart b/lib/model/content.dart index 78fc961c00..9f906d1c4c 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -429,6 +429,42 @@ 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)); + } + + @override + List debugDescribeChildren() { + return [node.toDiagnosticsNode()]; + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 057f7076bc..28b4417e93 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -209,6 +209,112 @@ 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: []), + ]) && final vlist, + ]), + ]) { + // 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(); + } + } + + 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) { + // 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) { + if (innerSpan case dom.Element( + localName: 'span', + 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(); + 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 @@ -224,7 +330,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; @@ -474,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); @@ -492,8 +611,10 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? topEm; double? marginRightEm; double? marginLeftEm; + KatexSpanPosition? position; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -510,6 +631,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) { @@ -523,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 @@ -537,9 +669,11 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, + position: position, ); } else { throw _KatexHtmlParseError(); @@ -556,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 { @@ -573,11 +718,17 @@ enum KatexSpanTextAlign { right, } +enum KatexSpanPosition { + relative, +} + @immutable class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? topEm; + final double? marginRightEm; final double? marginLeftEm; @@ -587,9 +738,12 @@ class KatexSpanStyles { final KatexSpanFontStyle? fontStyle; final KatexSpanTextAlign? textAlign; + final KatexSpanPosition? position; + const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.topEm, this.marginRightEm, this.marginLeftEm, this.fontFamily, @@ -597,6 +751,7 @@ class KatexSpanStyles { this.fontWeight, this.fontStyle, this.textAlign, + this.position, }); @override @@ -604,6 +759,7 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + topEm, marginRightEm, marginLeftEm, fontFamily, @@ -611,6 +767,7 @@ class KatexSpanStyles { fontWeight, fontStyle, textAlign, + position, ); @override @@ -618,13 +775,15 @@ class KatexSpanStyles { return other is KatexSpanStyles && other.heightEm == heightEm && other.verticalAlignEm == verticalAlignEm && + other.topEm == topEm && other.marginRightEm == marginRightEm && other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && other.fontStyle == fontStyle && - other.textAlign == textAlign; + other.textAlign == textAlign && + other.position == position; } @override @@ -632,6 +791,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'); @@ -639,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(', ')})'; } @@ -653,6 +814,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, @@ -660,12 +822,14 @@ class KatexSpanStyles { fontStyle: other.fontStyle ?? fontStyle, fontWeight: other.fontWeight ?? fontWeight, textAlign: other.textAlign ?? textAlign, + position: other.position ?? position, ); } KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool topEm = true, bool marginRightEm = true, bool marginLeftEm = true, bool fontFamily = true, @@ -673,10 +837,12 @@ class KatexSpanStyles { bool fontWeight = true, bool fontStyle = true, bool textAlign = true, + bool position = true, }) { 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, @@ -684,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 52ab7008b8..01d41a9108 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), })); })))); } @@ -996,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; } } @@ -1024,6 +1032,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..7c372f5485 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -943,6 +943,252 @@ 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 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 @@ -2033,6 +2279,11 @@ 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.mathBlockKatexBigOperators); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f6366a9215..78fe5691f2 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -602,6 +602,26 @@ 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)), + ]), + (ContentExample.mathBlockKatexBigOperators, skip: false, [ + ('⨆', Offset(0.00, 6.46), Size(22.84, 25.00)), + ]), ]; for (final testCase in testCases) {