feat(svg): XML tokenizer + inline-style normalizer for the drawing card (T-320)

Zero-dependency, tolerant XML reader (elements/attrs/text/comments/prolog/
CDATA/entities; <style> read as raw text) producing a generic element tree.
The normalizer folds d2/graphviz's class-based <style> rules into inline
presentation attributes — cascade presentation-attr < tag < class < style —
then drops <style>/class/style, so the painter only ever sees inline attrs
(D-103). Flutter-free, covered by dart test (26 cases). No user-visible
behaviour yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:18:53 +02:00
co-authored by Claude Opus 4.8
parent 9cc3d68862
commit fb96e5238f
4 changed files with 569 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
/// Inline-style normalizer for the SVG renderer (T-320 / D-103).
///
/// d2 / graphviz style their output with a `<style>` block of flat single-class
/// selectors (`.fill-B1`, `.shape`, `.connection`, `.text-bold`) plus `class=`
/// references, not inline presentation attributes. Rather than teach the painter
/// CSS, [inlineStyles] runs ONCE up front: it parses the `<style>` rules, folds
/// each element's matching tag/class declarations (and any `style=""`) into
/// explicit presentation attributes, then drops the `<style>` elements and the
/// `class`/`style` attributes. Downstream the painter only ever sees inline
/// attributes — a pure presentation-attribute renderer (D-103).
///
/// Cascade (low → high precedence), per the SVG/CSS model: presentation
/// attributes < tag rule < class rules (document order) < `style=""`. Only the
/// **simple** selectors d2/graphviz emit are honoured (a bare tag or a single
/// `.class`); compound/descendant selectors are ignored. Geometry attributes
/// (`x`, `d`, `transform`, `href`, …) are never touched.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'svg_xml.dart';
/// Presentation properties the renderer cares about — the only attributes read
/// as the lowest cascade layer (so geometry attributes stay untouched).
const Set<String> presentationProps = {
'fill',
'fill-opacity',
'fill-rule',
'stroke',
'stroke-width',
'stroke-opacity',
'stroke-linecap',
'stroke-linejoin',
'stroke-dasharray',
'stroke-dashoffset',
'opacity',
'color',
'font-family',
'font-size',
'font-weight',
'font-style',
'text-anchor',
'dominant-baseline',
'alignment-baseline',
'visibility',
'display',
};
/// Flatten `<style>`/`class`/`style=` into inline presentation attributes,
/// in place. After this, the tree has no `<style>` elements and no `class`/
/// `style` attributes — every style is an explicit presentation attribute.
void inlineStyles(XmlElement root) {
final rules = <String, Map<String, String>>{};
_collectStyleRules(root, rules);
_removeStyleElements(root);
_fold(root, rules);
}
void _collectStyleRules(XmlElement el, Map<String, Map<String, String>> into) {
if (el.name == 'style') {
final css = el.children.whereType<XmlText>().map((t) => t.text).join('\n');
parseCss(css).forEach((sel, decls) => (into[sel] ??= <String, String>{}).addAll(decls));
}
for (final c in el.children) {
if (c is XmlElement) _collectStyleRules(c, into);
}
}
void _removeStyleElements(XmlElement el) {
el.children.removeWhere((c) => c is XmlElement && c.name == 'style');
for (final c in el.children) {
if (c is XmlElement) _removeStyleElements(c);
}
}
void _fold(XmlElement el, Map<String, Map<String, String>> rules) {
final eff = <String, String>{};
// 1. existing presentation attributes (lowest precedence)
for (final p in presentationProps) {
final v = el.attrs[p];
if (v != null) eff[p] = v;
}
// 2. tag rule
final tagRule = rules[el.name];
if (tagRule != null) eff.addAll(tagRule);
// 3. class rules, in the order classes are listed on the element
final cls = el.attrs['class'];
if (cls != null) {
for (final c in cls.split(RegExp(r'\s+'))) {
if (c.isEmpty) continue;
final r = rules['.$c'];
if (r != null) eff.addAll(r);
}
}
// 4. inline style="" (highest precedence)
final style = el.attrs['style'];
if (style != null) eff.addAll(parseDecls(style));
el.attrs
..remove('class')
..remove('style');
eff.forEach((k, v) => el.attrs[k] = v);
for (final c in el.children) {
if (c is XmlElement) _fold(c, rules);
}
}
/// Parse a CSS text block into `selector → { prop: value }`, keeping only the
/// simple selectors d2/graphviz emit (a bare tag or a single `.class`). Keys
/// are the raw selector (`.fill-B1` or `text`).
Map<String, Map<String, String>> parseCss(String css) {
final out = <String, Map<String, String>>{};
final cleaned = css.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), ''); // strip comments
for (final m in RegExp(r'([^{}]+)\{([^{}]*)\}').allMatches(cleaned)) {
final decls = parseDecls(m.group(2)!);
if (decls.isEmpty) continue;
for (final raw in m.group(1)!.split(',')) {
final sel = raw.trim();
if (_isSimpleSelector(sel)) {
(out[sel] ??= <String, String>{}).addAll(decls);
}
}
}
return out;
}
/// Parse `prop: value; prop: value` into a map (lower-cased property names).
Map<String, String> parseDecls(String decls) {
final out = <String, String>{};
for (final decl in decls.split(';')) {
final c = decl.indexOf(':');
if (c < 0) continue;
final k = decl.substring(0, c).trim().toLowerCase();
final v = decl.substring(c + 1).trim();
if (k.isNotEmpty && v.isNotEmpty) out[k] = v;
}
return out;
}
/// A bare tag (`text`) or a single class (`.fill-B1`) — no combinators,
/// compounds, ids, or attribute selectors.
bool _isSimpleSelector(String s) => RegExp(r'^\.?[A-Za-z][\w-]*$').hasMatch(s);
+248
View File
@@ -0,0 +1,248 @@
/// A minimal, tolerant XML reader for the SVG renderer (T-320 / D-103).
///
/// SVG is XML; rather than take a dependency (prefer-zero-deps), we parse the
/// bounded shape that d2 / graphviz / hand-authored SVG emit into a generic
/// element tree. NOT a conformant XML processor — it understands elements,
/// attributes, text, comments, the XML/DOCTYPE prolog, CDATA, and the common
/// entities; it deliberately reads `<style>`/`<script>` bodies as raw text
/// (their CSS/JS is not markup). Namespaced names (`xlink:href`) are kept
/// verbatim — we don't resolve namespaces.
///
/// Tolerant by construction: malformed input yields the tree understood so far
/// (or `null` for a non-element root) and never throws — a broken document must
/// not crash the conversation. Mismatched close tags are accepted.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
/// A node in the parsed tree — either an [XmlElement] or [XmlText].
sealed class XmlNode {}
/// An element: a tag [name], its [attrs], and ordered [children].
/// Mutable so the style normalizer can fold classes into [attrs] in place.
class XmlElement extends XmlNode {
XmlElement(this.name, this.attrs, this.children);
final String name;
final Map<String, String> attrs;
final List<XmlNode> children;
/// Depth-first descendants (excluding `this`), elements and text alike.
Iterable<XmlNode> descendants() sync* {
for (final c in children) {
yield c;
if (c is XmlElement) yield* c.descendants();
}
}
@override
String toString() => '<$name ${attrs.length} attrs, ${children.length} children>';
}
/// A run of text content (e.g. inside `<text>`, or a `<style>` body).
class XmlText extends XmlNode {
XmlText(this.text);
final String text;
@override
String toString() => 'text(${text.length})';
}
/// Parse [src] into its root [XmlElement], or `null` if there is no element
/// root. Never throws.
XmlElement? parseXml(String src) => _XmlParser(src).parseDocument();
class _XmlParser {
_XmlParser(this.s);
final String s;
int i = 0;
XmlElement? parseDocument() {
_skipProlog();
if (i >= s.length || s[i] != '<') return null;
return _parseElement();
}
void _skipProlog() {
while (i < s.length) {
_skipWs();
if (_at('<?')) {
_skipPast('?>');
} else if (_at('<!--')) {
_skipPast('-->');
} else if (_at('<!')) {
// DOCTYPE or other declaration
_skipPast('>');
} else {
break;
}
}
}
XmlElement? _parseElement() {
if (i >= s.length || s[i] != '<') return null;
i++; // '<'
final name = _readName();
if (name.isEmpty) return null;
final attrs = <String, String>{};
// Attributes up to '>' or '/>'.
while (i < s.length) {
_skipWs();
if (i >= s.length) return XmlElement(name, attrs, const []);
final c = s[i];
if (c == '/') {
i++;
if (i < s.length && s[i] == '>') i++;
return XmlElement(name, attrs, []); // self-closing
}
if (c == '>') {
i++;
break;
}
final an = _readName();
if (an.isEmpty) {
i++; // skip a stray char rather than spin
continue;
}
_skipWs();
if (i < s.length && s[i] == '=') {
i++;
_skipWs();
attrs[an] = _readAttrValue();
} else {
attrs[an] = '';
}
}
// Raw-text elements: their body is not markup.
if (name == 'style' || name == 'script') {
final raw = _readRawUntilClose(name);
return XmlElement(name, attrs, raw.isEmpty ? [] : [XmlText(raw)]);
}
final children = <XmlNode>[];
while (i < s.length) {
if (_at('</')) {
i += 2;
_readName(); // tolerate a mismatched close name
_skipWs();
if (i < s.length && s[i] == '>') i++;
break;
} else if (_at('<!--')) {
_skipPast('-->');
} else if (_at('<![CDATA[')) {
i += 9;
final end = s.indexOf(']]>', i);
children.add(XmlText(end < 0 ? s.substring(i) : s.substring(i, end)));
i = end < 0 ? s.length : end + 3;
} else if (s[i] == '<') {
final child = _parseElement();
if (child == null) break;
children.add(child);
} else {
final start = i;
while (i < s.length && s[i] != '<') {
i++;
}
final text = _decodeEntities(s.substring(start, i));
if (text.trim().isNotEmpty) children.add(XmlText(text));
}
}
return XmlElement(name, attrs, children);
}
String _readName() {
final start = i;
while (i < s.length) {
final c = s.codeUnitAt(i);
final isNameChar =
(c >= 0x41 && c <= 0x5A) || // A-Z
(c >= 0x61 && c <= 0x7A) || // a-z
(c >= 0x30 && c <= 0x39) || // 0-9
c == 0x2D || // -
c == 0x5F || // _
c == 0x2E || // .
c == 0x3A; // : (namespaced)
if (!isNameChar) break;
i++;
}
return s.substring(start, i);
}
String _readAttrValue() {
if (i >= s.length) return '';
final q = s[i];
if (q == '"' || q == "'") {
i++;
final start = i;
while (i < s.length && s[i] != q) {
i++;
}
final v = s.substring(start, i);
if (i < s.length) i++; // closing quote
return _decodeEntities(v);
}
// Unquoted value (not valid XML, but be tolerant).
final start = i;
while (i < s.length && s[i] != ' ' && s[i] != '>' && s[i] != '/') {
i++;
}
return _decodeEntities(s.substring(start, i));
}
String _readRawUntilClose(String tag) {
final close = '</$tag';
final idx = s.indexOf(close, i);
if (idx < 0) {
final rest = s.substring(i);
i = s.length;
return rest;
}
final body = s.substring(i, idx);
i = idx + close.length;
_skipPast('>');
return body;
}
void _skipWs() {
while (i < s.length) {
final c = s.codeUnitAt(i);
if (c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D) {
i++;
} else {
break;
}
}
}
bool _at(String tok) => s.startsWith(tok, i);
void _skipPast(String tok) {
final idx = s.indexOf(tok, i);
i = idx < 0 ? s.length : idx + tok.length;
}
}
/// Decode the handful of XML entities SVG actually uses.
String _decodeEntities(String s) {
if (!s.contains('&')) return s;
return s.replaceAllMapped(RegExp(r'&(#x?[0-9A-Fa-f]+|amp|lt|gt|quot|apos);'), (m) {
final e = m.group(1)!;
switch (e) {
case 'amp':
return '&';
case 'lt':
return '<';
case 'gt':
return '>';
case 'quot':
return '"';
case 'apos':
return "'";
default:
final hex = e.startsWith('#x') || e.startsWith('#X');
final digits = e.substring(hex ? 2 : 1);
final code = int.tryParse(digits, radix: hex ? 16 : 10);
return code == null ? m.group(0)! : String.fromCharCode(code);
}
});
}