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: 4 additions & 0 deletions packages/vector_graphics_compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9.

## 1.1.20

* Adds support for percentage units in SVG shape attributes (rect, circle, ellipse, line).

## 1.1.19

* Updates allowed version range of `xml` to include up to 6.6.1.
Expand Down
28 changes: 27 additions & 1 deletion packages/vector_graphics_compiler/lib/src/svg/numbers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import 'theme.dart';

/// Parses a [rawDouble] `String` to a `double`.
///
/// The [rawDouble] might include a unit (`px`, `em` or `ex`)
/// The [rawDouble] might include a unit (`px`, `pt`, `em`, `ex`, `rem`, or `%`)
/// which is stripped off when parsed to a `double`.
///
/// Passing `null` will return `null`.
Expand All @@ -24,6 +24,7 @@ double? parseDouble(String? rawDouble, {bool tryParse = false}) {
.replaceFirst('ex', '')
.replaceFirst('px', '')
.replaceFirst('pt', '')
.replaceFirst('%', '')
.trim();

if (tryParse) {
Expand Down Expand Up @@ -56,6 +57,10 @@ const double kPointsToPixelFactor = kCssPixelsPerInch / kCssPointsPerInch;
/// relative to the provided [xHeight]:
/// 1 ex = 1 * `xHeight`.
///
/// Passing a `%` value will calculate the result
/// relative to the provided [percentageRef]:
/// 50% with percentageRef=100 = 50.
///
/// The `rawDouble` might include a unit which is
/// stripped off when parsed to a `double`.
///
Expand All @@ -64,9 +69,30 @@ double? parseDoubleWithUnits(
String? rawDouble, {
bool tryParse = false,
required SvgTheme theme,
double? percentageRef,
}) {
var unit = 1.0;

// Handle percentage values first.
// Check inline to avoid circular import with parsers.dart.
final bool isPercent = rawDouble?.endsWith('%') ?? false;
if (isPercent) {
if (percentageRef == null || percentageRef.isInfinite) {
// If no reference dimension is available, treat as 0.
// This maintains backwards compatibility for cases where
// percentages can't be resolved.
Comment on lines +81 to +83

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment here is a bit misleading. The code doesn't treat unresolved percentages as 0; it either returns null or throws a FormatException. Also, this is a change in behavior from before, where 50% would be parsed as 50.0, so the claim of maintaining backwards compatibility isn't quite accurate (though the new behavior is more correct). I suggest updating the comment to more accurately reflect the code's logic and the SVG specification's requirements.

Suggested change
// If no reference dimension is available, treat as 0.
// This maintains backwards compatibility for cases where
// percentages can't be resolved.
// If no reference dimension is available, the percentage cannot be resolved.
// Per the SVG specification, this is an error.

if (tryParse) {
return null;
}
throw FormatException(
'Percentage value "$rawDouble" requires a reference dimension '
'(viewport width/height) but none was available.',
);
}
final double? value = parseDouble(rawDouble, tryParse: tryParse);
return value != null ? (value / 100) * percentageRef : null;
}

// 1 rem unit is equal to the root font size.
// 1 em unit is equal to the current font size.
// 1 ex unit is equal to the current x-height.
Expand Down
57 changes: 54 additions & 3 deletions packages/vector_graphics_compiler/lib/src/svg/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';

import 'package:meta/meta.dart';
Expand Down Expand Up @@ -217,9 +218,11 @@ class _Elements {
.translated(
parserState.parseDoubleWithUnits(
parserState.attribute('x', def: '0'),
percentageRef: parserState.viewportWidth,
)!,
parserState.parseDoubleWithUnits(
parserState.attribute('y', def: '0'),
percentageRef: parserState.viewportHeight,
)!,
);

Expand Down Expand Up @@ -482,14 +485,23 @@ class _Elements {
// ignore: avoid_classes_with_only_static_members
class _Paths {
static Path circle(SvgParser parserState) {
final double? vw = parserState.viewportWidth;
final double? vh = parserState.viewportHeight;
final double cx = parserState.parseDoubleWithUnits(
parserState.attribute('cx', def: '0'),
percentageRef: vw,
)!;
final double cy = parserState.parseDoubleWithUnits(
parserState.attribute('cy', def: '0'),
percentageRef: vh,
)!;
// For radius percentage, use the normalized diagonal per SVG spec.
final double? diagRef = (vw != null && vh != null)
? math.sqrt(vw * vw + vh * vh) / math.sqrt(2)
: null;
final double r = parserState.parseDoubleWithUnits(
parserState.attribute('r', def: '0'),
percentageRef: diagRef,
)!;
final oval = Rect.fromCircle(cx, cy, r);
return PathBuilder(
Expand All @@ -503,26 +515,38 @@ class _Paths {
}

static Path rect(SvgParser parserState) {
final double? vw = parserState.viewportWidth;
final double? vh = parserState.viewportHeight;
final double x = parserState.parseDoubleWithUnits(
parserState.attribute('x', def: '0'),
percentageRef: vw,
)!;
final double y = parserState.parseDoubleWithUnits(
parserState.attribute('y', def: '0'),
percentageRef: vh,
)!;
final double w = parserState.parseDoubleWithUnits(
parserState.attribute('width', def: '0'),
percentageRef: vw,
)!;
final double h = parserState.parseDoubleWithUnits(
parserState.attribute('height', def: '0'),
percentageRef: vh,
)!;
String? rxRaw = parserState.attribute('rx');
String? ryRaw = parserState.attribute('ry');
rxRaw ??= ryRaw;
ryRaw ??= rxRaw;

if (rxRaw != null && rxRaw != '') {
final double rx = parserState.parseDoubleWithUnits(rxRaw)!;
final double ry = parserState.parseDoubleWithUnits(ryRaw)!;
final double rx = parserState.parseDoubleWithUnits(
rxRaw,
percentageRef: vw,
)!;
final double ry = parserState.parseDoubleWithUnits(
ryRaw,
percentageRef: vh,
)!;
return PathBuilder(
parserState._currentAttributes.fillRule,
).addRRect(Rect.fromLTWH(x, y, w, h), rx, ry).toPath();
Expand Down Expand Up @@ -552,17 +576,23 @@ class _Paths {
}

static Path ellipse(SvgParser parserState) {
final double? vw = parserState.viewportWidth;
final double? vh = parserState.viewportHeight;
final double cx = parserState.parseDoubleWithUnits(
parserState.attribute('cx', def: '0'),
percentageRef: vw,
)!;
final double cy = parserState.parseDoubleWithUnits(
parserState.attribute('cy', def: '0'),
percentageRef: vh,
)!;
final double rx = parserState.parseDoubleWithUnits(
parserState.attribute('rx', def: '0'),
percentageRef: vw,
)!;
final double ry = parserState.parseDoubleWithUnits(
parserState.attribute('ry', def: '0'),
percentageRef: vh,
)!;

final r = Rect.fromLTWH(cx - rx, cy - ry, rx * 2, ry * 2);
Expand All @@ -572,17 +602,23 @@ class _Paths {
}

static Path line(SvgParser parserState) {
final double? vw = parserState.viewportWidth;
final double? vh = parserState.viewportHeight;
final double x1 = parserState.parseDoubleWithUnits(
parserState.attribute('x1', def: '0'),
percentageRef: vw,
)!;
final double x2 = parserState.parseDoubleWithUnits(
parserState.attribute('x2', def: '0'),
percentageRef: vw,
)!;
final double y1 = parserState.parseDoubleWithUnits(
parserState.attribute('y1', def: '0'),
percentageRef: vh,
)!;
final double y2 = parserState.parseDoubleWithUnits(
parserState.attribute('y2', def: '0'),
percentageRef: vh,
)!;

return PathBuilder(
Expand Down Expand Up @@ -968,18 +1004,33 @@ class SvgParser {
/// relative to the provided [xHeight]:
/// 1 ex = 1 * `xHeight`.
///
/// Passing a `%` value will calculate the result
/// relative to the provided [percentageRef]:
/// 50% with percentageRef=100 = 50.
///
/// The `rawDouble` might include a unit which is
/// stripped off when parsed to a `double`.
///
/// Passing `null` will return `null`.
double? parseDoubleWithUnits(String? rawDouble, {bool tryParse = false}) {
double? parseDoubleWithUnits(
String? rawDouble, {
bool tryParse = false,
double? percentageRef,
}) {
return numbers.parseDoubleWithUnits(
rawDouble,
tryParse: tryParse,
theme: theme,
percentageRef: percentageRef,
);
}

/// Returns the viewport width, or null if not yet parsed.
double? get viewportWidth => _root?.width;

/// Returns the viewport height, or null if not yet parsed.
double? get viewportHeight => _root?.height;

static final Map<String, double> _kTextSizeMap = <String, double>{
'xx-small': 10,
'x-small': 12,
Expand Down
2 changes: 1 addition & 1 deletion packages/vector_graphics_compiler/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.1.19
version: 1.1.20

executables:
vector_graphics_compiler:
Expand Down
67 changes: 67 additions & 0 deletions packages/vector_graphics_compiler/test/parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3155,6 +3155,73 @@ void main() {

expect(parseWithoutOptimizers(svgStr), isA<VectorInstructions>());
});

test('Parse rect with percentage width and height', () {
// This SVG uses percentage values for rect dimensions, like placeholder images
const svgStr = '''
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400">
<rect width="100%" height="100%" fill="#c73c3c" />
<rect x="25%" y="25%" width="50%" height="50%" fill="#22e8a6" />
</svg>
''';

final VectorInstructions instructions = parseWithoutOptimizers(svgStr);

// Expect 2 rect paths
expect(instructions.paths.length, 2);

// First rect should be full size (100% = 600x400)
expect(instructions.paths[0].commands, const <PathCommand>[
MoveToCommand(0.0, 0.0),
LineToCommand(600.0, 0.0),
LineToCommand(600.0, 400.0),
LineToCommand(0.0, 400.0),
CloseCommand(),
]);

// Second rect should be at 25%,25% (150,100) with 50% size (300x200)
expect(instructions.paths[1].commands, const <PathCommand>[
MoveToCommand(150.0, 100.0),
LineToCommand(450.0, 100.0),
LineToCommand(450.0, 300.0),
LineToCommand(150.0, 300.0),
CloseCommand(),
]);
});

test('Parse circle with percentage cx, cy', () {
const svgStr = '''
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<circle cx="50%" cy="50%" r="40" fill="blue" />
</svg>
''';

final VectorInstructions instructions = parseWithoutOptimizers(svgStr);

// Expect 1 circle path centered at 50%,50% = 100,100
expect(instructions.paths.length, 1);
// Circle paths are represented as ovals, check they're centered correctly
final commands = instructions.paths[0].commands.toList();
expect(commands.isNotEmpty, true);
// The first command should move to the top of the circle (100, 100-40 = 60)
expect(commands[0], const MoveToCommand(100.0, 60.0));
});

test('Parse line with percentage coordinates', () {
const svgStr = '''
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<line x1="0%" y1="0%" x2="100%" y2="100%" stroke="black" />
</svg>
''';

final VectorInstructions instructions = parseWithoutOptimizers(svgStr);

expect(instructions.paths.length, 1);
expect(instructions.paths[0].commands, const <PathCommand>[
MoveToCommand(0.0, 0.0),
LineToCommand(100.0, 100.0),
]);
});
}

const List<Paint> ghostScriptTigerPaints = <Paint>[
Expand Down