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
1 change: 1 addition & 0 deletions packages/vector_graphics_compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## NEXT

* Adds support for percentage units in SVG shape attributes (rect, circle, ellipse, line).
* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9.

## 1.1.19
Expand Down
27 changes: 26 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,29 @@ 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?.trim().endsWith('%') ?? false;
if (isPercent) {
if (percentageRef == null || percentageRef.isInfinite) {
// If no reference dimension is available, the percentage cannot be
// resolved. Return null for tryParse, otherwise throw an exception.
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
60 changes: 57 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,26 @@ 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 circle radius percentage, use the normalized diagonal per SVG spec:
// https://www.w3.org/TR/SVG2/coords.html#Units
// "For any other length value expressed as a percentage of the SVG viewport,
// the percentage must be calculated as a percentage of the normalized diagonal"
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 +518,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 +579,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 +605,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 +1007,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 List<PathCommand> 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