Skip to content

KaTeX (3/n): Support negative margins for spans #1559

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<KatexNode> nodes;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm));
}

@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}

class MathBlockNode extends MathNode implements BlockContentNode {
const MathBlockNode({
super.debugHtmlNode,
Expand Down
90 changes: 73 additions & 17 deletions lib/model/katex.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -167,16 +168,56 @@ class _KatexParser {
}

List<KatexNode> _parseChildSpans(List<dom.Node> nodes) {
return List.unmodifiable(nodes.map((node) {
if (node case dom.Element(localName: 'span')) {
return _parseSpan(node);
} else {
var resultSpans = QueueList<KatexNode>();
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<KatexNode>();
resultSpans.addFirst(KatexNegativeMarginNode(
leftOffsetEm: negativeRightMarginEm,
nodes: previousSpans));
}

resultSpans.addFirst(span);

if (negativeLeftMarginEm != null) {
final previousSpans = resultSpans;
resultSpans = QueueList<KatexNode>();
resultSpans.addFirst(KatexNegativeMarginNode(
leftOffsetEm: negativeLeftMarginEm,
nodes: previousSpans));
}
}
return resultSpans;
}

static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$');
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -898,6 +899,7 @@ class _KatexNodeList extends StatelessWidget {
KatexSpanNode() => _KatexSpan(e),
KatexStrutNode() => _KatexStrut(e),
KatexVlistNode() => _KatexVlist(e),
KatexNegativeMarginNode() => _KatexNegativeMargin(e),
}));
}))));
}
Expand Down Expand Up @@ -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});

Expand Down
199 changes: 199 additions & 0 deletions lib/widgets/katex.dart
Original file line number Diff line number Diff line change
@@ -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<EdgeInsetsGeometry>('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<EdgeInsetsGeometry>('padding', padding));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
}
}
Loading