diff --git a/lib/src/ast/nodes/symbol.dart b/lib/src/ast/nodes/symbol.dart index 3ec674b9..ab3dbc64 100644 --- a/lib/src/ast/nodes/symbol.dart +++ b/lib/src/ast/nodes/symbol.dart @@ -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; diff --git a/lib/src/ast/options.dart b/lib/src/ast/options.dart index 98f19b82..650ae968 100644 --- a/lib/src/ast/options.dart +++ b/lib/src/ast/options.dart @@ -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, @@ -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, }); @@ -97,6 +109,7 @@ class MathOptions { FontOptions? mathFontOptions, double? fontSize, double? logicalPpi, + bool centerOperators = false, // required this.maxSize, // required this.minRuleThickness, }) { @@ -114,6 +127,7 @@ class MathOptions { sizeUnderTextStyle: sizeUnderTextStyle, mathFontOptions: mathFontOptions, textFontOptions: textFontOptions, + centerOperators: centerOperators, ); } @@ -233,6 +247,8 @@ class MathOptions { MathSize? sizeUnderTextStyle, FontOptions? textFontOptions, FontOptions? mathFontOptions, + bool? centerOperators, + bool? forceVariableBaseline, // double maxSize, // num minRuleThickness, }) => @@ -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, ); diff --git a/lib/src/ast/syntax_tree.dart b/lib/src/ast/syntax_tree.dart index 3491b637..a0941c23 100644 --- a/lib/src/ast/syntax_tree.dart +++ b/lib/src/ast/syntax_tree.dart @@ -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'; @@ -646,8 +647,69 @@ class EquationRowNode extends ParentableNode } @override - List computeChildOptions(MathOptions options) => - List.filled(children.length, options, growable: false); + List 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) => diff --git a/lib/src/render/layout/reset_dimension.dart b/lib/src/render/layout/reset_dimension.dart index 59988233..2d986700 100644 --- a/lib/src/render/layout/reset_dimension.dart +++ b/lib/src/render/layout/reset_dimension.dart @@ -8,6 +8,7 @@ class ResetDimension extends SingleChildRenderObjectWidget { final double? height; final double? depth; final double? width; + final double verticalOffset; final CrossAxisAlignment horizontalAlignment; const ResetDimension({ @@ -15,6 +16,7 @@ class ResetDimension extends SingleChildRenderObjectWidget { this.height, this.depth, this.width, + this.verticalOffset = 0.0, this.horizontalAlignment = CrossAxisAlignment.center, required Widget child, }) : super(key: key, child: child); @@ -25,6 +27,7 @@ class ResetDimension extends SingleChildRenderObjectWidget { layoutHeight: height, layoutWidth: width, layoutDepth: depth, + verticalOffset: verticalOffset, horizontalAlignment: horizontalAlignment, ); @@ -35,6 +38,7 @@ class ResetDimension extends SingleChildRenderObjectWidget { ..layoutHeight = height ..layoutDepth = depth ..layoutWidth = width + ..verticalOffset = verticalOffset ..horizontalAlignment = horizontalAlignment; } @@ -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); @@ -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) { @@ -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); diff --git a/lib/src/render/symbols/make_symbol.dart b/lib/src/render/symbols/make_symbol.dart index dc30b731..1af56408 100644 --- a/lib/src/render/symbols/make_symbol.dart +++ b/lib/src/render/symbols/make_symbol.dart @@ -1,4 +1,3 @@ - import 'package:flutter/widgets.dart'; import '../../ast/options.dart'; @@ -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({ @@ -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') { @@ -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, @@ -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, ); @@ -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, diff --git a/lib/src/render/utils/alignment_utils.dart b/lib/src/render/utils/alignment_utils.dart new file mode 100644 index 00000000..d34d0bfe --- /dev/null +++ b/lib/src/render/utils/alignment_utils.dart @@ -0,0 +1,71 @@ +/// Utilities for calculating vertical alignment offsets to visually center +/// operators relative to numbers (at the math axis). +/// +/// KaTeX font metrics assign different heights and depths to different +/// character types. Operators are designed to sit at the "math axis" which +/// is centered vertically relative to numbers. +/// +/// Characters that get centered (at math axis): +/// - Binary operators: +, -, ×, ÷, ·, ± (AtomType.bin) +/// - Relations: =, ≠, <, >, ≤, ≥ (AtomType.rel) +/// +/// Characters that stay at baseline (NOT centered): +/// - Numbers: 0-9 (tall, height ~0.64) +/// - Variables: x, y, a, b, etc. (AtomType.ord) - share baseline with numbers +/// - All other ordinary characters + +/// Constants and calculations for visual alignment of math characters. +/// +/// Reference metrics (from KaTeX Main-Regular font): +/// - Numbers (0-9): height=0.64444, depth=0 +/// - Plus (+): height=0.58333, depth=0.08333 +/// - Minus (−): height=0.58333, depth=0.08333 +/// - Equals (=): height=0.36687, depth=-0.13313 +/// +/// Reference metrics (from KaTeX Math-Italic font): +/// - Variable x: height=0.43056, depth=0 +/// - Variable y: height=0.43056, depth=0.19444 +class AlignmentConstants { + AlignmentConstants._(); + + /// Reference height for numbers (0-9) in Main-Regular font (em units) + static const double numberHeight = 0.64444; + + /// Reference depth for numbers (0-9) in Main-Regular font (em units) + static const double numberDepth = 0.0; + + /// Visual center of numbers from baseline (em units) + /// Calculated as: (height - depth) / 2 = (0.64444 - 0) / 2 = 0.32222 + static const double numberVisualCenter = (numberHeight - numberDepth) / 2; + + /// Threshold below which we consider a character "short" and apply centering + /// Characters with height >= this value are considered tall enough (like numbers) + static const double heightThreshold = numberHeight - 0.05; + + /// Calculate vertical offset to align a character's visual center with + /// the number's visual center. + /// + /// [charHeight] - The character's height from font metrics (em units) + /// [charDepth] - The character's depth from font metrics (em units) + /// + /// Returns the offset in em units. Positive values shift the character UP. + static double calculateAlignmentOffset({ + required double charHeight, + required double charDepth, + }) { + // Character's visual center from baseline + // For a character that extends from -depth to +height, + // the visual center is at (height - depth) / 2 from the baseline + final charVisualCenter = (charHeight - charDepth) / 2; + + // Offset needed to align with number's visual center + // Positive offset = shift up (character center is below number center) + return numberVisualCenter - charVisualCenter; + } + + /// Check if a character should have centering applied based on its height. + /// Numbers and tall characters should NOT be centered. + static bool shouldApplyCentering(double charHeight) { + return charHeight < heightThreshold; + } +}