From c6d8b3baa0651a30b86aadd3f741264fa5a4fceb Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Sun, 12 Oct 2025 20:18:11 +0530 Subject: [PATCH 1/6] KaTeX overline underline --- lib/model/katex.dart | 102 ++++++++++++++++++++++++++++++++- lib/widgets/katex.dart | 22 +++++-- test/model/katex_test.dart | 107 ++++++++++++++++++++++++++++++++++- test/widgets/katex_test.dart | 6 ++ 4 files changed, 231 insertions(+), 6 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index e4bd080e95..983d9de28b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -411,6 +411,7 @@ class _KatexParser { KatexSpanFontWeight? fontWeight; KatexSpanFontStyle? fontStyle; KatexSpanTextAlign? textAlign; + KatexBorderStyle? borderStyle; var index = 0; while (index < spanClasses.length) { final spanClass = spanClasses[index++]; @@ -626,6 +627,32 @@ class _KatexParser { case 'nobreak': case 'allowbreak': case 'mathdefault': + case 'tag': + case 'eqn-num': + case 'mtable': + case 'col-align-l': + case 'col-align-c': + case 'col-align-r': + case 'delimcenter': + case 'accent': + case 'accent-body': + case 'vlist': + case 'vlist-r': + case 'vlist-s': + case 'svg-align': + case 'hide-tail': + case 'halfarrow-left': + case 'halfarrow-right': + case 'brace-left': + case 'brace-center': + case 'brace-right': + case 'root': + case 'sqrt': + case 'pstrut': + case 'arraycolsep': + case 'vertical-separator': + case 'frac-line': + case 'mfrac': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. // (Why are they there if they're not used? The story seems to be: @@ -636,6 +663,24 @@ class _KatexParser { // ) break; + case 'overline': + case 'underline': + break; + + case 'overline-line': + borderStyle = KatexBorderStyle( + position: KatexBorderPosition.bottom, + widthEm: 0.04, + ); + break; + + case 'underline-line': + borderStyle = KatexBorderStyle( + position: KatexBorderPosition.bottom, + widthEm: 0.04, + ); + break; + default: assert(debugLog('KaTeX: Unsupported CSS class: $spanClass')); unsupportedCssClasses.add(spanClass); @@ -644,6 +689,18 @@ class _KatexParser { } final inlineStyles = _parseInlineStyles(element); + // Extract border width if borderStyle was set + if (borderStyle != null) { + if (inlineStyles != null) { + final borderWidthEm = _takeStyleEm(inlineStyles, 'border-bottom-width'); + if (borderWidthEm != null) { + borderStyle = KatexBorderStyle( + position: borderStyle.position, + widthEm: borderWidthEm, + color: borderStyle.color, + ); + }} + } final styles = KatexSpanStyles( widthEm: widthEm, fontFamily: fontFamily, @@ -657,10 +714,13 @@ class _KatexParser { marginRightEm: _takeStyleEm(inlineStyles, 'margin-right'), color: _takeStyleColor(inlineStyles, 'color'), position: _takeStylePosition(inlineStyles, 'position'), + borderStyle: borderStyle, // TODO handle more CSS properties ); if (inlineStyles != null && inlineStyles.isNotEmpty) { for (final property in inlineStyles.keys) { + // Ignore known properties that don't need special handling + if (property == 'width' || property == 'min-width' || property == 'border-bottom-width') {continue;} assert(debugLog('KaTeX: Unexpected inline CSS property: $property')); unsupportedInlineCssProperties.add(property); _hasError = true; @@ -840,6 +900,39 @@ enum KatexSpanPosition { relative, } +enum KatexBorderPosition { + top, + bottom, +} + +class KatexBorderStyle { + const KatexBorderStyle({ + required this.position, + required this.widthEm, + this.color, + }); + + final KatexBorderPosition position; + final double widthEm; + final KatexSpanColor? color; + + @override + bool operator ==(Object other) { + return other is KatexBorderStyle && + other.position == position && + other.widthEm == widthEm && + other.color == color; + } + + @override + int get hashCode => Object.hash('KatexBorderStyle', position, widthEm, color); + + @override + String toString() { + return '${objectRuntimeType(this, 'KatexBorderStyle')}($position, $widthEm, $color)'; + } +} + class KatexSpanColor { const KatexSpanColor(this.r, this.g, this.b, this.a); @@ -893,6 +986,7 @@ class KatexSpanStyles { final KatexSpanColor? color; final KatexSpanPosition? position; + final KatexBorderStyle? borderStyle; const KatexSpanStyles({ this.widthEm, @@ -907,6 +1001,7 @@ class KatexSpanStyles { this.textAlign, this.color, this.position, + this.borderStyle, }); @override @@ -924,6 +1019,7 @@ class KatexSpanStyles { textAlign, color, position, + borderStyle, ); @override @@ -940,7 +1036,8 @@ class KatexSpanStyles { other.fontStyle == fontStyle && other.textAlign == textAlign && other.color == color && - other.position == position; + other.position == position && + other.borderStyle == borderStyle; } @override @@ -958,6 +1055,7 @@ class KatexSpanStyles { if (textAlign != null) args.add('textAlign: $textAlign'); if (color != null) args.add('color: $color'); if (position != null) args.add('position: $position'); + if (borderStyle != null) args.add('borderStyle: $borderStyle'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } @@ -975,6 +1073,7 @@ class KatexSpanStyles { bool textAlign = true, bool color = true, bool position = true, + bool borderStyle = true, }) { return KatexSpanStyles( widthEm: widthEm ? this.widthEm : null, @@ -989,6 +1088,7 @@ class KatexSpanStyles { textAlign: textAlign ? this.textAlign : null, color: color ? this.color : null, position: position ? this.position : null, + borderStyle: borderStyle ? this.borderStyle : null, ); } } diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 4b4f39aa3f..5fdc9e5a80 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -42,12 +42,13 @@ class KatexWidget extends StatelessWidget { Widget build(BuildContext context) { Widget widget = _KatexNodeList(nodes: nodes); - return Directionality( + return IntrinsicWidth( + child: Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( style: mkBaseKatexTextStyle(textStyle).copyWith( color: ContentTheme.of(context).textStylePlainParagraph.color), - child: widget)); + child: widget))); } } @@ -122,6 +123,19 @@ class _KatexSpan extends StatelessWidget { Color.fromARGB(katexColor.a, katexColor.r, katexColor.g, katexColor.b), null => null, }; + if (styles.borderStyle case final borderStyle?) { + final currentColor = color ?? DefaultTextStyle.of(context).style.color!; + final Color borderColor = borderStyle.color != null + ? Color.fromARGB(borderStyle.color!.a, borderStyle.color!.r, borderStyle.color!.g, borderStyle.color!.b) + : currentColor; + final double borderWidth = borderStyle.widthEm * em; + + return Container( + constraints: const BoxConstraints(minWidth: double.infinity), + height: borderWidth, + color: borderColor, + ); + } TextStyle? textStyle; if (fontFamily != null || @@ -232,11 +246,11 @@ 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 IntrinsicWidth(child: Stack(children: List.unmodifiable(node.rows.map((row) { return Transform.translate( offset: Offset(0, row.verticalOffsetEm * em), child: _KatexSpan(row.node)); - }))); + })))); } } diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 6ebc832e02..52b03dee8b 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -731,6 +731,109 @@ class KatexExample extends ContentExample { ]), ]), ]); + + static final overline = KatexExample.block( + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Saif.20KaTeX/near/2285099 + r'overline: \overline{AB}', + r'\overline{AB}', + '

' + '' + 'AB\\overline{AB}' + '

',[ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8833, verticalAlignEm: null), + KatexSpanNode(nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'A'), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05017, fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'B'), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.8033 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(borderStyle: KatexBorderStyle(position: KatexBorderPosition.bottom, widthEm: 0.04, color: null)), + nodes: []), + ])), + ]), + ]), + ]), + ]); + + static final underline = KatexExample.block( + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Saif.20KaTeX/near/2285099 + r'underline: \underline{AB}', + r'\underline{AB}', + '

' + '' + 'AB\\underline{AB}' + '

',[ + KatexSpanNode(nodes: [ + KatexStrutNode(heightEm: 0.8833, verticalAlignEm: -0.2), + KatexSpanNode(nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.84 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(borderStyle: KatexBorderStyle(position: KatexBorderPosition.bottom, widthEm: 0.04, color: null)), + nodes: []), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3 + 3, + node: KatexSpanNode(nodes: [ + KatexSpanNode(nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'A'), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05017, fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'B'), + ]), + ])), + ]), + ]), + ]), + ]); } void main() async { @@ -754,6 +857,8 @@ void main() async { testParseExample(KatexExample.bigOperators); testParseExample(KatexExample.colonEquals); testParseExample(KatexExample.nulldelimiter); + testParseExample(KatexExample.overline); + testParseExample(KatexExample.underline); group('parseCssHexColor', () { const testCases = [ @@ -821,4 +926,4 @@ void main() async { }, skip: Platform.isWindows, // [intended] purely analyzes source, so // any one platform is enough; avoid dealing with Windows file paths ); -} +} \ No newline at end of file diff --git a/test/widgets/katex_test.dart b/test/widgets/katex_test.dart index 0d6a93ca52..b2f1d420e5 100644 --- a/test/widgets/katex_test.dart +++ b/test/widgets/katex_test.dart @@ -81,6 +81,12 @@ void main() { ('a', Offset(2.47, 3.36), Size(10.88, 25.00)), ('b', Offset(15.81, 3.36), Size(8.82, 25.00)), ]), + (KatexExample.overline, skip: false, [ + ('A', Offset(0.0, 5.6), Size(15.4, 25.0)), + ]), + (KatexExample.underline, skip: false, [ + ('B', Offset(15.4, 5.6), Size(15.6, 25.0)), + ]), ]; for (final testCase in testCases) { From 444a32f44175492a4bb7cae1a932d9f1749bdbbd Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Mon, 27 Oct 2025 23:19:23 +0530 Subject: [PATCH 2/6] base katex overline, underline implementation --- lib/widgets/katex.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 5fdc9e5a80..86ef886739 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -42,13 +42,12 @@ class KatexWidget extends StatelessWidget { Widget build(BuildContext context) { Widget widget = _KatexNodeList(nodes: nodes); - return IntrinsicWidth( - child: Directionality( + return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( style: mkBaseKatexTextStyle(textStyle).copyWith( color: ContentTheme.of(context).textStylePlainParagraph.color), - child: widget))); + child: widget)); } } @@ -246,11 +245,11 @@ class _KatexVlist extends StatelessWidget { Widget build(BuildContext context) { final em = DefaultTextStyle.of(context).style.fontSize!; - return IntrinsicWidth(child: Stack(children: List.unmodifiable(node.rows.map((row) { + return Stack(children: List.unmodifiable(node.rows.map((row) { return Transform.translate( offset: Offset(0, row.verticalOffsetEm * em), child: _KatexSpan(row.node)); - })))); + }))); } } From 676ad48247e7b10457da96b78bfda3ebb9b64d91 Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Mon, 27 Oct 2025 23:26:09 +0530 Subject: [PATCH 3/6] Refactor: wrap KaTeXWidget and KatexVlistNode in IntrinsicWidth for consistent sizing --- lib/widgets/katex.dart | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 86ef886739..1b3d951e9e 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -42,12 +42,14 @@ class KatexWidget extends StatelessWidget { Widget build(BuildContext context) { Widget widget = _KatexNodeList(nodes: nodes); - return Directionality( - textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: mkBaseKatexTextStyle(textStyle).copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), - child: widget)); + return IntrinsicWidth( + child: Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: mkBaseKatexTextStyle(textStyle).copyWith( + color: ContentTheme.of(context).textStylePlainParagraph.color), + child: widget)), + ); } } @@ -245,11 +247,13 @@ 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: Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node)); + }))), + ); } } From 0766f7a1d5e10bbafe2c0aa60ca27329008701ac Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:02:29 +0530 Subject: [PATCH 4/6] removing unnecessary classes getting ignored --- lib/model/katex.dart | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 983d9de28b..41d5180567 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -627,32 +627,6 @@ class _KatexParser { case 'nobreak': case 'allowbreak': case 'mathdefault': - case 'tag': - case 'eqn-num': - case 'mtable': - case 'col-align-l': - case 'col-align-c': - case 'col-align-r': - case 'delimcenter': - case 'accent': - case 'accent-body': - case 'vlist': - case 'vlist-r': - case 'vlist-s': - case 'svg-align': - case 'hide-tail': - case 'halfarrow-left': - case 'halfarrow-right': - case 'brace-left': - case 'brace-center': - case 'brace-right': - case 'root': - case 'sqrt': - case 'pstrut': - case 'arraycolsep': - case 'vertical-separator': - case 'frac-line': - case 'mfrac': // Ignore these classes because they don't have a CSS definition // in katex.scss, but we encounter them in the generated HTML. // (Why are they there if they're not used? The story seems to be: @@ -668,6 +642,7 @@ class _KatexParser { break; case 'overline-line': + // borderStyle = KatexBorderStyle( position: KatexBorderPosition.bottom, widthEm: 0.04, From ea5ff37d161e2aa214ec267e99d10d7a69283ccf Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:24:17 +0530 Subject: [PATCH 5/6] add CSS source inline from katex.scss --- 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 41d5180567..77fffaea8d 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -639,10 +639,12 @@ class _KatexParser { case 'overline': case 'underline': + // Wrapper spans for overline/underline, the actual border is on -line child break; case 'overline-line': - // + // .overline-line { display: inline-block; width: 100%; border-bottom-style: solid; } + // Border applied via inline style: border-top-width: 0.04em; borderStyle = KatexBorderStyle( position: KatexBorderPosition.bottom, widthEm: 0.04, @@ -650,6 +652,8 @@ class _KatexParser { break; case 'underline-line': + // .underline-line { display: inline-block; width: 100%; border-bottom-style: solid; } + // Border applied via inline style: border-bottom-width: 0.04em; borderStyle = KatexBorderStyle( position: KatexBorderPosition.bottom, widthEm: 0.04, From c2eda0c8184e1c9600e89048956bf3857a985c24 Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:34:31 +0530 Subject: [PATCH 6/6] Not discarding base widget anymore,using BoxDecoration instead --- lib/widgets/katex.dart | 7 ++++--- test/model/katex_test.dart | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 1b3d951e9e..997638df8b 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -131,10 +131,11 @@ class _KatexSpan extends StatelessWidget { : currentColor; final double borderWidth = borderStyle.widthEm * em; - return Container( + widget = Container( constraints: const BoxConstraints(minWidth: double.infinity), - height: borderWidth, - color: borderColor, + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor, width: borderWidth))), + child: widget, ); } diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 52b03dee8b..bd79e5939e 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -926,4 +926,4 @@ void main() async { }, skip: Platform.isWindows, // [intended] purely analyzes source, so // any one platform is enough; avoid dealing with Windows file paths ); -} \ No newline at end of file +}