Skip to content
Open
3 changes: 2 additions & 1 deletion packages/vector_graphics_compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## NEXT
## 1.1.20

* Fixes color parsing for modern rgb and rgba CSS syntax.
* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9.

## 1.1.19
Expand Down
142 changes: 142 additions & 0 deletions packages/vector_graphics_compiler/lib/src/svg/colors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,145 @@ const Map<String, Color> namedColors = <String, Color>{
'yellow': Color.fromARGB(255, 255, 255, 0),
'yellowgreen': Color.fromARGB(255, 154, 205, 50),
};

/// Parses a CSS `rgb()` or `rgba()` function color string and returns a Color.
///
/// The [colorString] should be the full color string including the function
/// name (`rgb` or `rgba`) and parentheses.
///
/// Both `rgb()` and `rgba()` accept the same syntax variations:
/// - `rgb(R G B)` or `rgba(R G B)` - modern space-separated
/// - `rgb(R G B / A)` or `rgba(R G B / A)` - modern with slash before alpha
/// - `rgb(R,G,B)` or `rgba(R,G,B)` - legacy comma-separated
/// - `rgb(R,G,B,A)` or `rgba(R,G,B,A)` - legacy with alpha
///
/// Throws [ArgumentError] if the color string is invalid.
Color parseRgbFunction(String colorString) {
Copy link
Member

Choose a reason for hiding this comment

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

Are you opposed to just having a top level regex to return a record - and then converting the values of that record into a color? This is the syntax for matching: https://www.w3.org/TR/css-color-4/#rgb-functions

then the tests just have to be a list of expectations agianst

/// CSS Number Matching
/// Matches integers, decimals, and percentages, with optional leading minus sign
/// and trailing "d."
const String cssDigit = r'(-?(?:\d*\.?\d+|\d+\.)%?)';

// Explicitly named groups for the Legacy (Comma) Syntax
final String legacySyntax =
    '' // string alignment
            r'(?<commaRed>%DIGIT%)\s*,\s*'
            r'(?<commaGreen>%DIGIT%)\s*,\s*'
            r'(?<commaBlue>%DIGIT%)'
            r'(?:\s*,\s*(?<commaAlpha>%DIGIT%))?'
        .replaceAll('%DIGIT%', cssDigit);

// Explicitly named groups for the Modern (Space) Syntax
final String modernSyntax =
    '' // string alignment
            r'(?<spaceRed>%DIGIT%)\s+'
            r'(?<spaceGreen>%DIGIT%)\s+'
            r'(?<spaceBlue>%DIGIT%)'
            r'(?:\s*\/\s*(?<spaceAlpha>%DIGIT%))?'
        .replaceAll('%DIGIT%', cssDigit);

final cssRgbColorMatcher = RegExp(
  'rgba?\\(\\s*(?:$legacySyntax|$modernSyntax)\\s*\\)',
  caseSensitive: false,
);

({String r, String g, String b, String a})? parseCssRgb(String input) {
  final match = cssRgbColorMatcher.firstMatch(input);
  if (match == null) {
    return null;
  }
  final r = match.namedGroup('commaRed') ?? match.namedGroup('spaceRed');
  final g = match.namedGroup('commaGreen') ?? match.namedGroup('spaceGreen');
  final b = match.namedGroup('commaBlue') ?? match.namedGroup('spaceBlue');
  final a =
      match.namedGroup('commaAlpha') ?? match.namedGroup('spaceAlpha') ?? '1';

  return (r: r!, g: g!, b: b!, a: a);
}

then you can test something like:

test('CSS RGB Parser comprehensive validation', () {
    // --- POSITIVE MATCHES: LEGACY SYNTAX ---
    expect(parseCssRgb('rgb(255, 0, 0)'), 
        (r: '255', g: '0', b: '0', a: '1'), reason: 'Legacy RGB');
    expect(parseCssRgb('rgba(255, 0, 0, 0.5)'), 
        (r: '255', g: '0', b: '0', a: '0.5'), reason: 'Legacy RGBA');
    expect(parseCssRgb('rgb(10%, 20%, 30%)'), 
        (r: '10%', g: '20%', b: '30%', a: '1'), reason: 'Legacy Percentages');

    // --- POSITIVE MATCHES: MODERN SYNTAX ---
    expect(parseCssRgb('rgb(255 0 0)'), 
        (r: '255', g: '0', b: '0', a: '1'), reason: 'Modern Space RGB');
    expect(parseCssRgb('rgba(255 0 0)'), 
        (r: '255', g: '0', b: '0', a: '1'), reason: 'Modern Space RGBA (no alpha)');
    expect(parseCssRgb('rgb(255 0 0 / 0.5)'), 
        (r: '255', g: '0', b: '0', a: '0.5'), reason: 'Modern Alpha Slash');
    expect(parseCssRgb('rgba(255 0 0 / 50%)'), 
        (r: '255', g: '0', b: '0', a: '50%'), reason: 'Modern Alpha Percentage');

    // --- POSITIVE MATCHES: NEGATIVES, DECIMALS, WHITESPACE ---
    expect(parseCssRgb('rgb(-10, 5, .5)'), 
        (r: '-10', g: '5', b: '.5', a: '1'), reason: 'Leading decimal and negative');
    expect(parseCssRgb('rgb(5. 5. 5. / .1)'), 
        (r: '5.', g: '5.', b: '5.', a: '.1'), reason: 'Trailing and leading decimals');
    expect(parseCssRgb('rgb(-50% 120% 0 / -1)'), 
        (r: '-50%', g: '120%', b: '0', a: '-1'), reason: 'Negative percentage/alpha');
    expect(parseCssRgb('RGBA( 255,255,255 )'), 
        (r: '255', g: '255', b: '255', a: '1'), reason: 'Case/Tight spacing');
    expect(parseCssRgb('rgb(  0  0  0  /  0  )'), 
        (r: '0', g: '0', b: '0', a: '0'), reason: 'Extra spacing');

    // --- NEGATIVE MATCHES (Should return null) ---
    // Mixing separators
    expect(parseCssRgb('rgb(255, 255 255)'), isNull, reason: 'Mixed comma/space');
    expect(parseCssRgb('rgb(255 255, 255)'), isNull, reason: 'Mixed space/comma');
    expect(parseCssRgb('rgba(255, 255, 255 / 0.5)'), isNull, reason: 'Mixed legacy with slash');

    // Malformed syntax
    expect(parseCssRgb('rgb(255 255 255 0.5)'), isNull, reason: 'Modern missing slash');
    expect(parseCssRgb('rgb(255, 255)'), isNull, reason: 'Missing blue');
    expect(parseCssRgb('rgba(255, 255, 255, 1, 1)'), isNull, reason: 'Too many args');
    expect(parseCssRgb('rgb 255, 255, 255'), isNull, reason: 'Missing parens');
    expect(parseCssRgb('rgb(red, green, blue)'), isNull, reason: 'Named colors');
    expect(parseCssRgb('rgb()'), isNull, reason: 'Empty');
  });

Copy link
Member

Choose a reason for hiding this comment

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

You could also choose to parse the "number" into a Color rather than a record type.

final String content = colorString.substring(
colorString.indexOf('(') + 1,
colorString.indexOf(')'),
);

if (content.isEmpty) {
throw ArgumentError.value(colorString, 'colorString', 'Empty content');
}
final List<String> stringRgbValues;
final String? stringAlphaValue;

final List<String> commaSplit = content
.split(',')
.map((String value) => value.trim())
.toList();

if (commaSplit.length > 1) {
// We are dealing with comma-separated syntax
(stringAlphaValue, stringRgbValues) = switch (commaSplit.length) {
3 => (null, commaSplit),
4 => (commaSplit.removeLast(), commaSplit),
_ => throw ArgumentError.value(
colorString,
'colorString',
'Expected 3 or 4 values, got ${commaSplit.length}',
),
};
} else {
final List<String> slashSplit = content
.split('/')
.map((String value) => value.trim())
.toList();

final (
String tempStringRgbValues,
String? tempStringAlphaValue,
) = switch (slashSplit.length) {
1 => (slashSplit.first, null),
2 => (slashSplit.first, slashSplit.last),
_ => throw ArgumentError.value(
colorString,
'colorString',
'Multiple slashes not allowed',
),
};

final List<String> rgbSpaceSplit = tempStringRgbValues
.split(' ')
.map((String value) => value.trim())
.where((String value) => value.isNotEmpty)
.toList();

if (rgbSpaceSplit.length != 3) {
throw ArgumentError.value(
colorString,
'colorString',
'Expected 3 space-separated RGB values',
);
}

stringRgbValues = rgbSpaceSplit;
stringAlphaValue = tempStringAlphaValue;
}

final List<int> rgbIntValues = stringRgbValues
.map(
(String value) => _parseRgbaFunctionComponent(
isAlpha: false,
rawComponentValue: value,
originalColorString: colorString,
),
)
.toList();

final int a = stringAlphaValue == null
? 255
: _parseRgbaFunctionComponent(
isAlpha: true,
rawComponentValue: stringAlphaValue,
originalColorString: colorString,
);

return Color.fromARGB(a, rgbIntValues[0], rgbIntValues[1], rgbIntValues[2]);
}

