From 5677317bcef6f44b2e97facfa36fbc319b052326 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 22:04:00 +0530 Subject: [PATCH 1/7] content [nfc]: Remove the `inline` property in _Katex widget And inline the behaviour for `inline: false` in MathBlock widget. --- lib/widgets/content.dart | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5f0171b5a5..bd4ade091f 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -822,7 +822,13 @@ class MathBlock extends StatelessWidget { children: [TextSpan(text: node.texSource)]))); } - return _Katex(inline: false, nodes: nodes); + return Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: _Katex( + nodes: nodes)))); } } @@ -835,24 +841,15 @@ const kBaseKatexTextStyle = TextStyle( class _Katex extends StatelessWidget { const _Katex({ - required this.inline, required this.nodes, }); - final bool inline; final List nodes; @override Widget build(BuildContext context) { Widget widget = _KatexNodeList(nodes: nodes); - if (!inline) { - widget = Center( - child: SingleChildScrollViewWithScrollbar( - scrollDirection: Axis.horizontal, - child: widget)); - } - return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( @@ -1274,7 +1271,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(inline: true, nodes: nodes)); + child: _Katex(nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, From b38301fba0bdccb43ccf592493d10475ccfcb9dd Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 7 Jul 2025 14:22:54 +0530 Subject: [PATCH 2/7] content test: Add offset and size based widget tests for KaTeX content And remove font and fontsize based tests, as the newer rect offset/size based tests are more accurate anyway. --- lib/widgets/content.dart | 10 +- test/widgets/content_test.dart | 190 +++++++++++++++++---------------- 2 files changed, 106 insertions(+), 94 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index bd4ade091f..edc11d473c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -827,7 +827,7 @@ class MathBlock extends StatelessWidget { textDirection: TextDirection.ltr, child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: _Katex( + child: KatexWidget( nodes: nodes)))); } } @@ -839,8 +839,10 @@ const kBaseKatexTextStyle = TextStyle( fontFamily: 'KaTeX_Main', height: 1.2); -class _Katex extends StatelessWidget { - const _Katex({ +@visibleForTesting +class KatexWidget extends StatelessWidget { + const KatexWidget({ + super.key, required this.nodes, }); @@ -1271,7 +1273,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(nodes: nodes)); + child: KatexWidget(nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 9ed263fce9..c87668514c 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -3,6 +3,7 @@ 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'; @@ -572,98 +573,65 @@ void main() { tester.widget(find.text('λ', findRichText: true)); }); - void checkKatexText( - WidgetTester tester, - String text, { - required String fontFamily, - required double fontSize, - required double fontHeight, - }) { - check(mergedStyleOf(tester, text)).isNotNull() - ..fontFamily.equals(fontFamily) - ..fontSize.equals(fontSize); - check(tester.getSize(find.text(text))) - .height.isCloseTo(fontSize * fontHeight, 0.5); - } - - testWidgets('displays KaTeX content with different sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexSizing; - await prepareContent(tester, plainContent(content.html)); - - final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; - final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (var katexNode in nodes) { - katexNode = katexNode as KatexSpanNode; - final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; - checkKatexText(tester, katexNode.text!, - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + 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, 9.90), Size(21.33, 51.00)), + ('3', Offset(46.91, 16.30), Size(17.77, 43.00)), + ('4', Offset(64.68, 21.63), Size(14.80, 36.00)), + ('5', Offset(79.48, 26.07), Size(12.34, 30.00)), + ('6', Offset(91.82, 29.77), Size(10.28, 25.00)), + ('7', Offset(102.10, 31.62), Size(9.25, 22.00)), + ('8', Offset(111.35, 33.47), Size(8.23, 20.00)), + ('9', Offset(119.58, 35.32), Size(7.20, 17.00)), + ('0', Offset(126.77, 39.02), Size(5.14, 12.00)), + ]), + (ContentExample.mathBlockKatexNestedSizing, skip: false, [ + ('1', Offset(0.00, 39.58), Size(5.14, 12.00)), + ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), + ]), + // TODO: Re-enable this test after adding support for parsing + // `vertical-align` in inline styles. Currently it fails + // because `strut` span has `vertical-align`. + (ContentExample.mathBlockKatexDelimSizing, skip: true, [ + ('(', Offset(8.00, 46.36), Size(9.42, 25.00)), + ('[', Offset(17.42, 48.36), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 49.36), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 49.36), Size(13.14, 25.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); } }); - - testWidgets('displays KaTeX content with nested sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexNestedSizing; - await prepareContent(tester, plainContent(content.html)); - - var fontSize = 0.5 * kBaseKatexTextStyle.fontSize!; - checkKatexText(tester, '1', - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - - fontSize = 4.976 * fontSize; - checkKatexText(tester, '2', - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - }); - - testWidgets('displays KaTeX content with different delimiter sizing', (tester) async { - addTearDown(testBinding.reset); - final globalSettings = testBinding.globalStore.settings; - await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); - check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); - - final content = ContentExample.mathBlockKatexDelimSizing; - await prepareContent(tester, plainContent(content.html)); - - final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; - var nodes = baseNode.nodes!.skip(1); // Skip .strut node. - - final fontSize = kBaseKatexTextStyle.fontSize!; - - final firstNode = nodes.first as KatexSpanNode; - checkKatexText(tester, firstNode.text!, - fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - nodes = nodes.skip(1); - - for (var katexNode in nodes) { - katexNode = katexNode as KatexSpanNode; - katexNode = katexNode.nodes!.single as KatexSpanNode; // Skip empty .mord parent. - final fontFamily = katexNode.styles.fontFamily!; - checkKatexText(tester, katexNode.text!, - fontFamily: fontFamily, - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); - } - }, skip: true); // TODO: Re-enable this test after adding support for parsing - // `vertical-align` in inline styles. Currently it fails - // because `strut` span has `vertical-align`. }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -1432,3 +1400,45 @@ 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(); + } +} From bd353ef8bd5f3b5926b76a030d04d0f1f35be444 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 7 Jul 2025 23:45:21 +0530 Subject: [PATCH 3/7] content: Add a workaround for incorrect sizing in WidgetSpan, for KaTeX --- lib/widgets/content.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index edc11d473c..543352e0bb 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -873,9 +873,15 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: switch (e) { - KatexSpanNode() => _KatexSpan(e), - }); + // 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), + })); })))); } } From e8e8f410570712e34dd228b09e7b7b0d0eb10780 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 9 Jul 2025 02:22:19 +0530 Subject: [PATCH 4/7] content: Update base KaTeX text style to be explicit This fixes a bug about `leadingDistribution` where previously it was taking the default value of `TextLeadingDistribution.proportional`, now it uses `TextLeadingDistribution.even` which seems to be the default strategy used by CSS, and it doesn't look like `katex.scss` overrides it. The vertical offsets being updated in tests are because of this fix. Another potential bug fix is about `textBaseline` where on some locale systems the default value for this could be `TextBaseline.ideographic`, and for KaTeX we always want `TextBaseline.alphabetic`. --- lib/widgets/content.dart | 8 +++++++- test/widgets/content_test.dart | 26 +++++++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 543352e0bb..7f018a5c46 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -837,7 +837,13 @@ class MathBlock extends StatelessWidget { const kBaseKatexTextStyle = TextStyle( fontSize: kBaseFontSize * 1.21, fontFamily: 'KaTeX_Main', - height: 1.2); + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + decoration: TextDecoration.none, + fontFamilyFallback: []); @visibleForTesting class KatexWidget extends StatelessWidget { diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index c87668514c..d09fc5a989 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -577,18 +577,18 @@ void main() { 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, 9.90), Size(21.33, 51.00)), - ('3', Offset(46.91, 16.30), Size(17.77, 43.00)), - ('4', Offset(64.68, 21.63), Size(14.80, 36.00)), - ('5', Offset(79.48, 26.07), Size(12.34, 30.00)), - ('6', Offset(91.82, 29.77), Size(10.28, 25.00)), - ('7', Offset(102.10, 31.62), Size(9.25, 22.00)), - ('8', Offset(111.35, 33.47), Size(8.23, 20.00)), - ('9', Offset(119.58, 35.32), Size(7.20, 17.00)), - ('0', Offset(126.77, 39.02), Size(5.14, 12.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, 39.58), Size(5.14, 12.00)), + ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), ]), // TODO: Re-enable this test after adding support for parsing @@ -596,9 +596,9 @@ void main() { // because `strut` span has `vertical-align`. (ContentExample.mathBlockKatexDelimSizing, skip: true, [ ('(', Offset(8.00, 46.36), Size(9.42, 25.00)), - ('[', Offset(17.42, 48.36), Size(9.71, 25.00)), - ('⌈', Offset(27.12, 49.36), Size(11.99, 25.00)), - ('⌊', Offset(39.11, 49.36), Size(13.14, 25.00)), + ('[', Offset(17.42, 46.36), Size(9.71, 25.00)), + ('⌈', Offset(27.12, 46.36), Size(11.99, 25.00)), + ('⌊', Offset(39.11, 46.36), Size(13.14, 25.00)), ]), ]; From 0ec1641fbbc554abc2962356e1803e06b2da0bca Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 22 Apr 2025 18:01:46 +0530 Subject: [PATCH 5/7] content: Scale inline KaTeX content based on the surrounding text This applies the correct font scaling if the KaTeX content is inside a header. --- lib/widgets/content.dart | 37 +++++++++++++++++++++------------- test/widgets/content_test.dart | 26 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 7f018a5c46..8a06988a12 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -828,30 +828,39 @@ class MathBlock extends StatelessWidget { child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, child: KatexWidget( + textStyle: ContentTheme.of(context).textStylePlainParagraph, nodes: nodes)))); } } -// Base text style from .katex class in katex.scss : -// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 -const kBaseKatexTextStyle = TextStyle( - fontSize: kBaseFontSize * 1.21, - fontFamily: 'KaTeX_Main', - height: 1.2, - fontWeight: FontWeight.normal, - fontStyle: FontStyle.normal, - textBaseline: TextBaseline.alphabetic, - leadingDistribution: TextLeadingDistribution.even, - decoration: TextDecoration.none, - fontFamilyFallback: []); +/// 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 @@ -861,7 +870,7 @@ class KatexWidget extends StatelessWidget { return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( - style: kBaseKatexTextStyle.copyWith( + style: mkBaseKatexTextStyle(textStyle).copyWith( color: ContentTheme.of(context).textStylePlainParagraph.color), child: widget)); } @@ -1285,7 +1294,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: KatexWidget(nodes: nodes)); + child: KatexWidget(textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index d09fc5a989..e0e4180c13 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1042,6 +1042,32 @@ void main() { testContentSmoke(ContentExample.mathInline); testWidgets('maintains font-size ratio with surrounding text', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings.getBool(BoolGlobalSetting.renderKatex)).isTrue(); + + const html = '' + 'λ' + ' \\lambda ' + ''; + await checkFontSizeRatio(tester, + targetHtml: html, + targetFontSizeFinder: (rootSpan) { + late final double result; + rootSpan.visitChildren((span) { + if (span case WidgetSpan(child: KatexWidget() && var widget)) { + result = mergedStyleOf(tester, + findAncestor: find.byWidget(widget), r'λ')!.fontSize!; + return false; + } + return true; + }); + return result; + }); + }); + + testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async { const html = '' 'λ' ' \\lambda ' From 85f4ecfcd1e4c564aee3da7135adf751625a6d2f Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 29 May 2025 20:00:22 +0530 Subject: [PATCH 6/7] content: Handle 'strut' span in KaTeX content In KaTeX HTML it is used to set the baseline of the content in a span, so handle it separately here. --- lib/model/content.dart | 18 ++++++++++ lib/model/katex.dart | 64 ++++++++++++++++++++++++++++++++-- lib/widgets/content.dart | 29 +++++++++++++++ test/model/content_test.dart | 36 +++++++------------ test/widgets/content_test.dart | 15 ++++---- 5 files changed, 127 insertions(+), 35 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index e80247325f..78fc961c00 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -411,6 +411,24 @@ class KatexSpanNode extends KatexNode { } } +class KatexStrutNode extends KatexNode { + const KatexStrutNode({ + required this.heightEm, + required this.verticalAlignEm, + super.debugHtmlNode, + }); + + final double heightEm; + final double? verticalAlignEm; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('heightEm', heightEm)); + properties.add(DoubleProperty('verticalAlignEm', verticalAlignEm)); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 922546c676..f896bbdc86 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -187,7 +187,34 @@ class _KatexParser { final debugHtmlNode = kDebugMode ? element : null; + if (element.className == 'strut') { + if (element.nodes.isNotEmpty) throw _KatexHtmlParseError(); + + final styles = _parseSpanInlineStyles(element); + if (styles == null) throw _KatexHtmlParseError(); + + final heightEm = styles.heightEm; + if (heightEm == null) throw _KatexHtmlParseError(); + final verticalAlignEm = styles.verticalAlignEm; + + // Ensure only `height` and `vertical-align` inline styles are present. + if (styles.filter(heightEm: false, verticalAlignEm: false) + != const KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm, + debugHtmlNode: debugHtmlNode); + } + 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(); + } // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. @@ -214,8 +241,9 @@ class _KatexParser { case 'strut': // .strut { ... } - // Do nothing, it has properties that don't need special handling. - break; + // We expect the 'strut' class to be the only class in a span, + // in which case we handle it separately and emit `KatexStrutNode`. + throw _KatexHtmlParseError(); case 'textbf': // .textbf { font-weight: bold; } @@ -463,6 +491,7 @@ class _KatexParser { final stylesheet = css_parser.parse('*{$styleStr}'); if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; + double? verticalAlignEm; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -474,6 +503,10 @@ class _KatexParser { case 'height': heightEm = _getEm(expression); if (heightEm != null) continue; + + case 'vertical-align': + verticalAlignEm = _getEm(expression); + if (verticalAlignEm != null) continue; } // TODO handle more CSS properties @@ -488,6 +521,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + verticalAlignEm: verticalAlignEm, ); } else { throw _KatexHtmlParseError(); @@ -524,6 +558,7 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { final double? heightEm; + final double? verticalAlignEm; final String? fontFamily; final double? fontSizeEm; @@ -533,6 +568,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, + this.verticalAlignEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -544,6 +580,7 @@ class KatexSpanStyles { int get hashCode => Object.hash( 'KatexSpanStyles', heightEm, + verticalAlignEm, fontFamily, fontSizeEm, fontWeight, @@ -555,6 +592,7 @@ class KatexSpanStyles { bool operator ==(Object other) { return other is KatexSpanStyles && other.heightEm == heightEm && + other.verticalAlignEm == verticalAlignEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -566,6 +604,7 @@ class KatexSpanStyles { String toString() { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); + if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -584,6 +623,7 @@ class KatexSpanStyles { KatexSpanStyles merge(KatexSpanStyles other) { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, + verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, fontFamily: other.fontFamily ?? fontFamily, fontSizeEm: other.fontSizeEm ?? fontSizeEm, fontStyle: other.fontStyle ?? fontStyle, @@ -591,6 +631,26 @@ class KatexSpanStyles { textAlign: other.textAlign ?? textAlign, ); } + + KatexSpanStyles filter({ + bool heightEm = true, + bool verticalAlignEm = true, + bool fontFamily = true, + bool fontSizeEm = true, + bool fontWeight = true, + bool fontStyle = true, + bool textAlign = true, + }) { + return KatexSpanStyles( + heightEm: heightEm ? this.heightEm : null, + verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + fontFamily: fontFamily ? this.fontFamily : null, + fontSizeEm: fontSizeEm ? this.fontSizeEm : null, + fontWeight: fontWeight ? this.fontWeight : null, + fontStyle: fontStyle ? this.fontStyle : null, + textAlign: textAlign ? this.textAlign : null, + ); + } } class _KatexHtmlParseError extends Error { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 8a06988a12..8cdb1e79ec 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -896,6 +896,7 @@ class _KatexNodeList extends StatelessWidget { data: MediaQueryData(textScaler: TextScaler.noScaling), child: switch (e) { KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), })); })))); } @@ -918,6 +919,10 @@ class _KatexSpan extends StatelessWidget { } final styles = node.styles; + // We expect vertical-align to be only present with the + // `strut` span, for which parser explicitly emits `KatexStrutNode`. + // So, this should always be null for non `strut` spans. + assert(styles.verticalAlignEm == null); final fontFamily = styles.fontFamily; final fontSize = switch (styles.fontSizeEm) { @@ -976,6 +981,30 @@ class _KatexSpan extends StatelessWidget { } } +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 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 f9b461e17c..652a6f10b8 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -519,7 +519,7 @@ class ContentExample { '

', MathInlineNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -539,7 +539,7 @@ class ContentExample { '

', [MathBlockNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -564,7 +564,7 @@ class ContentExample { '

', [ MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -575,7 +575,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'b', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -603,7 +603,7 @@ class ContentExample { [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -632,7 +632,7 @@ class ContentExample { [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -643,7 +643,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'b', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -681,7 +681,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306),text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -731,10 +731,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 1.6034), - text: null, - nodes: []), + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', @@ -800,10 +797,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 1.6034), - text: null, - nodes: []), + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, @@ -845,10 +839,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 3.0), - text: null, - nodes: []), + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', @@ -1981,10 +1972,7 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); - // TODO: Re-enable this test after adding support for parsing - // `vertical-align` in inline styles. Currently it fails - // because `strut` span has `vertical-align`. - testParseExample(ContentExample.mathBlockKatexDelimSizing, skip: true); + testParseExample(ContentExample.mathBlockKatexDelimSizing); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index e0e4180c13..7657a80c3f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -591,14 +591,11 @@ void main() { ('1', Offset(0.00, 40.24), Size(5.14, 12.00)), ('2', Offset(5.14, 2.80), Size(25.59, 61.00)), ]), - // TODO: Re-enable this test after adding support for parsing - // `vertical-align` in inline styles. Currently it fails - // because `strut` span has `vertical-align`. - (ContentExample.mathBlockKatexDelimSizing, skip: true, [ - ('(', Offset(8.00, 46.36), Size(9.42, 25.00)), - ('[', Offset(17.42, 46.36), Size(9.71, 25.00)), - ('⌈', Offset(27.12, 46.36), Size(11.99, 25.00)), - ('⌊', Offset(39.11, 46.36), Size(13.14, 25.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)), ]), ]; @@ -629,7 +626,7 @@ void main() { check(size) .within(distance: 0.05, from: expectedSize); } - }, skip: testCase.skip); + }); } }); }); From 1fe817cfce109c8e74339a5ddae10ab552df9e59 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:46:15 +0530 Subject: [PATCH 7/7] content: Handle positive margin-right and margin-left in KaTeX spans --- lib/model/katex.dart | 35 ++++++++++++++++++++ lib/widgets/content.dart | 25 ++++++++++++-- test/model/content_test.dart | 60 ++++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 5 +++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index f896bbdc86..057f7076bc 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -492,6 +492,8 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? marginRightEm; + double? marginLeftEm; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -507,6 +509,20 @@ class _KatexParser { case 'vertical-align': verticalAlignEm = _getEm(expression); if (verticalAlignEm != null) continue; + + case 'margin-right': + marginRightEm = _getEm(expression); + if (marginRightEm != null) { + if (marginRightEm < 0) throw _KatexHtmlParseError(); + continue; + } + + case 'margin-left': + marginLeftEm = _getEm(expression); + if (marginLeftEm != null) { + if (marginLeftEm < 0) throw _KatexHtmlParseError(); + continue; + } } // TODO handle more CSS properties @@ -522,6 +538,8 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, verticalAlignEm: verticalAlignEm, + marginRightEm: marginRightEm, + marginLeftEm: marginLeftEm, ); } else { throw _KatexHtmlParseError(); @@ -560,6 +578,9 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? marginRightEm; + final double? marginLeftEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -569,6 +590,8 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.marginRightEm, + this.marginLeftEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -581,6 +604,8 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + marginRightEm, + marginLeftEm, fontFamily, fontSizeEm, fontWeight, @@ -593,6 +618,8 @@ class KatexSpanStyles { return other is KatexSpanStyles && other.heightEm == heightEm && other.verticalAlignEm == verticalAlignEm && + other.marginRightEm == marginRightEm && + other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -605,6 +632,8 @@ class KatexSpanStyles { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); + if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); + if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -624,6 +653,8 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, + marginRightEm: other.marginRightEm ?? marginRightEm, + marginLeftEm: other.marginLeftEm ?? marginLeftEm, fontFamily: other.fontFamily ?? fontFamily, fontSizeEm: other.fontSizeEm ?? fontSizeEm, fontStyle: other.fontStyle ?? fontStyle, @@ -635,6 +666,8 @@ class KatexSpanStyles { KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool marginRightEm = true, + bool marginLeftEm = true, bool fontFamily = true, bool fontSizeEm = true, bool fontWeight = true, @@ -644,6 +677,8 @@ class KatexSpanStyles { return KatexSpanStyles( heightEm: heightEm ? this.heightEm : null, verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + marginRightEm: marginRightEm ? this.marginRightEm : null, + marginLeftEm: marginLeftEm ? this.marginLeftEm : null, fontFamily: fontFamily ? this.fontFamily : null, fontSizeEm: fontSizeEm ? this.fontSizeEm : null, fontWeight: fontWeight ? this.fontWeight : null, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 8cdb1e79ec..52ab7008b8 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -909,7 +909,7 @@ class _KatexSpan extends StatelessWidget { @override Widget build(BuildContext context) { - final em = DefaultTextStyle.of(context).style.fontSize!; + var em = DefaultTextStyle.of(context).style.fontSize!; Widget widget = const SizedBox.shrink(); if (node.text != null) { @@ -929,6 +929,8 @@ class _KatexSpan extends StatelessWidget { double fontSizeEm => fontSizeEm * em, null => null, }; + if (fontSize != null) em = fontSize; + final fontWeight = switch (styles.fontWeight) { KatexSpanFontWeight.bold => FontWeight.bold, null => null, @@ -973,11 +975,28 @@ class _KatexSpan extends StatelessWidget { child: widget); } - return SizedBox( + widget = SizedBox( height: styles.heightEm != null - ? styles.heightEm! * (fontSize ?? em) + ? 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; } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 652a6f10b8..c19e1c02a5 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -884,6 +884,65 @@ class ContentExample { ]), ]); + 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 imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -1973,6 +2032,7 @@ void main() async { testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); testParseExample(ContentExample.mathBlockKatexDelimSizing); + testParseExample(ContentExample.mathBlockKatexSpace); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7657a80c3f..f6366a9215 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -597,6 +597,11 @@ void main() { ('⌈', 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)), + ]), ]; for (final testCase in testCases) {