diff --git a/lib/src/svg/svg_color.dart b/lib/src/svg/svg_color.dart new file mode 100644 index 00000000..9fe30c41 --- /dev/null +++ b/lib/src/svg/svg_color.dart @@ -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 _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, +}; diff --git a/lib/src/svg/svg_transform.dart b/lib/src/svg/svg_transform.dart new file mode 100644 index 00000000..501054b6 --- /dev/null +++ b/lib/src/svg/svg_transform.dart @@ -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 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; + } +} diff --git a/test/svg/svg_color_test.dart b/test/svg/svg_color_test.dart new file mode 100644 index 00000000..ba04b1a3 --- /dev/null +++ b/test/svg/svg_color_test.dart @@ -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); + }); + }); +} diff --git a/test/svg/svg_transform_test.dart b/test/svg/svg_transform_test.dart new file mode 100644 index 00000000..8434dbd8 --- /dev/null +++ b/test/svg/svg_transform_test.dart @@ -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)); + }); + }); +}