diff --git a/lib/model/content.dart b/lib/model/content.dart index 9f906d1c4c..28486f634d 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -465,6 +465,28 @@ class KatexVlistRowNode extends ContentNode { } } +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.leftOffsetEm, + required this.nodes, + super.debugHtmlNode, + }) : assert(leftOffsetEm < 0); + + final double leftOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm)); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 8fe55dde77..42b3277cee 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:csslib/parser.dart' as css_parser; import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; @@ -167,16 +168,56 @@ class _KatexParser { } List _parseChildSpans(List nodes) { - return List.unmodifiable(nodes.map((node) { - if (node case dom.Element(localName: 'span')) { - return _parseSpan(node); - } else { + var resultSpans = QueueList(); + for (final node in nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { throw _KatexHtmlParseError( node is dom.Element ? 'unsupported html node: ${node.localName}' : 'unsupported html node'); } - })); + + var span = _parseSpan(node); + final negativeRightMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginRightEm?)) + when marginRightEm.isNegative => marginRightEm, + _ => null, + }; + final negativeLeftMarginEm = switch (span) { + KatexSpanNode(styles: KatexSpanStyles(:final marginLeftEm?)) + when marginLeftEm.isNegative => marginLeftEm, + _ => null, + }; + if (span is KatexSpanNode) { + if (negativeRightMarginEm != null || negativeLeftMarginEm != null) { + span = KatexSpanNode( + styles: span.styles.filter( + marginRightEm: negativeRightMarginEm == null, + marginLeftEm: negativeLeftMarginEm == null), + text: span.text, + nodes: span.nodes); + } + } + + if (negativeRightMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeRightMarginEm, + nodes: previousSpans)); + } + + resultSpans.addFirst(span); + + if (negativeLeftMarginEm != null) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: negativeLeftMarginEm, + nodes: previousSpans)); + } + } + return resultSpans; } static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); @@ -291,13 +332,34 @@ class _KatexParser { } final pstrutHeight = pstrutStyles.heightEm ?? 0; + final KatexSpanNode innerSpanNode; + + final marginRightEm = styles.marginRightEm; + final marginLeftEm = styles.marginLeftEm; + if (marginRightEm != null && marginRightEm.isNegative) { + throw _KatexHtmlParseError(); + } + if (marginLeftEm != null && marginLeftEm.isNegative) { + innerSpanNode = KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: [KatexSpanNode( + styles: styles.filter(marginLeftEm: false), + text: null, + nodes: _parseChildSpans(otherSpans))])]); + } else { + innerSpanNode = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); + } + rows.add(KatexVlistRowNode( verticalOffsetEm: topEm + pstrutHeight, debugHtmlNode: kDebugMode ? innerSpan : null, - node: KatexSpanNode( - styles: styles, - text: null, - nodes: _parseChildSpans(otherSpans)))); + node: innerSpanNode)); } else { throw _KatexHtmlParseError(); } @@ -630,17 +692,11 @@ class _KatexParser { case 'margin-right': marginRightEm = _getEm(expression); - if (marginRightEm != null) { - if (marginRightEm < 0) throw _KatexHtmlParseError(); - continue; - } + if (marginRightEm != null) continue; case 'margin-left': marginLeftEm = _getEm(expression); - if (marginLeftEm != null) { - if (marginLeftEm < 0) throw _KatexHtmlParseError(); - continue; - } + if (marginLeftEm != null) continue; } // TODO handle more CSS properties diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index ba05f5205e..b4ef707355 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -24,6 +24,7 @@ import 'dialog.dart'; import 'emoji.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; @@ -898,6 +899,7 @@ class _KatexNodeList extends StatelessWidget { KatexSpanNode() => _KatexSpan(e), KatexStrutNode() => _KatexStrut(e), KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), })); })))); } @@ -1046,6 +1048,21 @@ class _KatexVlist extends StatelessWidget { } } +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart new file mode 100644 index 0000000000..9b89270c8b --- /dev/null +++ b/lib/widgets/katex.dart @@ -0,0 +1,199 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +class NegativeLeftOffset extends SingleChildRenderObjectWidget { + NegativeLeftOffset({super.key, required this.leftOffset, super.child}) + : assert(leftOffset.isNegative), + _padding = EdgeInsets.only(left: leftOffset); + + final double leftOffset; + final EdgeInsetsGeometry _padding; + + @override + RenderNegativePadding createRenderObject(BuildContext context) { + return RenderNegativePadding( + padding: _padding, + textDirection: Directionality.maybeOf(context)); + } + + @override + void updateRenderObject( + BuildContext context, + RenderNegativePadding renderObject, + ) { + renderObject + ..padding = _padding + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', _padding)); + } +} + +// Like [RenderPadding] but only supports negative values. +// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working) +class RenderNegativePadding extends RenderShiftedBox { + RenderNegativePadding({ + required EdgeInsetsGeometry padding, + TextDirection? textDirection, + RenderBox? child, + }) : assert(!padding.isNonNegative), + _textDirection = textDirection, + _padding = padding, + super(child); + + EdgeInsets? _resolvedPaddingCache; + EdgeInsets get _resolvedPadding { + final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection); + return returnValue; + } + + void _markNeedResolution() { + _resolvedPaddingCache = null; + markNeedsLayout(); + } + + /// The amount to pad the child in each dimension. + /// + /// If this is set to an [EdgeInsetsDirectional] object, then [textDirection] + /// must not be null. + EdgeInsetsGeometry get padding => _padding; + EdgeInsetsGeometry _padding; + set padding(EdgeInsetsGeometry value) { + assert(!value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedResolution(); + } + + /// The text direction with which to resolve [padding]. + /// + /// This may be changed to null, but only after the [padding] has been changed + /// to a value that does not depend on the direction. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + _markNeedResolution(); + } + + @override + double computeMinIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + return constraints.constrain(Size(padding.horizontal, padding.vertical)); + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain( + Size(padding.horizontal + childSize.width, padding.vertical + childSize.height), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final EdgeInsets padding = _resolvedPadding; + final BoxConstraints innerConstraints = constraints.deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top; + return result.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + size = constraints.constrain(Size(padding.horizontal, padding.vertical)); + return; + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + child!.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset(padding.left, padding.top); + size = constraints.constrain( + Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height), + ); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + assert(() { + final Rect outerRect = offset & size; + debugPaintPadding( + context.canvas, + outerRect, + child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null, + ); + return true; + }()); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + } +} diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 93e94b5f85..e4dd622b19 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1165,6 +1165,181 @@ class ContentExample { ]), ]); + static const mathBlockKatexNegativeMargin = ContentExample( + 'math block, KaTeX negative margin', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2223563 + '```math\n1 \\! 2\n```', + '

' + '' + '1 ⁣21 \\! 2' + '

', [ + MathBlockNode(texSource: '1 \\! 2', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode(styles: KatexSpanStyles(), text: '1', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: '2', nodes: null), + ]), + ]), + ]), + ]); + + static const mathBlockKatexLogo = ContentExample( + 'math block, KaTeX logo', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2141902 + '```math\n\\KaTeX\n```', + '

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

', [ + MathBlockNode(texSource: '\\KaTeX', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.17, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main', fontSizeEm: 0.7), // .reset-size6.size3 + text: 'A', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.15, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'T', nodes: null), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.1667, nodes: [ + KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'E', nodes: null), + ]), + ])), + ]), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: []), + KatexNegativeMarginNode(leftOffsetEm: -0.125, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'X', nodes: null), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexNegativeMarginsOnVlistRow = ContentExample( + 'math block, KaTeX negative margins on a vlist row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2224918 + '```math\nX_n\n```', + '

' + '' + 'XnX_n' + '

', [ + MathBlockNode(texSource: 'X_n', nodes: [ + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexStrutNode(heightEm: 0.8333, verticalAlignEm: -0.15), + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + marginRightEm: 0.07847, + 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(), text: null, nodes: [ + KatexNegativeMarginNode(leftOffsetEm: -0.0785, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 + text: null, nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), + text: 'n', nodes: null), + ]), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2259,6 +2434,9 @@ void main() async { testParseExample(ContentExample.mathBlockKatexSubscript); testParseExample(ContentExample.mathBlockKatexSubSuperScript); testParseExample(ContentExample.mathBlockKatexRaisebox); + testParseExample(ContentExample.mathBlockKatexNegativeMargin); + testParseExample(ContentExample.mathBlockKatexLogo); + testParseExample(ContentExample.mathBlockKatexNegativeMarginsOnVlistRow); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 7cc249d79b..964d7e1208 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -619,6 +619,21 @@ void main() { ('b', Offset(10.88, -0.66), Size(8.82, 25.00)), ('c', Offset(19.70, 4.16), Size(8.90, 25.00)), ]), + (ContentExample.mathBlockKatexNegativeMargin, skip: false, [ + ('1', Offset(0.00, 3.12), Size(10.28, 25.00)), + ('2', Offset(6.85, 3.36), Size(10.28, 25.00)), + ]), + (ContentExample.mathBlockKatexLogo, skip: false, [ + ('K', Offset(0.0, 8.64), Size(16.0, 25.0)), + ('A', Offset(12.50, 10.85), Size(10.79, 17.0)), + ('T', Offset(20.21, 9.36), Size(14.85, 25.0)), + ('E', Offset(31.63, 14.52), Size(14.0, 25.0)), + ('X', Offset(43.06, 9.85), Size(15.42, 25.0)), + ]), + (ContentExample.mathBlockKatexNegativeMarginsOnVlistRow, skip: false, [ + ('X', Offset(0.00, 7.04), Size(17.03, 25.00)), + ('n', Offset(17.03, 15.90), Size(8.63, 17.00)), + ]), ]; for (final testCase in testCases) { @@ -648,7 +663,7 @@ void main() { check(size) .within(distance: 0.05, from: expectedSize); } - }); + }, skip: testCase.skip); } }); });