/// Parses a single RGB/RGBA component value and returns an integer 0-255.
///
/// The [rawComponentValue] can be:
/// - A percentage (e.g., "50%") - converted to 0-255 range
/// - A decimal number (e.g., "128" or "128.5") - clamped to 0-255 for RGB
/// - For alpha (index 3): decimal 0-1 range, converted to 0-255
///
/// Out-of-bounds values are clamped rather than rejected.
///
/// Throws [ArgumentError] if the value cannot be parsed as a number.
int _parseRgbaFunctionComponent({
required bool isAlpha,
required String rawComponentValue,
required String originalColorString,
}) {
if (rawComponentValue.endsWith('%')) {
final String numPart = rawComponentValue.substring(
0,
rawComponentValue.length - 1,
);
final double? percent = double.tryParse(numPart);
if (percent == null) {
throw ArgumentError.value(
originalColorString,
'originalColorString',
'invalid percentage "$rawComponentValue"',
);
}
return (percent.clamp(0, 100) * 2.55).round();
}
final double? value = double.tryParse(rawComponentValue);
if (value == null) {
throw ArgumentError.value(
originalColorString,
'originalColorString',
'invalid value "$rawComponentValue"',
);
}
if (isAlpha) {
return (value.clamp(0, 1) * 255).round();
}
return value.clamp(0, 255).round();
}
39 changes: 4 additions & 35 deletions packages/vector_graphics_compiler/lib/src/svg/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1372,21 +1372,10 @@ class SvgParser {
}
}

