From 018c2a01f56a733db6bda63a50741ab4fb8a2fd3 Mon Sep 17 00:00:00 2001 From: Rohan Jariwala Date: Thu, 22 Oct 2026 14:11:59 -0400 Subject: [PATCH 1/4] [vector_graphics_compiler] Fix em units in text positions Text `x`, `y`, `dx` and `dy` values in `em` were parsed with the unit stripped, so `dy=".35em"` moved the text by 0.35 pixels. They are now multiplied by the element's font size, falling back to the same default of 16 used when drawing the text. Fixes https://github.com/flutter/flutter/issues/158758 --- .../vector_graphics_compiler/CHANGELOG.md | 4 ++++ .../lib/src/svg/node.dart | 9 +++---- .../lib/src/svg/parser.dart | 24 ++++++++++++------- .../vector_graphics_compiler/pubspec.yaml | 2 +- .../test/parser_test.dart | 18 ++++++++++++++ 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/vector_graphics_compiler/CHANGELOG.md b/packages/vector_graphics_compiler/CHANGELOG.md index 48a7feef4d99..c3bf82f066f1 100644 --- a/packages/vector_graphics_compiler/CHANGELOG.md +++ b/packages/vector_graphics_compiler/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.3.3 + +* Fixes `em` units in text `x`, `y`, `dx` and `dy` being treated as pixels. + ## 1.3.2 * Fixes a `tspan` without its own position being drawn with its parent group diff --git a/packages/vector_graphics_compiler/lib/src/svg/node.dart b/packages/vector_graphics_compiler/lib/src/svg/node.dart index 87ffdd75f323..83e44e2bd007 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/node.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/node.dart @@ -241,10 +241,11 @@ class TextPositionNode extends ParentNode { TextPosition computeTextPosition(Rect bounds, AffineMatrix transform, {TextPosition? parent}) { final AffineMatrix computedTransform = concatTransform(transform); - double? x = attributes.x?.calculate(bounds.width); - double? y = attributes.y?.calculate(bounds.height); - double? dx = attributes.dx?.calculate(bounds.width); - double? dy = attributes.dy?.calculate(bounds.height); + final double? fontSize = attributes.fontSize; + double? x = attributes.x?.calculate(bounds.width, fontSize: fontSize); + double? y = attributes.y?.calculate(bounds.height, fontSize: fontSize); + double? dx = attributes.dx?.calculate(bounds.width, fontSize: fontSize); + double? dy = attributes.dy?.calculate(bounds.height, fontSize: fontSize); final bool hasXY = x != null && y != null; final bool hasDxDy = dx != null && dy != null; diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index b3adb82c3607..ff1a12cc462f 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -2070,11 +2070,11 @@ class SvgAttributes { } } -/// A value that represents either an absolute pixel coordinate or a percentage -/// of a bounding dimension. +/// A value that represents either an absolute pixel coordinate, a percentage +/// of a bounding dimension, or a multiple of the font size (`em`). @immutable class DoubleOrPercentage { - const DoubleOrPercentage._(this._value, this._isPercentage); + const DoubleOrPercentage._(this._value, this._isPercentage, {bool isEm = false}) : _isEm = isEm; /// Constructs a [DoubleOrPercentage] from a raw SVG attribute string. static DoubleOrPercentage? fromString(String? raw) { @@ -2085,30 +2085,38 @@ class DoubleOrPercentage { if (isPercentage(raw)) { return DoubleOrPercentage._(parsePercentage(raw), true); } - return DoubleOrPercentage._(parseDouble(raw)!, false); + final String trimmed = raw.trim(); + final bool isEm = trimmed.endsWith('em') && !trimmed.endsWith('rem'); + return DoubleOrPercentage._(parseDouble(raw)!, false, isEm: isEm); } final double _value; final bool _isPercentage; + final bool _isEm; /// Calculates the result of applying this dimension within [bound]. /// - /// If this is a percentage, it will return a percentage of bound. Otherwise, - /// it returns the value of this. - double calculate(double bound) { + /// If this is a percentage, it will return a percentage of bound. If this is + /// an `em` value, it will return a multiple of [fontSize], which defaults to + /// 16. Otherwise, it returns the value of this. + double calculate(double bound, {double? fontSize}) { if (_isPercentage) { return _value * bound; } + if (_isEm) { + return _value * (fontSize ?? 16); + } return _value; } @override - int get hashCode => Object.hash(_value, _isPercentage); + int get hashCode => Object.hash(_value, _isPercentage, _isEm); @override bool operator ==(Object other) { return other is DoubleOrPercentage && other._isPercentage == _isPercentage && + other._isEm == _isEm && other._value == _value; } } diff --git a/packages/vector_graphics_compiler/pubspec.yaml b/packages/vector_graphics_compiler/pubspec.yaml index 5ec1806a61e0..0654ab800ee4 100644 --- a/packages/vector_graphics_compiler/pubspec.yaml +++ b/packages/vector_graphics_compiler/pubspec.yaml @@ -2,7 +2,7 @@ name: vector_graphics_compiler description: A compiler to convert SVGs to the binary format used by `package:vector_graphics`. repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics_compiler issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22 -version: 1.3.2 +version: 1.3.3 executables: vector_graphics_compiler: diff --git a/packages/vector_graphics_compiler/test/parser_test.dart b/packages/vector_graphics_compiler/test/parser_test.dart index 556fea2792ce..427f88ab5952 100644 --- a/packages/vector_graphics_compiler/test/parser_test.dart +++ b/packages/vector_graphics_compiler/test/parser_test.dart @@ -433,6 +433,24 @@ ${[for (var i = 2; i <= 30; i++) ' + 21 + + inherited + + default + +'''); + + expect(instructions.textPositions, const [ + TextPosition(x: 500, y: 250, dy: 200, reset: true), + TextPosition(x: 20, y: 40, dx: 20, dy: -20, reset: true), + TextPosition(x: 10, y: 10, dy: 16, reset: true), + ]); + }); + test('Non-standard root font-weight="regular" uses initial weight', () { final VectorInstructions instructions = parseWithoutOptimizers(''' From e65c4548eb1df78df206120530aab1c7e4fdf961 Mon Sep 17 00:00:00 2001 From: Rohan Jariwala <34390549+rohanjariwala03@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:42:44 -0400 Subject: [PATCH 2/4] Update packages/vector_graphics_compiler/lib/src/svg/parser.dart Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/vector_graphics_compiler/lib/src/svg/parser.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index ff1a12cc462f..2ab6d9f00a6b 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -2085,7 +2085,7 @@ class DoubleOrPercentage { if (isPercentage(raw)) { return DoubleOrPercentage._(parsePercentage(raw), true); } - final String trimmed = raw.trim(); + final String trimmed = raw.trim().toLowerCase(); final bool isEm = trimmed.endsWith('em') && !trimmed.endsWith('rem'); return DoubleOrPercentage._(parseDouble(raw)!, false, isEm: isEm); } From 818e9963d39328014a2a3bdec6add37548f447d5 Mon Sep 17 00:00:00 2001 From: Rohan Jariwala Date: Tue, 27 Oct 2026 22:25:30 -0400 Subject: [PATCH 3/4] Use a unit enum and a named default font size --- .../lib/src/svg/constants.dart | 5 ++ .../lib/src/svg/node.dart | 5 +- .../lib/src/svg/parser.dart | 49 +++++++++++-------- .../test/parser_test.dart | 2 + 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/vector_graphics_compiler/lib/src/svg/constants.dart b/packages/vector_graphics_compiler/lib/src/svg/constants.dart index 0f1cf3621eb3..ef171b8eec46 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/constants.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/constants.dart @@ -8,3 +8,8 @@ const int kMaxReferenceExpansions = 1000; /// The error message thrown when the nested reference expansions limit is exceeded. const String kMaxReferenceExpansionsErrorMessage = 'SVG contains too many nested reference expansions (possible Denial of Service exploit).'; + +/// The font size used for text without a `font-size`. +/// +/// This is the size of the CSS initial value `medium` in browsers. +const double kDefaultFontSize = 16; diff --git a/packages/vector_graphics_compiler/lib/src/svg/node.dart b/packages/vector_graphics_compiler/lib/src/svg/node.dart index 83e44e2bd007..49e93a13fffd 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/node.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/node.dart @@ -11,6 +11,7 @@ import '../geometry/matrix.dart'; import '../geometry/path.dart'; import '../image/image_info.dart'; import '../paint.dart'; +import 'constants.dart'; import 'parser.dart' show SvgAttributes; import 'visitor.dart'; @@ -241,7 +242,7 @@ class TextPositionNode extends ParentNode { TextPosition computeTextPosition(Rect bounds, AffineMatrix transform, {TextPosition? parent}) { final AffineMatrix computedTransform = concatTransform(transform); - final double? fontSize = attributes.fontSize; + final double fontSize = attributes.fontSize ?? kDefaultFontSize; double? x = attributes.x?.calculate(bounds.width, fontSize: fontSize); double? y = attributes.y?.calculate(bounds.height, fontSize: fontSize); double? dx = attributes.dx?.calculate(bounds.width, fontSize: fontSize); @@ -510,7 +511,7 @@ class TextNode extends AttributedNode { attributes.textAnchorMultiplier ?? 0, attributes.fontFamily, attributes.fontWeight ?? normalFontWeight, - attributes.fontSize ?? 16, // default in many browsers + attributes.fontSize ?? kDefaultFontSize, attributes.textDecoration ?? TextDecoration.none, attributes.textDecorationStyle ?? TextDecorationStyle.solid, attributes.textDecorationColor ?? Color.opaqueBlack, diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index e70b1539d664..114992240f34 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -2085,11 +2085,23 @@ class SvgAttributes { } } +/// The unit of a [DoubleOrPercentage]. +enum _DoubleOrPercentageUnit { + /// An absolute pixel coordinate. + pixels, + + /// A fraction of a bounding dimension. + percentage, + + /// A multiple of the font size. + em, +} + /// A value that represents either an absolute pixel coordinate, a percentage /// of a bounding dimension, or a multiple of the font size (`em`). @immutable class DoubleOrPercentage { - const DoubleOrPercentage._(this._value, this._isPercentage, {bool isEm = false}) : _isEm = isEm; + const DoubleOrPercentage._(this._value, this._unit); /// Constructs a [DoubleOrPercentage] from a raw SVG attribute string. static DoubleOrPercentage? fromString(String? raw) { @@ -2098,41 +2110,38 @@ class DoubleOrPercentage { } if (isPercentage(raw)) { - return DoubleOrPercentage._(parsePercentage(raw), true); + return DoubleOrPercentage._(parsePercentage(raw), _DoubleOrPercentageUnit.percentage); } final String trimmed = raw.trim().toLowerCase(); final bool isEm = trimmed.endsWith('em') && !trimmed.endsWith('rem'); - return DoubleOrPercentage._(parseDouble(raw)!, false, isEm: isEm); + return DoubleOrPercentage._( + parseDouble(trimmed)!, + isEm ? _DoubleOrPercentageUnit.em : _DoubleOrPercentageUnit.pixels, + ); } final double _value; - final bool _isPercentage; - final bool _isEm; + final _DoubleOrPercentageUnit _unit; /// Calculates the result of applying this dimension within [bound]. /// /// If this is a percentage, it will return a percentage of bound. If this is - /// an `em` value, it will return a multiple of [fontSize], which defaults to - /// 16. Otherwise, it returns the value of this. - double calculate(double bound, {double? fontSize}) { - if (_isPercentage) { - return _value * bound; - } - if (_isEm) { - return _value * (fontSize ?? 16); - } - return _value; + /// an `em` value, it will return a multiple of [fontSize]. Otherwise, it + /// returns the value of this. + double calculate(double bound, {double fontSize = kDefaultFontSize}) { + return switch (_unit) { + _DoubleOrPercentageUnit.pixels => _value, + _DoubleOrPercentageUnit.percentage => _value * bound, + _DoubleOrPercentageUnit.em => _value * fontSize, + }; } @override - int get hashCode => Object.hash(_value, _isPercentage, _isEm); + int get hashCode => Object.hash(_value, _unit); @override bool operator ==(Object other) { - return other is DoubleOrPercentage && - other._isPercentage == _isPercentage && - other._isEm == _isEm && - other._value == _value; + return other is DoubleOrPercentage && other._unit == _unit && other._value == _value; } } diff --git a/packages/vector_graphics_compiler/test/parser_test.dart b/packages/vector_graphics_compiler/test/parser_test.dart index 71b42700c046..a6d9419a15f0 100644 --- a/packages/vector_graphics_compiler/test/parser_test.dart +++ b/packages/vector_graphics_compiler/test/parser_test.dart @@ -478,6 +478,7 @@ ${[for (var i = 2; i <= 30; i++) ' inherited default + upper case '''); @@ -485,6 +486,7 @@ ${[for (var i = 2; i <= 30; i++) ' Date: Wed, 28 Oct 2026 20:09:30 -0400 Subject: [PATCH 4/4] Add isEm and parseEm helpers that only accept a number followed by em --- .../lib/src/svg/parser.dart | 10 ++++------ .../lib/src/svg/parsers.dart | 17 +++++++++++++++++ .../test/parsers_test.dart | 13 +++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index 114992240f34..023eb82bd177 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -2112,12 +2112,10 @@ class DoubleOrPercentage { if (isPercentage(raw)) { return DoubleOrPercentage._(parsePercentage(raw), _DoubleOrPercentageUnit.percentage); } - final String trimmed = raw.trim().toLowerCase(); - final bool isEm = trimmed.endsWith('em') && !trimmed.endsWith('rem'); - return DoubleOrPercentage._( - parseDouble(trimmed)!, - isEm ? _DoubleOrPercentageUnit.em : _DoubleOrPercentageUnit.pixels, - ); + if (isEm(raw)) { + return DoubleOrPercentage._(parseEm(raw), _DoubleOrPercentageUnit.em); + } + return DoubleOrPercentage._(parseDouble(raw)!, _DoubleOrPercentageUnit.pixels); } final double _value; diff --git a/packages/vector_graphics_compiler/lib/src/svg/parsers.dart b/packages/vector_graphics_compiler/lib/src/svg/parsers.dart index cae31634a532..b351a4d2ccf2 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parsers.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parsers.dart @@ -167,6 +167,23 @@ double parsePercentage(String val, {double multiplier = 1.0}) { /// Whether a string should be treated as a percentage (i.e. if it ends with a `'%'`). bool isPercentage(String? val) => val?.endsWith('%') ?? false; +/// Parses values in the form of '1.5em' as a multiple of the font size. +double parseEm(String val) { + final String trimmed = val.trim(); + return double.parse(trimmed.substring(0, trimmed.length - 2)); +} + +/// Whether a string should be treated as an `em` value (i.e. if it is a number +/// followed by `'em'`, in any case). +bool isEm(String? val) { + if (val == null) { + return false; + } + final String trimmed = val.trim(); + return trimmed.toLowerCase().endsWith('em') && + double.tryParse(trimmed.substring(0, trimmed.length - 2)) != null; +} + /// Parses value from the form '25%', 0.25 or 25.0 as a double. /// Note: Percentage or decimals will be multiplied by the total /// view box size, where as doubles will be returned as is. diff --git a/packages/vector_graphics_compiler/test/parsers_test.dart b/packages/vector_graphics_compiler/test/parsers_test.dart index 306bc0078e46..f787892207a7 100644 --- a/packages/vector_graphics_compiler/test/parsers_test.dart +++ b/packages/vector_graphics_compiler/test/parsers_test.dart @@ -478,6 +478,19 @@ void main() { expect(parser.parseMaskType(' luminance '), MaskType.luminance); expect(parser.parseMaskType('unknown'), null); }); + + test('em parsing', () { + expect(isEm(null), false); + expect(isEm('1'), false); + expect(isEm('em'), false); + expect(isEm('1rem'), false); + expect(isEm('1xem'), false); + expect(isEm('1em'), true); + expect(isEm(' -.5EM '), true); + expect(parseEm('1em'), 1); + expect(parseEm(' -.5EM '), -0.5); + expect(parseEm('1e2em'), 100); + }); } class TestColorMapper extends ColorMapper {