Skip to content
Open
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
4 changes: 3 additions & 1 deletion lib/src/ast/nodes/symbol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ class SymbolNode extends LeafNode {
oldOptions.color != newOptions.color ||
oldOptions.mathFontOptions != newOptions.mathFontOptions ||
oldOptions.textFontOptions != newOptions.textFontOptions ||
oldOptions.sizeMultiplier != newOptions.sizeMultiplier;
oldOptions.sizeMultiplier != newOptions.sizeMultiplier ||
oldOptions.centerOperators != newOptions.centerOperators ||
oldOptions.forceVariableBaseline != newOptions.forceVariableBaseline;

@override
AtomType get leftType => atomType;
Expand Down
18 changes: 18 additions & 0 deletions lib/src/ast/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ class MathOptions {
/// {@endtemplate}
final double logicalPpi;

/// Whether to vertically center binary operators and relations relative to
/// numbers. When true, operators like +, -, =, <, > will be visually centered
/// with the middle of numbers instead of sitting at the baseline.
final bool centerOperators;

/// When true, forces variables to stay at the baseline (not centered).
/// This is used for variables adjacent to numbers like "3x" where x should
/// share the baseline with 3.
final bool forceVariableBaseline;

MathOptions._({
required this.fontSize,
required this.logicalPpi,
Expand All @@ -78,6 +88,8 @@ class MathOptions {
this.sizeUnderTextStyle = MathSize.normalsize,
this.textFontOptions,
this.mathFontOptions,
this.centerOperators = false,
this.forceVariableBaseline = false,
// required this.maxSize,
// required this.minRuleThickness,
});
Expand All @@ -97,6 +109,7 @@ class MathOptions {
FontOptions? mathFontOptions,
double? fontSize,
double? logicalPpi,
bool centerOperators = false,
// required this.maxSize,
// required this.minRuleThickness,
}) {
Expand All @@ -114,6 +127,7 @@ class MathOptions {
sizeUnderTextStyle: sizeUnderTextStyle,
mathFontOptions: mathFontOptions,
textFontOptions: textFontOptions,
centerOperators: centerOperators,
);
}

Expand Down Expand Up @@ -233,6 +247,8 @@ class MathOptions {
MathSize? sizeUnderTextStyle,
FontOptions? textFontOptions,
FontOptions? mathFontOptions,
bool? centerOperators,
bool? forceVariableBaseline,
// double maxSize,
// num minRuleThickness,
}) =>
Expand All @@ -244,6 +260,8 @@ class MathOptions {
sizeUnderTextStyle: sizeUnderTextStyle ?? this.sizeUnderTextStyle,
textFontOptions: textFontOptions ?? this.textFontOptions,
mathFontOptions: mathFontOptions ?? this.mathFontOptions,
centerOperators: centerOperators ?? this.centerOperators,
forceVariableBaseline: forceVariableBaseline ?? this.forceVariableBaseline,
// maxSize: maxSize ?? this.maxSize,
// minRuleThickness: minRuleThickness ?? this.minRuleThickness,
);
Expand Down
66 changes: 64 additions & 2 deletions lib/src/ast/syntax_tree.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '../widgets/mode.dart';
import '../widgets/selectable.dart';
import 'nodes/space.dart';
import 'nodes/sqrt.dart';
import 'nodes/symbol.dart';
import 'options.dart';
import 'spacing.dart';
import 'types.dart';
Expand Down Expand Up @@ -646,8 +647,69 @@ class EquationRowNode extends ParentableNode<GreenNode>
}

@override
List<MathOptions> computeChildOptions(MathOptions options) =>
List.filled(children.length, options, growable: false);
List<MathOptions> computeChildOptions(MathOptions options) {
// Check each child to determine if it's a variable adjacent to a number.
// Variables adjacent to numbers (like "3x") should stay at baseline.
// Variables not adjacent to numbers should be centered.
return List.generate(children.length, (index) {
final child = children[index];

// Only process SymbolNode children
if (child is! SymbolNode) {
return options;
}

// Check if this is an ordinary character (variable/number)
if (child.atomType != AtomType.ord) {
return options;
}

// Check if this is a digit (numbers always stay at baseline)
final symbol = child.symbol;
if (_isDigit(symbol)) {
return options;
}

// This is a variable (letter). Check if it's adjacent to a number.
final isAdjacentToNumber = _isAdjacentToNumber(index);

if (isAdjacentToNumber) {
// Variable is adjacent to number - force baseline
return options.copyWith(forceVariableBaseline: true);
}

// Variable is not adjacent to number - will be centered
return options;
}, growable: false);
}

