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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:clide/src/svg/svg_style.dart';
|
||||||
|
import 'package:clide/src/svg/svg_xml.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseCss', () {
|
||||||
|
test('class and tag rules', () {
|
||||||
|
final r = parseCss('.fill-B1 { fill: #0D32B2 } text { font-weight: bold }');
|
||||||
|
expect(r['.fill-B1'], {'fill': '#0D32B2'});
|
||||||
|
expect(r['text'], {'font-weight': 'bold'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comma-separated selectors share a block', () {
|
||||||
|
final r = parseCss('.a, .b { stroke: none }');
|
||||||
|
expect(r['.a'], {'stroke': 'none'});
|
||||||
|
expect(r['.b'], {'stroke': 'none'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comments are stripped', () {
|
||||||
|
final r = parseCss('/* c */ .a { fill: red /* x */ }');
|
||||||
|
expect(r['.a'], {'fill': 'red'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compound / descendant / id selectors are ignored', () {
|
||||||
|
final r = parseCss('.a .b { fill: red } .a.b { fill: blue } #id { fill: green }');
|
||||||
|
expect(r, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('inlineStyles', () {
|
||||||
|
XmlElement norm(String svg) {
|
||||||
|
final root = parseXml(svg)!;
|
||||||
|
inlineStyles(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
XmlElement only(XmlElement e) => e.children.whereType<XmlElement>().single;
|
||||||
|
|
||||||
|
test('folds a class into an inline presentation attribute', () {
|
||||||
|
final r = norm('<svg><style>.fill-B1{fill:#0D32B2}</style><rect class="shape fill-B1"/></svg>');
|
||||||
|
final rect = only(r);
|
||||||
|
expect(rect.attrs['fill'], '#0D32B2');
|
||||||
|
expect(rect.attrs.containsKey('class'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes <style> elements', () {
|
||||||
|
final r = norm('<svg><style>.a{fill:red}</style><rect class="a"/></svg>');
|
||||||
|
expect(r.children.whereType<XmlElement>().map((e) => e.name), ['rect']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cascade: class overrides presentation attr, style="" overrides class', () {
|
||||||
|
final r = norm('<svg><style>.c{fill:green}</style><rect fill="red" class="c" style="fill:blue"/></svg>');
|
||||||
|
expect(only(r).attrs['fill'], 'blue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('class order: the later class wins', () {
|
||||||
|
final r = norm('<svg><style>.a{fill:red}.b{fill:blue}</style><rect class="a b"/></svg>');
|
||||||
|
expect(only(r).attrs['fill'], 'blue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tag rule applies and a class overrides it', () {
|
||||||
|
final r = norm('<svg><style>rect{stroke:black}.s{stroke:red}</style><rect class="s"/></svg>');
|
||||||
|
expect(only(r).attrs['stroke'], 'red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geometry attributes are left untouched', () {
|
||||||
|
final r = norm('<svg><style>.a{fill:red}</style><rect class="a" x="1" y="2" transform="translate(5,5)"/></svg>');
|
||||||
|
final rect = only(r);
|
||||||
|
expect(rect.attrs['x'], '1');
|
||||||
|
expect(rect.attrs['transform'], 'translate(5,5)');
|
||||||
|
expect(rect.attrs['fill'], 'red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested elements are folded too', () {
|
||||||
|
final r = norm('<svg><style>.a{fill:red}</style><g><rect class="a"/></g></svg>');
|
||||||
|
expect(only(only(r)).attrs['fill'], 'red');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:clide/src/svg/svg_xml.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
XmlElement parse(String s) {
|
||||||
|
final root = parseXml(s);
|
||||||
|
expect(root, isNotNull, reason: 'expected an element root for: $s');
|
||||||
|
return root!;
|
||||||
|
}
|
||||||
|
|
||||||
|
group('parseXml', () {
|
||||||
|
test('element with attributes', () {
|
||||||
|
final e = parse('<rect x="1" y="2" width="3" height="4"/>');
|
||||||
|
expect(e.name, 'rect');
|
||||||
|
expect(e.attrs, {'x': '1', 'y': '2', 'width': '3', 'height': '4'});
|
||||||
|
expect(e.children, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-quoted and unquoted attribute values', () {
|
||||||
|
final e = parse("<g fill='red' opacity=0.5/>");
|
||||||
|
expect(e.attrs['fill'], 'red');
|
||||||
|
expect(e.attrs['opacity'], '0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested children preserve order', () {
|
||||||
|
final e = parse('<g><rect/><circle/></g>');
|
||||||
|
expect(e.name, 'g');
|
||||||
|
expect(e.children.whereType<XmlElement>().map((c) => c.name), ['rect', 'circle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text content is captured', () {
|
||||||
|
final e = parse('<text>hello</text>');
|
||||||
|
final t = e.children.single as XmlText;
|
||||||
|
expect(t.text, 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comments are skipped', () {
|
||||||
|
final e = parse('<g><!-- a comment --><rect/></g>');
|
||||||
|
expect(e.children.whereType<XmlElement>().map((c) => c.name), ['rect']);
|
||||||
|
expect(e.children.whereType<XmlText>(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xml prolog and doctype are skipped', () {
|
||||||
|
final e = parse('<?xml version="1.0"?><!DOCTYPE svg><svg width="10"/>');
|
||||||
|
expect(e.name, 'svg');
|
||||||
|
expect(e.attrs['width'], '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CDATA content is captured verbatim', () {
|
||||||
|
final e = parse('<style><![CDATA[ .a { fill: red } ]]></style>');
|
||||||
|
expect((e.children.single as XmlText).text, contains('.a { fill: red }'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entities are decoded in text and attributes', () {
|
||||||
|
final e = parse('<text title="a & b"><tag> A</text>');
|
||||||
|
expect(e.attrs['title'], 'a & b');
|
||||||
|
expect((e.children.single as XmlText).text, '<tag> A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style body is read as raw text, not markup', () {
|
||||||
|
// CSS with > and { } must not be parsed as elements.
|
||||||
|
final e = parse('<style>.edge > .head { fill: #0D32B2 } .b{stroke:none}</style>');
|
||||||
|
expect(e.name, 'style');
|
||||||
|
final css = (e.children.single as XmlText).text;
|
||||||
|
expect(css, contains('.edge > .head'));
|
||||||
|
expect(css, contains('stroke:none'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('namespaced attribute names are kept verbatim', () {
|
||||||
|
final e = parse('<image xlink:href="a.png" href="b.png"/>');
|
||||||
|
expect(e.attrs['xlink:href'], 'a.png');
|
||||||
|
expect(e.attrs['href'], 'b.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mismatched close tag is tolerated, no throw', () {
|
||||||
|
final e = parse('<g><rect></wrong></g>');
|
||||||
|
expect(e.name, 'g');
|
||||||
|
expect(e.children.whereType<XmlElement>().single.name, 'rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('descendants walks the whole subtree', () {
|
||||||
|
final e = parse('<svg><g><rect/></g><circle/></svg>');
|
||||||
|
final names = e.descendants().whereType<XmlElement>().map((c) => c.name).toList();
|
||||||
|
expect(names, ['g', 'rect', 'circle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-element root returns null, never throws', () {
|
||||||
|
expect(parseXml(''), isNull);
|
||||||
|
expect(parseXml(' '), isNull);
|
||||||
|
expect(parseXml('just text'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unterminated tag does not throw', () {
|
||||||
|
// Should not hang or throw; returns whatever was understood.
|
||||||
|
expect(() => parseXml('<svg><rect x="1"'), returnsNormally);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user