feat(svg): SVG colour + transform parsers for the drawing card (T-320)
parseSvgColor → packed ARGB (#rgb/#rgba/#rrggbb/#rrggbbaa, rgb()/rgba() integer or %, common names, none/transparent → 0x0); null on unrecognised so the caller can inherit. parseTransform → a composed 2-D Affine (translate/scale/rotate[/about-point]/matrix/skew), applied left-to-right. Both tolerant and Flutter-free, covered by dart test (22 cases). These feed the typed node model next. No user-visible behaviour yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/// SVG / CSS colour parsing for the renderer (T-320 / D-103).
|
||||
///
|
||||
/// Converts an SVG colour string into a packed `0xAARRGGBB` int the painter can
|
||||
/// hand to a `dart:ui` Color. Handles `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`,
|
||||
/// `rgb()/rgba()` (integer or percentage channels), the common named colours,
|
||||
/// and `none`/`transparent` (→ fully transparent, so the painter simply skips
|
||||
/// it). Returns `null` for anything unrecognised so the caller can fall back to
|
||||
/// the inherited / default paint. Never throws.
|
||||
///
|
||||
/// A colour is CONTENT, not a clide theme token (D-103 / D-7): an SVG fill is
|
||||
/// whatever the document says, independent of clide's palette.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
/// Parse an SVG colour to packed ARGB (`0xAARRGGBB`); `null` if unrecognised.
|
||||
/// `none` / `transparent` → `0x00000000`.
|
||||
int? parseSvgColor(String raw) {
|
||||
final s = raw.trim().toLowerCase();
|
||||
if (s.isEmpty) return null;
|
||||
if (s == 'none' || s == 'transparent') return 0x00000000;
|
||||
if (s.startsWith('#')) return _hex(s.substring(1));
|
||||
if (s.startsWith('rgb')) return _rgb(s);
|
||||
return _named[s];
|
||||
}
|
||||
|
||||
int? _hex(String h) {
|
||||
String dbl(String c) => '$c$c';
|
||||
String rr, gg, bb, aa;
|
||||
switch (h.length) {
|
||||
case 3:
|
||||
rr = dbl(h[0]);
|
||||
gg = dbl(h[1]);
|
||||
bb = dbl(h[2]);
|
||||
aa = 'ff';
|
||||
case 4:
|
||||
rr = dbl(h[0]);
|
||||
gg = dbl(h[1]);
|
||||
bb = dbl(h[2]);
|
||||
aa = dbl(h[3]);
|
||||
case 6:
|
||||
rr = h.substring(0, 2);
|
||||
gg = h.substring(2, 4);
|
||||
bb = h.substring(4, 6);
|
||||
aa = 'ff';
|
||||
case 8:
|
||||
rr = h.substring(0, 2);
|
||||
gg = h.substring(2, 4);
|
||||
bb = h.substring(4, 6);
|
||||
aa = h.substring(6, 8);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
final r = int.tryParse(rr, radix: 16);
|
||||
final g = int.tryParse(gg, radix: 16);
|
||||
final b = int.tryParse(bb, radix: 16);
|
||||
final a = int.tryParse(aa, radix: 16);
|
||||
if (r == null || g == null || b == null || a == null) return null;
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
int? _rgb(String s) {
|
||||
final open = s.indexOf('('), close = s.indexOf(')');
|
||||
if (open < 0 || close < open) return null;
|
||||
final parts = s.substring(open + 1, close).split(',').map((p) => p.trim()).toList();
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
int chan(String p) {
|
||||
if (p.endsWith('%')) {
|
||||
final pct = double.tryParse(p.substring(0, p.length - 1)) ?? 0;
|
||||
return (pct / 100 * 255).round().clamp(0, 255);
|
||||
}
|
||||
return (double.tryParse(p) ?? 0).round().clamp(0, 255);
|
||||
}
|
||||
|
||||
final r = chan(parts[0]), g = chan(parts[1]), b = chan(parts[2]);
|
||||
var a = 255;
|
||||
if (parts.length >= 4) {
|
||||
final af = double.tryParse(parts[3]);
|
||||
if (af != null) a = (af * 255).round().clamp(0, 255);
|
||||
}
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
/// Common named colours (the CSS basics plus a few greys d2/graphviz emit).
|
||||
/// Extended names can be added as needed — d2 uses hex, so this is mostly for
|
||||
/// hand-authored SVG.
|
||||
const Map<String, int> _named = {
|
||||
'black': 0xFF000000,
|
||||
'white': 0xFFFFFFFF,
|
||||
'red': 0xFFFF0000,
|
||||
'lime': 0xFF00FF00,
|
||||
'green': 0xFF008000,
|
||||
'blue': 0xFF0000FF,
|
||||
'yellow': 0xFFFFFF00,
|
||||
'cyan': 0xFF00FFFF,
|
||||
'aqua': 0xFF00FFFF,
|
||||
'magenta': 0xFFFF00FF,
|
||||
'fuchsia': 0xFFFF00FF,
|
||||
'silver': 0xFFC0C0C0,
|
||||
'gray': 0xFF808080,
|
||||
'grey': 0xFF808080,
|
||||
'maroon': 0xFF800000,
|
||||
'olive': 0xFF808000,
|
||||
'teal': 0xFF008080,
|
||||
'navy': 0xFF000080,
|
||||
'purple': 0xFF800080,
|
||||
'orange': 0xFFFFA500,
|
||||
'pink': 0xFFFFC0CB,
|
||||
'brown': 0xFFA52A2A,
|
||||
'gold': 0xFFFFD700,
|
||||
'lightgray': 0xFFD3D3D3,
|
||||
'lightgrey': 0xFFD3D3D3,
|
||||
'darkgray': 0xFFA9A9A9,
|
||||
'darkgrey': 0xFFA9A9A9,
|
||||
'whitesmoke': 0xFFF5F5F5,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/// SVG `transform` parsing → a 2-D affine for the renderer (T-320 / D-103).
|
||||
///
|
||||
/// Parses a transform list (`translate(..) scale(..) rotate(..) matrix(..)
|
||||
/// skewX(..) skewY(..)`) into a single composed [Affine], applied left-to-right
|
||||
/// (leftmost outermost, per SVG). Unknown functions are skipped; malformed
|
||||
/// input yields whatever composed so far. Never throws.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`. The painter converts the
|
||||
/// [Affine] to a canvas transform.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// A 2-D affine transform mapping `(x, y) → (a·x + c·y + e, b·x + d·y + f)` —
|
||||
/// the SVG `matrix(a b c d e f)` convention.
|
||||
class Affine {
|
||||
const Affine(this.a, this.b, this.c, this.d, this.e, this.f);
|
||||
final double a, b, c, d, e, f;
|
||||
|
||||
static const identity = Affine(1, 0, 0, 1, 0, 0);
|
||||
|
||||
/// `this · other` — `other` applied first, then `this`.
|
||||
Affine multiply(Affine o) => Affine(a * o.a + c * o.b, b * o.a + d * o.b, a * o.c + c * o.d, b * o.c + d * o.d, a * o.e + c * o.f + e, b * o.e + d * o.f + f);
|
||||
|
||||
/// Map a point through this transform.
|
||||
(double, double) apply(double x, double y) => (a * x + c * y + e, b * x + d * y + f);
|
||||
|
||||
bool get isIdentity => a == 1 && b == 0 && c == 0 && d == 1 && e == 0 && f == 0;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Affine && other.a == a && other.b == b && other.c == c && other.d == d && other.e == e && other.f == f;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(a, b, c, d, e, f);
|
||||
|
||||
@override
|
||||
String toString() => 'Affine($a, $b, $c, $d, $e, $f)';
|
||||
}
|
||||
|
||||
/// Parse an SVG `transform` list into a composed [Affine]. Returns
|
||||
/// [Affine.identity] for empty / unrecognised input.
|
||||
Affine parseTransform(String s) {
|
||||
var m = Affine.identity;
|
||||
for (final fn in RegExp(r'(\w+)\s*\(([^)]*)\)').allMatches(s)) {
|
||||
final args = fn.group(2)!.split(RegExp(r'[\s,]+')).where((x) => x.isNotEmpty).map(double.tryParse).toList();
|
||||
final t = _fn(fn.group(1)!, args);
|
||||
if (t != null) m = m.multiply(t);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
Affine? _fn(String fn, List<double?> args) {
|
||||
double a(int i) => (i < args.length && args[i] != null) ? args[i]! : 0;
|
||||
switch (fn) {
|
||||
case 'translate':
|
||||
return Affine(1, 0, 0, 1, a(0), args.length > 1 ? a(1) : 0);
|
||||
case 'scale':
|
||||
final sx = a(0);
|
||||
return Affine(sx, 0, 0, args.length > 1 ? a(1) : sx, 0, 0);
|
||||
case 'rotate':
|
||||
final rad = a(0) * math.pi / 180;
|
||||
final cos = math.cos(rad), sin = math.sin(rad);
|
||||
final r = Affine(cos, sin, -sin, cos, 0, 0);
|
||||
if (args.length >= 3) {
|
||||
final cx = a(1), cy = a(2);
|
||||
// translate(cx,cy) · R · translate(-cx,-cy)
|
||||
return Affine(1, 0, 0, 1, cx, cy).multiply(r).multiply(Affine(1, 0, 0, 1, -cx, -cy));
|
||||
}
|
||||
return r;
|
||||
case 'matrix':
|
||||
return args.length >= 6 ? Affine(a(0), a(1), a(2), a(3), a(4), a(5)) : null;
|
||||
case 'skewX':
|
||||
return Affine(1, 0, math.tan(a(0) * math.pi / 180), 1, 0, 0);
|
||||
case 'skewY':
|
||||
return Affine(1, math.tan(a(0) * math.pi / 180), 0, 1, 0, 0);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:clide/src/svg/svg_color.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('parseSvgColor', () {
|
||||
test('#rrggbb', () {
|
||||
expect(parseSvgColor('#0D32B2'), 0xFF0D32B2);
|
||||
});
|
||||
|
||||
test('#rgb shorthand expands each nibble', () {
|
||||
expect(parseSvgColor('#abc'), 0xFFAABBCC);
|
||||
});
|
||||
|
||||
test('#rrggbbaa carries alpha', () {
|
||||
expect(parseSvgColor('#11223344'), 0x44112233);
|
||||
});
|
||||
|
||||
test('#rgba shorthand', () {
|
||||
expect(parseSvgColor('#1234'), 0x44112233);
|
||||
});
|
||||
|
||||
test('rgb() integer channels', () {
|
||||
expect(parseSvgColor('rgb(13, 50, 178)'), 0xFF0D32B2);
|
||||
});
|
||||
|
||||
test('rgba() with fractional alpha', () {
|
||||
expect(parseSvgColor('rgba(0,0,0,0.5)'), 0x80000000);
|
||||
});
|
||||
|
||||
test('rgb() percentage channels', () {
|
||||
expect(parseSvgColor('rgb(100%, 0%, 0%)'), 0xFFFF0000);
|
||||
});
|
||||
|
||||
test('named colours', () {
|
||||
expect(parseSvgColor('red'), 0xFFFF0000);
|
||||
expect(parseSvgColor('white'), 0xFFFFFFFF);
|
||||
expect(parseSvgColor('grey'), parseSvgColor('gray'));
|
||||
});
|
||||
|
||||
test('none and transparent are fully transparent', () {
|
||||
expect(parseSvgColor('none'), 0x00000000);
|
||||
expect(parseSvgColor('transparent'), 0x00000000);
|
||||
});
|
||||
|
||||
test('case-insensitive and whitespace-tolerant', () {
|
||||
expect(parseSvgColor(' #0d32b2 '), 0xFF0D32B2);
|
||||
expect(parseSvgColor('RED'), 0xFFFF0000);
|
||||
});
|
||||
|
||||
test('unrecognised input is null, never throws', () {
|
||||
expect(parseSvgColor(''), isNull);
|
||||
expect(parseSvgColor('bogus'), isNull);
|
||||
expect(parseSvgColor('#xyz'), isNull);
|
||||
expect(parseSvgColor('#12'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:clide/src/svg/svg_transform.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Affine', () {
|
||||
test('apply maps a point', () {
|
||||
final (x, y) = const Affine(2, 0, 0, 3, 5, 7).apply(1, 1);
|
||||
expect(x, 7); // 2*1 + 0 + 5
|
||||
expect(y, 10); // 3*1 + 0 + 7
|
||||
});
|
||||
|
||||
test('multiply composes (this after other)', () {
|
||||
// translate(10,0) · scale(2): scale first, then translate.
|
||||
const t = Affine(1, 0, 0, 1, 10, 0);
|
||||
const s = Affine(2, 0, 0, 2, 0, 0);
|
||||
final (x, y) = t.multiply(s).apply(1, 1);
|
||||
expect(x, 12);
|
||||
expect(y, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseTransform', () {
|
||||
test('empty / garbage → identity', () {
|
||||
expect(parseTransform(''), Affine.identity);
|
||||
expect(parseTransform('not a transform'), Affine.identity);
|
||||
});
|
||||
|
||||
test('translate with one and two args', () {
|
||||
expect(parseTransform('translate(5,10)'), const Affine(1, 0, 0, 1, 5, 10));
|
||||
expect(parseTransform('translate(5)'), const Affine(1, 0, 0, 1, 5, 0));
|
||||
});
|
||||
|
||||
test('scale uniform and non-uniform', () {
|
||||
expect(parseTransform('scale(2)'), const Affine(2, 0, 0, 2, 0, 0));
|
||||
expect(parseTransform('scale(2,3)'), const Affine(2, 0, 0, 3, 0, 0));
|
||||
});
|
||||
|
||||
test('matrix is taken verbatim', () {
|
||||
expect(parseTransform('matrix(1,2,3,4,5,6)'), const Affine(1, 2, 3, 4, 5, 6));
|
||||
});
|
||||
|
||||
test('rotate(90) turns +x into +y', () {
|
||||
final (x, y) = parseTransform('rotate(90)').apply(1, 0);
|
||||
expect(x, closeTo(0, 1e-9));
|
||||
expect(y, closeTo(1, 1e-9));
|
||||
});
|
||||
|
||||
test('rotate about a point leaves that point fixed', () {
|
||||
final (x, y) = parseTransform('rotate(90, 1, 1)').apply(1, 1);
|
||||
expect(x, closeTo(1, 1e-9));
|
||||
expect(y, closeTo(1, 1e-9));
|
||||
});
|
||||
|
||||
test('composes a list left-to-right, leftmost outermost', () {
|
||||
// translate(10,0) scale(2): point (1,1) → scale → (2,2) → translate → (12,2)
|
||||
final (x, y) = parseTransform('translate(10,0) scale(2)').apply(1, 1);
|
||||
expect(x, closeTo(12, 1e-9));
|
||||
expect(y, closeTo(2, 1e-9));
|
||||
});
|
||||
|
||||
test('skips unknown functions but keeps the rest', () {
|
||||
expect(parseTransform('frobnicate(9) translate(3,4)'), const Affine(1, 0, 0, 1, 3, 4));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user