/// Check if character at index is adjacent to a number (no operator between)
bool _isAdjacentToNumber(int index) {
// Check previous sibling
if (index > 0) {
final prev = children[index - 1];
if (prev is SymbolNode && _isDigit(prev.symbol)) {
return true;
}
}

// Check next sibling
if (index < children.length - 1) {
final next = children[index + 1];
if (next is SymbolNode && _isDigit(next.symbol)) {
return true;
}
}

return false;
}

/// Check if a symbol is a digit (0-9)
static bool _isDigit(String symbol) {
if (symbol.isEmpty) return false;
final code = symbol.codeUnitAt(0);
return code >= 0x30 && code <= 0x39; // '0' to '9'
}

@override
bool shouldRebuildWidget(MathOptions oldOptions, MathOptions newOptions) =>
Expand Down
19 changes: 18 additions & 1 deletion lib/src/render/layout/reset_dimension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ class ResetDimension extends SingleChildRenderObjectWidget {
final double? height;
final double? depth;
final double? width;
final double verticalOffset;
final CrossAxisAlignment horizontalAlignment;

const ResetDimension({
Key? key,
this.height,
this.depth,
this.width,
this.verticalOffset = 0.0,
this.horizontalAlignment = CrossAxisAlignment.center,
required Widget child,
}) : super(key: key, child: child);
Expand All @@ -25,6 +27,7 @@ class ResetDimension extends SingleChildRenderObjectWidget {
layoutHeight: height,
layoutWidth: width,
layoutDepth: depth,
verticalOffset: verticalOffset,
horizontalAlignment: horizontalAlignment,
);

Expand All @@ -35,6 +38,7 @@ class ResetDimension extends SingleChildRenderObjectWidget {
..layoutHeight = height
..layoutDepth = depth
..layoutWidth = width
..verticalOffset = verticalOffset
..horizontalAlignment = horizontalAlignment;
}

Expand All @@ -44,10 +48,12 @@ class RenderResetDimension extends RenderShiftedBox {
double? layoutHeight,
double? layoutDepth,
double? layoutWidth,
double verticalOffset = 0.0,
CrossAxisAlignment horizontalAlignment = CrossAxisAlignment.center,
}) : _layoutHeight = layoutHeight,
_layoutDepth = layoutDepth,
_layoutWidth = layoutWidth,
_verticalOffset = verticalOffset,
_horizontalAlignment = horizontalAlignment,
super(child);

Expand Down Expand Up @@ -78,6 +84,15 @@ class RenderResetDimension extends RenderShiftedBox {
}
}

double get verticalOffset => _verticalOffset;
double _verticalOffset;
set verticalOffset(double value) {
if (_verticalOffset != value) {
_verticalOffset = value;
markNeedsLayout();
}
}

