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,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