// handle rgba() colors e.g. rgba(255, 255, 255, 1.0)
if (colorString.toLowerCase().startsWith('rgba')) {
final List<String> rawColorElements = colorString
.substring(colorString.indexOf('(') + 1, colorString.indexOf(')'))
.split(',')
.map((String rawColor) => rawColor.trim())
.toList();

final double opacity = parseDouble(rawColorElements.removeLast())!;

final List<int> rgb = rawColorElements
.map((String rawColor) => int.parse(rawColor))
.toList();

return Color.fromRGBO(rgb[0], rgb[1], rgb[2], opacity);
// handle rgba() colors e.g. rgb(255, 255, 255) and rgba(255, 255, 255, 1.0)
// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/rgb
if (colorString.toLowerCase().startsWith('rgb')) {
return parseRgbFunction(colorString);
}

// Conversion code from: https://github.com/MichaelFenwick/Color, thanks :)
Expand Down Expand Up @@ -1456,26 +1445,6 @@ class SvgParser {
);
}

// handle rgb() colors e.g. rgb(255, 255, 255)
if (colorString.toLowerCase().startsWith('rgb')) {
final List<int> rgb = colorString
.substring(colorString.indexOf('(') + 1, colorString.indexOf(')'))
.split(',')
.map((String rawColor) {
rawColor = rawColor.trim();
if (rawColor.endsWith('%')) {
rawColor = rawColor.substring(0, rawColor.length - 1);
return (parseDouble(rawColor)! * 2.55).round();
}
return int.parse(rawColor);
})
.toList();

// rgba() isn't really in the spec, but Firefox supported it at one point so why not.
final int a = rgb.length > 3 ? rgb[3] : 255;
return Color.fromARGB(a, rgb[0], rgb[1], rgb[2]);
}

// handle named colors ('red', 'green', etc.).
final Color? namedColor = namedColors[colorString];
if (namedColor != null) {
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
Loading