Skip to content

Commit bd56117

Browse files
content: Support negative margins on KaTeX spans
Negative margin spans on web render to the offset being applied to the specific span and all the adjacent spans, so mimic the same behaviour here.
1 parent 0bc5ab3 commit bd56117

File tree

6 files changed

+451
-17
lines changed

6 files changed

+451
-17
lines changed

lib/model/content.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,28 @@ class KatexVlistRowNode extends ContentNode {
460460
}
461461
}
462462

463+
class KatexNegativeMarginNode extends KatexNode {
464+
const KatexNegativeMarginNode({
465+
required this.leftOffsetEm,
466+
required this.nodes,
467+
super.debugHtmlNode,
468+
}) : assert(leftOffsetEm < 0);
469+
470+
final double leftOffsetEm;
471+
final List<KatexNode> nodes;
472+
473+
@override
474+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
475+
super.debugFillProperties(properties);
476+
properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm));
477+
}
478+
479+
@override
480+
List<DiagnosticsNode> debugDescribeChildren() {
481+
return nodes.map((node) => node.toDiagnosticsNode()).toList();
482+
}
483+
}
484+
463485
class MathBlockNode extends MathNode implements BlockContentNode {
464486
const MathBlockNode({
465487
super.debugHtmlNode,

lib/model/katex.dart

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:collection/collection.dart';
12
import 'package:csslib/parser.dart' as css_parser;
23
import 'package:csslib/visitor.dart' as css_visitor;
34
import 'package:flutter/foundation.dart';
@@ -167,16 +168,56 @@ class _KatexParser {
167168
}
168169

169170
List<KatexNode> _parseChildSpans(List<dom.Node> nodes) {
170-
return List.unmodifiable(nodes.map((node) {
171-
if (node case dom.Element(localName: 'span')) {
172-
return _parseSpan(node);
173-
} else {
171+
var resultSpans = QueueList<KatexNode>();
172+
for (final node in nodes.reversed) {
173+
if (node is! dom.Element || node.localName != 'span') {
174174
throw _KatexHtmlParseError(
175175
node is dom.Element
176176
? 'unsupported html node: ${node.localName}'
177177
: 'unsupported html node');
178178
}
179-
}));
179+
180+
var span = _parseSpan(node);
181+
final negativeRightMarginEm = switch (span) {
182+
KatexSpanNode(styles: KatexSpanStyles(:final marginRightEm?))
183+
when marginRightEm.isNegative => marginRightEm,
184+
_ => null,
185+
};
186+
final negativeLeftMarginEm = switch (span) {
187+
KatexSpanNode(styles: KatexSpanStyles(:final marginLeftEm?))
188+
when marginLeftEm.isNegative => marginLeftEm,
189+
_ => null,
190+
};
191+
if (span is KatexSpanNode) {
192+
if (negativeRightMarginEm != null || negativeLeftMarginEm != null) {
193+
span = KatexSpanNode(
194+
styles: span.styles.filter(
195+
marginRightEm: negativeRightMarginEm == null,
196+
marginLeftEm: negativeLeftMarginEm == null),
197+
text: span.text,
198+
nodes: span.nodes);
199+
}
200+
}
201+
202+
if (negativeRightMarginEm != null) {
203+
final previousSpans = resultSpans;
204+
resultSpans = QueueList<KatexNode>();
205+
resultSpans.addFirst(KatexNegativeMarginNode(
206+
leftOffsetEm: negativeRightMarginEm,
207+
nodes: previousSpans));
208+
}
209+
210+
resultSpans.addFirst(span);
211+
212+
if (negativeLeftMarginEm != null) {
213+
final previousSpans = resultSpans;
214+
resultSpans = QueueList<KatexNode>();
215+
resultSpans.addFirst(KatexNegativeMarginNode(
216+
leftOffsetEm: negativeLeftMarginEm,
217+
nodes: previousSpans));
218+
}
219+
}
220+
return resultSpans;
180221
}
181222

182223
static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$');
@@ -272,13 +313,31 @@ class _KatexParser {
272313
}
273314
final pstrutHeight = pstrutStyles.heightEm ?? 0;
274315

316+
KatexSpanNode innerSpanNode = KatexSpanNode(
317+
styles: styles,
318+
text: null,
319+
nodes: _parseChildSpans(otherSpans));
320+
321+
final marginRightEm = styles.marginRightEm;
322+
final marginLeftEm = styles.marginLeftEm;
323+
if (marginRightEm != null && marginRightEm.isNegative) {
324+
throw _KatexHtmlParseError();
325+
}
326+
if (marginLeftEm != null && marginLeftEm.isNegative) {
327+
innerSpanNode = KatexSpanNode(
328+
styles: KatexSpanStyles(),
329+
text: null,
330+
nodes: [
331+
KatexNegativeMarginNode(
332+
leftOffsetEm: marginLeftEm,
333+
nodes: [innerSpanNode]),
334+
]);
335+
}
336+
275337
rows.add(KatexVlistRowNode(
276338
verticalOffsetEm: topEm + pstrutHeight,
277339
debugHtmlNode: kDebugMode ? innerSpan : null,
278-
node: KatexSpanNode(
279-
styles: styles,
280-
text: null,
281-
nodes: _parseChildSpans(otherSpans))));
340+
node: innerSpanNode));
282341
} else {
283342
throw _KatexHtmlParseError();
284343
}
@@ -606,17 +665,11 @@ class _KatexParser {
606665

607666
case 'margin-right':
608667
marginRightEm = _getEm(expression);
609-
if (marginRightEm != null) {
610-
if (marginRightEm < 0) throw _KatexHtmlParseError();
611-
continue;
612-
}
668+
if (marginRightEm != null) continue;
613669

614670
case 'margin-left':
615671
marginLeftEm = _getEm(expression);
616-
if (marginLeftEm != null) {
617-
if (marginLeftEm < 0) throw _KatexHtmlParseError();
618-
continue;
619-
}
672+
if (marginLeftEm != null) continue;
620673
}
621674

622675
// TODO handle more CSS properties

lib/widgets/content.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'code_block.dart';
2222
import 'dialog.dart';
2323
import 'icons.dart';
2424
import 'inset_shadow.dart';
25+
import 'katex.dart';
2526
import 'lightbox.dart';
2627
import 'message_list.dart';
2728
import 'poll.dart';
@@ -894,6 +895,7 @@ class _KatexNodeList extends StatelessWidget {
894895
KatexSpanNode() => _KatexSpan(e),
895896
KatexStrutNode() => _KatexStrut(e),
896897
KatexVlistNode() => _KatexVlist(e),
898+
KatexNegativeMarginNode() => _KatexNegativeMargin(e),
897899
}));
898900
}))));
899901
}
@@ -1043,6 +1045,21 @@ class _KatexVlist extends StatelessWidget {
10431045
}
10441046
}
10451047

