decode method
Parses source and returns its supported shapes with the warnings the
import produced.
Imported element ids start with idPrefix. Throws a FormatException if
source is not an SVG document, is over 10 MB, declares a document type
or entities, or is too complex.
Implementation
SvgImportResult decode(String source, {String idPrefix = 'svg'}) {
if (source.length > 10 * 1024 * 1024) {
throw const FormatException('SVG must be smaller than 10 MB.');
}
if (RegExp(r'<!DOCTYPE|<!ENTITY', caseSensitive: false).hasMatch(source)) {
throw const FormatException(
'SVG document types and entities are not supported.',
);
}
final root = XmlDocument.parse(source).rootElement;
if (root.name.local != 'svg') {
throw const FormatException('Expected an SVG document.');
}
final elements = <ShapeElement>[];
final warnings = <String>{};
var visited = 0;
void visit(
XmlElement element,
_Matrix parent,
Map<String, String> inherited,
int depth,
) {
if (++visited > 10000 || depth > 64) {
throw const FormatException('SVG is too complex.');
}
final tag = element.name.local;
final style = {...inherited};
for (final key in [
'fill',
'stroke',
'stroke-width',
'fill-opacity',
'stroke-opacity',
'display',
'visibility',
]) {
final value = element.getAttribute(key);
if (value != null) style[key] = value;
}
for (final declaration in (element.getAttribute('style') ?? '').split(
';',
)) {
final split = declaration.indexOf(':');
if (split > 0) {
style[declaration.substring(0, split).trim()] = declaration
.substring(split + 1)
.trim();
}
}
if (style['display'] == 'none' || style['visibility'] == 'hidden') return;
final matrix = parent.multiply(
_Matrix.parse(element.getAttribute('transform')),
);
for (final key in ['clip-path', 'mask', 'filter']) {
if (element.getAttribute(key) != null || style[key] != null) {
warnings.add('$key is not supported.');
}
}
if (element.getAttribute('opacity') != null || style['opacity'] != null) {
warnings.add(
'Group and element opacity is not supported; fill/stroke opacity is supported.',
);
}
if (tag == 'svg' || tag == 'g') {
if (tag == 'svg' && element != root) {
warnings.add('Nested SVG viewports are omitted.');
return;
}
for (final child in element.childElements) {
visit(child, matrix, style, depth + 1);
}
return;
}
if (const {'title', 'desc', 'metadata'}.contains(tag)) return;
double n(String key, [double fallback = 0]) {
final raw = element.getAttribute(key);
if (raw == null) return fallback;
final value = double.tryParse(raw.replaceFirst(RegExp(r'px$'), ''));
if (value == null || !value.isFinite) {
throw FormatException('Unsupported SVG length: $raw');
}
return value;
}
String data;
switch (tag) {
case 'path':
data = element.getAttribute('d') ?? '';
case 'rect':
final x = n('x'), y = n('y'), w = n('width'), h = n('height');
if (w <= 0 || h <= 0) return;
if (n('rx') != 0 || n('ry') != 0) {
warnings.add(
'Rounded rectangle corners are imported as square corners.',
);
}
data = 'M$x $y h$w v$h h${-w} Z';
case 'circle' || 'ellipse':
final x = n('cx'), y = n('cy');
final rx = tag == 'circle' ? n('r') : n('rx');
final ry = tag == 'circle' ? rx : n('ry');
if (rx <= 0 || ry <= 0) return;
data =
'M${x - rx} $y A$rx $ry 0 1 0 ${x + rx} $y A$rx $ry 0 1 0 ${x - rx} $y Z';
case 'line':
data = 'M${n('x1')} ${n('y1')} L${n('x2')} ${n('y2')}';
case 'polyline' || 'polygon':
final points = element.getAttribute('points') ?? '';
data = points.trim().isEmpty
? ''
: 'M$points${tag == 'polygon' ? ' Z' : ''}';
default:
warnings.add('<$tag> is omitted.');
return;
}
final proxy = _SvgPath();
writeSvgPathDataToPath(data, proxy);
proxy.finish();
if (proxy.paths.length > 1) {
warnings.add('Compound paths are split; holes and winding may differ.');
}
if (style['stroke-dasharray'] != null ||
element.getAttribute('stroke-dasharray') != null) {
warnings.add('Dashed strokes are imported as solid strokes.');
}
for (final path in proxy.paths) {
if (path.nodes.length < (path.closed ? 3 : 2)) {
warnings.add('A degenerate path was omitted.');
continue;
}
final nodes = [
for (final node in path.nodes)
node.copyWith(
position: matrix.point(node.position),
incoming: matrix.vector(node.incoming),
outgoing: matrix.vector(node.outgoing),
),
];
final points = [
for (final node in nodes) ...[
node.position,
node.position + node.incoming,
node.position + node.outgoing,
],
];
if (points.any(
(p) =>
!p.x.isFinite ||
!p.y.isFinite ||
p.x.abs() > 1e8 ||
p.y.abs() > 1e8,
)) {
throw const FormatException(
'SVG coordinates exceed the supported range.',
);
}
final extent = DiagramRect.fromPoints(points);
final x = extent.left, y = extent.top;
final width = math.max(1.0, extent.width);
final height = math.max(1.0, extent.height);
DiagramPoint normalized(DiagramPoint p) =>
DiagramPoint(p.x / width, p.y / height);
final vector = DiagramVectorPath(
closed: path.closed,
nodes: [
for (final node in nodes)
node.copyWith(
position: normalized(node.position - DiagramPoint(x, y)),
incoming: normalized(node.incoming),
outgoing: normalized(node.outgoing),
),
],
)..validate();
if (!path.closed && (style['fill'] ?? 'black') != 'none') {
warnings.add('Open paths have no fill.');
}
final strokeWidth = double.tryParse(style['stroke-width'] ?? '1');
if (strokeWidth == null || !strokeWidth.isFinite || strokeWidth < 0) {
throw const FormatException('Invalid SVG stroke width.');
}
elements.add(
ShapeElement(
id: '$idPrefix-${elements.length}',
type: 'path',
frame: DiagramRect.fromLTWH(x, y, width, height),
values: {'path': vector.toValue()},
style: ShapeStyle(
fill: _color(
style['fill'] ?? 'black',
style['fill-opacity'],
warnings,
),
stroke: _color(
style['stroke'] ?? 'none',
style['stroke-opacity'],
warnings,
),
strokeWidth: strokeWidth * matrix.scale,
cornerRadius: 0,
),
),
);
}
}
var viewport = const _Matrix();
final viewBox = root.getAttribute('viewBox');
if (viewBox != null) {
final values = _numbers(viewBox);
if (values.length != 4 || values[2] <= 0 || values[3] <= 0) {
throw const FormatException('Invalid SVG viewBox.');
}
viewport = _Matrix(e: -values[0], f: -values[1]);
warnings.add(
'Imported in viewBox units; viewport sizing and clipping are not applied.',
);
}
visit(root, viewport, {}, 0);
return SvgImportResult(elements, warnings);
}