Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions DOM/Sources/DOM.SVG+Parse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ import Foundation

package extension DOM.SVG {

static func parse(fileURL url: URL, options: XMLParser.Options = .skipInvalidElements) throws -> DOM.SVG {
static func parse(fileURL url: URL, options: XMLParser.Options = .skipInvalidElements, defaultViewport: XMLParser.Viewport? = nil) throws -> DOM.SVG {
let element = try XML.SAXParser.parse(contentsOf: url)
let parser = XMLParser(options: options, filename: url.lastPathComponent)
let parser = XMLParser(options: options, filename: url.lastPathComponent, defaultViewport: defaultViewport)
return try parser.parseSVG(element)
}

static func parse(data: Data, options: XMLParser.Options = .skipInvalidElements) throws -> DOM.SVG {
static func parse(data: Data, options: XMLParser.Options = .skipInvalidElements, defaultViewport: XMLParser.Viewport? = nil) throws -> DOM.SVG {
let element = try XML.SAXParser.parse(data: data)
let parser = XMLParser(options: options)
let parser = XMLParser(options: options, defaultViewport: defaultViewport)
return try parser.parseSVG(element)
}
}
57 changes: 42 additions & 15 deletions DOM/Sources/Parser.XML.SVG.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,28 @@ package extension XMLParser {
}

let att = try parseAttributes(e)
var width: DOM.Coordinate?
var height: DOM.Coordinate?

if (try? att.parsePercentage("width")) == nil {
width = try att.parseCoordinate("width")
}
if (try? att.parsePercentage("height")) == nil {
height = try att.parseCoordinate("height")
}

let widthRaw = try? att.parseString("width")
let heightRaw = try? att.parseString("height")
let viewBox: DOM.SVG.ViewBox? = try parseViewBox(try att.parseString("viewBox"))

width = width ?? viewBox?.width
height = height ?? viewBox?.height
var width = try resolveRootDimension(widthRaw, viewport: defaultViewport?.width, attribute: "width")
var height = try resolveRootDimension(heightRaw, viewport: defaultViewport?.height, attribute: "height")

guard let w = width else { throw XMLParser.Error.missingAttribute(name: "width") }
guard let h = height else { throw XMLParser.Error.missingAttribute(name: "height") }
width = width ?? viewBox?.width ?? defaultViewport?.width
height = height ?? viewBox?.height ?? defaultViewport?.height

guard let w = width else {
throw XMLParser.Error.unresolvableDimension(reason: makeUnresolvedReason(attribute: "width", raw: widthRaw, hasViewBox: viewBox != nil))
}
guard let h = height else {
throw XMLParser.Error.unresolvableDimension(reason: makeUnresolvedReason(attribute: "height", raw: heightRaw, hasViewBox: viewBox != nil))
}

let svg = DOM.SVG(width: DOM.Length(w), height: DOM.Length(h))
svg.x = try att.parseCoordinate("x")
svg.y = try att.parseCoordinate("y")
svg.childElements = try parseGraphicsElements(e.children)
svg.viewBox = try parseViewBox(try att.parseString("viewBox"))
svg.viewBox = viewBox

svg.defs = try parseSVGDefs(e)
svg.styles = parseStyleSheetElements(within: e)
Expand All @@ -85,6 +84,34 @@ package extension XMLParser {
return DOM.SVG.ViewBox(x: x, y: y, width: width, height: height)
}

// Returns nil when raw is missing, or when a percent value has no viewport to resolve against
func resolveRootDimension(_ raw: String?, viewport: DOM.Coordinate?, attribute: String) throws -> DOM.Coordinate? {
guard let raw, !raw.isEmpty else { return nil }
var scanner = XMLParser.Scanner(text: raw)
let value = try scanner.scanCoordinate()
if scanner.scanStringIfPossible("%") {
guard let viewport else { return nil }
return value / 100 * viewport
}
guard scanner.isEOF else {
throw Error.invalidAttribute(name: attribute, value: raw)
}
return value
}

func makeUnresolvedReason(attribute: String, raw: String?, hasViewBox: Bool) -> String {
if let raw, raw.contains("%") {
return "<svg> \(attribute)=\"\(raw)\" cannot be resolved without a viewBox or an explicit viewport (--size on the command line)"
}
if raw == nil {
if hasViewBox {
return "<svg> \(attribute) attribute is missing"
}
return "<svg> \(attribute) attribute is missing and no viewBox or explicit viewport (--size on the command line) was provided"
}
return "<svg> \(attribute)=\"\(raw ?? "")\" cannot be resolved"
}


// search all nodes within document for any defs
// not just the <defs> node
Expand Down
18 changes: 17 additions & 1 deletion DOM/Sources/Parser.XML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,26 @@ package struct XMLParser {
case invalidAttribute(name: String, value: any Sendable)
case invalidElement(name: String, error: Swift.Error, line: Int?, column: Int?)
case invalidDocument(error: Swift.Error?, element: String?, line: Int, column: Int)
case unresolvableDimension(reason: String)
}

package var options: Options = []
package var filename: String?

/// When set, percent root <svg> width/height attributes resolve against this viewport,
/// and SVGs with no width/height/viewBox fall back to it
package var defaultViewport: Viewport?

package struct Viewport: Equatable, Sendable {
package var width: DOM.Coordinate
package var height: DOM.Coordinate

package init(width: DOM.Coordinate, height: DOM.Coordinate) {
self.width = width
self.height = height
}
}

package struct Options: OptionSet {
package let rawValue: Int
package init(rawValue: Int) {
Expand All @@ -54,9 +69,10 @@ package struct XMLParser {
package static let skipInvalidElements = Options(rawValue: 1 << 1)
}

package init(options: Options = [], filename: String? = nil) {
package init(options: Options = [], filename: String? = nil, defaultViewport: Viewport? = nil) {
self.options = options
self.filename = filename
self.defaultViewport = defaultViewport
}
}

Expand Down
64 changes: 64 additions & 0 deletions DOM/Tests/Parser.SVGTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,70 @@ struct ParserSVGTests {
}
}

@Test
func svgPercentDimensionsResolveAgainstViewport() throws {
let node = XML.Element(name: "svg", attributes: ["width": "100%", "height": "50%"])
var parser = DOMXMLParser()
parser.defaultViewport = .init(width: 800, height: 600)

let parsed = try parser.parseSVG(node)
#expect(parsed.width == 800)
#expect(parsed.height == 300)
}

@Test
func svgPercentDimensionsWithoutViewportThrows() {
let node = XML.Element(name: "svg", attributes: ["width": "100%", "height": "50%"])
#expect(throws: XMLParser.Error.self) {
try XMLParser().parseSVG(node)
}
}

@Test
func svgMissingDimensionsFallBackToViewport() throws {
let node = XML.Element(name: "svg")
var parser = DOMXMLParser()
parser.defaultViewport = .init(width: 320, height: 240)

let parsed = try parser.parseSVG(node)
#expect(parsed.width == 320)
#expect(parsed.height == 240)
}

@Test
func svgMissingDimensionsFallBackToViewBox() throws {
let node = XML.Element(name: "svg", attributes: ["viewBox": "0 0 150 75"])
let parsed = try DOMXMLParser().parseSVG(node)
#expect(parsed.width == 150)
#expect(parsed.height == 75)
}

@Test
func svgMissingDimensionsAndViewBoxThrowsUnresolvable() {
let node = XML.Element(name: "svg")
var thrown: (any Error)?
do {
_ = try XMLParser().parseSVG(node)
} catch {
thrown = error
}
guard case XMLParser.Error.unresolvableDimension? = thrown else {
Issue.record("expected unresolvableDimension, got \(String(describing: thrown))")
return
}
}

@Test
func svgPercentWidthWithExplicitHeight() throws {
let node = XML.Element(name: "svg", attributes: ["width": "50%", "height": "120"])
var parser = DOMXMLParser()
parser.defaultViewport = .init(width: 400, height: 999)

let parsed = try parser.parseSVG(node)
#expect(parsed.width == 200)
#expect(parsed.height == 120)
}

@Test
func viewBox() throws {
let parsed = try #require(try XMLParser().parseViewBox(" 10\t20 300.0 5e2"))
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ $ swiftdraw simple.svg --format png --scale 3x
$ swiftdraw simple.svg --format pdf
```

#### Resolving relative dimensions

When the root `<svg>` uses percent values (`width="100%"`) or omits `width`/`height` entirely, SwiftDraw cannot determine the canvas size from the file alone. In those cases provide an explicit canvas size with `--size`, which acts as the viewport that percent values resolve against and as a fallback when both `width`/`height` and `viewBox` are missing:

```bash
$ swiftdraw responsive.svg --format png --size 800x600
```

If the SVG already declares a `viewBox`, missing `width`/`height` fall back to its dimensions automatically and `--size` is not required.

### Installation

You can install the `swiftdraw` command-line tool on macOS using [Homebrew](http://brew.sh/). Assuming you already have Homebrew installed, just type:
Expand Down
2 changes: 1 addition & 1 deletion SwiftDraw/Sources/CommandLine/CommandLine+Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public extension CommandLine {
case .jpeg, .pdf, .png:
#if canImport(CoreGraphics)
let options = makeSVGOptions(for: config)
guard let image = SVG(fileURL: config.input, options: options) else {
guard let image = SVG(fileURL: config.input, options: options, defaultViewport: config.size.cgValue) else {
throw Error.invalid
}
return try processImage(image, with: config)
Expand Down
13 changes: 13 additions & 0 deletions SwiftDraw/Sources/SVG.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ public struct SVG: Hashable, Sendable {
}
}

init?(fileURL url: URL, options: SVG.Options, defaultViewport: CGSize?) {
let viewport = defaultViewport.map {
XMLParser.Viewport(width: DOM.Coordinate($0.width), height: DOM.Coordinate($0.height))
}
do {
let svg = try DOM.SVG.parse(fileURL: url, defaultViewport: viewport)
self.init(dom: svg, options: options)
} catch {
XMLParser.logParsingError(for: error, filename: url.lastPathComponent, parsing: nil)
return nil
}
}

public init?(named name: String, in bundle: Bundle = Bundle.main, options: SVG.Options = .default) {
guard let url = bundle.url(forResource: name, withExtension: nil) else { return nil }
self.init(fileURL: url, options: options)
Expand Down
Loading