diff --git a/packages/vector_graphics_compiler/CHANGELOG.md b/packages/vector_graphics_compiler/CHANGELOG.md index 13682cba547..0b9deea8611 100644 --- a/packages/vector_graphics_compiler/CHANGELOG.md +++ b/packages/vector_graphics_compiler/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.2.2 + +* Adds support for modern space-separated HSL and HSLA color syntax. + ## 1.2.1 * Fixes HSL/HSLA color parsing for decimal percentage components (e.g. `hsl(270, 100%, 76.27%)`). diff --git a/packages/vector_graphics_compiler/lib/src/svg/colors.dart b/packages/vector_graphics_compiler/lib/src/svg/colors.dart index c47eeba71ab..1d1d0fadf9c 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/colors.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/colors.dart @@ -193,9 +193,40 @@ final RegExp _cssRgbColorMatcher = RegExp( caseSensitive: false, ); +/// Legacy (comma-separated) HSL syntax pattern with named capture groups. +/// Matches: hsl(H, S, L) or hsla(H, S, L, A) +final String _legacyHslSyntax = + '' // string alignment + r'(?%DIGIT%)\s*,\s*' + r'(?%DIGIT%)\s*,\s*' + r'(?%DIGIT%)' + r'(?:\s*,\s*(?%DIGIT%))?' + .replaceAll('%DIGIT%', _cssDigit); + +/// Modern (space-separated) HSL syntax pattern with named capture groups. +/// Matches: hsl(H S L) or hsl(H S L / A) +final String _modernHslSyntax = + '' // string alignment + r'(?%DIGIT%)\s+' + r'(?%DIGIT%)\s+' + r'(?%DIGIT%)' + r'(?:\s*\/\s*(?%DIGIT%))?' + .replaceAll('%DIGIT%', _cssDigit); + +/// Combined regex for matching CSS hsl/hsla color functions. +/// Supports both legacy (comma) and modern (space) syntax. +/// https://www.w3.org/TR/css-color-4/#the-hsl-notation +final RegExp _cssHslColorMatcher = RegExp( + 'hsla?\\(\\s*(?:$_legacyHslSyntax|$_modernHslSyntax)\\s*\\)', + caseSensitive: false, +); + /// Record type representing parsed CSS RGB values as strings. typedef CssRgbRecord = ({String r, String g, String b, String a}); +/// Record type representing parsed CSS HSL values as strings. +typedef CssHslRecord = ({String h, String s, String l, String a}); + /// Parses a CSS `rgb()` or `rgba()` function string into a record of string values. /// /// Returns a record with r, g, b, and a string values if the input matches @@ -222,6 +253,35 @@ CssRgbRecord? parseCssRgb(String input) { return (r: r!, g: g!, b: b!, a: a); } +/// Parses a CSS `hsl()` or `hsla()` function string into a record of string values. +/// +/// Returns a record with h, s, l, and a string values if the input matches +/// valid CSS hsl/hsla syntax, or null if the syntax is invalid. +/// +/// Both legacy (comma-separated) and modern (space-separated) syntax are supported: +/// - Legacy: `hsl(270, 100%, 76%)` or `hsla(270, 100%, 76%, 0.5)` +/// - Modern: `hsl(270 100% 76%)` or `hsl(270 100% 76% / 0.5)` +@visibleForTesting +CssHslRecord? parseCssHsl(String input) { + final RegExpMatch? match = _cssHslColorMatcher.firstMatch(input); + if (match == null) { + return null; + } + final String? h = + match.namedGroup('commaHue') ?? match.namedGroup('spaceHue'); + final String? s = + match.namedGroup('commaSaturation') ?? + match.namedGroup('spaceSaturation'); + final String? l = + match.namedGroup('commaLightness') ?? match.namedGroup('spaceLightness'); + final String a = + match.namedGroup('commaHslAlpha') ?? + match.namedGroup('spaceHslAlpha') ?? + '1'; + + return (h: h!, s: s!, l: l!, a: a); +} + /// Parses a CSS `rgb()` or `rgba()` function color string and returns a Color. /// /// The [colorString] should be the full color string including the function @@ -246,6 +306,30 @@ Color parseRgbFunction(String colorString) { return _cssRgbRecordToColor(parsed); } +/// Parses a CSS `hsl()` or `hsla()` function color string and returns a Color. +/// +/// The [colorString] should be the full color string including the function +/// name (`hsl` or `hsla`) and parentheses. +/// +/// Both `hsl()` and `hsla()` accept the same syntax variations: +/// - `hsl(H S L)` or `hsla(H S L)` - modern space-separated +/// - `hsl(H S L / A)` or `hsla(H S L / A)` - modern with slash before alpha +/// - `hsl(H,S,L)` or `hsla(H,S,L)` - legacy comma-separated +/// - `hsl(H,S,L,A)` or `hsla(H,S,L,A)` - legacy with alpha +/// +/// Throws [ArgumentError] if the color string is invalid. +Color parseHslFunction(String colorString) { + final CssHslRecord? parsed = parseCssHsl(colorString); + if (parsed == null) { + throw ArgumentError.value( + colorString, + 'colorString', + 'Invalid CSS hsl/hsla color syntax', + ); + } + return _cssHslRecordToColor(parsed); +} + /// Converts a [CssRgbRecord] to a [Color]. /// /// Each component string can be: @@ -263,6 +347,54 @@ Color _cssRgbRecordToColor(CssRgbRecord record) { return Color.fromARGB(a, r, g, b); } +/// Converts a [CssHslRecord] to a [Color]. +Color _cssHslRecordToColor(CssHslRecord record) { + final double hue = _parseHslValue(record.h) / 360 % 1; + final double saturation = _parseHslValue(record.s).clamp(0, 100) / 100; + final double luminance = _parseHslValue(record.l).clamp(0, 100) / 100; + final int alpha = _parseHslAlpha(record.a); + double red = 0; + double green = 0; + double blue = 0; + + if (hue < 1 / 6) { + red = 1; + green = hue * 6; + } else if (hue < 2 / 6) { + red = 2 - hue * 6; + green = 1; + } else if (hue < 3 / 6) { + green = 1; + blue = hue * 6 - 2; + } else if (hue < 4 / 6) { + green = 4 - hue * 6; + blue = 1; + } else if (hue < 5 / 6) { + red = hue * 6 - 4; + blue = 1; + } else { + red = 1; + blue = 6 - hue * 6; + } + + return Color.fromARGB( + alpha, + _hslChannelToRgb(red, saturation, luminance), + _hslChannelToRgb(green, saturation, luminance), + _hslChannelToRgb(blue, saturation, luminance), + ); +} + +int _hslChannelToRgb(double channel, double saturation, double luminance) { + channel = channel + (1 - saturation) * (0.5 - channel); + if (luminance < 0.5) { + channel = luminance * 2 * channel; + } else { + channel = luminance * 2 * (1 - channel) + 2 * channel - 1; + } + return (channel.clamp(0, 1) * 255).round(); +} + /// Parses a single color component value and returns an integer 0-255. int _parseColorComponent(String value, {required bool isAlpha}) { if (value.endsWith('%')) { @@ -276,3 +408,20 @@ int _parseColorComponent(String value, {required bool isAlpha}) { } return numValue.clamp(0, 255).round(); } + +/// Parses a single HSL component value and returns its numeric value. +double _parseHslValue(String value) { + if (value.endsWith('%')) { + value = value.substring(0, value.length - 1); + } + return double.parse(value); +} + +/// Parses a single HSL alpha value and returns an integer 0-255. +int _parseHslAlpha(String value) { + if (value.endsWith('%')) { + // Avoid * 2.55 because floating-point rounding makes 50% produce 127. + return (_parseHslValue(value).clamp(0, 100) / 100 * 255).round(); + } + return (double.parse(value).clamp(0, 1) * 255).round(); +} diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index 52eb38612a4..a7f9b61f641 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -1432,73 +1432,10 @@ class SvgParser { return parseRgbFunction(colorString); } - // Conversion code from: https://github.com/MichaelFenwick/Color, thanks :) + // handle hsla() colors e.g. hsl(270, 100%, 76%) and hsla(270, 100%, 76%, 1.0) + // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/hsl if (colorString.toLowerCase().startsWith('hsl')) { - final List values = colorString - .substring(colorString.indexOf('(') + 1, colorString.indexOf(')')) - .split(',') - .map((String rawColor) => rawColor.trim()) - .toList(); - double parseHslValue(String rawColor) { - if (rawColor.endsWith('%')) { - rawColor = rawColor.substring(0, rawColor.length - 1); - } - return parseDouble(rawColor)!; - } - - int parseHslAlpha(String rawAlpha) { - if (rawAlpha.endsWith('%')) { - return (parseHslValue(rawAlpha).clamp(0, 100) * 2.55).round(); - } - return (parseDouble(rawAlpha)!.clamp(0, 1) * 255).round(); - } - - final double hue = parseHslValue(values[0]) / 360 % 1; - final double saturation = parseHslValue(values[1]) / 100; - final double luminance = parseHslValue(values[2]) / 100; - final int alpha = values.length > 3 ? parseHslAlpha(values[3]) : 255; - var rgb = [0, 0, 0]; - - if (hue < 1 / 6) { - rgb[0] = 1; - rgb[1] = hue * 6; - } else if (hue < 2 / 6) { - rgb[0] = 2 - hue * 6; - rgb[1] = 1; - } else if (hue < 3 / 6) { - rgb[1] = 1; - rgb[2] = hue * 6 - 2; - } else if (hue < 4 / 6) { - rgb[1] = 4 - hue * 6; - rgb[2] = 1; - } else if (hue < 5 / 6) { - rgb[0] = hue * 6 - 4; - rgb[2] = 1; - } else { - rgb[0] = 1; - rgb[2] = 6 - hue * 6; - } - - rgb = rgb - .map((double val) => val + (1 - saturation) * (0.5 - val)) - .toList(); - - if (luminance < 0.5) { - rgb = rgb.map((double val) => luminance * 2 * val).toList(); - } else { - rgb = rgb - .map((double val) => luminance * 2 * (1 - val) + 2 * val - 1) - .toList(); - } - - rgb = rgb.map((double val) => val * 255).toList(); - - return Color.fromARGB( - alpha, - rgb[0].round(), - rgb[1].round(), - rgb[2].round(), - ); + return parseHslFunction(colorString); } // handle named colors ('red', 'green', etc.). diff --git a/packages/vector_graphics_compiler/pubspec.yaml b/packages/vector_graphics_compiler/pubspec.yaml index 47e7b8c0218..7b3650331e0 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.2.1 +version: 1.2.2 executables: vector_graphics_compiler: diff --git a/packages/vector_graphics_compiler/test/colors_test.dart b/packages/vector_graphics_compiler/test/colors_test.dart index 03ac631f845..7c17644f49d 100644 --- a/packages/vector_graphics_compiler/test/colors_test.dart +++ b/packages/vector_graphics_compiler/test/colors_test.dart @@ -140,4 +140,162 @@ void main() { expect(parseCssRgb('rgb()'), isNull); }); }); + + group('parseCssHsl - record output validation', () { + // --- POSITIVE MATCHES: LEGACY SYNTAX --- + test('Legacy HSL', () { + expect(parseCssHsl('hsl(270, 100%, 76%)'), ( + h: '270', + s: '100%', + l: '76%', + a: '1', + )); + }); + + test('Legacy HSLA', () { + expect(parseCssHsl('hsla(270, 100%, 76%, 0.5)'), ( + h: '270', + s: '100%', + l: '76%', + a: '0.5', + )); + }); + + test('Legacy alpha percentage', () { + expect(parseCssHsl('hsla(270, 100%, 76%, 50%)'), ( + h: '270', + s: '100%', + l: '76%', + a: '50%', + )); + }); + + // --- POSITIVE MATCHES: MODERN SYNTAX --- + test('Modern Space HSL', () { + expect(parseCssHsl('hsl(270 100% 76%)'), ( + h: '270', + s: '100%', + l: '76%', + a: '1', + )); + }); + + test('Modern Space HSLA (no alpha)', () { + expect(parseCssHsl('hsla(270 100% 76%)'), ( + h: '270', + s: '100%', + l: '76%', + a: '1', + )); + }); + + test('Modern Alpha Slash', () { + expect(parseCssHsl('hsla(270 100% 76% / 0.5)'), ( + h: '270', + s: '100%', + l: '76%', + a: '0.5', + )); + }); + + test('Modern Alpha Percentage', () { + expect(parseCssHsl('hsla(270 100% 76% / 50%)'), ( + h: '270', + s: '100%', + l: '76%', + a: '50%', + )); + }); + + // --- POSITIVE MATCHES: NEGATIVES, DECIMALS, WHITESPACE --- + test('Leading decimal and negative', () { + expect(parseCssHsl('hsl(-.5 100% 76%)'), ( + h: '-.5', + s: '100%', + l: '76%', + a: '1', + )); + }); + + test('Trailing and leading decimals', () { + expect(parseCssHsl('hsl(270. 100% 76. / .5)'), ( + h: '270.', + s: '100%', + l: '76.', + a: '.5', + )); + }); + + test('Negative percentage/alpha', () { + expect(parseCssHsl('hsl(270 -10% 120% / -1)'), ( + h: '270', + s: '-10%', + l: '120%', + a: '-1', + )); + }); + + test('Case/Tight spacing', () { + expect(parseCssHsl('HSLA( 270,100%,76% )'), ( + h: '270', + s: '100%', + l: '76%', + a: '1', + )); + }); + + test('Extra spacing', () { + expect(parseCssHsl('hsl( 270 100% 76% / 0 )'), ( + h: '270', + s: '100%', + l: '76%', + a: '0', + )); + }); + + // --- NEGATIVE MATCHES (Should return null) --- + test('Mixed comma/space returns null', () { + expect(parseCssHsl('hsl(270, 100% 76%)'), isNull); + }); + + test('Mixed space/comma returns null', () { + expect(parseCssHsl('hsl(270 100%, 76%)'), isNull); + }); + + test('Mixed legacy with slash returns null', () { + expect(parseCssHsl('hsla(270, 100%, 76% / 0.5)'), isNull); + }); + + test('Modern missing slash returns null', () { + expect(parseCssHsl('hsl(270 100% 76% 0.5)'), isNull); + }); + + test('Missing lightness returns null', () { + expect(parseCssHsl('hsl(270, 100%)'), isNull); + }); + + test('Too many args returns null', () { + expect(parseCssHsl('hsla(270, 100%, 76%, 1, 1)'), isNull); + }); + + test('Missing parens returns null', () { + expect(parseCssHsl('hsl 270, 100%, 76%'), isNull); + }); + + test('Named colors returns null', () { + expect(parseCssHsl('hsl(red, 100%, 76%)'), isNull); + }); + + test('Empty returns null', () { + expect(parseCssHsl('hsl()'), isNull); + }); + + test('Empty value returns null', () { + expect(parseCssHsl('hsl(270,,76%)'), isNull); + }); + + test('Trailing comma returns null', () { + expect(parseCssHsl('hsl(270, 100%, 76%,)'), isNull); + }); + }); } diff --git a/packages/vector_graphics_compiler/test/parsers_test.dart b/packages/vector_graphics_compiler/test/parsers_test.dart index 84c408227b8..cb8139300df 100644 --- a/packages/vector_graphics_compiler/test/parsers_test.dart +++ b/packages/vector_graphics_compiler/test/parsers_test.dart @@ -244,6 +244,39 @@ void main() { ); }); + test('hsl with modern space-separated syntax', () { + expect( + parser.parseColor( + 'hsl(270 100% 76.2745098039%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 134, 255), + ); + }); + + test('hsla with modern space-separated syntax and decimal alpha', () { + expect( + parser.parseColor( + 'hsla(270 100% 76.2745098039% / 0.5)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(128, 194, 134, 255), + ); + }); + + test('hsla with modern space-separated syntax and percentage alpha', () { + expect( + parser.parseColor( + 'hsla(270 100% 76.2745098039% / 50%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(128, 194, 134, 255), + ); + }); + test('hsla with integer percentages and decimal alpha', () { expect( parser.parseColor( @@ -276,6 +309,122 @@ void main() { const Color.fromARGB(128, 194, 134, 255), ); }); + + test('hsl saturation and lightness clamp to percentages', () { + expect( + parser.parseColor( + 'hsl(270, 150%, 76%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsl(270, -10%, 76%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 194, 194), + ); + expect( + parser.parseColor( + 'hsl(270, 100%, -10%)', + attributeName: 'fill', + id: null, + ), + Color.opaqueBlack, + ); + expect( + parser.parseColor( + 'hsl(270, 100%, 150%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 255, 255, 255), + ); + }); + + test('hsla alpha accepts boundary values', () { + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 0)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(0, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 0%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(0, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 1)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 100%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 50%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(128, 194, 133, 255), + ); + }); + + test('hsla alpha clamps to 0', () { + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, -0.5)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(0, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, -10%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(0, 194, 133, 255), + ); + }); + + test('hsla alpha clamps to 255', () { + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 2)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 133, 255), + ); + expect( + parser.parseColor( + 'hsla(270, 100%, 76%, 150%)', + attributeName: 'fill', + id: null, + ), + const Color.fromARGB(255, 194, 133, 255), + ); + }); }); test('Colors - mapped', () async {