Skip to content

Commit efeb1a7

Browse files
committed
[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
1 parent a199238 commit efeb1a7

File tree

4 files changed

+325
-46
lines changed

4 files changed

+325
-46
lines changed

packages/vector_graphics_compiler/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## 1.1.20
22

3-
* Fix rgb and rgba color parsing to handle modern CSS syntax
3+
* Fixes color parsing for modern rgb and rgba CSS syntax.
44
* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9.
55

66
## 1.1.19

packages/vector_graphics_compiler/lib/src/svg/colors.dart

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,184 @@ const Map<String, Color> namedColors = <String, Color>{
157157
'yellow': Color.fromARGB(255, 255, 255, 0),
158158
'yellowgreen': Color.fromARGB(255, 154, 205, 50),
159159
};
160+
161+
/// Parses a CSS `rgb()` or `rgba()` function color string and returns a Color.
162+
///
163+
/// The [colorString] should be the full color string including the function
164+
/// name (`rgb` or `rgba`) and parentheses.
165+
///
166+
/// Both `rgb()` and `rgba()` accept the same syntax variations:
167+
/// - `rgb(R G B)` or `rgba(R G B)` - modern space-separated
168+
/// - `rgb(R G B / A)` or `rgba(R G B / A)` - modern with slash before alpha
169+
/// - `rgb(R,G,B)` or `rgba(R,G,B)` - legacy comma-separated
170+
/// - `rgb(R,G,B,A)` or `rgba(R,G,B,A)` - legacy with alpha
171+
/// - `rgb(R G,B,A)` or `rgba(R G,B,A)` - mixed: spaces before first comma
172+
///
173+
/// Throws [StateError] if the color string is invalid.
174+
Color parseRgbFunction(String colorString) {
175+
final String content = colorString.substring(
176+
colorString.indexOf('(') + 1,
177+
colorString.indexOf(')'),
178+
);
179+
final currentValue = StringBuffer();
180+
final values = <String>[];
181+
var hasSlash = false;
182+
var commaCount = 0;
183+
var pendingSpaceSeparator = false;
184+
var justSawComma = false;
185+
186+
void finalizeCurrentValue() {
187+
final String trimmed = currentValue.toString().trim();
188+
if (trimmed.isNotEmpty) {
189+
values.add(trimmed);
190+
}
191+
currentValue.clear();
192+
pendingSpaceSeparator = false;
193+
}
194+
195+
// Parse character by character
196+
for (var i = 0; i < content.length; i++) {
197+
final String char = content[i];
198+
final bool isWhitespace =
199+
char == ' ' || char == '\t' || char == '\n' || char == '\r';
200+
201+
if (isWhitespace) {
202+
if (currentValue.isNotEmpty) {
203+
pendingSpaceSeparator = true;
204+
}
205+
justSawComma = false;
206+
continue;
207+
}
208+
209+
if (char == '/') {
210+
if (commaCount > 0) {
211+
throw StateError(
212+
'Invalid color "$colorString": cannot mix comma and slash separators',
213+
);
214+
}
215+
if (hasSlash) {
216+
throw StateError(
217+
'Invalid color "$colorString": multiple slashes not allowed',
218+
);
219+
}
220+
finalizeCurrentValue();
221+
if (values.length != 3) {
222+
throw StateError(
223+
'Invalid color "$colorString": expected 3 RGB values before slash, '
224+
'got ${values.length}',
225+
);
226+
}
227+
hasSlash = true;
228+
justSawComma = false;
229+
continue;
230+
}
231+
232+
if (char == ',') {
233+
if (hasSlash) {
234+
throw StateError(
235+
'Invalid color "$colorString": cannot mix comma and slash separators',
236+
);
237+
}
238+
final bool hasContent = currentValue.isNotEmpty || pendingSpaceSeparator;
239+
if (justSawComma || (commaCount == 0 && !hasContent && values.isEmpty)) {
240+
throw StateError(
241+
'Invalid color "$colorString": empty value in comma-separated list',
242+
);
243+
}
244+
finalizeCurrentValue();
245+
commaCount++;
246+
justSawComma = true;
247+
continue;
248+
}
249+
250+
// Regular character
251+
justSawComma = false;
252+
if (pendingSpaceSeparator && currentValue.isNotEmpty) {
253+
if (commaCount > 0) {
254+
throw StateError(
255+
'Invalid color "$colorString": space-separated values not allowed '
256+
'after comma',
257+
);
258+
}
259+
finalizeCurrentValue();
260+
}
261+
currentValue.write(char);
262+
pendingSpaceSeparator = false;
263+
}
264+
265+
// Finalize last value
266+
final bool hadContent = currentValue.isNotEmpty;
267+
finalizeCurrentValue();
268+
269+
if (justSawComma) {
270+
throw StateError(
271+
'Invalid color "$colorString": empty value in comma-separated list',
272+
);
273+
}
274+
if (hasSlash && values.length == 3 && !hadContent) {
275+
throw StateError(
276+
'Invalid color "$colorString": missing alpha value after slash',
277+
);
278+
}
279+
280+
// Validate value count
281+
if (values.length < 3) {
282+
throw StateError(
283+
'Invalid color "$colorString": expected at least 3 values, '
284+
'got ${values.length}',
285+
);
286+
}
287+
if (values.length > 4) {
288+
throw StateError(
289+
'Invalid color "$colorString": expected at most 4 values, '
290+
'got ${values.length}',
291+
);
292+
}
293+
294+
// Validate 4-value syntax rules
295+
if (values.length == 4 && !hasSlash && commaCount < 2) {
296+
if (commaCount == 0) {
297+
throw StateError(
298+
'Invalid color "$colorString": modern syntax requires "/" '
299+
'before alpha value',
300+
);
301+
} else {
302+
throw StateError(
303+
'Invalid color "$colorString": legacy syntax with alpha '
304+
'requires at least 2 commas',
305+
);
306+
}
307+
}
308+
309+
// Convert a single value to an integer color component
310+
int parseComponent(int index, String rawValue) {
311+
final isAlpha = index == 3;
312+
if (rawValue.endsWith('%')) {
313+
final String numPart = rawValue.substring(0, rawValue.length - 1);
314+
final double? percent = double.tryParse(numPart);
315+
if (percent == null) {
316+
throw StateError(
317+
'Invalid color "$colorString": invalid percentage "$rawValue"',
318+
);
319+
}
320+
return (percent.clamp(0, 100) * 2.55).round();
321+
}
322+
final double? value = double.tryParse(rawValue);
323+
if (value == null) {
324+
throw StateError(
325+
'Invalid color "$colorString": invalid value "$rawValue"',
326+
);
327+
}
328+
if (isAlpha) {
329+
return (value.clamp(0, 1) * 255).round();
330+
}
331+
return value.clamp(0, 255).round();
332+
}
333+
334+
final int r = parseComponent(0, values[0]);
335+
final int g = parseComponent(1, values[1]);
336+
final int b = parseComponent(2, values[2]);
337+
final int a = values.length == 4 ? parseComponent(3, values[3]) : 255;
338+
339+
return Color.fromARGB(a, r, g, b);
340+
}

packages/vector_graphics_compiler/lib/src/svg/parser.dart

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,38 +1374,8 @@ class SvgParser {
13741374

13751375
// handle rgba() colors e.g. rgb(255, 255, 255) and rgba(255, 255, 255, 1.0)
13761376
// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/rgb
1377-
if (colorString.toLowerCase().startsWith('rgba') ||
1378-
colorString.toLowerCase().startsWith('rgb')) {
1379-
final List<int> rgba = colorString
1380-
.substring(colorString.indexOf('(') + 1, colorString.indexOf(')'))
1381-
.split(RegExp(r'[,/\s]'))
1382-
.map((String rawColor) => rawColor.trim())
1383-
.where((e) => e.isNotEmpty)
1384-
.indexed
1385-
.map((indexedColor) {
1386-
var (index, rawColor) = indexedColor;
1387-
if (rawColor.endsWith('%')) {
1388-
rawColor = rawColor.substring(0, rawColor.length - 1);
1389-
return (parseDouble(rawColor)! * 2.55).round();
1390-
}
1391-
if (index == 3) {
1392-
// if alpha is not percentage, it means it's a double between 0 and 1
1393-
final double opacity = parseDouble(rawColor)!;
1394-
if (opacity < 0 || opacity > 1) {
1395-
throw StateError('Invalid "opacity": $opacity');
1396-
}
1397-
return (opacity * 255).round();
1398-
}
1399-
// If rgb is not percentage, it means it's an integer between 0 and 255
1400-
return int.parse(rawColor);
1401-
})
1402-
.toList();
1403-
1404-
if (rgba.length == 3) {
1405-
rgba.add(255);
1406-
}
1407-
1408-
return Color.fromARGB(rgba[3], rgba[0], rgba[1], rgba[2]);
1377+
if (colorString.toLowerCase().startsWith('rgb')) {
1378+
return parseRgbFunction(colorString);
14091379
}
14101380

14111381
// Conversion code from: https://github.com/MichaelFenwick/Color, thanks :)

0 commit comments

Comments
 (0)