CrossAxisAlignment get horizontalAlignment => _horizontalAlignment;
CrossAxisAlignment _horizontalAlignment;
set horizontalAlignment(CrossAxisAlignment value) {
Expand Down Expand Up @@ -162,7 +177,9 @@ class RenderResetDimension extends RenderShiftedBox {
}

if (!dry) {
child.offset = Offset(dx, height - childHeight);
// Apply vertical offset after baseline alignment
// Negative offset shifts up, positive shifts down
child.offset = Offset(dx, height - childHeight - verticalOffset);
}

return Size(width, height + depth);
Expand Down
71 changes: 61 additions & 10 deletions lib/src/render/symbols/make_symbol.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import 'package:flutter/widgets.dart';

import '../../ast/options.dart';
Expand All @@ -10,6 +9,7 @@ import '../../ast/syntax_tree.dart';
import '../../ast/types.dart';
import '../../font/metrics/font_metrics.dart';
import '../layout/reset_dimension.dart';
import '../utils/alignment_utils.dart';
import 'make_composite.dart';

BuildResult makeBaseSymbol({
Expand Down Expand Up @@ -61,7 +61,7 @@ BuildResult makeBaseSymbol({
italic: italic,
skew: charMetrics.skew.cssEm.toLpUnder(options),
widget: makeChar(symbol, font, charMetrics, options,
needItalic: mode == Mode.math),
needItalic: mode == Mode.math, atomType: atomType),
);
} else if (ligatures.containsKey(symbol) &&
font.fontFamily == 'Typewriter') {
Expand All @@ -73,8 +73,8 @@ BuildResult makeBaseSymbol({
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: expandedText
.map((e) =>
makeChar(e, font!, lookupChar(e, font, mode), options))
.map((e) => makeChar(e, font!, lookupChar(e, font, mode),
options, atomType: atomType))
.toList(growable: false),
),
italic: 0.0,
Expand All @@ -97,7 +97,7 @@ BuildResult makeBaseSymbol({
return BuildResult(
options: options,
widget: makeChar(char, defaultFont, characterMetrics, options,
needItalic: mode == Mode.math),
needItalic: mode == Mode.math, atomType: atomType),
italic: italic,
skew: characterMetrics?.skew.cssEm.toLpUnder(options) ?? 0.0,
);
Expand All @@ -123,16 +123,67 @@ BuildResult makeBaseSymbol({
italic: 0.0,
skew: 0.0,
widget: makeChar(symbol, const FontOptions(), null, options,
needItalic: mode == Mode.math),
needItalic: mode == Mode.math, atomType: atomType),
);
}

Widget makeChar(String character, FontOptions font,
CharacterMetrics? characterMetrics, MathOptions options,
{bool needItalic = false}) {
Widget makeChar(
String character,
FontOptions font,
CharacterMetrics? characterMetrics,
MathOptions options, {
bool needItalic = false,
AtomType? atomType,
}) {
// Calculate vertical offset for visual centering
double verticalOffset = 0.0;

if (options.centerOperators &&
characterMetrics != null &&
atomType != null) {
final height = characterMetrics.height;
final depth = characterMetrics.depth;

// Centering rules:
// 1. Numbers: Always at baseline (tall, serve as reference)
// 2. Operators (+, -, =, etc.): Always centered at math axis
// 3. Variables (x, y, a, b):
// - If adjacent to number (like "3x"): at baseline (forceVariableBaseline=true)
// - If standalone (like "x" in "3 + 5 = x"): centered
// - If with other variables (like "xy"): centered
//
// This makes expressions look balanced while keeping "3x" style
// coefficients properly aligned.

final isOperator = atomType == AtomType.bin || atomType == AtomType.rel;
final isVariable = atomType == AtomType.ord &&
AlignmentConstants.shouldApplyCentering(height);

// Center operators always, and center variables unless forced to baseline
final shouldCenter = AlignmentConstants.shouldApplyCentering(height) &&
(isOperator || (isVariable && !options.forceVariableBaseline));

if (shouldCenter) {
// Calculate offset in em units, then convert to logical pixels
final offsetEm = AlignmentConstants.calculateAlignmentOffset(
charHeight: height,
charDepth: depth,
);
verticalOffset = offsetEm.cssEm.toLpUnder(options);
}
}

// When centering is applied, report the number height as the baseline
// so that all characters in a line contribute the same baseline,
// ensuring consistent alignment between formulas in Quill.
final reportedHeight = (verticalOffset != 0.0)
? AlignmentConstants.numberHeight.cssEm.toLpUnder(options)
: characterMetrics?.height.cssEm.toLpUnder(options);

final charWidget = ResetDimension(
height: characterMetrics?.height.cssEm.toLpUnder(options),
height: reportedHeight,
depth: characterMetrics?.depth.cssEm.toLpUnder(options),
verticalOffset: verticalOffset,
child: RichText(
text: TextSpan(
text: character,
Expand Down
Loading