decode method

SvgImportResult decode(
  1. String source, {
  2. String idPrefix = 'svg',
})

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);
}