Skip to content

Commit fff56f2

Browse files
feat: Add vertical centering for operators and relations
- Add centerOperators flag to MathOptions (default: true) to vertically center binary operators (+, -, ×, ÷) and relations (=, <, >, ≤, ≥) relative to numbers at the math axis - Add forceVariableBaseline flag for context-aware variable alignment: - Variables adjacent to numbers (like '3x') stay at baseline - Standalone variables (like 'x' in '3 + 5 = x') get centered - Add alignment_utils.dart with KaTeX font metric calculations for determining vertical offsets based on character heights/depths - Add verticalOffset parameter to ResetDimension widget to enable shifting characters vertically while maintaining baseline reporting - Update shouldRebuildWidget in SymbolNode to respond to alignment option changes This improves visual alignment of math expressions, making operators appear centered with numbers rather than sitting at the baseline. Amp-Thread-ID: https://ampcode.com/threads/T-019b2f3b-eff3-74bb-bd05-6cddd682d980 Co-authored-by: Amp <amp@ampcode.com>
1 parent 2f270ae commit fff56f2

File tree

6 files changed

+235
-14
lines changed

6 files changed

+235
-14
lines changed

lib/src/ast/nodes/symbol.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ class SymbolNode extends LeafNode {
103103
oldOptions.color != newOptions.color ||
104104
oldOptions.mathFontOptions != newOptions.mathFontOptions ||
105105
oldOptions.textFontOptions != newOptions.textFontOptions ||
106-
oldOptions.sizeMultiplier != newOptions.sizeMultiplier;
106+
oldOptions.sizeMultiplier != newOptions.sizeMultiplier ||
107+
oldOptions.centerOperators != newOptions.centerOperators ||
108+
oldOptions.forceVariableBaseline != newOptions.forceVariableBaseline;
107109

108110
@override
109111
AtomType get leftType => atomType;

lib/src/ast/options.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ class MathOptions {
7070
/// {@endtemplate}
7171
final double logicalPpi;
7272

73+
/// Whether to vertically center binary operators and relations relative to
74+
/// numbers. When true, operators like +, -, =, <, > will be visually centered
75+
/// with the middle of numbers instead of sitting at the baseline.
76+
final bool centerOperators;
77+
78+
/// When true, forces variables to stay at the baseline (not centered).
79+
/// This is used for variables adjacent to numbers like "3x" where x should
80+
/// share the baseline with 3.
81+
final bool forceVariableBaseline;
82+
7383
MathOptions._({
7484
required this.fontSize,
7585
required this.logicalPpi,
@@ -78,6 +88,8 @@ class MathOptions {
7888
this.sizeUnderTextStyle = MathSize.normalsize,
7989
this.textFontOptions,
8090
this.mathFontOptions,
91+
this.centerOperators = true,
92+
this.forceVariableBaseline = false,
8193
// required this.maxSize,
8294
// required this.minRuleThickness,
8395
});
@@ -97,6 +109,7 @@ class MathOptions {
97109
FontOptions? mathFontOptions,
98110
double? fontSize,
99111
double? logicalPpi,
112+
bool centerOperators = true,
100113
// required this.maxSize,
101114
// required this.minRuleThickness,
102115
}) {
@@ -114,6 +127,7 @@ class MathOptions {
114127
sizeUnderTextStyle: sizeUnderTextStyle,
115128
mathFontOptions: mathFontOptions,
116129
textFontOptions: textFontOptions,
130+
centerOperators: centerOperators,
117131
);
118132
}
119133

@@ -233,6 +247,8 @@ class MathOptions {
233247
MathSize? sizeUnderTextStyle,
234248
FontOptions? textFontOptions,
235249
FontOptions? mathFontOptions,
250+
bool? centerOperators,
251+
bool? forceVariableBaseline,
236252
// double maxSize,
237253
// num minRuleThickness,
238254
}) =>
@@ -244,6 +260,8 @@ class MathOptions {
244260
sizeUnderTextStyle: sizeUnderTextStyle ?? this.sizeUnderTextStyle,
245261
textFontOptions: textFontOptions ?? this.textFontOptions,
246262
mathFontOptions: mathFontOptions ?? this.mathFontOptions,
263+
centerOperators: centerOperators ?? this.centerOperators,
264+
forceVariableBaseline: forceVariableBaseline ?? this.forceVariableBaseline,
247265
// maxSize: maxSize ?? this.maxSize,
248266
// minRuleThickness: minRuleThickness ?? this.minRuleThickness,
249267
);

lib/src/ast/syntax_tree.dart

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import '../widgets/mode.dart';
1717
import '../widgets/selectable.dart';
1818
import 'nodes/space.dart';
1919
import 'nodes/sqrt.dart';
20+
import 'nodes/symbol.dart';
2021
import 'options.dart';
2122
import 'spacing.dart';
2223
import 'types.dart';
@@ -646,8 +647,69 @@ class EquationRowNode extends ParentableNode<GreenNode>
646647
}
647648

