diff --git a/DOM/Sources/DOM.SVG+Parse.swift b/DOM/Sources/DOM.SVG+Parse.swift index e13a813..a1dd2fb 100644 --- a/DOM/Sources/DOM.SVG+Parse.swift +++ b/DOM/Sources/DOM.SVG+Parse.swift @@ -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) } } diff --git a/DOM/Sources/Parser.XML.SVG.swift b/DOM/Sources/Parser.XML.SVG.swift index 2a57a36..53a9488 100644 --- a/DOM/Sources/Parser.XML.SVG.swift +++ b/DOM/Sources/Parser.XML.SVG.swift @@ -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) @@ -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 " \(attribute)=\"\(raw)\" cannot be resolved without a viewBox or an explicit viewport (--size on the command line)" + } + if raw == nil { + if hasViewBox { + return " \(attribute) attribute is missing" + } + return " \(attribute) attribute is missing and no viewBox or explicit viewport (--size on the command line) was provided" + } + return " \(attribute)=\"\(raw ?? "")\" cannot be resolved" + } + // search all nodes within document for any defs // not just the node diff --git a/DOM/Sources/Parser.XML.swift b/DOM/Sources/Parser.XML.swift index 029271c..0ea4759 100644 --- a/DOM/Sources/Parser.XML.swift +++ b/DOM/Sources/Parser.XML.swift @@ -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 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) { @@ -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 } } diff --git a/DOM/Tests/Parser.SVGTests.swift b/DOM/Tests/Parser.SVGTests.swift index b1fc05e..f6b5ed6 100644 --- a/DOM/Tests/Parser.SVGTests.swift +++ b/DOM/Tests/Parser.SVGTests.swift @@ -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")) diff --git a/README.md b/README.md index fd1a5ca..2fe86d8 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,16 @@ $ swiftdraw simple.svg --format png --scale 3x $ swiftdraw simple.svg --format pdf ``` +#### Resolving relative dimensions + +When the root `` 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: diff --git a/SwiftDraw/Sources/CommandLine/CommandLine+Process.swift b/SwiftDraw/Sources/CommandLine/CommandLine+Process.swift index 7dd05b9..36a7462 100644 --- a/SwiftDraw/Sources/CommandLine/CommandLine+Process.swift +++ b/SwiftDraw/Sources/CommandLine/CommandLine+Process.swift @@ -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) diff --git a/SwiftDraw/Sources/SVG.swift b/SwiftDraw/Sources/SVG.swift index 97a1085..6533f97 100644 --- a/SwiftDraw/Sources/SVG.swift +++ b/SwiftDraw/Sources/SVG.swift @@ -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)