From ea017f757093a312b431deab2056c57b5d73af1c Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Tue, 24 Mar 2026 11:05:02 +0200 Subject: [PATCH] Support patternContentUnits=objectBoundingBox for pattern fills When a pattern uses patternContentUnits="objectBoundingBox", scale the pattern frame to the bounding box of the filled element. This fixes rendering of patterns that reference images via elements in , where the pattern coordinates are in [0,1] range. Also fixes a bug in firstGraphicsElement(with:) where the search would stop prematurely at the first container element, even if the target element was a later sibling. Closes #52 --- DOM/Sources/DOM.Use.swift | 5 +++-- DOM/Tests/UseTests.swift | 18 +++++++++++++++ .../Sources/LayerTree/LayerTree.Builder.swift | 3 ++- .../LayerTree.CommandGenerator.swift | 16 ++++++++++++-- .../Sources/LayerTree/LayerTree.Pattern.swift | 12 ++++++++-- .../LayerTree/LayerTree.BuilderTests.swift | 22 +++++++++++++++++++ 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/DOM/Sources/DOM.Use.swift b/DOM/Sources/DOM.Use.swift index c116b0f4..9393657f 100644 --- a/DOM/Sources/DOM.Use.swift +++ b/DOM/Sources/DOM.Use.swift @@ -61,8 +61,9 @@ package extension Array { if element.id == id { return element } - if let container = element as? any ContainerElement { - return container.childElements.firstGraphicsElement(with: id) + if let container = element as? any ContainerElement, + let found = container.childElements.firstGraphicsElement(with: id) { + return found } } return nil diff --git a/DOM/Tests/UseTests.swift b/DOM/Tests/UseTests.swift index 1732d773..d1e402f9 100644 --- a/DOM/Tests/UseTests.swift +++ b/DOM/Tests/UseTests.swift @@ -35,6 +35,24 @@ import Testing @Suite("Use Tests") struct UseTests { + @Test + func firstGraphicsElementSearchesPastContainers() throws { + let svg = DOM.SVG(width: 100, height: 100) + + // Place a group (container) before the target element + let group = DOM.Group() + group.childElements = [DOM.Circle(cx: 0, cy: 0, r: 5)] + + let target = DOM.Rect(width: 10, height: 10) + target.id = "target" + + svg.childElements = [group, target] + + // Should find "target" even though a container (group) comes first + let found = svg.firstGraphicsElement(with: "target") + #expect(found?.id == "target") + } + @Test func use() throws { var node = ["xlink:href": "#line2", "href": "#line1"] diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift index 01e7ff93..f5d43224 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift @@ -352,7 +352,8 @@ extension LayerTree.Builder { func makePattern(for element: DOM.Pattern) -> LayerTree.Pattern { let frame = LayerTree.Rect(x: 0, y: 0, width: element.width, height: element.height) - let pattern = LayerTree.Pattern(frame: frame) + let contentUnits: LayerTree.PatternUnits = element.patternContentUnits == .objectBoundingBox ? .objectBoundingBox : .userSpaceOnUse + let pattern = LayerTree.Pattern(frame: frame, contentUnits: contentUnits) pattern.contents = element.childElements.compactMap { .layer(makeLayer(from: $0, inheriting: .init())) } return pattern } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift b/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift index f46494ce..79b33154 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift @@ -207,12 +207,24 @@ extension LayerTree { commands.append(.fill(path, rule: rule)) } case .pattern(let fillPattern): + var resolvedPattern = fillPattern + if fillPattern.contentUnits == .objectBoundingBox { + let bounds = provider.getBounds(from: shape) + let scaledFrame = LayerTree.Rect( + x: fillPattern.frame.x * bounds.width + bounds.x, + y: fillPattern.frame.y * bounds.height + bounds.y, + width: fillPattern.frame.width * bounds.width, + height: fillPattern.frame.height * bounds.height + ) + resolvedPattern = LayerTree.Pattern(frame: scaledFrame) + resolvedPattern.contents = fillPattern.contents + } var patternCommands = [RendererCommand]() - for contents in fillPattern.contents { + for contents in resolvedPattern.contents { patternCommands.append(contentsOf: renderCommands(for: contents, colorConverter: colorConverter)) } - let pattern = provider.createPattern(from: fillPattern, contents: patternCommands) + let pattern = provider.createPattern(from: resolvedPattern, contents: patternCommands) let rule = provider.createFillRule(from: fill.rule) commands.append(.setFillPattern(pattern)) commands.append(.fill(path, rule: rule)) diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift index bc48ba9c..eea4f573 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift @@ -31,23 +31,31 @@ extension LayerTree { + enum PatternUnits: Hashable { + case userSpaceOnUse + case objectBoundingBox + } + final class Pattern: Hashable { var frame: LayerTree.Rect + var contentUnits: PatternUnits var contents: [LayerTree.Layer.Contents] - init(frame: LayerTree.Rect) { + init(frame: LayerTree.Rect, contentUnits: PatternUnits = .userSpaceOnUse) { self.frame = frame + self.contentUnits = contentUnits self.contents = [] } func hash(into hasher: inout Hasher) { frame.hash(into: &hasher) + contentUnits.hash(into: &hasher) contents.hash(into: &hasher) } static func == (lhs: LayerTree.Pattern, rhs: LayerTree.Pattern) -> Bool { - return lhs.frame == rhs.frame && lhs.contents == rhs.contents + return lhs.frame == rhs.frame && lhs.contentUnits == rhs.contentUnits && lhs.contents == rhs.contents } } } diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift index 6c64ec60..3c041bab 100644 --- a/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift +++ b/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift @@ -122,6 +122,28 @@ final class LayerTreeBuilderTests: XCTestCase { XCTAssertEqual(pattern.contents, [.layer(expected)]) } + func testDOMPatternObjectBoundingBox() { + let builder = LayerTree.Builder(svg: DOM.SVG(width: 100, height: 100)) + + var element = DOM.Pattern(id: "p1", width: 1, height: 1) + element.patternContentUnits = .objectBoundingBox + element.childElements = [DOM.Circle(cx: 0, cy: 0, r: 5)] + + let pattern = builder.makePattern(for: element) + XCTAssertEqual(pattern.contentUnits, LayerTree.PatternUnits.objectBoundingBox) + XCTAssertEqual(pattern.frame, LayerTree.Rect(x: 0, y: 0, width: 1, height: 1)) + } + + func testDOMPatternUserSpaceOnUse() { + let builder = LayerTree.Builder(svg: DOM.SVG(width: 100, height: 100)) + + var element = DOM.Pattern(id: "p2", width: 20, height: 20) + element.childElements = [DOM.Circle(cx: 10, cy: 10, r: 5)] + + let pattern = builder.makePattern(for: element) + XCTAssertEqual(pattern.contentUnits, LayerTree.PatternUnits.userSpaceOnUse) + } + func testStrokeAttributes() { var state = LayerTree.Builder.State() state.stroke = .color(.rgbf(1.0, 0.0, 0.0, 1.0))