From f17ab3543c94de53ac2a747fed1b00adef733ca8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 22:04:00 +0530 Subject: [PATCH 01/12] content [nfc]: Remove the `inline` property in _Katex widget And inline the behaviour for `inline: false` in MathBlock widget. --- lib/widgets/content.dart | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index b49fdb4d9c..396bbbba1a 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -820,7 +820,11 @@ class MathBlock extends StatelessWidget { children: [TextSpan(text: node.texSource)]))); } - return _Katex(inline: false, nodes: nodes); + return Center( + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: _Katex( + nodes: nodes))); } } @@ -833,24 +837,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( @@ -1272,7 +1267,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 c4628c7f0c2f37a2b3fad5465ce1dbb1d8ff524b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 22 Apr 2025 18:01:46 +0530 Subject: [PATCH 02/12] 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 | 38 ++++++++++++++++++++++------------ test/widgets/content_test.dart | 30 ++++++++++++++++++--------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 396bbbba1a..722741b7ee 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -824,22 +824,33 @@ class MathBlock extends StatelessWidget { child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, child: _Katex( + 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); +/// 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); +} class _Katex extends StatelessWidget { const _Katex({ + required this.textStyle, required this.nodes, }); + final TextStyle textStyle; final List nodes; @override @@ -848,9 +859,8 @@ class _Katex extends StatelessWidget { return Directionality( textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: kBaseKatexTextStyle.copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), + child: DefaultTextStyle.merge( + style: mkBaseKatexTextStyle(textStyle), child: widget)); } } @@ -867,9 +877,11 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: switch (e) { - KatexSpanNode() => _KatexSpan(e), - }); + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + })); })))); } } @@ -1267,7 +1279,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(nodes: nodes)); + child: _Katex(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 754410fddc..2f64aad97a 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -595,16 +595,20 @@ void main() { final content = ContentExample.mathBlockKatexSizing; await prepareContent(tester, plainContent(content.html)); + final context = tester.element(find.byType(MathBlock)); + final baseTextStyle = + mkBaseKatexTextStyle(ContentTheme.of(context).textStylePlainParagraph); + 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!; + final fontSize = katexNode.styles.fontSizeEm! * baseTextStyle.fontSize!; checkKatexText(tester, katexNode.text!, fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); } }); @@ -617,17 +621,21 @@ void main() { final content = ContentExample.mathBlockKatexNestedSizing; await prepareContent(tester, plainContent(content.html)); - var fontSize = 0.5 * kBaseKatexTextStyle.fontSize!; + final context = tester.element(find.byType(MathBlock)); + final baseTextStyle = + mkBaseKatexTextStyle(ContentTheme.of(context).textStylePlainParagraph); + + var fontSize = 0.5 * baseTextStyle.fontSize!; checkKatexText(tester, '1', fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); fontSize = 4.976 * fontSize; checkKatexText(tester, '2', fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); }); testWidgets('displays KaTeX content with different delimiter sizing', (tester) async { @@ -643,13 +651,15 @@ void main() { final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; var nodes = baseNode.nodes!.skip(1); // Skip .strut node. - final fontSize = kBaseKatexTextStyle.fontSize!; + final context = tester.element(find.byType(MathBlock)); + final baseTextStyle = + mkBaseKatexTextStyle(ContentTheme.of(context).textStylePlainParagraph); final firstNode = nodes.first as KatexSpanNode; checkKatexText(tester, firstNode.text!, fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontSize: baseTextStyle.fontSize!, + fontHeight: baseTextStyle.height!); nodes = nodes.skip(1); for (var katexNode in nodes) { @@ -658,8 +668,8 @@ void main() { final fontFamily = katexNode.styles.fontFamily!; checkKatexText(tester, katexNode.text!, fontFamily: fontFamily, - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontSize: baseTextStyle.fontSize!, + fontHeight: baseTextStyle.height!); } }, skip: true); // TODO: Re-enable this test after adding support for parsing // `vertical-align` in inline styles. Currently it fails From 832d1b2689e8ca4fc182abd32e6ddc3942ff9a0f Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 29 May 2025 20:00:22 +0530 Subject: [PATCH 03/12] 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 | 65 ++++++++++++++++++++++++++++++++-- lib/widgets/content.dart | 25 +++++++++++++ test/model/content_test.dart | 36 +++++++------------ test/widgets/content_test.dart | 4 +-- 5 files changed, 119 insertions(+), 29 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index e4273f1b3a..d365d5dc75 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..87e6cf7d43 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -185,9 +185,37 @@ class _KatexParser { KatexNode _parseSpan(dom.Element element) { // TODO maybe check if the sequence of ancestors matter for spans. + if (element.className.startsWith('strut')) { + if (element.className == 'strut' && element.nodes.isEmpty) { + 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) != + KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm); + } else { + throw _KatexHtmlParseError(); + } + } + final debugHtmlNode = kDebugMode ? element : null; 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 +242,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 +492,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 +504,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 +522,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + verticalAlignEm: verticalAlignEm, ); } else { throw _KatexHtmlParseError(); @@ -524,6 +559,7 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { final double? heightEm; + final double? verticalAlignEm; final String? fontFamily; final double? fontSizeEm; @@ -533,6 +569,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, + this.verticalAlignEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -544,6 +581,7 @@ class KatexSpanStyles { int get hashCode => Object.hash( 'KatexSpanStyles', heightEm, + verticalAlignEm, fontFamily, fontSizeEm, fontWeight, @@ -555,6 +593,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 +605,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 +624,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 +632,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 722741b7ee..c446b3f87d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -881,6 +881,7 @@ class _KatexNodeList extends StatelessWidget { data: MediaQueryData(textScaler: TextScaler.noScaling), child: switch (e) { KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), })); })))); } @@ -961,6 +962,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 c90ac54b33..d3820a91b3 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: '⟨', @@ -1963,10 +1954,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 2f64aad97a..333cb175a0 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -671,9 +671,7 @@ void main() { fontSize: baseTextStyle.fontSize!, fontHeight: baseTextStyle.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], From c8558fb4037b2feebfe33f007535bc254e97a1dd Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:46:15 +0530 Subject: [PATCH 04/12] content: Handle positive margin-right and margin-left in KaTeX spans --- lib/model/katex.dart | 35 +++++++++++++++++++++++++++++++++++ lib/widgets/content.dart | 25 ++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 87e6cf7d43..237ff8a156 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -493,6 +493,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( @@ -508,6 +510,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 @@ -523,6 +539,8 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, verticalAlignEm: verticalAlignEm, + marginRightEm: marginRightEm, + marginLeftEm: marginLeftEm, ); } else { throw _KatexHtmlParseError(); @@ -561,6 +579,9 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? marginRightEm; + final double? marginLeftEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -570,6 +591,8 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.marginRightEm, + this.marginLeftEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -582,6 +605,8 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + marginRightEm, + marginLeftEm, fontFamily, fontSizeEm, fontWeight, @@ -594,6 +619,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 && @@ -606,6 +633,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'); @@ -625,6 +654,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, @@ -636,6 +667,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, @@ -645,6 +678,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 c446b3f87d..b28282f8e0 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -954,7 +954,30 @@ class _KatexSpan extends StatelessWidget { child: widget); } - return SizedBox( + final marginRight = switch (styles.marginRightEm) { + double marginRightEm => marginRightEm * em, + null => null, + }; + final marginLeft = switch (styles.marginLeftEm) { + double marginLeftEm => marginLeftEm * em, + null => null, + }; + + EdgeInsets? margin; + if (marginRight != null || marginLeft != null) { + margin = EdgeInsets.zero; + if (marginRight != null) { + assert(marginRight >= 0); + margin += EdgeInsets.only(right: marginRight); + } + if (marginLeft != null) { + assert(marginLeft >= 0); + margin += EdgeInsets.only(left: marginLeft); + } + } + + return Container( + margin: margin, height: styles.heightEm != null ? styles.heightEm! * (fontSize ?? em) : null, From 113e3dcc69dbac35c6f2d7f61df743f489ce8276 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:54:24 +0530 Subject: [PATCH 05/12] content: Handle 'top' property in KaTeX span inline style --- lib/model/katex.dart | 15 +++++++++++++++ lib/widgets/content.dart | 3 +++ 2 files changed, 18 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 237ff8a156..6f33176748 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -493,6 +493,7 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? topEm; double? marginRightEm; double? marginLeftEm; @@ -511,6 +512,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) { @@ -538,6 +543,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, @@ -579,6 +585,8 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? topEm; + final double? marginRightEm; final double? marginLeftEm; @@ -591,6 +599,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.topEm, this.marginRightEm, this.marginLeftEm, this.fontFamily, @@ -605,6 +614,7 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + topEm, marginRightEm, marginLeftEm, fontFamily, @@ -619,6 +629,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 && @@ -633,6 +644,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'); @@ -654,6 +666,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, @@ -667,6 +680,7 @@ class KatexSpanStyles { KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool topEm = true, bool marginRightEm = true, bool marginLeftEm = true, bool fontFamily = true, @@ -678,6 +692,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 b28282f8e0..132f2336d2 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -981,6 +981,9 @@ class _KatexSpan extends StatelessWidget { height: styles.heightEm != null ? styles.heightEm! * (fontSize ?? em) : null, + transform: styles.topEm != null + ? Matrix4.translationValues(0, styles.topEm! * em, 0) + : null, child: widget); } } From 9827271027c26ccaf89f844416637eb50c8011aa Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Apr 2025 21:29:15 +0530 Subject: [PATCH 06/12] 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 | 85 +++++++- lib/widgets/content.dart | 27 ++- test/model/content_test.dart | 386 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 99 +++++++++ 5 files changed, 623 insertions(+), 5 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index d365d5dc75..27ac40d41b 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 6f33176748..27bf2cd8d3 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -208,6 +208,87 @@ class _KatexParser { } } + if (element.className.startsWith('vlist')) { + if (element case dom.Element( + localName: 'span', + className: 'vlist-t' || 'vlist-t vlist-t2', + nodes: [...], + ) && final vlistT) { + 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)!; + final topEm = styles.topEm ?? 0; + + styles = styles.filter(topEm: false); + + final pstrutStyles = _parseSpanInlineStyles(pstrutSpan)!; + 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: kDebugMode ? vlistT : null, + ); + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + final debugHtmlNode = kDebugMode ? element : null; final inlineStyles = _parseSpanInlineStyles(element); @@ -225,7 +306,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; diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 132f2336d2..85cdf5476a 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -823,7 +823,7 @@ class MathBlock extends StatelessWidget { return Center( child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: _Katex( + child: Katex( textStyle: ContentTheme.of(context).textStylePlainParagraph, nodes: nodes))); } @@ -844,8 +844,9 @@ TextStyle mkBaseKatexTextStyle(TextStyle style) { fontStyle: FontStyle.normal); } -class _Katex extends StatelessWidget { - const _Katex({ +class Katex extends StatelessWidget { + const Katex({ + super.key, required this.textStyle, required this.nodes, }); @@ -882,6 +883,7 @@ class _KatexNodeList extends StatelessWidget { child: switch (e) { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), })); })))); } @@ -1012,6 +1014,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}); @@ -1330,7 +1349,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(textStyle: widget.style, nodes: nodes)); + child: Katex(textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/test/model/content_test.dart b/test/model/content_test.dart index d3820a91b3..3d497bfdef 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -884,6 +884,387 @@ class ContentExample { ]), ]); + static const mathBlockKatexVertical1 = ContentExample( + 'math block katex vertical 1', + // 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 mathBlockKatexVertical2 = ContentExample( + 'math block katex vertical 2', + // 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), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'n', + nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical3 = ContentExample( + 'math block katex vertical 3', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176737 + '```math\ne^x\n```', + '

' + '' + 'ex' + 'e^x' + '

', + [ + MathBlockNode( + texSource: 'e^x', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.7144, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'e', + 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( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'x', + nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical4 = ContentExample( + 'math block katex vertical 4', + // 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), + 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), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'o', + nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical5 = ContentExample( + 'math block katex vertical 5', + // 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 @@ -1955,6 +2336,11 @@ void main() async { testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); testParseExample(ContentExample.mathBlockKatexDelimSizing); + testParseExample(ContentExample.mathBlockKatexVertical1); + testParseExample(ContentExample.mathBlockKatexVertical2); + testParseExample(ContentExample.mathBlockKatexVertical3); + testParseExample(ContentExample.mathBlockKatexVertical4); + testParseExample(ContentExample.mathBlockKatexVertical5); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 333cb175a0..a98fbdddaf 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'; @@ -672,6 +673,59 @@ void main() { fontHeight: baseTextStyle.height!); } }); + + group('characters render at specific offsets with specific size: ', () { + final testCases = [ + (ContentExample.mathBlockKatexVertical1, [ + ('a', Offset(0.0, 5.28), Size(10.88, 25.0)), + ('′', Offset(10.88, 1.13), Size(3.96, 17.0)), + ]), + (ContentExample.mathBlockKatexVertical2, [ + ('x', Offset(0.0, 5.28), Size(11.76, 25.0)), + ('n', Offset(11.76, 13.65), Size(8.63, 17.0)), + ]), + (ContentExample.mathBlockKatexVertical3, [ + ('e', Offset(0.0, 5.28), Size(9.58, 25.0)), + ('x', Offset(9.58, 2.07), Size(8.23, 17.0)), + ]), + (ContentExample.mathBlockKatexVertical4, [ + ('u', Offset(0.0, 15.65), Size(8.23, 17.0)), + ('o', Offset(0.0, 2.07), Size(6.98, 17.0)), + ]), + (ContentExample.mathBlockKatexVertical5, [ + ('a', Offset(0.0, 4.16), Size(10.88, 25.0)), + ('b', Offset(10.88, -0.66), Size(8.82, 25.0)), + ('c', Offset(19.70, 4.16), Size(8.90, 25.0)), + ]), + ]; + + 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(Katex)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final topLeftOffset = characterData.$2; + final size = characterData.$3; + + final rect = tester.getRect(find.text(character)); + check(rect.topLeft - baseRect.topLeft) + .within(distance: 0.02, from: topLeftOffset); + check(rect.size) + .within(distance: 0.02, from: size); + } + }); + } + }); }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -1420,3 +1474,48 @@ 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 entry in fonts.entries) { + final fontFamily = entry.key; + final fontFiles = entry.value; + + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +} From 22f09ede7259c0c6550536223afb853ad56d41f5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 20 Jun 2025 21:04:39 +0530 Subject: [PATCH 07/12] 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 27bf2cd8d3..65971f0a2f 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -247,13 +247,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)!; final topEm = styles.topEm ?? 0; From e8cd58add45f229df1d828a4e39d4fae2b46f915 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Apr 2025 20:06:41 +0530 Subject: [PATCH 08/12] content: Support negative margins on KaTeX spans Negative margin spans on web render to the offset being applied to the specific span and all the adjacent spans, so mimic the same behaviour here. --- lib/model/content.dart | 22 ++++ lib/model/katex.dart | 73 +++++++++--- lib/widgets/content.dart | 27 ++++- lib/widgets/katex.dart | 199 +++++++++++++++++++++++++++++++++ test/model/content_test.dart | 136 ++++++++++++++++++++++ test/widgets/content_test.dart | 7 ++ 6 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 lib/widgets/katex.dart diff --git a/lib/model/content.dart b/lib/model/content.dart index 27ac40d41b..e49cb93246 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -460,6 +460,28 @@ class KatexVlistRowNode extends ContentNode { } } +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.leftOffsetEm, + required this.nodes, + super.debugHtmlNode, + }) : assert(leftOffsetEm < 0); + + final double leftOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm)); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 65971f0a2f..353b6c5c97 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:csslib/parser.dart' as css_parser; import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; @@ -167,16 +168,42 @@ class _KatexParser { } List _parseChildSpans(List nodes) { - return List.unmodifiable(nodes.map((node) { - if (node case dom.Element(localName: 'span')) { - return _parseSpan(node); - } else { + var resultSpans = QueueList(); + for (final node in nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { throw _KatexHtmlParseError( node is dom.Element ? 'unsupported html node: ${node.localName}' : 'unsupported html node'); } - })); + + final span = _parseSpan(node); + + if (span is KatexSpanNode) { + final marginRightEm = span.styles.marginRightEm; + if (marginRightEm != null && marginRightEm.isNegative) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: marginRightEm, + nodes: previousSpans)); + } + } + + resultSpans.addFirst(span); + + if (span is KatexSpanNode) { + final marginLeftEm = span.styles.marginLeftEm; + if (marginLeftEm != null && marginLeftEm.isNegative) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: previousSpans)); + } + } + } + return resultSpans; } static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); @@ -266,13 +293,31 @@ class _KatexParser { final pstrutStyles = _parseSpanInlineStyles(pstrutSpan)!; final pstrutHeight = pstrutStyles.heightEm ?? 0; + KatexSpanNode innerSpanNode = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); + + final marginRightEm = styles.marginRightEm; + final marginLeftEm = styles.marginLeftEm; + if (marginRightEm != null && marginRightEm.isNegative) { + throw _KatexHtmlParseError(); + } + if (marginLeftEm != null && marginLeftEm.isNegative) { + innerSpanNode = KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: [innerSpanNode]), + ]); + } + rows.add(KatexVlistRowNode( verticalOffsetEm: topEm + pstrutHeight, debugHtmlNode: kDebugMode ? innerSpan : null, - node: KatexSpanNode( - styles: styles, - text: null, - nodes: _parseChildSpans(otherSpans)))); + node: innerSpanNode)); } else { throw _KatexHtmlParseError(); } @@ -605,17 +650,11 @@ class _KatexParser { case 'margin-right': marginRightEm = _getEm(expression); - if (marginRightEm != null) { - if (marginRightEm < 0) throw _KatexHtmlParseError(); - continue; - } + if (marginRightEm != null) continue; case 'margin-left': marginLeftEm = _getEm(expression); - if (marginLeftEm != null) { - if (marginLeftEm < 0) throw _KatexHtmlParseError(); - continue; - } + if (marginLeftEm != null) continue; } // TODO handle more CSS properties diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 85cdf5476a..0afb1799ca 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -22,6 +22,7 @@ import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; @@ -884,6 +885,7 @@ class _KatexNodeList extends StatelessWidget { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), })); })))); } @@ -957,23 +959,21 @@ class _KatexSpan extends StatelessWidget { } final marginRight = switch (styles.marginRightEm) { - double marginRightEm => marginRightEm * em, - null => null, + double marginRightEm when marginRightEm >= 0 => marginRightEm * em, + _ => null, }; final marginLeft = switch (styles.marginLeftEm) { - double marginLeftEm => marginLeftEm * em, - null => null, + double marginLeftEm when marginLeftEm >= 0 => marginLeftEm * em, + _ => null, }; EdgeInsets? margin; if (marginRight != null || marginLeft != null) { margin = EdgeInsets.zero; if (marginRight != null) { - assert(marginRight >= 0); margin += EdgeInsets.only(right: marginRight); } if (marginLeft != null) { - assert(marginLeft >= 0); margin += EdgeInsets.only(left: marginLeft); } } @@ -1031,6 +1031,21 @@ class _KatexVlist extends StatelessWidget { } } +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart new file mode 100644 index 0000000000..9b89270c8b --- /dev/null +++ b/lib/widgets/katex.dart @@ -0,0 +1,199 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +class NegativeLeftOffset extends SingleChildRenderObjectWidget { + NegativeLeftOffset({super.key, required this.leftOffset, super.child}) + : assert(leftOffset.isNegative), + _padding = EdgeInsets.only(left: leftOffset); + + final double leftOffset; + final EdgeInsetsGeometry _padding; + + @override + RenderNegativePadding createRenderObject(BuildContext context) { + return RenderNegativePadding( + padding: _padding, + textDirection: Directionality.maybeOf(context)); + } + + @override + void updateRenderObject( + BuildContext context, + RenderNegativePadding renderObject, + ) { + renderObject + ..padding = _padding + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', _padding)); + } +} + +// Like [RenderPadding] but only supports negative values. +// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working) +class RenderNegativePadding extends RenderShiftedBox { + RenderNegativePadding({ + required EdgeInsetsGeometry padding, + TextDirection? textDirection, + RenderBox? child, + }) : assert(!padding.isNonNegative), + _textDirection = textDirection, + _padding = padding, + super(child); + + EdgeInsets? _resolvedPaddingCache; + EdgeInsets get _resolvedPadding { + final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection); + return returnValue; + } + + void _markNeedResolution() { + _resolvedPaddingCache = null; + markNeedsLayout(); + } + + /// The amount to pad the child in each dimension. + /// + /// If this is set to an [EdgeInsetsDirectional] object, then [textDirection] + /// must not be null. + EdgeInsetsGeometry get padding => _padding; + EdgeInsetsGeometry _padding; + set padding(EdgeInsetsGeometry value) { + assert(!value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedResolution(); + } + + /// The text direction with which to resolve [padding]. + /// + /// This may be changed to null, but only after the [padding] has been changed + /// to a value that does not depend on the direction. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + _markNeedResolution(); + } + + @override + double computeMinIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + return constraints.constrain(Size(padding.horizontal, padding.vertical)); + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain( + Size(padding.horizontal + childSize.width, padding.vertical + childSize.height), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final EdgeInsets padding = _resolvedPadding; + final BoxConstraints innerConstraints = constraints.deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top; + return result.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + size = constraints.constrain(Size(padding.horizontal, padding.vertical)); + return; + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + child!.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset(padding.left, padding.top); + size = constraints.constrain( + Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height), + ); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + assert(() { + final Rect outerRect = offset & size; + debugPaintPadding( + context.canvas, + outerRect, + child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null, + ); + return true; + }()); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + } +} diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 3d497bfdef..308023f46f 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1265,6 +1265,141 @@ class ContentExample { ]), ]); + static const mathBlockKatexNegativeMargins = ContentExample( + 'math block katex negative margins', + '```math\n\\KaTeX\n```', + '