648649
@override
649-
List<MathOptions> computeChildOptions(MathOptions options) =>
650-
List.filled(children.length, options, growable: false);
650+
List<MathOptions> computeChildOptions(MathOptions options) {
651+
// Check each child to determine if it's a variable adjacent to a number.
652+
// Variables adjacent to numbers (like "3x") should stay at baseline.
653+
// Variables not adjacent to numbers should be centered.
654+
return List.generate(children.length, (index) {
655+
final child = children[index];
656+
657+
// Only process SymbolNode children
658+
if (child is! SymbolNode) {
659+
return options;
660+
}
661+
662+
// Check if this is an ordinary character (variable/number)
663+
if (child.atomType != AtomType.ord) {
664+
return options;
665+
}
666+
667+
// Check if this is a digit (numbers always stay at baseline)
668+
final symbol = child.symbol;
669+
if (_isDigit(symbol)) {
670+
return options;
671+
}
672+
673+
// This is a variable (letter). Check if it's adjacent to a number.
674+
final isAdjacentToNumber = _isAdjacentToNumber(index);
675+
676+
if (isAdjacentToNumber) {
677+
// Variable is adjacent to number - force baseline
678+
return options.copyWith(forceVariableBaseline: true);
679+
}
680+
681+
// Variable is not adjacent to number - will be centered
682+
return options;
683+
}, growable: false);
684+
}
685+
686+
/// Check if character at index is adjacent to a number (no operator between)
687+
bool _isAdjacentToNumber(int index) {
688+
// Check previous sibling
689+
if (index > 0) {
690+
final prev = children[index - 1];
691+
if (prev is SymbolNode && _isDigit(prev.symbol)) {
692+
return true;
693+
}
694+
}
695+
696+
// Check next sibling
697+
if (index < children.length - 1) {
698+
final next = children[index + 1];
699+
if (next is SymbolNode && _isDigit(next.symbol)) {
700+
return true;
701+
}
702+
}
703+
704+
return false;
705+
}
706+
707+
/// Check if a symbol is a digit (0-9)
708+
static bool _isDigit(String symbol) {
709+
if (symbol.isEmpty) return false;
710+
final code = symbol.codeUnitAt(0);
711+
return code >= 0x30 && code <= 0x39; // '0' to '9'
712+
}
651713

652714
@override
653715
bool shouldRebuildWidget(MathOptions oldOptions, MathOptions newOptions) =>

lib/src/render/layout/reset_dimension.dart

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ class ResetDimension extends SingleChildRenderObjectWidget {
88
final double? height;
99
final double? depth;
1010
final double? width;
11+
final double verticalOffset;
1112
final CrossAxisAlignment horizontalAlignment;
1213

1314
const ResetDimension({
1415
Key? key,
1516
this.height,
1617
this.depth,
1718
this.width,
19+
this.verticalOffset = 0.0,
1820
this.horizontalAlignment = CrossAxisAlignment.center,
1921
required Widget child,
2022
}) : super(key: key, child: child);
@@ -25,6 +27,7 @@ class ResetDimension extends SingleChildRenderObjectWidget {
2527
layoutHeight: height,
2628
layoutWidth: width,
2729
layoutDepth: depth,
30+
verticalOffset: verticalOffset,
2831
horizontalAlignment: horizontalAlignment,
2932
);
3033

@@ -35,6 +38,7 @@ class ResetDimension extends SingleChildRenderObjectWidget {
3538
..layoutHeight = height
3639
..layoutDepth = depth
3740
..layoutWidth = width
41+
..verticalOffset = verticalOffset
3842
..horizontalAlignment = horizontalAlignment;
3943
}
4044

@@ -44,10 +48,12 @@ class RenderResetDimension extends RenderShiftedBox {
4448
double? layoutHeight,
4549
double? layoutDepth,
4650
double? layoutWidth,
51+
double verticalOffset = 0.0,
4752
CrossAxisAlignment horizontalAlignment = CrossAxisAlignment.center,
4853
}) : _layoutHeight = layoutHeight,
4954
_layoutDepth = layoutDepth,
5055
_layoutWidth = layoutWidth,
56+
_verticalOffset = verticalOffset,
5157
_horizontalAlignment = horizontalAlignment,
5258
super(child);
5359

@@ -78,6 +84,15 @@ class RenderResetDimension extends RenderShiftedBox {
7884
}
7985
}
8086

