Skip to content

Commit 6e02708

Browse files
authored
[vector_graphics_compiler] support percentage units SVG shape attributes (#10577)
Adds percentage unit support for SVG shape attributes SVG shapes like `<rect>`, `<circle>`, `<ellipse>`, and `<line>` can have percentage values for their position and size attributes (e.g., width="50%", cx="50%"). Previously these weren't handled correctly. Easy to test with any image like https://placeholdit.com/600x400/dddddd/999999 This adds: - Percentage parsing in parseDoubleWithUnits that resolves values against the viewport dimensions - Viewport width/height getters on SvgParser - Proper percentage reference handling for all basic shape elements - Circle radius uses the normalized diagonal per SVG spec *List which issues are fixed by this PR. You must list at least one issue.* I'm not sure if it fixes these issues, as they have no description. I can file a new issue if needed. flutter/flutter#158844 flutter/flutter#158845 ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 87ea370 commit 6e02708

File tree

5 files changed

+155
-5
lines changed

5 files changed

+155
-5
lines changed

packages/vector_graphics_compiler/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.2.0
2+
3+
* Adds support for percentage units in SVG shape attributes (rect, circle, ellipse, line).
4+
15
## 1.1.20
26

37
* Fixes color parsing for modern rgb and rgba CSS syntax.

packages/vector_graphics_compiler/lib/src/svg/numbers.dart

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import 'theme.dart';
88

99
/// Parses a [rawDouble] `String` to a `double`.
1010
///
11-
/// The [rawDouble] might include a unit (`px`, `em` or `ex`)
11+
/// The [rawDouble] might include a unit (`px`, `pt`, `em`, `ex`, `rem`, or `%`)
1212
/// which is stripped off when parsed to a `double`.
1313
///
1414
/// Passing `null` will return `null`.
@@ -24,6 +24,7 @@ double? parseDouble(String? rawDouble, {bool tryParse = false}) {
2424
.replaceFirst('ex', '')
2525
.replaceFirst('px', '')
2626
.replaceFirst('pt', '')
27+
.replaceFirst('%', '')
2728
.trim();
2829

2930
if (tryParse) {
@@ -56,6 +57,10 @@ const double kPointsToPixelFactor = kCssPixelsPerInch / kCssPointsPerInch;
5657
/// relative to the provided [xHeight]:
5758
/// 1 ex = 1 * `xHeight`.
5859
///
60+
/// Passing a `%` value will calculate the result
61+
/// relative to the provided [percentageRef]:
62+
/// 50% with percentageRef=100 = 50.
63+
///
5964
/// The `rawDouble` might include a unit which is
6065
/// stripped off when parsed to a `double`.
6166
///
@@ -64,9 +69,29 @@ double? parseDoubleWithUnits(
6469
String? rawDouble, {
6570
bool tryParse = false,
6671
required SvgTheme theme,
72+
double? percentageRef,
6773
}) {
6874
var unit = 1.0;
6975

76+
// Handle percentage values first.
77+
// Check inline to avoid circular import with parsers.dart.
78+
final bool isPercent = rawDouble?.trim().endsWith('%') ?? false;
79+
if (isPercent) {
80+
if (percentageRef == null || percentageRef.isInfinite) {
81+
// If no reference dimension is available, the percentage cannot be
82+
// resolved. Return null for tryParse, otherwise throw an exception.
83+
if (tryParse) {
84+
return null;
85+
}
86+
throw FormatException(
87+
'Percentage value "$rawDouble" requires a reference dimension '
88+
'(viewport width/height) but none was available.',
89+
);
90+
}
91+
final double? value = parseDouble(rawDouble, tryParse: tryParse);
92+
return value != null ? (value / 100) * percentageRef : null;
93+
}
94+
7095
// 1 rem unit is equal to the root font size.
7196
// 1 em unit is equal to the current font size.
7297
// 1 ex unit is equal to the current x-height.

packages/vector_graphics_compiler/lib/src/svg/parser.dart

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import 'dart:collection';
88
import 'dart:convert';
9+
import 'dart:math' as math;
910
import 'dart:typed_data';
1011

1112
import 'package:meta/meta.dart';
@@ -217,9 +218,11 @@ class _Elements {
217218
.translated(
218219
parserState.parseDoubleWithUnits(
219220
parserState.attribute('x', def: '0'),
221+
percentageRef: parserState.viewportWidth,
220222
)!,
221223
parserState.parseDoubleWithUnits(
222224
parserState.attribute('y', def: '0'),
225+
percentageRef: parserState.viewportHeight,
223226
)!,
224227
);
225228

@@ -482,14 +485,26 @@ class _Elements {
482485
// ignore: avoid_classes_with_only_static_members
483486
class _Paths {
484487
static Path circle(SvgParser parserState) {
488+
final double? vw = parserState.viewportWidth;
489+
final double? vh = parserState.viewportHeight;
485490
final double cx = parserState.parseDoubleWithUnits(
486491
parserState.attribute('cx', def: '0'),
492+
percentageRef: vw,
487493
)!;
488494
final double cy = parserState.parseDoubleWithUnits(
489495
parserState.attribute('cy', def: '0'),
496+
percentageRef: vh,
490497
)!;
498+
// For circle radius percentage, use the normalized diagonal per SVG spec:
499+
// https://www.w3.org/TR/SVG2/coords.html#Units
500+
// "For any other length value expressed as a percentage of the SVG viewport,
501+
// the percentage must be calculated as a percentage of the normalized diagonal"
502+
final double? diagRef = (vw != null && vh != null)
503+
? math.sqrt(vw * vw + vh * vh) / math.sqrt(2)
504+
: null;
491505
final double r = parserState.parseDoubleWithUnits(
492506
parserState.attribute('r', def: '0'),
507+
percentageRef: diagRef,
493508
)!;
494509
final oval = Rect.fromCircle(cx, cy, r);
495510
return PathBuilder(
@@ -503,26 +518,38 @@ class _Paths {
503518
}
504519

505520
static Path rect(SvgParser parserState) {
521+
final double? vw = parserState.viewportWidth;
522+
final double? vh = parserState.viewportHeight;
506523
final double x = parserState.parseDoubleWithUnits(
507524
parserState.attribute('x', def: '0'),
525+
percentageRef: vw,
508526
)!;
509527
final double y = parserState.parseDoubleWithUnits(
510528
parserState.attribute('y', def: '0'),
529+
percentageRef: vh,
511530
)!;
512531
final double w = parserState.parseDoubleWithUnits(
513532
parserState.attribute('width', def: '0'),
533+
percentageRef: vw,
514534
)!;
515535
final double h = parserState.parseDoubleWithUnits(
516536
parserState.attribute('height', def: '0'),
537+
percentageRef: vh,
517538
)!;
518539
String? rxRaw = parserState.attribute('rx');
519540
String? ryRaw = parserState.attribute('ry');
520541
rxRaw ??= ryRaw;
521542
ryRaw ??= rxRaw;
522543

523544
if (rxRaw != null && rxRaw != '') {
524-
final double rx = parserState.parseDoubleWithUnits(rxRaw)!;
525-
final double ry = parserState.parseDoubleWithUnits(ryRaw)!;
545+
final double rx = parserState.parseDoubleWithUnits(
546+
rxRaw,
547+
percentageRef: vw,
548+
)!;
549+
final double ry = parserState.parseDoubleWithUnits(
550+
ryRaw,
551+
percentageRef: vh,
552+
)!;
526553
return PathBuilder(
527554
parserState._currentAttributes.fillRule,
528555
).addRRect(Rect.fromLTWH(x, y, w, h), rx, ry).toPath();
@@ -552,17 +579,23 @@ class _Paths {
552579
}
553580

554581
static Path ellipse(SvgParser parserState) {
582+
final double? vw = parserState.viewportWidth;
583+
final double? vh = parserState.viewportHeight;
555584
final double cx = parserState.parseDoubleWithUnits(
556585
parserState.attribute('cx', def: '0'),
586+
percentageRef: vw,
557587
)!;
558588
final double cy = parserState.parseDoubleWithUnits(
559589
parserState.attribute('cy', def: '0'),
590+
percentageRef: vh,
560591
)!;
561592
final double rx = parserState.parseDoubleWithUnits(
562593
parserState.attribute('rx', def: '0'),
594+
percentageRef: vw,
563595
)!;
564596
final double ry = parserState.parseDoubleWithUnits(
565597
parserState.attribute('ry', def: '0'),
598+
percentageRef: vh,
566599
)!;
567600

568601
final r = Rect.fromLTWH(cx - rx, cy - ry, rx * 2, ry * 2);
@@ -572,17 +605,23 @@ class _Paths {
572605
}
573606

574607
static Path line(SvgParser parserState) {
608+
final double? vw = parserState.viewportWidth;
609+
final double? vh = parserState.viewportHeight;
575610
final double x1 = parserState.parseDoubleWithUnits(
576611
parserState.attribute('x1', def: '0'),
612+
percentageRef: vw,
577613
)!;
578614
final double x2 = parserState.parseDoubleWithUnits(
579615
parserState.attribute('x2', def: '0'),
616+
percentageRef: vw,
580617
)!;
581618
final double y1 = parserState.parseDoubleWithUnits(
582619
parserState.attribute('y1', def: '0'),
620+
percentageRef: vh,
583621
)!;
584622
final double y2 = parserState.parseDoubleWithUnits(
585623
parserState.attribute('y2', def: '0'),
624+
percentageRef: vh,
586625
)!;
587626

588627
return PathBuilder(
@@ -968,18 +1007,33 @@ class SvgParser {
9681007
/// relative to the provided [xHeight]:
9691008
/// 1 ex = 1 * `xHeight`.
9701009
///
1010+
/// Passing a `%` value will calculate the result
1011+
/// relative to the provided [percentageRef]:
1012+
/// 50% with percentageRef=100 = 50.
1013+
///
9711014
/// The `rawDouble` might include a unit which is
9721015
/// stripped off when parsed to a `double`.
9731016
///
9741017
/// Passing `null` will return `null`.
975-
double? parseDoubleWithUnits(String? rawDouble, {bool tryParse = false}) {
1018+
double? parseDoubleWithUnits(
1019+
String? rawDouble, {
1020+
bool tryParse = false,
1021+
double? percentageRef,
1022+
}) {
9761023
return numbers.parseDoubleWithUnits(
9771024
rawDouble,
9781025
tryParse: tryParse,
9791026
theme: theme,
1027+
percentageRef: percentageRef,
9801028
);
9811029
}
9821030

1031+
/// Returns the viewport width, or null if not yet parsed.
1032+
double? get viewportWidth => _root?.width;
1033+
1034+
/// Returns the viewport height, or null if not yet parsed.
1035+
double? get viewportHeight => _root?.height;
1036+
9831037
static final Map<String, double> _kTextSizeMap = <String, double>{
9841038
'xx-small': 10,
9851039
'x-small': 12,

packages/vector_graphics_compiler/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: vector_graphics_compiler
22
description: A compiler to convert SVGs to the binary format used by `package:vector_graphics`.
33
repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics_compiler
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22
5-
version: 1.1.20
5+
version: 1.2.0
66

77
executables:
88
vector_graphics_compiler:

packages/vector_graphics_compiler/test/parser_test.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3155,6 +3155,73 @@ void main() {
31553155

31563156
expect(parseWithoutOptimizers(svgStr), isA<VectorInstructions>());
31573157
});
3158+
3159+
test('Parse rect with percentage width and height', () {
3160+
// This SVG uses percentage values for rect dimensions, like placeholder images
3161+
const svgStr = '''
3162+
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400">
3163+
<rect width="100%" height="100%" fill="#c73c3c" />
3164+
<rect x="25%" y="25%" width="50%" height="50%" fill="#22e8a6" />
3165+
</svg>
3166+
''';
3167+
3168+
final VectorInstructions instructions = parseWithoutOptimizers(svgStr);
3169+
3170+
// Expect 2 rect paths
3171+
expect(instructions.paths.length, 2);
3172+
3173+
// First rect should be full size (100% = 600x400)
3174+
expect(instructions.paths[0].commands, const <PathCommand>[
3175+
MoveToCommand(0.0, 0.0),
3176+
LineToCommand(600.0, 0.0),
3177+
LineToCommand(600.0, 400.0),
3178+
LineToCommand(0.0, 400.0),
3179+
CloseCommand(),
3180+
]);
3181+
3182+
// Second rect should be at 25%,25% (150,100) with 50% size (300x200)
3183+
expect(instructions.paths[1].commands, const <PathCommand>[
3184+
MoveToCommand(150.0, 100.0),
3185+
LineToCommand(450.0, 100.0),
3186+
LineToCommand(450.0, 300.0),
3187+
LineToCommand(150.0, 300.0),
3188+
CloseCommand(),
3189+
]);
3190+
});
3191+
3192+
test('Parse circle with percentage cx, cy', () {
3193+
const svgStr = '''
3194+
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
3195+
<circle cx="50%" cy="50%" r="40" fill="blue" />
3196+
</svg>
3197+
''';
3198+
3199+
final VectorInstructions instructions = parseWithoutOptimizers(svgStr);
3200+
3201+
// Expect 1 circle path centered at 50%,50% = 100,100
3202+
expect(instructions.paths.length, 1);
3203+
// Circle paths are represented as ovals, check they're centered correctly
3204+
final List<PathCommand> commands = instructions.paths[0].commands.toList();
3205+
expect(commands.isNotEmpty, true);
3206+
// The first command should move to the top of the circle (100, 100-40 = 60)
3207+
expect(commands[0], const MoveToCommand(100.0, 60.0));
3208+
});
3209+
3210+
test('Parse line with percentage coordinates', () {
3211+
const svgStr = '''
3212+
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
3213+
<line x1="0%" y1="0%" x2="100%" y2="100%" stroke="black" />
3214+
</svg>
3215+
''';
3216+
3217+
final VectorInstructions instructions = parseWithoutOptimizers(svgStr);
3218+
3219+
expect(instructions.paths.length, 1);
3220+
expect(instructions.paths[0].commands, const <PathCommand>[
3221+
MoveToCommand(0.0, 0.0),
3222+
LineToCommand(100.0, 100.0),
3223+
]);
3224+
});
31583225
}
31593226

31603227
const List<Paint> ghostScriptTigerPaints = <Paint>[

0 commit comments

Comments
 (0)