' + '' + 'KaTeX' + '\\KaTeX' + '

', + [ + MathBlockNode( + texSource: '\\KaTeX', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.17), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.17, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main', + fontSizeEm: 0.7), + text: 'A', + nodes: null), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.15), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.15, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main'), + text: 'T', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.1667), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.1667, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main'), + text: 'E', + nodes: null), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.125), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.125, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main'), + text: 'X', + nodes: null), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2341,6 +2476,7 @@ void main() async { testParseExample(ContentExample.mathBlockKatexVertical3); testParseExample(ContentExample.mathBlockKatexVertical4); testParseExample(ContentExample.mathBlockKatexVertical5); + testParseExample(ContentExample.mathBlockKatexNegativeMargins); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index a98fbdddaf..2611dedb46 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -697,6 +697,13 @@ void main() { ('b', Offset(10.88, -0.66), Size(8.82, 25.0)), ('c', Offset(19.70, 4.16), Size(8.90, 25.0)), ]), + (ContentExample.mathBlockKatexNegativeMargins, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), ]; for (final testCase in testCases) { From a795c3fe89000454b4c48b48938f0b2ecc8532da Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 27 Jun 2025 23:07:01 +0530 Subject: [PATCH 09/12] content: Make KaTeX content in math blocks use `text-align: center` This matches the katex.scss here: https://github.com/KaTeX/KaTeX/blob/613c3da85/src/styles/katex.scss#L629 --- lib/widgets/content.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 0afb1799ca..d6da1ead75 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -826,6 +826,7 @@ class MathBlock extends StatelessWidget { scrollDirection: Axis.horizontal, child: Katex( textStyle: ContentTheme.of(context).textStylePlainParagraph, + textAlign: TextAlign.center, nodes: nodes))); } } @@ -849,10 +850,12 @@ class Katex extends StatelessWidget { const Katex({ super.key, required this.textStyle, + this.textAlign, required this.nodes, }); final TextStyle textStyle; + final TextAlign? textAlign; final List nodes; @override @@ -863,6 +866,7 @@ class Katex extends StatelessWidget { textDirection: TextDirection.ltr, child: DefaultTextStyle.merge( style: mkBaseKatexTextStyle(textStyle), + textAlign: textAlign, child: widget)); } } From 7b952ee802746bfe94dfffd3aa38984d4a02b87a Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 27 Jun 2025 23:17:38 +0530 Subject: [PATCH 10/12] content: Record ancestor CSS classes in KaTeX parser This will be use in later commits to apply styles based on some preconditions that depend on a class being present on an ancestor. --- lib/model/katex.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 353b6c5c97..af6672a6ab 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -161,6 +161,8 @@ class _KatexParser { final unsupportedCssClasses = []; final unsupportedInlineCssProperties = []; + final List _ancestorClasses = []; + List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); @@ -177,7 +179,9 @@ class _KatexParser { : 'unsupported html node'); } + _ancestorClasses.add(node.className); final span = _parseSpan(node); + assert(_ancestorClasses.removeLast() == node.className); if (span is KatexSpanNode) { final marginRightEm = span.styles.marginRightEm; From f10a0783646f95593ae381c3bd0caae8f92ae694 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 4 Jul 2025 04:26:37 +0530 Subject: [PATCH 11/12] content: Redo vlist implementation to be `Column` based, instead of `Stack` This allows the children in the vlist to expand horizontally as wide as their siblings. That will enable support for vlist's children to be center aligned if they had (or inherited) a `text-align: center`. --- lib/widgets/content.dart | 19 +++++++++++++----- test/widgets/content_test.dart | 36 +++++++++++++++++----------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index d6da1ead75..752fe9e071 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1027,11 +1027,20 @@ class _KatexVlist extends StatelessWidget { 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)); - }))); + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + textBaseline: TextBaseline.alphabetic, + children: List.unmodifiable(node.rows.map((row) { + return SizedBox( + height: 0, + child: OverflowBox( + maxHeight: double.infinity, + child: Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node), + ))); + })))); } } diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 2611dedb46..f963e478c9 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -677,32 +677,32 @@ void main() { group('characters render at specific offsets with specific size: ', () { final testCases = [ (ContentExample.mathBlockKatexVertical1, [ - ('a', Offset(0.0, 5.28), Size(10.88, 25.0)), - ('′', Offset(10.88, 1.13), Size(3.96, 17.0)), + ('a', Offset(0.0, 4.36), Size(10.88, 25.0)), + ('′', Offset(10.87, 0.26), Size(3.96, 17.0)) ]), (ContentExample.mathBlockKatexVertical2, [ - ('x', Offset(0.0, 5.28), Size(11.76, 25.0)), - ('n', Offset(11.76, 13.65), Size(8.63, 17.0)), + ('x', Offset(0.0, 4.36), Size(11.76, 25.0)), + ('n', Offset(11.76, 12.79), Size(8.63, 17.0)) ]), (ContentExample.mathBlockKatexVertical3, [ - ('e', Offset(0.0, 5.28), Size(9.58, 25.0)), - ('x', Offset(9.58, 2.07), Size(8.23, 17.0)), + ('e', Offset(0.0, 4.36), Size(9.58, 25.0)), + ('x', Offset(9.58, 1.20), Size(8.23, 17.0)) ]), (ContentExample.mathBlockKatexVertical4, [ - ('u', Offset(0.0, 15.65), Size(8.23, 17.0)), - ('o', Offset(0.0, 2.07), Size(6.98, 17.0)), + ('u', Offset(0.0, 14.82), Size(8.23, 17.0)), + ('o', Offset(0.0, 1.24), Size(6.98, 17.0)) ]), (ContentExample.mathBlockKatexVertical5, [ - ('a', Offset(0.0, 4.16), Size(10.88, 25.0)), - ('b', Offset(10.88, -0.66), Size(8.82, 25.0)), - ('c', Offset(19.70, 4.16), Size(8.90, 25.0)), + ('a', Offset(0.0, 4.24), Size(10.88, 25.0)), + ('b', Offset(10.87, -0.57), Size(8.82, 25.0)), + ('c', Offset(19.69, 4.24), Size(8.9, 25.0)) ]), (ContentExample.mathBlockKatexNegativeMargins, [ - ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), - ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), - ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), - ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), - ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ('K', Offset(0.0, 7.75), Size(16.0, 25.0)), + ('A', Offset(12.50, 9.97), Size(10.79, 17.0)), + ('T', Offset(20.20, 8.48), Size(14.85, 25.0)), + ('E', Offset(31.62, 13.64), Size(14.0, 25.0)), + ('X', Offset(43.05, 8.96), Size(15.42, 25.0)), ]), ]; @@ -726,9 +726,9 @@ void main() { final rect = tester.getRect(find.text(character)); check(rect.topLeft - baseRect.topLeft) - .within(distance: 0.02, from: topLeftOffset); + .within(distance: 0.05, from: topLeftOffset); check(rect.size) - .within(distance: 0.02, from: size); + .within(distance: 0.05, from: size); } }); } From 594d4e63601d884832a1538e239027cae0911ff8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 4 Jul 2025 04:33:12 +0530 Subject: [PATCH 12/12] content: Handle 'op-limits' KaTeX CSS --- lib/model/katex.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index af6672a6ab..ed41b440f9 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -327,10 +327,21 @@ class _KatexParser { } } - return KatexVlistNode( + final result = KatexVlistNode( rows: rows, debugHtmlNode: kDebugMode ? vlistT : null, ); + + if (_ancestorClasses.any( + (classes) => classes.split(' ').contains('op-limits'), + )) { + return KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.center), + text: null, + nodes: [result]); + } + + return result; } else { throw _KatexHtmlParseError(); } @@ -563,6 +574,10 @@ class _KatexParser { _ => throw _KatexHtmlParseError(), }; + case 'op-limits': + // .op-limits > .vlist-t { text-align: center; } + // We handle this above while parsing the vlist. + // TODO handle more classes from katex.scss case 'mord':