From 3c786104ca7e8460dc07087342bf98c994c74fda Mon Sep 17 00:00:00 2001 From: Alexandru Dochioiu Date: Sun, 30 Nov 2025 13:49:37 +0200 Subject: [PATCH 1/5] [vector_graphics_compiler] Fix rgb/rgba color parsing to support modern CSS syntax - Consolidate rgb() and rgba() parsing into single implementation - Add support for space-separated color values - Add support for percentage-based RGB values - Add support for slash (/) separator before alpha channel - Handle both comma and space delimiters - Add comprehensive test coverage for various color formats --- .../lib/src/svg/parser.dart | 59 ++++++++-------- .../test/parsers_test.dart | 70 +++++++++++++++++-- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index b8f67adc89c..3825aa37245 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -1372,21 +1372,40 @@ class SvgParser { } } - // handle rgba() colors e.g. rgba(255, 255, 255, 1.0) - if (colorString.toLowerCase().startsWith('rgba')) { - final List rawColorElements = colorString + // 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('rgba') || + colorString.toLowerCase().startsWith('rgb')) { + final List rgba = colorString .substring(colorString.indexOf('(') + 1, colorString.indexOf(')')) - .split(',') + .split(RegExp(r'[,/\s]')) .map((String rawColor) => rawColor.trim()) + .where((e) => e.isNotEmpty) + .indexed + .map((indexedColor) { + var (index, rawColor) = indexedColor; + if (rawColor.endsWith('%')) { + rawColor = rawColor.substring(0, rawColor.length - 1); + return (parseDouble(rawColor)! * 2.55).round(); + } + if (index == 3) { + // if alpha is not percentage, it means it's a double between 0 and 1 + final double opacity = parseDouble(rawColor)!; + if (opacity < 0 || opacity > 1) { + throw StateError('Invalid "opacity": $opacity'); + } + return (opacity * 255).round(); + } + // If rgb is not percentage, it means it's an integer between 0 and 255 + return int.parse(rawColor); + }) .toList(); - final double opacity = parseDouble(rawColorElements.removeLast())!; - - final List rgb = rawColorElements - .map((String rawColor) => int.parse(rawColor)) - .toList(); + if (rgba.length == 3) { + rgba.add(255); + } - return Color.fromRGBO(rgb[0], rgb[1], rgb[2], opacity); + return Color.fromARGB(rgba[3], rgba[0], rgba[1], rgba[2]); } // Conversion code from: https://github.com/MichaelFenwick/Color, thanks :) @@ -1456,26 +1475,6 @@ class SvgParser { ); } - // handle rgb() colors e.g. rgb(255, 255, 255) - if (colorString.toLowerCase().startsWith('rgb')) { - final List 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) { diff --git a/packages/vector_graphics_compiler/test/parsers_test.dart b/packages/vector_graphics_compiler/test/parsers_test.dart index 6062682368a..7f288fe1964 100644 --- a/packages/vector_graphics_compiler/test/parsers_test.dart +++ b/packages/vector_graphics_compiler/test/parsers_test.dart @@ -21,11 +21,71 @@ void main() { parser.parseColor('#ABCDEF', attributeName: 'foo', id: null), const Color.fromARGB(255, 0xAB, 0xCD, 0xEF), ); - // RGBA in svg/css, ARGB in this library. - expect( - parser.parseColor('#ABCDEF88', attributeName: 'foo', id: null), - const Color.fromARGB(0x88, 0xAB, 0xCD, 0xEF), - ); + }); + + group('Colors - svg/css', () { + final parser = SvgParser('', const SvgTheme(), 'test_key', true, null); + + group('with no opacity', () { + const rgbContentVariations = [ + '171, 205, 239', + '171,205,239', + '171 205 239', + '171 205 239', + '171 205 239', + '171 205 239', + '171 205 239', + '67% 80.5% 93.7%', + ]; + + final List rgbaVariations = [ + '#ABCDEF', + ...rgbContentVariations.map((rgb) => 'rgba($rgb)'), + ...rgbContentVariations.map((rgb) => 'rgb($rgb)'), + ]; + + for (final rgba in rgbaVariations) { + test(rgba, () { + expect( + parser.parseColor(rgba, attributeName: 'foo', id: null), + const Color.fromARGB(0xFF, 0xAB, 0xCD, 0xEF), + ); + }); + } + }); + group('with opacity', () { + const rgbContentVariations = [ + '171, 205, 239, 0.53', + '171,205,239,0.53', + '171 205 239 0.53', + '171 205 239 0.53', + '171 205 239 / 53%', + '171 205 239 / 0.53', + '171 205 239 / 53%', + '67% 80.5% 93.7% / 53%', + ]; + final List rgbaVariations = [ + '#ABCDEF87', + ...rgbContentVariations.map((rgb) => 'rgba($rgb)'), + ...rgbContentVariations.map((rgb) => 'rgb($rgb)'), + ]; + for (final rgba in rgbaVariations) { + test(rgba, () { + expect( + parser.parseColor(rgba, attributeName: 'foo', id: null), + const Color.fromARGB(0x87, 0xAB, 0xCD, 0xEF), + ); + }); + } + }); + + test('rgba with no opacity', () { + // RGBA is now an alias for RGB, so opacity can be missing entirely + expect( + parser.parseColor('rgba(171 205 239)', attributeName: 'foo', id: null), + const Color.fromARGB(0xFF, 0xAB, 0xCD, 0xEF), + ); + }); }); test('Colors - mapped', () async { From a199238d583070783def7f1f67f3e9efd7652c81 Mon Sep 17 00:00:00 2001 From: Alexandru Dochioiu Date: Sun, 30 Nov 2025 13:55:05 +0200 Subject: [PATCH 2/5] [vector_graphics_compiler] Bumped version and update CHANGELOG --- packages/vector_graphics_compiler/CHANGELOG.md | 3 ++- packages/vector_graphics_compiler/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vector_graphics_compiler/CHANGELOG.md b/packages/vector_graphics_compiler/CHANGELOG.md index b43c464030a..c0106e9eb8c 100644 --- a/packages/vector_graphics_compiler/CHANGELOG.md +++ b/packages/vector_graphics_compiler/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 1.1.20 +* Fix rgb and rgba color parsing to handle modern CSS syntax * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. ## 1.1.19 diff --git a/packages/vector_graphics_compiler/pubspec.yaml b/packages/vector_graphics_compiler/pubspec.yaml index 3e6911fc9a3..43d627e60eb 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.1.19 +version: 1.1.20 executables: vector_graphics_compiler: From efeb1a73a532ab7ba502edc74ef70734f116c87b Mon Sep 17 00:00:00 2001 From: Alexandru Dochioiu Date: Wed, 17 Dec 2025 11:56:06 +0200 Subject: [PATCH 3/5] [vector_graphics_compiler] Refactor RGB/RGBA color parsing - Add parseRgbFunction in colors.dart with character-by-character state machine parser for stricter CSS compliance - Support modern (space-separated with slash) and legacy (comma-separated) syntax variations - Clamp out-of-bounds values instead of throwing - Add comprehensive tests for valid syntax, out-of-bounds values, and invalid syntax detection --- .../vector_graphics_compiler/CHANGELOG.md | 2 +- .../lib/src/svg/colors.dart | 181 ++++++++++++++++++ .../lib/src/svg/parser.dart | 34 +--- .../test/parsers_test.dart | 154 +++++++++++++-- 4 files changed, 325 insertions(+), 46 deletions(-) diff --git a/packages/vector_graphics_compiler/CHANGELOG.md b/packages/vector_graphics_compiler/CHANGELOG.md index c0106e9eb8c..738b0afd563 100644 --- a/packages/vector_graphics_compiler/CHANGELOG.md +++ b/packages/vector_graphics_compiler/CHANGELOG.md @@ -1,6 +1,6 @@ ## 1.1.20 -* Fix rgb and rgba color parsing to handle modern CSS syntax +* 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 diff --git a/packages/vector_graphics_compiler/lib/src/svg/colors.dart b/packages/vector_graphics_compiler/lib/src/svg/colors.dart index 718cd42b184..b55d1d16e96 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/colors.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/colors.dart @@ -157,3 +157,184 @@ const Map namedColors = { '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 +/// - `rgb(R G,B,A)` or `rgba(R G,B,A)` - mixed: spaces before first comma +/// +/// Throws [StateError] if the color string is invalid. +Color parseRgbFunction(String colorString) { + final String content = colorString.substring( + colorString.indexOf('(') + 1, + colorString.indexOf(')'), + ); + final currentValue = StringBuffer(); + final values = []; + var hasSlash = false; + var commaCount = 0; + var pendingSpaceSeparator = false; + var justSawComma = false; + + void finalizeCurrentValue() { + final String trimmed = currentValue.toString().trim(); + if (trimmed.isNotEmpty) { + values.add(trimmed); + } + currentValue.clear(); + pendingSpaceSeparator = false; + } + + // Parse character by character + for (var i = 0; i < content.length; i++) { + final String char = content[i]; + final bool isWhitespace = + char == ' ' || char == '\t' || char == '\n' || char == '\r'; + + if (isWhitespace) { + if (currentValue.isNotEmpty) { + pendingSpaceSeparator = true; + } + justSawComma = false; + continue; + } + + if (char == '/') { + if (commaCount > 0) { + throw StateError( + 'Invalid color "$colorString": cannot mix comma and slash separators', + ); + } + if (hasSlash) { + throw StateError( + 'Invalid color "$colorString": multiple slashes not allowed', + ); + } + finalizeCurrentValue(); + if (values.length != 3) { + throw StateError( + 'Invalid color "$colorString": expected 3 RGB values before slash, ' + 'got ${values.length}', + ); + } + hasSlash = true; + justSawComma = false; + continue; + } + + if (char == ',') { + if (hasSlash) { + throw StateError( + 'Invalid color "$colorString": cannot mix comma and slash separators', + ); + } + final bool hasContent = currentValue.isNotEmpty || pendingSpaceSeparator; + if (justSawComma || (commaCount == 0 && !hasContent && values.isEmpty)) { + throw StateError( + 'Invalid color "$colorString": empty value in comma-separated list', + ); + } + finalizeCurrentValue(); + commaCount++; + justSawComma = true; + continue; + } + + // Regular character + justSawComma = false; + if (pendingSpaceSeparator && currentValue.isNotEmpty) { + if (commaCount > 0) { + throw StateError( + 'Invalid color "$colorString": space-separated values not allowed ' + 'after comma', + ); + } + finalizeCurrentValue(); + } + currentValue.write(char); + pendingSpaceSeparator = false; + } + + // Finalize last value + final bool hadContent = currentValue.isNotEmpty; + finalizeCurrentValue(); + + if (justSawComma) { + throw StateError( + 'Invalid color "$colorString": empty value in comma-separated list', + ); + } + if (hasSlash && values.length == 3 && !hadContent) { + throw StateError( + 'Invalid color "$colorString": missing alpha value after slash', + ); + } + + // Validate value count + if (values.length < 3) { + throw StateError( + 'Invalid color "$colorString": expected at least 3 values, ' + 'got ${values.length}', + ); + } + if (values.length > 4) { + throw StateError( + 'Invalid color "$colorString": expected at most 4 values, ' + 'got ${values.length}', + ); + } + + // Validate 4-value syntax rules + if (values.length == 4 && !hasSlash && commaCount < 2) { + if (commaCount == 0) { + throw StateError( + 'Invalid color "$colorString": modern syntax requires "/" ' + 'before alpha value', + ); + } else { + throw StateError( + 'Invalid color "$colorString": legacy syntax with alpha ' + 'requires at least 2 commas', + ); + } + } + + // Convert a single value to an integer color component + int parseComponent(int index, String rawValue) { + final isAlpha = index == 3; + if (rawValue.endsWith('%')) { + final String numPart = rawValue.substring(0, rawValue.length - 1); + final double? percent = double.tryParse(numPart); + if (percent == null) { + throw StateError( + 'Invalid color "$colorString": invalid percentage "$rawValue"', + ); + } + return (percent.clamp(0, 100) * 2.55).round(); + } + final double? value = double.tryParse(rawValue); + if (value == null) { + throw StateError( + 'Invalid color "$colorString": invalid value "$rawValue"', + ); + } + if (isAlpha) { + return (value.clamp(0, 1) * 255).round(); + } + return value.clamp(0, 255).round(); + } + + final int r = parseComponent(0, values[0]); + final int g = parseComponent(1, values[1]); + final int b = parseComponent(2, values[2]); + final int a = values.length == 4 ? parseComponent(3, values[3]) : 255; + + return Color.fromARGB(a, r, g, b); +} diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index 3825aa37245..a9746f76a54 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -1374,38 +1374,8 @@ class SvgParser { // 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('rgba') || - colorString.toLowerCase().startsWith('rgb')) { - final List rgba = colorString - .substring(colorString.indexOf('(') + 1, colorString.indexOf(')')) - .split(RegExp(r'[,/\s]')) - .map((String rawColor) => rawColor.trim()) - .where((e) => e.isNotEmpty) - .indexed - .map((indexedColor) { - var (index, rawColor) = indexedColor; - if (rawColor.endsWith('%')) { - rawColor = rawColor.substring(0, rawColor.length - 1); - return (parseDouble(rawColor)! * 2.55).round(); - } - if (index == 3) { - // if alpha is not percentage, it means it's a double between 0 and 1 - final double opacity = parseDouble(rawColor)!; - if (opacity < 0 || opacity > 1) { - throw StateError('Invalid "opacity": $opacity'); - } - return (opacity * 255).round(); - } - // If rgb is not percentage, it means it's an integer between 0 and 255 - return int.parse(rawColor); - }) - .toList(); - - if (rgba.length == 3) { - rgba.add(255); - } - - return Color.fromARGB(rgba[3], rgba[0], rgba[1], rgba[2]); + if (colorString.toLowerCase().startsWith('rgb')) { + return parseRgbFunction(colorString); } // Conversion code from: https://github.com/MichaelFenwick/Color, thanks :) diff --git a/packages/vector_graphics_compiler/test/parsers_test.dart b/packages/vector_graphics_compiler/test/parsers_test.dart index 7f288fe1964..6ab198fac37 100644 --- a/packages/vector_graphics_compiler/test/parsers_test.dart +++ b/packages/vector_graphics_compiler/test/parsers_test.dart @@ -28,14 +28,19 @@ void main() { group('with no opacity', () { const rgbContentVariations = [ + // Legacy syntax (comma-separated) '171, 205, 239', '171,205,239', + // Modern syntax (space-separated) '171 205 239', - '171 205 239', - '171 205 239', - '171 205 239', - '171 205 239', + // Percentage values '67% 80.5% 93.7%', + // Mixed percentage and decimal (space-separated) + '67% 205 93.7%', + // Decimal RGB values + '171.1 205.1 238.9', + // Mixed separators: spaces before first comma, then commas + '171 205,239', ]; final List rgbaVariations = [ @@ -53,22 +58,37 @@ void main() { }); } }); + group('with opacity', () { const rgbContentVariations = [ + // Legacy syntax (comma-separated) '171, 205, 239, 0.53', '171,205,239,0.53', - '171 205 239 0.53', - '171 205 239 0.53', + // Modern syntax (space-separated with slash before alpha) '171 205 239 / 53%', '171 205 239 / 0.53', - '171 205 239 / 53%', + '171 205 239 / .53', // leading dot + '171 205 239 / 0.53', + // Percentage RGB with slash alpha '67% 80.5% 93.7% / 53%', + // Mixed percentage and decimal RGB (space-separated) with slash alpha + '67% 205 93.7% / 53%', + // Decimal RGB values with percentage alpha + '171.1 205.1 238.9 / 53%', + // Decimal RGB values with decimal alpha + '171.1 205.1 238.9 / 0.53', + // Mixed separators: spaces before first comma, then commas (decimal alpha) + '171 205,239, 0.53', + // Mixed separators: spaces before first comma, then commas (percentage alpha) + '171 205,239, 53%', ]; + final List rgbaVariations = [ '#ABCDEF87', ...rgbContentVariations.map((rgb) => 'rgba($rgb)'), ...rgbContentVariations.map((rgb) => 'rgb($rgb)'), ]; + for (final rgba in rgbaVariations) { test(rgba, () { expect( @@ -79,12 +99,120 @@ void main() { } }); - test('rgba with no opacity', () { - // RGBA is now an alias for RGB, so opacity can be missing entirely - expect( - parser.parseColor('rgba(171 205 239)', attributeName: 'foo', id: null), - const Color.fromARGB(0xFF, 0xAB, 0xCD, 0xEF), - ); + group('with values out of bounds', () { + // RGB values > 255 clamp to 255, negative values clamp to 0 + // Percentages > 100% clamp to 100%, negative percentages clamp to 0% + // Alpha values > 1 clamp to 1, negative alpha clamps to 0 + + test('rgb(256.9, 0, 256) clamps RGB to 255', () { + expect( + parser.parseColor( + 'rgb(256.9, 0, 256)', + attributeName: 'foo', + id: null, + ), + const Color.fromARGB(255, 255, 0, 255), + ); + }); + + test('rgb(-50, 300, -100) clamps negative to 0 and >255 to 255', () { + expect( + parser.parseColor( + 'rgb(-50, 300, -100)', + attributeName: 'foo', + id: null, + ), + const Color.fromARGB(255, 0, 255, 0), + ); + }); + + test('rgb(120%, -10%, 200%) clamps percentages', () { + expect( + parser.parseColor( + 'rgb(120%, -10%, 200%)', + attributeName: 'foo', + id: null, + ), + const Color.fromARGB(255, 255, 0, 255), + ); + }); + + test('rgb(255, 0, 255, -0.5) negative alpha clamps to 0', () { + expect( + parser.parseColor( + 'rgb(255, 0, 255, -0.5)', + attributeName: 'foo', + id: null, + ), + const Color.fromARGB(0, 255, 0, 255), + ); + }); + + test('rgb(128, 128, 128, 2.5) alpha > 1 clamps to 1', () { + expect( + parser.parseColor( + 'rgb(128, 128, 128, 2.5)', + attributeName: 'foo', + id: null, + ), + const Color.fromARGB(255, 128, 128, 128), + ); + }); + + test('rgb(999 -50 300 / 150%) clamps all values', () { + expect( + parser.parseColor( + 'rgb(999 -50 300 / 150%)', + attributeName: 'foo', + id: null, + ), + const Color.fromARGB(255, 255, 0, 255), + ); + }); + }); + + group('invalid syntax', () { + const rgbContentVariations = [ + // 4 space-separated values without slash (modern syntax requires slash before alpha) + '171.1 205.1 238.9 53%', + '255 255 255 0.5', + '255 122 127 80%', + // Mixed comma and slash separators (not allowed) + '171.1,205.1 238.9/53%', + '255, 255, 255 / 0.5', + // Space-separated values after comma (not allowed) + '171.1,205.1 238.9, 53%', + '129% ,09% 255%,5.5', + // Single comma with 4 values (legacy alpha needs 2+ commas) + '129% 09% 255%,5.5', + // Empty values (double comma, leading comma, trailing comma) + '67%,,93.7%, 53%', + '10,,10', + '50,90,,0', + '255, 128, 0,', + // Slash between RGB values (slash only allowed before alpha) + '255 / 255 / 255', + // Too few values + '255 255', + // Too many values + '255 255 255 128 64', + // Missing alpha after slash + '255 255 255 /', + ]; + + final List rgbaVariations = [ + ...rgbContentVariations.map((rgb) => 'rgba($rgb)'), + ...rgbContentVariations.map((rgb) => 'rgb($rgb)'), + ]; + + for (final rgba in rgbaVariations) { + test(rgba, () { + expect( + () => parser.parseColor(rgba, attributeName: 'foo', id: null), + throwsStateError, + ); + }); + } }); }); From f94365fa04dc7a0f821bc91871364aa18a1488b0 Mon Sep 17 00:00:00 2001 From: Alexandru Dochioiu Date: Wed, 17 Dec 2025 12:34:37 +0200 Subject: [PATCH 4/5] [vector_graphics_compiler] Simplify RGB/RGBA parsing implementation Replace character-by-character state machine with cleaner split-based approach using standard string operations (split, trim, where). --- .../lib/src/svg/colors.dart | 244 ++++++++---------- 1 file changed, 103 insertions(+), 141 deletions(-) diff --git a/packages/vector_graphics_compiler/lib/src/svg/colors.dart b/packages/vector_graphics_compiler/lib/src/svg/colors.dart index b55d1d16e96..b62ed62e388 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/colors.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/colors.dart @@ -176,165 +176,127 @@ Color parseRgbFunction(String colorString) { colorString.indexOf('(') + 1, colorString.indexOf(')'), ); - final currentValue = StringBuffer(); - final values = []; - var hasSlash = false; - var commaCount = 0; - var pendingSpaceSeparator = false; - var justSawComma = false; - void finalizeCurrentValue() { - final String trimmed = currentValue.toString().trim(); - if (trimmed.isNotEmpty) { - values.add(trimmed); - } - currentValue.clear(); - pendingSpaceSeparator = false; + if (content.isEmpty) { + throw StateError('Invalid color "$colorString": empty content'); } + final List stringValues; - // Parse character by character - for (var i = 0; i < content.length; i++) { - final String char = content[i]; - final bool isWhitespace = - char == ' ' || char == '\t' || char == '\n' || char == '\r'; + final List commaSplit = content + .split(',') + .map((String value) => value.trim()) + .toList(); - if (isWhitespace) { - if (currentValue.isNotEmpty) { - pendingSpaceSeparator = true; - } - justSawComma = false; - continue; - } + if (commaSplit.length > 1) { + // We are dealing with comma-separated syntax - if (char == '/') { - if (commaCount > 0) { - throw StateError( - 'Invalid color "$colorString": cannot mix comma and slash separators', - ); - } - if (hasSlash) { - throw StateError( - 'Invalid color "$colorString": multiple slashes not allowed', - ); - } - finalizeCurrentValue(); - if (values.length != 3) { - throw StateError( - 'Invalid color "$colorString": expected 3 RGB values before slash, ' - 'got ${values.length}', - ); - } - hasSlash = true; - justSawComma = false; - continue; + // First handle the weird case where "R G, B" and "R G, B, A" are valid + final List firstValueSpaceSplit = commaSplit.first + .split(' ') + .map((String value) => value.trim()) + .toList(); + if (firstValueSpaceSplit.length > 2) { + throw StateError( + 'Invalid color "$colorString": expected at most 2 space-separated values in first value', + ); } - - if (char == ',') { - if (hasSlash) { - throw StateError( - 'Invalid color "$colorString": cannot mix comma and slash separators', - ); - } - final bool hasContent = currentValue.isNotEmpty || pendingSpaceSeparator; - if (justSawComma || (commaCount == 0 && !hasContent && values.isEmpty)) { - throw StateError( - 'Invalid color "$colorString": empty value in comma-separated list', - ); - } - finalizeCurrentValue(); - commaCount++; - justSawComma = true; - continue; + stringValues = [...firstValueSpaceSplit, ...commaSplit.skip(1)]; + } else { + final List slashSplit = content + .split('/') + .map((String value) => value.trim()) + .toList(); + if (slashSplit.length > 2) { + throw StateError( + 'Invalid color "$colorString": multiple slashes not allowed', + ); } - - // Regular character - justSawComma = false; - if (pendingSpaceSeparator && currentValue.isNotEmpty) { - if (commaCount > 0) { - throw StateError( - 'Invalid color "$colorString": space-separated values not allowed ' - 'after comma', - ); - } - finalizeCurrentValue(); + final List rgbSpaceSplit = slashSplit.first + .split(' ') + .map((String value) => value.trim()) + .where((String value) => value.isNotEmpty) + .toList(); + if (rgbSpaceSplit.length != 3) { + throw StateError( + 'Invalid color "$colorString": expected 3 space-separated RGB values', + ); } - currentValue.write(char); - pendingSpaceSeparator = false; + stringValues = [...rgbSpaceSplit, ...slashSplit.skip(1)]; } - // Finalize last value - final bool hadContent = currentValue.isNotEmpty; - finalizeCurrentValue(); - - if (justSawComma) { + if (stringValues.length < 3 || stringValues.length > 4) { throw StateError( - 'Invalid color "$colorString": empty value in comma-separated list', - ); - } - if (hasSlash && values.length == 3 && !hadContent) { - throw StateError( - 'Invalid color "$colorString": missing alpha value after slash', + 'Invalid color "$colorString": expected 3-4 values, got ${stringValues.length}', ); } - // Validate value count - if (values.length < 3) { - throw StateError( - 'Invalid color "$colorString": expected at least 3 values, ' - 'got ${values.length}', - ); - } - if (values.length > 4) { - throw StateError( - 'Invalid color "$colorString": expected at most 4 values, ' - 'got ${values.length}', - ); - } + final int r = _parseRgbFunctionComponent( + componentIndex: 0, + rawComponentValue: stringValues[0], + originalColorString: colorString, + ); + final int g = _parseRgbFunctionComponent( + componentIndex: 1, + rawComponentValue: stringValues[1], + originalColorString: colorString, + ); + final int b = _parseRgbFunctionComponent( + componentIndex: 2, + rawComponentValue: stringValues[2], + originalColorString: colorString, + ); + final int a = stringValues.length == 4 + ? _parseRgbFunctionComponent( + componentIndex: 3, + rawComponentValue: stringValues[3], + originalColorString: colorString, + ) + : 255; - // Validate 4-value syntax rules - if (values.length == 4 && !hasSlash && commaCount < 2) { - if (commaCount == 0) { - throw StateError( - 'Invalid color "$colorString": modern syntax requires "/" ' - 'before alpha value', - ); - } else { - throw StateError( - 'Invalid color "$colorString": legacy syntax with alpha ' - 'requires at least 2 commas', - ); - } - } + return Color.fromARGB(a, r, g, b); +} - // Convert a single value to an integer color component - int parseComponent(int index, String rawValue) { - final isAlpha = index == 3; - if (rawValue.endsWith('%')) { - final String numPart = rawValue.substring(0, rawValue.length - 1); - final double? percent = double.tryParse(numPart); - if (percent == null) { - throw StateError( - 'Invalid color "$colorString": invalid percentage "$rawValue"', - ); - } - return (percent.clamp(0, 100) * 2.55).round(); - } - final double? value = double.tryParse(rawValue); - if (value == null) { +/// Parses a single RGB/RGBA component value and returns an integer 0-255. +/// +/// The [componentIndex] indicates which component is being parsed: +/// - 0, 1, 2: RGB values (red, green, blue) +/// - 3: Alpha value +/// +/// 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 [StateError] if the value cannot be parsed as a number. +int _parseRgbFunctionComponent({ + required int componentIndex, + required String rawComponentValue, + required String originalColorString, +}) { + final isAlpha = componentIndex == 3; + if (rawComponentValue.endsWith('%')) { + final String numPart = rawComponentValue.substring( + 0, + rawComponentValue.length - 1, + ); + final double? percent = double.tryParse(numPart); + if (percent == null) { throw StateError( - 'Invalid color "$colorString": invalid value "$rawValue"', + 'Invalid color "$originalColorString": invalid percentage "$rawComponentValue"', ); } - if (isAlpha) { - return (value.clamp(0, 1) * 255).round(); - } - return value.clamp(0, 255).round(); + return (percent.clamp(0, 100) * 2.55).round(); } - - final int r = parseComponent(0, values[0]); - final int g = parseComponent(1, values[1]); - final int b = parseComponent(2, values[2]); - final int a = values.length == 4 ? parseComponent(3, values[3]) : 255; - - return Color.fromARGB(a, r, g, b); + final double? value = double.tryParse(rawComponentValue); + if (value == null) { + throw StateError( + 'Invalid color "$originalColorString": invalid value "$rawComponentValue"', + ); + } + if (isAlpha) { + return (value.clamp(0, 1) * 255).round(); + } + return value.clamp(0, 255).round(); } From 300674b92f13b30208b127581072670693fe5fd2 Mon Sep 17 00:00:00 2001 From: Alexandru Dochioiu Date: Wed, 17 Dec 2025 13:00:58 +0200 Subject: [PATCH 5/5] [vector_graphics_compiler] Filter empty strings in comma-separated RGB parsing Handle multiple spaces in first value of comma-separated syntax by filtering empty strings after split. --- packages/vector_graphics_compiler/lib/src/svg/colors.dart | 1 + packages/vector_graphics_compiler/test/parsers_test.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vector_graphics_compiler/lib/src/svg/colors.dart b/packages/vector_graphics_compiler/lib/src/svg/colors.dart index b62ed62e388..9d6c09122bc 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/colors.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/colors.dart @@ -194,6 +194,7 @@ Color parseRgbFunction(String colorString) { final List firstValueSpaceSplit = commaSplit.first .split(' ') .map((String value) => value.trim()) + .where((String value) => value.isNotEmpty) .toList(); if (firstValueSpaceSplit.length > 2) { throw StateError( diff --git a/packages/vector_graphics_compiler/test/parsers_test.dart b/packages/vector_graphics_compiler/test/parsers_test.dart index 6ab198fac37..4696312ebf1 100644 --- a/packages/vector_graphics_compiler/test/parsers_test.dart +++ b/packages/vector_graphics_compiler/test/parsers_test.dart @@ -80,7 +80,7 @@ void main() { // Mixed separators: spaces before first comma, then commas (decimal alpha) '171 205,239, 0.53', // Mixed separators: spaces before first comma, then commas (percentage alpha) - '171 205,239, 53%', + '171 205,239, 53%', ]; final List rgbaVariations = [