From 31381d6ee5ca9a3181eb105ea4918c6df31d3f8f Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 11 Aug 2025 21:16:02 +0530 Subject: [PATCH] katex: Handle `position` & `top` property in span inline styles Allowing support for handling KaTeX HTML for big operators. Fixes: #1671 --- lib/model/katex.dart | 50 ++++++++++++++++++++++++++++++++---- lib/widgets/katex.dart | 21 ++++++++++++--- test/model/katex_test.dart | 24 +++++++++++++++++ test/widgets/katex_test.dart | 3 +++ 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 8a793f663a..370e0a2239 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -631,6 +631,7 @@ class _KatexParser { marginLeftEm: _takeStyleEm(inlineStyles, 'margin-left'), marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), color: _takeStyleColor(inlineStyles, 'color'), + position: _takeStylePosition(inlineStyles, 'position'), // TODO handle more CSS properties ); if (inlineStyles != null && inlineStyles.isNotEmpty) { @@ -640,10 +641,10 @@ class _KatexParser { _hasError = true; } } - // Currently, we expect `top` to only be inside a vlist, and - // we handle that case separately above. - if (styles.topEm != null) { - throw _KatexHtmlParseError('unsupported inline CSS property: top'); + // Currently, we expect `top` to be inside a vlist (which we handle it + // separately above), and if it's along with a `position: relative`. + if (styles.topEm != null && styles.position != KatexSpanPosition.relative) { + throw _KatexHtmlParseError('unsupported inline CSS property "top", when "position: ${styles.position}"'); } String? text; @@ -765,6 +766,34 @@ class _KatexParser { _hasError = true; return null; } + + /// Remove the given property from the given style map, + /// and parse as a literal value of CSS position attribute. + /// + /// If the property is present but is not a valid literal value of + /// position attribute, record an error and return null. + /// + /// If the property is absent, return null with no error. + /// + /// If the map is null, treat it as empty. + /// + /// To produce the map this method expects, see [_parseInlineStyles]. + KatexSpanPosition? _takeStylePosition(Map? styles, String property) { + final expression = styles?.remove(property); + if (expression == null) return null; + if (expression case css_visitor.LiteralTerm(:final value)) { + if (value case css_visitor.Identifier(:final name)) { + if (name == 'relative') { + return KatexSpanPosition.relative; + } + } + } + assert(debugLog('KaTeX: Unsupported value for CSS property $property,' + ' expected a position literal value: ${expression.toDebugString()}')); + unsupportedInlineCssProperties.add(property); + _hasError = true; + return null; + } } enum KatexSpanFontWeight { @@ -782,6 +811,10 @@ enum KatexSpanTextAlign { right, } +enum KatexSpanPosition { + relative, +} + class KatexSpanColor { const KatexSpanColor(this.r, this.g, this.b, this.a); @@ -832,6 +865,7 @@ class KatexSpanStyles { final KatexSpanTextAlign? textAlign; final KatexSpanColor? color; + final KatexSpanPosition? position; const KatexSpanStyles({ this.heightEm, @@ -844,6 +878,7 @@ class KatexSpanStyles { this.fontStyle, this.textAlign, this.color, + this.position, }); @override @@ -859,6 +894,7 @@ class KatexSpanStyles { fontStyle, textAlign, color, + position, ); @override @@ -873,7 +909,8 @@ class KatexSpanStyles { other.fontWeight == fontWeight && other.fontStyle == fontStyle && other.textAlign == textAlign && - other.color == color; + other.color == color && + other.position == position; } @override @@ -889,6 +926,7 @@ class KatexSpanStyles { if (fontStyle != null) args.add('fontStyle: $fontStyle'); if (textAlign != null) args.add('textAlign: $textAlign'); if (color != null) args.add('color: $color'); + if (position != null) args.add('position: $position'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } @@ -904,6 +942,7 @@ class KatexSpanStyles { bool fontStyle = true, bool textAlign = true, bool color = true, + bool position = true, }) { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, @@ -916,6 +955,7 @@ class KatexSpanStyles { fontStyle: fontStyle ? this.fontStyle : null, textAlign: textAlign ? this.textAlign : null, color: color ? this.color : null, + position: position ? this.position : null, ); } } diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 2cae2ce5fe..5c52b9f554 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -97,10 +97,6 @@ class _KatexSpan extends StatelessWidget { 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, @@ -180,6 +176,23 @@ class _KatexSpan extends StatelessWidget { widget = Padding(padding: margin, child: widget); } + switch (styles.position) { + case KatexSpanPosition.relative: + final offset = switch (styles.topEm) { + final topEm? => Offset(0, topEm * em), + null => null, + }; + + if (offset != null) { + widget = Transform.translate( + offset: offset, + child: widget); + } + + case null: + break; + } + return widget; } } diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 7c28d013d3..1da9564876 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -643,6 +643,29 @@ class KatexExample extends ContentExample { text: '∗'), ]), ]); + + static final bigOperators = KatexExample.block( + r'big operators: \int', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2240766 + r'\int', + '

' + '' + '\\int' + '

', [ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 2.2222, verticalAlignEm: -0.8622), + KatexSpanNode( + styles: KatexSpanStyles( + topEm: -0.0011, + marginRightEm: 0.44445, + fontFamily: 'KaTeX_Size2', + position: KatexSpanPosition.relative), + text: '∫'), + ]), + ]); } void main() async { @@ -663,6 +686,7 @@ void main() async { testParseExample(KatexExample.textColor); testParseExample(KatexExample.customColorMacro); testParseExample(KatexExample.phantom); + testParseExample(KatexExample.bigOperators); group('parseCssHexColor', () { const testCases = [ diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart index 11af76c9eb..808780e549 100644 --- a/test/widgets/katex_test.dart +++ b/test/widgets/katex_test.dart @@ -73,6 +73,9 @@ void main() { ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), ]), + (KatexExample.bigOperators, skip: false, [ + ('∫', Offset(0.00, 12.02), Size(11.43, 25.00)), + ]) ]; for (final testCase in testCases) {