87+
double get verticalOffset => _verticalOffset;
88+
double _verticalOffset;
89+
set verticalOffset(double value) {
90+
if (_verticalOffset != value) {
91+
_verticalOffset = value;
92+
markNeedsLayout();
93+
}
94+
}
95+
8196
CrossAxisAlignment get horizontalAlignment => _horizontalAlignment;
8297
CrossAxisAlignment _horizontalAlignment;
8398
set horizontalAlignment(CrossAxisAlignment value) {
@@ -162,7 +177,9 @@ class RenderResetDimension extends RenderShiftedBox {
162177
}
163178

164179
if (!dry) {
165-
child.offset = Offset(dx, height - childHeight);
180+
// Apply vertical offset after baseline alignment
181+
// Negative offset shifts up, positive shifts down
182+
child.offset = Offset(dx, height - childHeight - verticalOffset);
166183
}
167184

168185
return Size(width, height + depth);

lib/src/render/symbols/make_symbol.dart

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import 'package:flutter/widgets.dart';
32

43
import '../../ast/options.dart';
@@ -10,6 +9,7 @@ import '../../ast/syntax_tree.dart';
109
import '../../ast/types.dart';
1110
import '../../font/metrics/font_metrics.dart';
1211
import '../layout/reset_dimension.dart';
12+
import '../utils/alignment_utils.dart';
1313
import 'make_composite.dart';
1414

1515
BuildResult makeBaseSymbol({
@@ -61,7 +61,7 @@ BuildResult makeBaseSymbol({
6161
italic: italic,
6262
skew: charMetrics.skew.cssEm.toLpUnder(options),
6363
widget: makeChar(symbol, font, charMetrics, options,
64-
needItalic: mode == Mode.math),
64+
needItalic: mode == Mode.math, atomType: atomType),
6565
);
6666
} else if (ligatures.containsKey(symbol) &&
6767
font.fontFamily == 'Typewriter') {
@@ -73,8 +73,8 @@ BuildResult makeBaseSymbol({
7373
crossAxisAlignment: CrossAxisAlignment.baseline,
7474
textBaseline: TextBaseline.alphabetic,
7575
children: expandedText
76-
.map((e) =>
77-
makeChar(e, font!, lookupChar(e, font, mode), options))
76+
.map((e) => makeChar(e, font!, lookupChar(e, font, mode),
77+
options, atomType: atomType))
7878
.toList(growable: false),
7979
),
8080
italic: 0.0,
@@ -97,7 +97,7 @@ BuildResult makeBaseSymbol({
9797
return BuildResult(
9898
options: options,
9999
widget: makeChar(char, defaultFont, characterMetrics, options,
100-
needItalic: mode == Mode.math),
100+
needItalic: mode == Mode.math, atomType: atomType),
101101
italic: italic,
102102
skew: characterMetrics?.skew.cssEm.toLpUnder(options) ?? 0.0,
103103
);
@@ -123,16 +123,67 @@ BuildResult makeBaseSymbol({
123123
italic: 0.0,
124124
skew: 0.0,
125125
widget: makeChar(symbol, const FontOptions(), null, options,
126-
needItalic: mode == Mode.math),
126+
needItalic: mode == Mode.math, atomType: atomType),
127127
);
128128
}
129129

130-
Widget makeChar(String character, FontOptions font,
131-
CharacterMetrics? characterMetrics, MathOptions options,
132-
{bool needItalic = false}) {
130+
Widget makeChar(
131+
String character,
132+
FontOptions font,
133+
CharacterMetrics? characterMetrics,
134+
MathOptions options, {
135+
bool needItalic = false,
136+
AtomType? atomType,
137+
}) {
138+
// Calculate vertical offset for visual centering
139+
double verticalOffset = 0.0;
140+
141+
if (options.centerOperators &&
142+
characterMetrics != null &&
143+
atomType != null) {
144+
final height = characterMetrics.height;
145+
final depth = characterMetrics.depth;
146+
147+
// Centering rules:
148+
// 1. Numbers: Always at baseline (tall, serve as reference)
149+
// 2. Operators (+, -, =, etc.): Always centered at math axis
150+
// 3. Variables (x, y, a, b):
151+
// - If adjacent to number (like "3x"): at baseline (forceVariableBaseline=true)
152+
// - If standalone (like "x" in "3 + 5 = x"): centered
153+
// - If with other variables (like "xy"): centered
154+
//
155+
// This makes expressions look balanced while keeping "3x" style
156+
// coefficients properly aligned.
157+
158+
final isOperator = atomType == AtomType.bin || atomType == AtomType.rel;
159+
final isVariable = atomType == AtomType.ord &&
160+
AlignmentConstants.shouldApplyCentering(height);
161+
162+
// Center operators always, and center variables unless forced to baseline
163+
final shouldCenter = AlignmentConstants.shouldApplyCentering(height) &&
164+
(isOperator || (isVariable && !options.forceVariableBaseline));
165+
166+
if (shouldCenter) {
167+
// Calculate offset in em units, then convert to logical pixels
168+
final offsetEm = AlignmentConstants.calculateAlignmentOffset(
169+
charHeight: height,
170+
charDepth: depth,
171+
);
172+
verticalOffset = offsetEm.cssEm.toLpUnder(options);
173+
}
174+
}
175+
176+
// When centering is applied, report the number height as the baseline
177+
// so that all characters in a line contribute the same baseline,
178+
// ensuring consistent alignment between formulas in Quill.
179+
final reportedHeight = (verticalOffset != 0.0)
180+
? AlignmentConstants.numberHeight.cssEm.toLpUnder(options)
181+
: characterMetrics?.height.cssEm.toLpUnder(options);
182+
133183
final charWidget = ResetDimension(
134-
height: characterMetrics?.height.cssEm.toLpUnder(options),
184+
height: reportedHeight,
135185
depth: characterMetrics?.depth.cssEm.toLpUnder(options),
186+
verticalOffset: verticalOffset,
136187
child: RichText(
137188
text: TextSpan(
138189
text: character,

0 commit comments

Comments
 (0)