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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user