From b42f784e9832e94cb8d48e61b4fa8812b1cff3df Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 24 Apr 2025 11:37:46 +0530 Subject: [PATCH 01/16] content test [nfc]: Use const for math block tests --- test/model/content_test.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5ab60c8e7e..6a0bc6ebe7 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -529,7 +529,7 @@ class ContentExample { ]), ])); - static final mathBlock = ContentExample( + static const mathBlock = ContentExample( 'math block', "```math\n\\lambda\n```", expectedText: r'\lambda', @@ -549,7 +549,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInParagraph = ContentExample( + static const mathBlocksMultipleInParagraph = ContentExample( 'math blocks, multiple in paragraph', '```math\na\n\nb\n```', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2001490 @@ -586,7 +586,7 @@ class ContentExample { ]), ]); - static final mathBlockInQuote = ContentExample( + static const mathBlockInQuote = ContentExample( 'math block in quote', // There's sometimes a quirky extra `
\n` at the end of the `

` that // encloses the math block. In particular this happens when the math block @@ -614,7 +614,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInQuote = ContentExample( + static const mathBlocksMultipleInQuote = ContentExample( 'math blocks, multiple in quote', "````quote\n```math\na\n\nb\n```\n````", // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2029236 @@ -654,7 +654,7 @@ class ContentExample { ]), ])]); - static final mathBlockBetweenImages = ContentExample( + static const mathBlockBetweenImages = ContentExample( 'math block between images', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/2035891 'https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg\n```math\na\n```\nhttps://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg/1280px-Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg', @@ -702,7 +702,7 @@ class ContentExample { // The font sizes can be compared using the katex.css generated // from katex.scss : // https://unpkg.com/katex@0.16.21/dist/katex.css - static final mathBlockKatexSizing = ContentExample( + static const mathBlockKatexSizing = ContentExample( 'math block; KaTeX different sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 '```math\n\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0\n```', @@ -779,7 +779,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexNestedSizing = ContentExample( + static const mathBlockKatexNestedSizing = ContentExample( 'math block; KaTeX nested sizing', '```math\n\\tiny {1 \\Huge 2}\n```', '

' @@ -821,7 +821,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexDelimSizing = ContentExample( + static const mathBlockKatexDelimSizing = ContentExample( 'math block; KaTeX delimiter sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 '```math\n⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊\n```', From 423160883c1800f5913e6c512097f0dd408310be Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 29 May 2025 11:00:43 -0700 Subject: [PATCH 02/16] content test [nfc]: Enable skips in testParseExample and testParse --- test/model/content_test.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 6a0bc6ebe7..e8c22298ca 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1642,15 +1642,18 @@ UnimplementedInlineContentNode inlineUnimplemented(String html) { return UnimplementedInlineContentNode(htmlNode: fragment.nodes.single); } -void testParse(String name, String html, List nodes) { +void testParse(String name, String html, List nodes, { + Object? skip, +}) { test(name, () { check(parseContent(html)) .equalsNode(ZulipContent(nodes: nodes)); - }); + }, skip: skip); } -void testParseExample(ContentExample example) { - testParse('parse ${example.description}', example.html, example.expectedNodes); +void testParseExample(ContentExample example, {Object? skip}) { + testParse('parse ${example.description}', example.html, example.expectedNodes, + skip: skip); } void main() async { @@ -2034,7 +2037,7 @@ void main() async { r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*ContentExample\s*(?:\.\s*inline\s*)?\(', ).allMatches(source).map((m) => m.group(1)); final testedExamples = RegExp(multiLine: true, - r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)\);', + r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)(?:,\s*skip:\s*true)?\s*\);', ).allMatches(source).map((m) => m.group(1)); check(testedExamples).unorderedEquals(declaredExamples); }, skip: Platform.isWindows, // [intended] purely analyzes source, so From 04a3d2580a79c90cdd5b34ec9cdc2027347f2fc6 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 8 May 2025 21:04:10 +0530 Subject: [PATCH 03/16] content [nfc]: Inline _logError in _KatexParser._parseSpan This will prevent string interpolation being evaluated during release build. Especially useful in later commit where it becomes more expensive. --- lib/model/katex.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 709f91b4b2..7c4b2deb1b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -109,11 +109,6 @@ class _KatexParser { bool get hasError => _hasError; bool _hasError = false; - void _logError(String message) { - assert(debugLog(message)); - _hasError = true; - } - List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); @@ -334,7 +329,8 @@ class _KatexParser { break; default: - _logError('KaTeX: Unsupported CSS class: $spanClass'); + assert(debugLog('KaTeX: Unsupported CSS class: $spanClass')); + _hasError = true; } } final styles = KatexSpanStyles( From e2022de7d8980d2739dd871db475272696ea0a03 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 8 May 2025 21:34:09 +0530 Subject: [PATCH 04/16] content [nfc]: Refactor _KatexParser._parseChildSpans to take list of nodes --- lib/model/katex.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 7c4b2deb1b..f6375f907e 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -112,11 +112,11 @@ class _KatexParser { List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); - return _parseChildSpans(element); + return _parseChildSpans(element.nodes); } - List _parseChildSpans(dom.Element element) { - return List.unmodifiable(element.nodes.map((node) { + List _parseChildSpans(List nodes) { + return List.unmodifiable(nodes.map((node) { if (node case dom.Element(localName: 'span')) { return _parseSpan(node); } else { @@ -346,7 +346,7 @@ class _KatexParser { if (element.nodes case [dom.Text(:final data)]) { text = data; } else { - spans = _parseChildSpans(element); + spans = _parseChildSpans(element.nodes); } if (text == null && spans == null) throw KatexHtmlParseError(); From 82c6b014495564b525d86ab0c0fa801de485240b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 27 May 2025 23:02:56 +0530 Subject: [PATCH 05/16] content: Populate `debugHtmlNode` for KatexNode --- lib/model/katex.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index f6375f907e..4c7ddf7b64 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -131,6 +131,8 @@ class _KatexParser { KatexNode _parseSpan(dom.Element element) { // TODO maybe check if the sequence of ancestors matter for spans. + final debugHtmlNode = kDebugMode ? element : null; + // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. // @@ -353,7 +355,8 @@ class _KatexParser { return KatexNode( styles: styles, text: text, - nodes: spans); + nodes: spans, + debugHtmlNode: debugHtmlNode); } } From b6da144ae1173e696de835701cd38f1266165867 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 24 Apr 2025 13:21:18 +0530 Subject: [PATCH 06/16] content [nfc]: Reintroduce KatexNode as a base sealed class And rename previous type to KatexSpanNode, also while making it a subtype of KatexNode. --- lib/model/content.dart | 8 ++- lib/model/katex.dart | 2 +- lib/widgets/content.dart | 6 +- test/model/content_test.dart | 104 ++++++++++++++++----------------- test/widgets/content_test.dart | 12 ++-- 5 files changed, 70 insertions(+), 62 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 59f7b41aad..768031ae9a 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -369,8 +369,12 @@ abstract class MathNode extends ContentNode { } } -class KatexNode extends ContentNode { - const KatexNode({ +sealed class KatexNode extends ContentNode { + const KatexNode({super.debugHtmlNode}); +} + +class KatexSpanNode extends KatexNode { + const KatexSpanNode({ required this.styles, required this.text, required this.nodes, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 4c7ddf7b64..1fe747f210 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -352,7 +352,7 @@ class _KatexParser { } if (text == null && spans == null) throw KatexHtmlParseError(); - return KatexNode( + return KatexSpanNode( styles: styles, text: text, nodes: spans, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 44411434fd..dd4919edb7 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -870,7 +870,9 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _KatexSpan(e)); + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + }); })))); } } @@ -878,7 +880,7 @@ class _KatexNodeList extends StatelessWidget { class _KatexSpan extends StatelessWidget { const _KatexSpan(this.node); - final KatexNode node; + final KatexSpanNode node; @override Widget build(BuildContext context) { diff --git a/test/model/content_test.dart b/test/model/content_test.dart index e8c22298ca..baea1a8109 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,9 +518,9 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -538,9 +538,9 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -563,9 +563,9 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -574,9 +574,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -602,9 +602,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -631,9 +631,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -642,9 +642,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -680,9 +680,9 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(),text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(),text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -727,51 +727,51 @@ class ContentExample { MathBlockNode( texSource: "\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0", nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 text: '2', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 text: '3', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 text: '4', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 text: '5', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 text: '6', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 text: '7', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 text: '8', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 text: '9', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 text: '0', nodes: null), @@ -796,23 +796,23 @@ class ContentExample { MathBlockNode( texSource: '\\tiny {1 \\Huge 2}', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 text: '2', nodes: null), @@ -841,50 +841,50 @@ class ContentExample { MathBlockNode( texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), text: '(', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), text: '[', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), text: '⌈', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), text: '⌊', nodes: null), diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index b5150a54ee..7e1309478f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -596,9 +596,10 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (final katexNode in nodes) { + for (var katexNode in nodes) { + katexNode = katexNode as KatexSpanNode; final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; checkKatexText(tester, katexNode.text!, fontFamily: 'KaTeX_Main', @@ -639,12 +640,12 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; var nodes = baseNode.nodes!.skip(1); // Skip .strut node. final fontSize = kBaseKatexTextStyle.fontSize!; - final firstNode = nodes.first; + final firstNode = nodes.first as KatexSpanNode; checkKatexText(tester, firstNode.text!, fontFamily: 'KaTeX_Main', fontSize: fontSize, @@ -652,7 +653,8 @@ void main() { nodes = nodes.skip(1); for (var katexNode in nodes) { - katexNode = katexNode.nodes!.single; // Skip empty .mord parent. + 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, From c8f7d0bbcdc203890f76a792850a1d2bcd52c71a Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:36:45 +0530 Subject: [PATCH 07/16] content: Ignore more KaTeX classes that don't have CSS definition --- lib/model/katex.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 1fe747f210..dd69cc181a 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -326,6 +326,12 @@ class _KatexParser { case 'mord': case 'mopen': + case 'mtight': + case 'text': + case 'mrel': + case 'mop': + case 'mclose': + case 'minner': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. break; From 51fb5f58427b4056fa06f10c03fa8a7ced04931e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:42:55 +0530 Subject: [PATCH 08/16] content: Handle 'mspace' and 'msupsub' KaTeX CSS classes --- lib/model/katex.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index dd69cc181a..8d9a646f08 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -272,6 +272,21 @@ class _KatexParser { fontStyle = KatexSpanFontStyle.normal; // TODO handle skipped class declarations between .mainrm and + // .mspace . + + case 'mspace': + // .mspace { ... } + // Do nothing, it has properties that don't need special handling. + break; + + // TODO handle skipped class declarations between .mspace and + // .msupsub . + + case 'msupsub': + // .msupsub { text-align: left; } + textAlign = KatexSpanTextAlign.left; + + // TODO handle skipped class declarations between .msupsub and // .sizing . case 'sizing': From db0c1d83e296b2cf9043d15a97b0ad3e63df19ed Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 22:04:00 +0530 Subject: [PATCH 09/16] 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 dd4919edb7..ec8e940c1f 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -818,7 +818,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))); } } @@ -831,24 +835,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( @@ -1265,7 +1260,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 0bf40b8452b4b787e5914b1db5ab36ccc7a8fc13 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Apr 2025 18:32:25 +0530 Subject: [PATCH 10/16] content: Support parsing and handling inline styles for KaTeX content --- lib/model/katex.dart | 74 +++++++++++++++++++++++++++++++++- lib/widgets/content.dart | 8 +++- test/model/content_test.dart | 27 +++++++------ test/widgets/content_test.dart | 4 +- 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 8d9a646f08..190cd9941f 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,5 @@ +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; import 'package:html/dom.dart' as dom; @@ -133,6 +135,8 @@ class _KatexParser { final debugHtmlNode = kDebugMode ? element : null; + final inlineStyles = _parseSpanInlineStyles(element); + // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. // @@ -374,11 +378,62 @@ class _KatexParser { if (text == null && spans == null) throw KatexHtmlParseError(); return KatexSpanNode( - styles: styles, + styles: inlineStyles != null + ? styles.merge(inlineStyles) + : styles, text: text, nodes: spans, debugHtmlNode: debugHtmlNode); } + + KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) { + if (element.attributes case {'style': final styleStr}) { + // `package:csslib` doesn't seem to have a way to parse inline styles: + // https://github.com/dart-lang/tools/issues/1173 + // So, workaround that by wrapping it in a universal declaration. + final stylesheet = css_parser.parse('*{$styleStr}'); + if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { + double? heightEm; + + for (final declaration in rule.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + switch (property) { + case 'height': + heightEm = _getEm(expression); + if (heightEm != null) continue; + } + + // TODO handle more CSS properties + assert(debugLog('KaTeX: Unsupported CSS expression:' + ' ${expression.toDebugString()}')); + _hasError = true; + } else { + throw KatexHtmlParseError(); + } + } + + return KatexSpanStyles( + heightEm: heightEm, + ); + } else { + throw KatexHtmlParseError(); + } + } + return null; + } + + /// Returns the CSS `em` unit value if the given [expression] is actually an + /// `em` unit expression, else returns null. + double? _getEm(css_visitor.Expression expression) { + if (expression is css_visitor.EmTerm && expression.value is num) { + return (expression.value as num).toDouble(); + } + return null; + } } enum KatexSpanFontWeight { @@ -398,6 +453,8 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { + final double? heightEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -405,6 +462,7 @@ class KatexSpanStyles { final KatexSpanTextAlign? textAlign; const KatexSpanStyles({ + this.heightEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -415,6 +473,7 @@ class KatexSpanStyles { @override int get hashCode => Object.hash( 'KatexSpanStyles', + heightEm, fontFamily, fontSizeEm, fontWeight, @@ -425,6 +484,7 @@ class KatexSpanStyles { @override bool operator ==(Object other) { return other is KatexSpanStyles && + other.heightEm == heightEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -435,6 +495,7 @@ class KatexSpanStyles { @override String toString() { final args = []; + if (heightEm != null) args.add('heightEm: $heightEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -442,6 +503,17 @@ class KatexSpanStyles { if (textAlign != null) args.add('textAlign: $textAlign'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } + + KatexSpanStyles merge(KatexSpanStyles other) { + return KatexSpanStyles( + heightEm: other.heightEm ?? heightEm, + fontFamily: other.fontFamily ?? fontFamily, + fontSizeEm: other.fontSizeEm ?? fontSizeEm, + fontStyle: other.fontStyle ?? fontStyle, + fontWeight: other.fontWeight ?? fontWeight, + textAlign: other.textAlign ?? textAlign, + ); + } } class KatexHtmlParseError extends Error { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index ec8e940c1f..6bd604f7c9 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -938,7 +938,13 @@ class _KatexSpan extends StatelessWidget { textAlign: textAlign, child: widget); } - return widget; + + return SizedBox( + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget, + ); } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index baea1a8109..c90ac54b33 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), 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(), text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), 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(),text: null, nodes: []), + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306),text: null, nodes: []), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -732,7 +732,7 @@ class ContentExample { text: null, nodes: [ KatexSpanNode( - styles: KatexSpanStyles(), + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), KatexSpanNode( @@ -801,7 +801,7 @@ class ContentExample { text: null, nodes: [ KatexSpanNode( - styles: KatexSpanStyles(), + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), KatexSpanNode( @@ -846,7 +846,7 @@ class ContentExample { text: null, nodes: [ KatexSpanNode( - styles: KatexSpanStyles(), + styles: KatexSpanStyles(heightEm: 3.0), text: null, nodes: []), KatexSpanNode( @@ -1963,7 +1963,10 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); - testParseExample(ContentExample.mathBlockKatexDelimSizing); + // 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.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7e1309478f..754410fddc 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -661,7 +661,9 @@ void main() { 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], From 04348639a01e927d953ab2ac0f7373547c56efda Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 22 Apr 2025 18:01:46 +0530 Subject: [PATCH 11/16] 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 6bd604f7c9..308d307b8c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -822,22 +822,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 @@ -846,9 +857,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)); } } @@ -865,9 +875,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), + })); })))); } } @@ -1266,7 +1278,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 d81008f1b2def30e25d8a2d688016ef4d5739c79 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 29 May 2025 20:00:22 +0530 Subject: [PATCH 12/16] 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 768031ae9a..84dfa70bea 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -406,6 +406,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 190cd9941f..3a9430718e 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -133,9 +133,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. @@ -162,8 +190,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; } @@ -394,6 +423,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( @@ -405,6 +435,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 @@ -418,6 +452,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + verticalAlignEm: verticalAlignEm, ); } else { throw KatexHtmlParseError(); @@ -454,6 +489,7 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { final double? heightEm; + final double? verticalAlignEm; final String? fontFamily; final double? fontSizeEm; @@ -463,6 +499,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, + this.verticalAlignEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -474,6 +511,7 @@ class KatexSpanStyles { int get hashCode => Object.hash( 'KatexSpanStyles', heightEm, + verticalAlignEm, fontFamily, fontSizeEm, fontWeight, @@ -485,6 +523,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 && @@ -496,6 +535,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'); @@ -507,6 +547,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, @@ -514,6 +555,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 308d307b8c..451f889338 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -879,6 +879,7 @@ class _KatexNodeList extends StatelessWidget { data: MediaQueryData(textScaler: TextScaler.noScaling), child: switch (e) { KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), })); })))); } @@ -960,6 +961,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 532b3244f031d0624b6b1ab33209009d4d9779f8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:46:15 +0530 Subject: [PATCH 13/16] 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 3a9430718e..e9cee9b7ab 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -424,6 +424,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( @@ -439,6 +441,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 @@ -453,6 +469,8 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, verticalAlignEm: verticalAlignEm, + marginRightEm: marginRightEm, + marginLeftEm: marginLeftEm, ); } else { throw KatexHtmlParseError(); @@ -491,6 +509,9 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? marginRightEm; + final double? marginLeftEm; + final String? fontFamily; final double? fontSizeEm; final KatexSpanFontWeight? fontWeight; @@ -500,6 +521,8 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.marginRightEm, + this.marginLeftEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -512,6 +535,8 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + marginRightEm, + marginLeftEm, fontFamily, fontSizeEm, fontWeight, @@ -524,6 +549,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 && @@ -536,6 +563,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'); @@ -548,6 +577,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, @@ -559,6 +590,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, @@ -568,6 +601,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 451f889338..cb4e349406 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -952,7 +952,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! * em : null, From fef8a60edec183e7be3353c4e059be434da657a7 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 19 May 2025 21:54:24 +0530 Subject: [PATCH 14/16] 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 e9cee9b7ab..df32949c3a 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -424,6 +424,7 @@ class _KatexParser { if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; double? verticalAlignEm; + double? topEm; double? marginRightEm; double? marginLeftEm; @@ -442,6 +443,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) { @@ -468,6 +473,7 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, @@ -509,6 +515,8 @@ class KatexSpanStyles { final double? heightEm; final double? verticalAlignEm; + final double? topEm; + final double? marginRightEm; final double? marginLeftEm; @@ -521,6 +529,7 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, + this.topEm, this.marginRightEm, this.marginLeftEm, this.fontFamily, @@ -535,6 +544,7 @@ class KatexSpanStyles { 'KatexSpanStyles', heightEm, verticalAlignEm, + topEm, marginRightEm, marginLeftEm, fontFamily, @@ -549,6 +559,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 && @@ -563,6 +574,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'); @@ -577,6 +589,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, @@ -590,6 +603,7 @@ class KatexSpanStyles { KatexSpanStyles filter({ bool heightEm = true, bool verticalAlignEm = true, + bool topEm = true, bool marginRightEm = true, bool marginLeftEm = true, bool fontFamily = true, @@ -601,6 +615,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 cb4e349406..df3ca510ab 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -979,6 +979,9 @@ class _KatexSpan extends StatelessWidget { height: styles.heightEm != null ? styles.heightEm! * em : null, + transform: styles.topEm != null + ? Matrix4.translationValues(0, styles.topEm! * em, 0) + : null, child: widget, ); } From 2c5a28cd9b906315d7289975d03e8b4fb3d58126 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 1 Apr 2025 21:29:15 +0530 Subject: [PATCH 15/16] 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 84dfa70bea..2d256a80db 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -424,6 +424,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 df32949c3a..7022fb5a19 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -156,6 +156,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); @@ -173,7 +254,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 df3ca510ab..0b7c01f1a7 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -821,7 +821,7 @@ class MathBlock extends StatelessWidget { return Center( child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: _Katex( + child: Katex( textStyle: ContentTheme.of(context).textStylePlainParagraph, nodes: nodes))); } @@ -842,8 +842,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, }); @@ -880,6 +881,7 @@ class _KatexNodeList extends StatelessWidget { child: switch (e) { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), })); })))); } @@ -1011,6 +1013,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}); @@ -1329,7 +1348,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 7d76da76b4b21e6e6c2425126a40fa7fcd533764 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 20 Jun 2025 21:04:39 +0530 Subject: [PATCH 16/16] 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 7022fb5a19..e74a13ce49 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -195,13 +195,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;