1048+
class _KatexNegativeMargin extends StatelessWidget {
1049+
const _KatexNegativeMargin(this.node);
1050+
1051+
final KatexNegativeMarginNode node;
1052+
1053+
@override
1054+
Widget build(BuildContext context) {
1055+
final em = DefaultTextStyle.of(context).style.fontSize!;
1056+
1057+
return NegativeLeftOffset(
1058+
leftOffset: node.leftOffsetEm * em,
1059+
child: _KatexNodeList(nodes: node.nodes));
1060+
}
1061+
}
1062+
10461063
class WebsitePreview extends StatelessWidget {
10471064
const WebsitePreview({super.key, required this.node});
10481065

lib/widgets/katex.dart

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import 'dart:math' as math;
2+
3+
import 'package:flutter/foundation.dart';
4+
import 'package:flutter/widgets.dart';
5+
import 'package:flutter/rendering.dart';
6+
7+
class NegativeLeftOffset extends SingleChildRenderObjectWidget {
8+
NegativeLeftOffset({super.key, required this.leftOffset, super.child})
9+
: assert(leftOffset.isNegative),
10+
_padding = EdgeInsets.only(left: leftOffset);
11+
12+
final double leftOffset;
13+
final EdgeInsetsGeometry _padding;
14+
15+
@override
16+
RenderNegativePadding createRenderObject(BuildContext context) {
17+
return RenderNegativePadding(
18+
padding: _padding,
19+
textDirection: Directionality.maybeOf(context));
20+
}
21+
22+
@override
23+
void updateRenderObject(
24+
BuildContext context,
25+
RenderNegativePadding renderObject,
26+
) {
27+
renderObject
28+
..padding = _padding
29+
..textDirection = Directionality.maybeOf(context);
30+
}
31+
32+
@override
33+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
34+
super.debugFillProperties(properties);
35+
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', _padding));
36+
}
37+
}
38+
39+
// Like [RenderPadding] but only supports negative values.
40+
// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working)
41+
class RenderNegativePadding extends RenderShiftedBox {
42+
RenderNegativePadding({
43+
required EdgeInsetsGeometry padding,
44+
TextDirection? textDirection,
45+
RenderBox? child,
46+
}) : assert(!padding.isNonNegative),
47+
_textDirection = textDirection,
48+
_padding = padding,
49+
super(child);
50+
51+
EdgeInsets? _resolvedPaddingCache;
52+
EdgeInsets get _resolvedPadding {
53+
final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection);
54+
return returnValue;
55+
}
56+
57+
void _markNeedResolution() {
58+
_resolvedPaddingCache = null;
59+
markNeedsLayout();
60+
}
61+
62+
/// The amount to pad the child in each dimension.
63+
///
64+
/// If this is set to an [EdgeInsetsDirectional] object, then [textDirection]
65+
/// must not be null.
66+
EdgeInsetsGeometry get padding => _padding;
67+
EdgeInsetsGeometry _padding;
68+
set padding(EdgeInsetsGeometry value) {
69+
assert(!value.isNonNegative);
70+
if (_padding == value) {
71+
return;
72+
}
73+
_padding = value;
74+
_markNeedResolution();
75+
}
76+
77+
/// The text direction with which to resolve [padding].
78+
///
79+
/// This may be changed to null, but only after the [padding] has been changed
80+
/// to a value that does not depend on the direction.
81+
TextDirection? get textDirection => _textDirection;
82+
TextDirection? _textDirection;
83+
set textDirection(TextDirection? value) {
84+
if (_textDirection == value) {
85+
return;
86+
}
87+
_textDirection = value;
88+
_markNeedResolution();
89+
}
90+
91+
@override
92+
double computeMinIntrinsicWidth(double height) {
93+
final EdgeInsets padding = _resolvedPadding;
94+
if (child != null) {
95+
// Relies on double.infinity absorption.
96+
return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) +
97+
padding.horizontal;
98+
}
99+
return padding.horizontal;
100+
}
101+
102+
@override
103+
double computeMaxIntrinsicWidth(double height) {
104+
final EdgeInsets padding = _resolvedPadding;
105+
if (child != null) {
106+
// Relies on double.infinity absorption.
107+
return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) +
108+
padding.horizontal;
109+
}
110+
return padding.horizontal;
111+
}
112+
113+
@override
114+
double computeMinIntrinsicHeight(double width) {
115+
final EdgeInsets padding = _resolvedPadding;
116+
if (child != null) {
117+
// Relies on double.infinity absorption.
118+
return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) +
119+
padding.vertical;
120+
}
121+
return padding.vertical;
122+
}
123+
124+
@override
125+
double computeMaxIntrinsicHeight(double width) {
126+
final EdgeInsets padding = _resolvedPadding;
127+
if (child != null) {
128+
// Relies on double.infinity absorption.
129+
return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) +
130+
padding.vertical;
131+
}
132+
return padding.vertical;
133+
}
134+
135+
@override
136+
@protected
137+
Size computeDryLayout(covariant BoxConstraints constraints) {
138+
final EdgeInsets padding = _resolvedPadding;
139+
if (child == null) {
140+
return constraints.constrain(Size(padding.horizontal, padding.vertical));
141+
}
142+
final BoxConstraints innerConstraints = constraints.deflate(padding);
143+
final Size childSize = child!.getDryLayout(innerConstraints);
144+
return constraints.constrain(
145+
Size(padding.horizontal + childSize.width, padding.vertical + childSize.height),
146+
);
147+
}
148+
149+
@override
150+
double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) {
151+
final RenderBox? child = this.child;
152+
if (child == null) {
153+
return null;
154+
}
155+
final EdgeInsets padding = _resolvedPadding;
156+
final BoxConstraints innerConstraints = constraints.deflate(padding);
157+
final BaselineOffset result =
158+
BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top;
159+
return result.offset;
160+
}
161+
162+
@override
163+
void performLayout() {
164+
final BoxConstraints constraints = this.constraints;
165+
final EdgeInsets padding = _resolvedPadding;
166+
if (child == null) {
167+
size = constraints.constrain(Size(padding.horizontal, padding.vertical));
168+
return;
169+
}
170+
final BoxConstraints innerConstraints = constraints.deflate(padding);
171+
child!.layout(innerConstraints, parentUsesSize: true);
172+
final BoxParentData childParentData = child!.parentData! as BoxParentData;
173+
childParentData.offset = Offset(padding.left, padding.top);
174+
size = constraints.constrain(
175+
Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height),
176+
);
177+
}
178+
179+
@override
180+
void debugPaintSize(PaintingContext context, Offset offset) {
181+
super.debugPaintSize(context, offset);
182+
assert(() {
183+
final Rect outerRect = offset & size;
184+
debugPaintPadding(
185+
context.canvas,
186+
outerRect,
187+
child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null,
188+
);
189+
return true;
190+
}());
191+
}
192+
193+
@override
194+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
195+
super.debugFillProperties(properties);
196+
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding));
197+
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
198+
}
199+
}

0 commit comments

Comments
 (0)