diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1994b11..ca57f49c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,22 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage_report.lcov + # Apps ship optimized builds: SD12's floods were transparent under -O while every Debug job passed + xcode_release: + runs-on: macos-26 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Select the newest Xcode + run: echo "DEVELOPER_DIR=$(ls -d /Applications/Xcode_*.app | sort -V | tail -1)/Contents/Developer" >> "$GITHUB_ENV" + - name: Version + run: swift --version + - name: Build + run: swift build -c release --build-tests -Xswiftc -enable-testing + - name: Test + run: swift test -c release --skip-build -Xswiftc -enable-testing + xcode_26_2: runs-on: macos-26 env: diff --git a/Backdrop/ROADMAP.md b/Backdrop/ROADMAP.md new file mode 100644 index 00000000..0f3ac2d9 --- /dev/null +++ b/Backdrop/ROADMAP.md @@ -0,0 +1,233 @@ +# Roadmap — what this fork adds to SwiftDraw 0.29.0 + +Each item is one cloud session, one branch, one PR (rules in [CLAUDE.md](../CLAUDE.md)). Items in the same wave +can run in parallel; later waves build on earlier ones. **Corpus** is how many of Backdrop's 372-file FreeSVG test +set the gap rejects today (one file can hit several). Status is maintained by the integrator only. + +The gap catalogue behind this list was measured against 0.29.0 by reading the source and rendering; file:line +references below are into 0.29.0. The fork starts from upstream `main` at `cf234e7` (0.29.0 plus comma-separated +`viewBox` and SVG 2 `href` on ``), so those two are already done. + +## Wave 1 — parallel + +### SD1 — `stroke-dasharray` and `stroke-dashoffset` · corpus 44 · status: merged (#2) +Parsed into the DOM (`Parser.XML.Element.swift:244`) and carried in builder state, never drawn: dashes render +solid. Add a `RendererCommand` (e.g. `.setLineDash(phase:lengths:)`), emit it from the CommandGenerator, execute it +in CoreGraphics (`setLineDash`) and CGText. Spec details to honour: odd-length lists repeat to even, negative +values invalidate the attribute (render solid), all-zero lists render solid, `none`, CSS `style=` form, inheritance +from groups, percentages relative to the viewport diagonal. + +### SD2 — `feGaussianBlur` rendered · corpus 99 · status: merged (#5) +The one filter primitive SwiftDraw parses is never applied: the only consumer of `layer.filters` is a stderr +warning (`LayerTree.CommandGenerator.swift:75`, `:558`) and `hideUnsupportedFilters` is a no-op. Inkscape uses it +for every soft shadow and glow, so those draw as hard-edged blobs. Render a filtered layer into an offscreen +bitmap sized to the **filter region** (`filterUnits` default `objectBoundingBox`, region −10 % / −10 % / 120 % / +120 %; `userSpaceOnUse`), blur with `stdDeviation` (one or two values) converted through the current transform +to device pixels, and composite it back. CoreGraphics side: Core Image or Accelerate/vImage. Design the offscreen +step as a general "filter layer" command so SD12 can add primitives without redesign. CGText: generate equivalent +code or degrade with a comment. Keep the unsupported-filter path for primitives not yet implemented. + +### SD3 — reference cycles never crash · status: merged (#1) +`` recurses with no cycle check (`LayerTree.Builder.Layer.swift:43-59`): +SIGSEGV. Detect cycles and cap depth for ``, and for every `href` chain (gradients, patterns) — a cycle +drops the reference, never the process. + +### SD4 — tolerant parsing: a bad value drops the attribute, not the document · status: merged (#3) +Today each of these makes the **whole document** render nothing: `hsl()`/`hsla()`; capitalised colour keywords +and CSS named colours beyond SVG 1.1 (`rebeccapurple`); `inherit`; `clip-path`/`mask`/`filter="none"`; +`transform="none"` or with units (`translate(10px,10px)`, `rotate(45deg)`); empty values (`fill=""`); +`!important`; opacity outside 0…1 or non-numeric (clamp / ignore); non-numeric +`stroke-width`/`font-size`; enum attributes with unknown values (`display="inline-block"`, `stroke-linecap="none"`, +`dominant-baseline="text-top"`); SVG 2 plain `href=` on `` and on every other referencing element +(`` already has it upstream: `Parser.XML.Use.swift`, follow that pattern). Also fix 4- and 8-digit hex colours, which parse to a **different colour** (`#ff0000ff` +renders blue). Parser files: `DOM/Sources/Parser.XML.*.swift`. + +### SD5 — patterns complete · corpus 14+ · status: merged (#4) +`` `x`/`y`, `patternUnits`, `patternContentUnits`, `patternTransform` and `viewBox` are dropped, so tiles +land misplaced; a pattern without `width`/`height` is fatal (it should simply paint nothing, per spec). Inkscape +writes most patterns as ``: implement `href` inheritance of +attributes **and** content. + +## Wave 2 — parallel + +### SD6 — gradients to spec · status: merged (#8) +`xlink:href` inheritance follows one hop, same kind only, stops only (`LayerTree.Builder.swift:372-412`). Implement +multi-hop and cross-kind inheritance of stops **and** attributes (coordinates, `gradientUnits`, +`gradientTransform`, `spreadMethod`), `spreadMethod` `reflect`/`repeat`, a single stop painting a solid colour, zero +stops painting `none`, and `fx`/`fy` focal points if missing. + +### SD7 — `display` and `visibility` · status: merged (#10) +`display="none"` is ignored and the element draws — and the early return that should hide it skips transform, +clip, mask and opacity, so hidden geometry reappears misplaced and unclipped. `visibility="hidden"`/`collapse` is +not parsed (and a child may set `visible` again). Elements in an editor namespace (``) must drop +their subtree; today their children are re-parented onto the nearest ancestor and drawn. + +### SD8 — clip paths and masks to spec · status: merged (#11) +`clipPathUnits="objectBoundingBox"` is parsed and never used by CoreGraphics (geometry read as user units: clipped +to nearly nothing); same check for `maskUnits`/`maskContentUnits`. A `` containing a `` or `` +degenerates to no clip at all. `clip-path` on a `` itself. `clip-rule` belongs to the shapes inside the +`` (inherited from it, SVG 1.1 §14.3.5), not to the element that references it; SD10 left it on the +referencing element, and its test `testClipRuleFromStyleSheet` and the `.holed` sample pin that interim placement — +move them with it. + +### SD9 — `preserveAspectRatio` · status: merged (#7) +Not present in the codebase: the viewBox always maps as `none`, so a non-square drawing in a differently-shaped +frame is skewed. Root ``, nested ``, ``; all nine alignments × `meet`/`slice`. + +### SD10 — CSS cascade · status: merged (#9) +`transform`, `mask` and `clip-rule` written in CSS (`style=` or a class) are never applied. One bad declaration +(`var(--x)` is enough) discards the entire ` + + """ + let root = try XML.SAXParser.parse(data: xml.data(using: .utf8)!) + #expect(root.children.map(\.name) == ["g", "b"]) + #expect(root.children[0].children.map(\.name) == ["rect"]) + } + + @Test + func foreignNamespaceDropsSubtreeAndText() throws { + let xml = """ + + dropped + + + kept + + """ + let root = try XML.SAXParser.parse(data: xml.data(using: .utf8)!) + #expect(root.children.map(\.name) == ["text"]) + #expect(root.children[0].innerText?.contains("kept") == true) + #expect(root.innerText?.contains("dropped") != true) + } } diff --git a/README.md b/README.md index 2cbd6c0f..35866fec 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +> **This is an altered version of [SwiftDraw](https://github.com/swhitty/SwiftDraw) by Simon Whitty**, maintained by +> Misoservices for Backdrop. It renders SVG features the original drops; the changes are listed in +> [Backdrop/ROADMAP.md](Backdrop/ROADMAP.md). Licence unchanged: zlib, see [LICENSE.txt](LICENSE.txt). + [![Build](https://github.com/swhitty/SwiftDraw/actions/workflows/build.yml/badge.svg)](https://github.com/swhitty/SwiftDraw/actions/workflows/build.yml) [![CodeCov](https://codecov.io/gh/swhitty/SwiftDraw/graphs/badge.svg)](https://codecov.io/gh/swhitty/SwiftDraw) [![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswhitty%2FSwiftDraw%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swhitty/SwiftDraw) diff --git a/Samples.bundle/clip-mask-units.svg b/Samples.bundle/clip-mask-units.svg new file mode 100644 index 00000000..ed2deadb --- /dev/null +++ b/Samples.bundle/clip-mask-units.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + SD8 + + + + + + + + + + + + + + + + + + + diff --git a/Samples.bundle/dasharray.svg b/Samples.bundle/dasharray.svg new file mode 100644 index 00000000..8ed73ee3 --- /dev/null +++ b/Samples.bundle/dasharray.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Samples.bundle/display-visibility.svg b/Samples.bundle/display-visibility.svg new file mode 100644 index 00000000..c57a2cd6 --- /dev/null +++ b/Samples.bundle/display-visibility.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Samples.bundle/filter-drop-shadow.svg b/Samples.bundle/filter-drop-shadow.svg new file mode 100644 index 00000000..31d01f97 --- /dev/null +++ b/Samples.bundle/filter-drop-shadow.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples.bundle/filter-primitives.svg b/Samples.bundle/filter-primitives.svg new file mode 100644 index 00000000..69adb728 --- /dev/null +++ b/Samples.bundle/filter-primitives.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples.bundle/gaussian-blur.svg b/Samples.bundle/gaussian-blur.svg new file mode 100644 index 00000000..8e02e922 --- /dev/null +++ b/Samples.bundle/gaussian-blur.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Samples.bundle/gradient-spread.svg b/Samples.bundle/gradient-spread.svg new file mode 100644 index 00000000..ec9e1fdf --- /dev/null +++ b/Samples.bundle/gradient-spread.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples.bundle/invalid-elements.svg b/Samples.bundle/invalid-elements.svg new file mode 100644 index 00000000..b5cad27d --- /dev/null +++ b/Samples.bundle/invalid-elements.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Samples.bundle/pattern-attributes.svg b/Samples.bundle/pattern-attributes.svg new file mode 100644 index 00000000..bf0afaf5 --- /dev/null +++ b/Samples.bundle/pattern-attributes.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples.bundle/preserveaspectratio.svg b/Samples.bundle/preserveaspectratio.svg new file mode 100644 index 00000000..1ccaf38c --- /dev/null +++ b/Samples.bundle/preserveaspectratio.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Samples.bundle/stylesheet-cascade.svg b/Samples.bundle/stylesheet-cascade.svg new file mode 100644 index 00000000..8d41f6f7 --- /dev/null +++ b/Samples.bundle/stylesheet-cascade.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/Samples.bundle/tolerant-parsing.svg b/Samples.bundle/tolerant-parsing.svg new file mode 100644 index 00000000..fa873971 --- /dev/null +++ b/Samples.bundle/tolerant-parsing.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Samples.bundle/units-percent.svg b/Samples.bundle/units-percent.svg new file mode 100644 index 00000000..5b7d555d --- /dev/null +++ b/Samples.bundle/units-percent.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift b/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift index 3dbe534e..99c6527f 100644 --- a/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift +++ b/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift @@ -57,8 +57,8 @@ extension XML.Formatter { element.attributes["viewBox"] = makeViewBox(svg.viewBox) if svg.viewBox != .init(x: 0, y: 0, width: DOM.Coordinate(svg.width), height: DOM.Coordinate(svg.height)) { - element.attributes["width"] = formatter.formatLength(svg.width) - element.attributes["height"] = formatter.formatLength(svg.height) + element.attributes["width"] = formatter.format(svg.width) + element.attributes["height"] = formatter.format(svg.height) } try element.children.append(contentsOf: makeStyles(svg.styles)) @@ -120,14 +120,26 @@ extension XML.Formatter { var attributes: [String: String] = [:] attributes["opacity"] = formatter.format(graphic.opacity) attributes["display"] = graphic.display?.rawValue + attributes["visibility"] = graphic.visibility?.rawValue attributes["stroke"] = graphic.stroke.map(encodeFill) attributes["stroke-width"] = formatter.format(graphic.strokeWidth) attributes["stroke-opacity"] = formatter.format(graphic.strokeOpacity) attributes["stroke-linecap"] = graphic.strokeLineCap?.rawValue attributes["stroke-linejoin"] = graphic.strokeLineJoin?.rawValue - attributes["stroke-dasharray"] = graphic.strokeDashArray? - .map { formatter.format($0) } - .joined(separator: " ") + attributes["stroke-dasharray"] = graphic.strokeDashArray.map { lengths in + lengths.isEmpty ? "none" : lengths.map { length -> String in + switch length { + case .absolute(let v): return formatter.format(v) + case .percentage(let v): return formatter.format(v) + "%" + } + }.joined(separator: " ") + } + attributes["stroke-dashoffset"] = graphic.strokeDashOffset.map { length -> String in + switch length { + case .absolute(let v): return formatter.format(v) + case .percentage(let v): return formatter.format(v) + "%" + } + } attributes["fill-opacity"] = formatter.format(graphic.fillOpacity) attributes["fill"] = graphic.fill.map(encodeFill) @@ -281,6 +293,8 @@ extension XML.Formatter { return encodeColor(from: color) case .url(let url): return encodeURL(url) + case .urlWithFallback(let url, let color): + return "\(encodeURL(url)) \(encodeColor(from: color))" } } @@ -300,7 +314,7 @@ extension XML.Formatter { } func encodeURL(_ url: URL) -> String { - "url(\(url.absoluteString))" + url.isNone ? "none" : "url(\(url.absoluteString))" } func encodeColor(from color: DOM.Color) -> String { diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Clip.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Clip.swift new file mode 100644 index 00000000..acae4048 --- /dev/null +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Clip.swift @@ -0,0 +1,386 @@ +// +// LayerTree.Builder.Clip.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM +import Foundation + +extension LayerTree.Builder { + + /// What an element's `clip-path` resolves to (SVG 1.1 §14.3.5). + enum Clip { + /// A union of shapes sharing one clip-rule, applied with a single `setClip`. + case shapes([LayerTree.ClipShape], LayerTree.FillRule) + /// Geometry one clipping path cannot express (text, mixed clip-rules, a `clip-path` on the + /// `` itself): drawn opaque white and applied as an alpha mask. + case mask(LayerTree.Layer) + } + + /// A shape or text inside a ``, with the transform from its own coordinates to the + /// clip path's content coordinates and the clip-rule it resolves to. + struct ClipMember { + var element: DOM.GraphicsElement + var transform: LayerTree.Transform.Matrix + var rule: LayerTree.FillRule + /// A `clip-path` on the member itself (SVG 1.1 §14.3.5): it clips the member before the union. + var clipID: String? + } + + func makeClip(for element: DOM.GraphicsElement) -> Clip? { + let attributes = DOM.presentationAttributes(for: element, styles: svg.styles) + guard let clipID = attributes.clipPath?.fragmentID else { return nil } + return makeClip(id: clipID, bounds: { makeBoundingBox(for: element) }) + } + + /// `bounds` is the bounding box of the referencing element, evaluated only for objectBoundingBox units. + func makeClip(id: String, bounds: () -> LayerTree.Rect?) -> Clip? { + guard let clip = svg.defs.clipPaths.first(where: { $0.id == id }) else { return nil } + + // a clip path that (indirectly) clips itself is ignored; the active set is not counted + // against the document-wide reference budget, so any number of elements may share a clip. + // Each nested clip-path costs native stack, so nesting stops at ReferenceGuard.maxDepth. + guard activeClips.count < ReferenceGuard.maxDepth, + activeClips.insert(id).inserted else { return nil } + defer { activeClips.remove(id) } + + var units = LayerTree.Transform.Matrix.identity + if clip.clipPathUnits == .objectBoundingBox { + // the bounding box of text is unknown here: keep the element unclipped rather than lose content + guard let bounds = bounds() else { return nil } + // an empty bounding box clips everything away + guard bounds.width > 0, bounds.height > 0 else { return .shapes([.empty], .nonzero) } + units = LayerTree.Transform.Matrix(a: bounds.width, b: 0, c: 0, d: bounds.height, + tx: bounds.x, ty: bounds.y) + } + + // the 's own cascade (attributes < CSS rules < style=""), done by the parser + let own = clip.cascaded + let transform = own.transform ?? [] + // the bounding box mapping comes first, then the 's transform in user units + // (as Blink, Gecko and resvg do: clipTransform × translate(bbox) × scale(bbox)) + let space = units.concatenated(Self.createTransforms(from: transform).toMatrix()) + let rule = own.clipRule ?? .nonzero + // `visibility` is inherited: a hidden hides its children unless they say `visible` + let visibility = own.visibility ?? .visible + let members = clip.childElements.flatMap { makeClipMembers(for: $0, inheriting: rule, visibility: visibility) } + let nested = own.clipPath?.fragmentID + + let shapes = members.compactMap { member -> LayerTree.ClipShape? in + guard let shape = Self.makeShape(from: member.element) else { return nil } + return LayerTree.ClipShape(shape: shape, transform: member.transform.concatenated(space)) + } + let rules = Set(members.map(\.rule)) + + if shapes.count == members.count, rules.count <= 1, nested == nil, members.allSatisfy({ $0.clipID == nil }) { + // a clip path without any shape clips everything away + return .shapes(shapes.isEmpty ? [.empty] : shapes, rules.first ?? .nonzero) + } + + let content = LayerTree.Layer() + if space != .identity { + content.transform = [.matrix(space)] + } + for member in members { + content.appendContents(.layer(makeClipMaskLayer(for: member))) + } + + let region = LayerTree.Layer() + if let nested, let clip = makeClip(id: nested, bounds: bounds) { + apply(clip, to: region) + } + region.appendContents(.layer(content)) + + let mask = LayerTree.Layer() + mask.appendContents(.layer(region)) + return .mask(mask) + } + + /// The shapes and text a `` child contributes: a `` contributes the shape or text + /// it references directly; any other element is ignored (SVG 1.1 §14.3.5). + func makeClipMembers(for element: DOM.GraphicsElement, inheriting rule: LayerTree.FillRule, + visibility inherited: DOM.Visibility = .visible) -> [ClipMember] { + let attributes = DOM.presentationAttributes(for: element, styles: svg.styles) + let visibility = attributes.visibility ?? inherited + // SVG 1.1 §14.3.5: children with `display="none"` or hidden `visibility` do not contribute to the clip + guard attributes.display != DOM.DisplayMode.none, visibility == .visible else { return [] } + let transform = Self.createTransforms(from: attributes.transform ?? []).toMatrix() + let rule = attributes.clipRule ?? rule + + if let use = element as? DOM.Use { + guard let id = use.href.fragmentID, + let referenced = svg.firstGraphicsElement(with: id), + !(referenced is DOM.Use) else { return [] } + let translate = LayerTree.Transform.translate(tx: use.x ?? 0, ty: use.y ?? 0).toMatrix() + return makeClipMembers(for: referenced, inheriting: rule, visibility: visibility).map { + var member = $0 + member.transform = member.transform.concatenated(translate).concatenated(transform) + return member + } + } else if Self.makeShape(from: element) != nil || element is DOM.Text { + return [ClipMember(element: element, transform: transform, rule: rule, + clipID: attributes.clipPath?.fragmentID)] + } + return [] + } + + func makeClipMaskLayer(for member: ClipMember) -> LayerTree.Layer { + let l = LayerTree.Layer() + if member.transform != .identity { + l.transform = [.matrix(member.transform)] + } + if let shape = Self.makeShape(from: member.element) { + var state = State() + state.fill = .color(.keyword(.white)) + state.fillRule = member.rule + l.appendContents(makeShapeContents(from: shape, with: state)) + applyMemberClip(of: member, to: l) + } else if let text = member.element as? DOM.Text { + let state = createState(for: text, inheriting: State()) + if case .text(let string, let point, var attributes) = makeTextContents(from: text, with: state) { + attributes.color = .white + l.appendContents(.text(string, point, attributes)) + } + applyMemberClip(of: member, to: l) + } + return l + } + + /// A `clip-path` on a `` child clips that child only, in the child's user space. + private func applyMemberClip(of member: ClipMember, to layer: LayerTree.Layer) { + guard let id = member.clipID, + let clip = makeClip(id: id, bounds: { makeBoundingBox(for: member.element) }) else { return } + apply(clip, to: layer) + } + + func apply(_ clip: Clip, to layer: LayerTree.Layer) { + switch clip { + case let .shapes(shapes, rule): + layer.clip = shapes + layer.clipRule = rule + case let .mask(mask): + guard let existing = layer.mask else { + layer.mask = mask + layer.maskIsClip = true + return + } + // a mask and a clip together: the clip masks the mask's own contents + let inner = LayerTree.Layer() + inner.mask = mask + existing.contents.forEach(inner.appendContents) + let combined = LayerTree.Layer() + combined.appendContents(.layer(inner)) + layer.mask = combined + } + } + + /// The region outside which a `` is zero (SVG 1.1 §14.4): nil when it cannot be resolved + /// (an objectBoundingBox region on an element whose bounding box is unknown). + func makeMaskRegion(for mask: DOM.Mask, bounds: LayerTree.Rect?) -> LayerTree.Rect? { + let x = mask.x ?? .percentage(-10) + let y = mask.y ?? .percentage(-10) + let width = mask.width ?? .percentage(120) + let height = mask.height ?? .percentage(120) + + switch mask.maskUnits ?? .objectBoundingBox { + case .objectBoundingBox: + guard let bounds else { return nil } + func fraction(_ length: DOM.DashLength) -> LayerTree.Float { + switch length { + case .absolute(let value): return value + case .percentage(let value): return value / 100 + } + } + return LayerTree.Rect(x: bounds.x + fraction(x) * bounds.width, + y: bounds.y + fraction(y) * bounds.height, + width: fraction(width) * bounds.width, + height: fraction(height) * bounds.height) + case .userSpaceOnUse: + let viewport = svg.viewBox.map { LayerTree.Size($0.width, $0.height) } + ?? LayerTree.Size(LayerTree.Float(svg.width), LayerTree.Float(svg.height)) + func length(_ length: DOM.DashLength, _ reference: LayerTree.Float) -> LayerTree.Float { + switch length { + case .absolute(let value): return value + case .percentage(let value): return value / 100 * reference + } + } + return LayerTree.Rect(x: length(x, viewport.width), + y: length(y, viewport.height), + width: length(width, viewport.width), + height: length(height, viewport.height)) + } + } + + /// The object bounding box of an element in its own user space: the fill geometry of its shapes, + /// without stroke (SVG 1.1 §7.11). Nil when unknown: text, or a container holding text, since + /// a box measured without it would cut the text away. + func makeBoundingBox(for element: DOM.GraphicsElement) -> LayerTree.Rect? { + var visited = Set() + switch makeBounds(for: element, depth: 0, visited: &visited) { + case .known(let rect): return rect + case .empty, .unknown: return nil + } + } + + enum Bounds { + case known(LayerTree.Rect) + /// no geometry (an empty group, a move-only path): ignored by the enclosing container + case empty + /// geometry this platform-independent builder cannot measure (text) + case unknown + } + + /// Measurement keeps its own visited set, depth cap and step budget: it never spends the + /// document-wide reference budget that ``, masks and patterns share. + private func makeBounds(for element: DOM.GraphicsElement, depth: Int, visited: inout Set) -> Bounds { + guard depth < 32, measurement.spend() else { return .unknown } + + if let shape = Self.makeShape(from: element) { + return Bounds(shape.path.bounds) + } else if let image = element as? DOM.Image { + guard let width = image.width, let height = image.height else { return .unknown } + return Bounds(LayerTree.Rect(x: image.x ?? 0, y: image.y ?? 0, width: width, height: height)) + } else if let use = element as? DOM.Use { + guard let id = use.href.fragmentID, + let referenced = svg.firstGraphicsElement(with: id) else { return .empty } + guard visited.insert(id).inserted else { return .empty } + defer { visited.remove(id) } + let referencedBounds = makeBounds(for: referenced, depth: depth + 1, visited: &visited) + guard case .known(let bounds) = referencedBounds else { return referencedBounds } + let transform = Self.createTransforms(from: DOM.presentationAttributes(for: referenced, styles: svg.styles).transform ?? []).toMatrix() + .concatenated(LayerTree.Transform.translate(tx: use.x ?? 0, ty: use.y ?? 0).toMatrix()) + return Bounds(bounds.applying(transform)) + } else if let container = element as? any ContainerElement { + var result: LayerTree.Rect? + for child in container.childElements { + let childAttributes = DOM.presentationAttributes(for: child, styles: svg.styles) + guard childAttributes.display != DOM.DisplayMode.none else { continue } + switch makeBounds(for: child, depth: depth + 1, visited: &visited) { + case .unknown: + return .unknown + case .empty: + continue + case .known(let bounds): + let transform: LayerTree.Transform.Matrix + if let svg = child as? DOM.SVG { + transform = Self.makeTransform(x: svg.x, y: svg.y, viewBox: svg.viewBox, + width: svg.width, height: svg.height).toMatrix() + } else { + transform = Self.createTransforms(from: childAttributes.transform ?? []).toMatrix() + } + guard case .known(let childBounds) = Bounds(bounds.applying(transform)) else { continue } + let union = result.map { $0.union(childBounds) } ?? childBounds + // a union reaching infinity (1e308 + 1e308) carries no usable geometry + guard case .known(let checked) = Bounds(union) else { continue } + result = checked + } + } + return result.map(Bounds.known) ?? .empty + } else if element is DOM.Text { + return .unknown + } + return .empty + } +} + +extension LayerTree.Builder.Bounds { + /// Non-finite or negative-sized bounds (a move-only path, NaN from 0 × inf) carry no geometry. + init(_ rect: LayerTree.Rect) { + let values = [rect.x, rect.y, rect.width, rect.height] + guard values.allSatisfy(\.isFinite), rect.width >= 0, rect.height >= 0 else { + self = .empty + return + } + self = .known(rect) + } +} + +/// Ids of the clip paths being resolved, to drop cycles. +final class ActiveClipSet { + private var ids = Set() + + var count: Int { ids.count } + + func insert(_ id: String) -> (inserted: Bool, memberAfterInsert: String) { + ids.insert(id) + } + + func remove(_ id: String) { + ids.remove(id) + } +} + +extension LayerTree.Builder { + + /// Bounds the elements visited measuring bounding boxes, document-wide: `visited` only cuts cycles, + /// and non-cyclic `` fan-out (4 uses per level) is exponential. Past the budget every box is + /// unknown, so bbox clips and mask regions degrade to none rather than hide content. + final class MeasurementBudget { + static let maxSteps = 250_000 + private(set) var steps = 0 + + func spend() -> Bool { + guard steps < Self.maxSteps else { return false } + steps += 1 + return true + } + } +} + +extension LayerTree.ClipShape { + /// A clip that nothing passes through. + static var empty: Self { + LayerTree.ClipShape(shape: .rect(within: .zero, radii: .zero), transform: .identity) + } +} + +extension LayerTree.Rect { + + func applying(_ matrix: LayerTree.Transform.Matrix) -> LayerTree.Rect { + guard matrix != .identity else { return self } + let points = [ + LayerTree.Point(x, y), + LayerTree.Point(x + width, y), + LayerTree.Point(x, y + height), + LayerTree.Point(x + width, y + height) + ].map(matrix.transform(point:)) + let xs = points.map(\.x) + let ys = points.map(\.y) + let minX = xs.min()!, minY = ys.min()! + return LayerTree.Rect(x: minX, y: minY, width: xs.max()! - minX, height: ys.max()! - minY) + } + + func union(_ other: LayerTree.Rect) -> LayerTree.Rect { + let minX = Swift.min(x, other.x) + let minY = Swift.min(y, other.y) + let maxX = Swift.max(x + width, other.x + other.width) + let maxY = Swift.max(y + height, other.y + other.height) + return LayerTree.Rect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } +} diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift index 0dc613ea..fc59de41 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift @@ -40,14 +40,23 @@ extension LayerTree.Builder { return .shape(shape, stroke, fill) } - func makeUseLayerContents(from use: DOM.Use, with state: State) throws -> LayerTree.Layer.Contents { + func makeUseLayerContents(from use: DOM.Use, with state: State, ancestors: [String] = []) throws -> LayerTree.Layer.Contents { guard let id = use.href.fragmentID, let element = svg.firstGraphicsElement(with: id) else { throw LayerTree.Error.invalid("missing referenced element: \(use.href)") } - let l = makeLayer(from: element, inheriting: state) + // SVG 1.1 §5.6: a `` that (directly or indirectly) references one of its own ancestors is an error + guard !containsReferenceCycle(from: element, reaching: Set(ancestors).union([id])) else { + throw LayerTree.Error.invalid("circular reference: \(use.href)") + } + guard references.enter("use:\(id)") else { + throw LayerTree.Error.invalid("circular reference: \(use.href)") + } + defer { references.leave("use:\(id)") } + + let l = makeLayer(from: element, inheriting: state, ancestors: ancestors + [id]) let x = use.x ?? 0.0 let y = use.y ?? 0.0 @@ -60,13 +69,8 @@ extension LayerTree.Builder { func makeTextContents(from text: DOM.Text, with state: State) -> LayerTree.Layer.Contents { var point = Point(text.x ?? 0, text.y ?? 0) - var att = makeTextAttributes(with: state) - - if let fontFamily = text.attributes.fontFamily { - att.font = fontFamily.flatMap(makeFonts) - } - att.size = text.attributes.fontSize ?? att.size - att.anchor = text.attributes.textAnchor ?? att.anchor + // state already holds the cascaded font-family, font-size and text-anchor + let att = makeTextAttributes(with: state) let offset = Self.makeOffset(for: text.value, with: att) point.x += offset.width point.y += offset.height @@ -85,6 +89,7 @@ extension LayerTree.Builder { im.origin.y = LayerTree.Float(image.y ?? 0) im.width = image.width.map { LayerTree.Float($0) } im.height = image.height.map { LayerTree.Float($0) } + im.preserveAspectRatio = image.preserveAspectRatio ?? .default return .image(im) } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift index ca9ae1c8..62fd0b6e 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift @@ -39,6 +39,10 @@ extension LayerTree { struct Builder { let svg: DOM.SVG + let references = ReferenceGuard() + let activeClips = ActiveClipSet() + let measurement = MeasurementBudget() + let gradients = GradientCache() init(svg: DOM.SVG) { self.svg = svg @@ -48,15 +52,50 @@ extension LayerTree { makeLayer(svg: svg, inheriting: State()) } + /// SVG 1.1 §11.6.2: hidden shapes, text and images paint nothing, whatever mask, filter or + /// opacity they carry (a filter such as feFlood could otherwise still paint). + static func isHiddenLeaf(_ element: DOM.GraphicsElement, _ state: State) -> Bool { + guard state.visibility != .visible else { return false } + return makeShape(from: element) != nil || element is DOM.Text || element is DOM.Image + } + func makeLayer(svg: DOM.SVG, inheriting previousState: State) -> Layer { + // a root removed by `display` has no viewport mapping either + if DOM.presentationAttributes(for: svg, styles: self.svg.styles).display == DOM.DisplayMode.none { + return Layer() + } let l = makeLayer(from: svg, inheriting: previousState) l.transform = Builder.makeTransform( x: svg.x, y: svg.y, viewBox: svg.viewBox, width: svg.width, - height: svg.height + height: svg.height, + preserveAspectRatio: svg.preserveAspectRatio ) + // `slice` lets the viewBox overflow the viewport, which clips it (also when drawn into a larger context). + // layer.clip applies after layer.transform, so the viewport is expressed in viewBox space. + if let par = svg.preserveAspectRatio, par.align != .none, par.meetOrSlice == .slice { + let viewport = Builder.makeViewportClip( + viewBox: svg.viewBox, + width: svg.width, + height: svg.height, + preserveAspectRatio: par + ) + let viewportClip = [ClipShape(shape: .rect(within: viewport, radii: .zero), transform: .identity)] + if l.clip.isEmpty { + l.clip = viewportClip + } else { + // the root's own clip-path and the viewport both apply: layer clips are unioned, so + // the viewport clips an outer layer holding the root layer, which keeps its clip-path + let outer = Layer() + outer.transform = l.transform + outer.clip = viewportClip + l.transform = [] + outer.contents = [.layer(l)] + return outer + } + } return l } @@ -64,16 +103,14 @@ extension LayerTree { x: DOM.Coordinate?, y: DOM.Coordinate?, viewBox: DOM.SVG.ViewBox?, - width: DOM.Length, - height: DOM.Length + width: DOM.Coordinate, + height: DOM.Coordinate, + preserveAspectRatio: DOM.PreserveAspectRatio? = nil ) -> [LayerTree.Transform] { - let position = LayerTree.Transform.translate(tx: x ?? 0, ty: y ?? 0) - let viewBox = viewBox ?? DOM.SVG.ViewBox(x: 0, y: 0, width: .init(width), height: .init(height)) - - let sx = LayerTree.Float(width) / viewBox.width - let sy = LayerTree.Float(height) / viewBox.height - let scale = LayerTree.Transform.scale(sx: sx, sy: sy) - let translate = LayerTree.Transform.translate(tx: -viewBox.x, ty: -viewBox.y) + let fit = makeViewBoxFit(viewBox: viewBox, width: width, height: height, preserveAspectRatio: preserveAspectRatio) + let position = LayerTree.Transform.translate(tx: (x ?? 0) + fit.tx, ty: (y ?? 0) + fit.ty) + let scale = LayerTree.Transform.scale(sx: fit.sx, sy: fit.sy) + let translate = LayerTree.Transform.translate(tx: -fit.viewBox.x, ty: -fit.viewBox.y) var transform: [LayerTree.Transform] = [] @@ -92,19 +129,84 @@ extension LayerTree { return transform } - func makeLayer(from root: DOM.GraphicsElement, inheriting previousState: State) -> Layer { - var stack: [(DOM.GraphicsElement, State, Layer?)] = [(root, previousState, nil)] + /// Maps the viewBox into the `width` x `height` viewport (SVG 1.1 §7.8, `preserveAspectRatio`): + /// `viewport = (user - viewBox.origin) * scale + offset`. A missing or empty viewBox is the viewport itself. + static func makeViewBoxFit( + viewBox: DOM.SVG.ViewBox?, + width: DOM.Coordinate, + height: DOM.Coordinate, + preserveAspectRatio: DOM.PreserveAspectRatio? + ) -> (viewBox: DOM.SVG.ViewBox, sx: LayerTree.Float, sy: LayerTree.Float, tx: LayerTree.Float, ty: LayerTree.Float) { + var box = DOM.SVG.ViewBox(x: 0, y: 0, width: .init(width), height: .init(height)) + if let viewBox, viewBox.x.isFinite, viewBox.y.isFinite, + viewBox.width.isFinite, viewBox.height.isFinite, + viewBox.width > 0, viewBox.height > 0 { + box = viewBox + } + // an empty or non-finite viewport (or one that overflows the fit) has no mapping: identity + guard box.width > 0, box.height > 0, width > 0, height > 0 else { + return (box, 1, 1, 0, 0) + } + let fit = (preserveAspectRatio ?? .default).fit( + contentWidth: box.width, contentHeight: box.height, + viewportWidth: .init(width), viewportHeight: .init(height) + ) + // the free space of an exact fit is only rounding noise + let tx = abs(fit.tx) < 1e-4 ? 0 : fit.tx + let ty = abs(fit.ty) < 1e-4 ? 0 : fit.ty + guard [fit.sx, fit.sy, tx, ty].allSatisfy(\.isFinite), fit.sx > 0, fit.sy > 0 else { + return (DOM.SVG.ViewBox(x: 0, y: 0, width: .init(width), height: .init(height)), 1, 1, 0, 0) + } + return (box, fit.sx, fit.sy, tx, ty) + } + + /// The viewport of a nested `` in the coordinates of its contents (the viewBox space), + /// where its `overflow: hidden` clip applies. Larger than the viewBox when `meet` letterboxes it. + static func makeViewportClip( + viewBox: DOM.SVG.ViewBox?, + width: DOM.Coordinate, + height: DOM.Coordinate, + preserveAspectRatio: DOM.PreserveAspectRatio? + ) -> LayerTree.Rect { + let fit = makeViewBoxFit(viewBox: viewBox, width: width, height: height, preserveAspectRatio: preserveAspectRatio) + return LayerTree.Rect( + x: fit.viewBox.x - fit.tx / fit.sx, + y: fit.viewBox.y - fit.ty / fit.sy, + width: LayerTree.Float(width) / fit.sx, + height: LayerTree.Float(height) / fit.sy + ) + } + + /// `ancestors` holds the ids of the elements enclosing `root` (and, through ``, of the + /// elements being instanced), so a `` that references one of them can be dropped. + func makeLayer(from root: DOM.GraphicsElement, inheriting previousState: State, ancestors: [String] = []) -> Layer { + var stack: [(DOM.GraphicsElement, State, Layer?, [String])] = [(root, previousState, nil, ancestors)] var resultLayer: Layer? = nil - while let (currentElement, currentState, parentLayer) = stack.popLast() { - let (layer, newState) = makeBaseLayer(from: currentElement, inheriting: currentState) + while let (currentElement, currentState, parentLayer, currentAncestors) = stack.popLast() { + // transform, clip-rule and mask may come from CSS as well as attributes + let attributes = DOM.presentationAttributes(for: currentElement, styles: svg.styles) + let newState = Self.createState(for: attributes, inheriting: currentState) + // SVG 1.1 §11.6.2: `display="none"` removes the element and its whole subtree from rendering, + // before any transform, clip, mask, filter or opacity is built for it + if newState.display == .none || Self.isHiddenLeaf(currentElement, newState) { + if parentLayer == nil { + resultLayer = Layer() + } + continue + } + let layer = makeBaseLayer(from: currentElement, attributes: attributes, with: newState) + var childAncestors = currentAncestors + if let id = currentElement.id { + childAncestors.append(id) + } - if let contents = makeContents(from: currentElement, with: newState) { + if let contents = makeContents(from: currentElement, with: newState, ancestors: childAncestors) { layer.appendContents(contents) } else if let container = currentElement as? any ContainerElement { // Push children in reverse so they are processed in the original order for child in container.childElements.reversed() { - stack.append((child, newState, layer)) + stack.append((child, newState, layer, childAncestors)) } } @@ -112,15 +214,20 @@ extension LayerTree { parent.appendContents(.layer(layer)) if let svg = currentElement as? DOM.SVG { - let viewBox = svg.viewBox ?? DOM.SVG.ViewBox(x: 0, y: 0, width: .init(svg.width), height: .init(svg.height)) - let bounds = LayerTree.Rect(x: viewBox.x, y: viewBox.y, width: viewBox.width, height: viewBox.height) + let bounds = Builder.makeViewportClip( + viewBox: svg.viewBox, + width: svg.width, + height: svg.height, + preserveAspectRatio: svg.preserveAspectRatio + ) layer.clip = [ClipShape(shape: .rect(within: bounds, radii: .zero), transform: .identity)] layer.transform = Builder.makeTransform( x: svg.x, y: svg.y, viewBox: svg.viewBox, width: svg.width, - height: svg.height + height: svg.height, + preserveAspectRatio: svg.preserveAspectRatio ) } } else { @@ -132,88 +239,120 @@ extension LayerTree { return resultLayer! } - func makeBaseLayer(from element: DOM.GraphicsElement, inheriting previousState: State) -> (Layer, State) { - let state = createState(for: element, inheriting: previousState) - let attributes = element.attributes + func makeBaseLayer(from element: DOM.GraphicsElement, attributes: DOM.PresentationAttributes, with state: State) -> Layer { let l = Layer() l.class = element.class - guard state.display != .none else { return (l, state) } - l.transform = Builder.createTransforms(from: attributes.transform ?? []) - l.clip = makeClipShapes(for: element) - l.clipRule = attributes.clipRule - l.clipUnits = makeClipUnits(for: element) - l.mask = createMaskLayer(for: element) + l.mask = createMaskLayer(for: element, attributes: attributes) + // clip-rule comes from the contents and objectBoundingBox units are resolved + // here, so every clip reaches the renderers in user space (SVG 1.1 §14.3.5) + if let clipID = attributes.clipPath?.fragmentID, + let clip = makeClip(id: clipID, bounds: { makeBoundingBox(for: element) }) { + apply(clip, to: l) + } l.opacity = state.opacity - l.filters = makeFilters(for: state) - return (l, state) + if let filter = makeFilter(for: element) { + l.filters = filter.primitives + l.filterRegion = makeFilterRegion(for: filter) + } + return l } - func makeContents(from element: DOM.GraphicsElement, with state: State) -> Layer.Contents? { + func makeContents(from element: DOM.GraphicsElement, with state: State, ancestors: [String] = []) -> Layer.Contents? { + // SVG 1.1 §11.6.2: `visibility` hides graphics only; a child may set `visible` again + let isVisible = state.visibility == .visible if let shape = Builder.makeShape(from: element) { - return makeShapeContents(from: shape, with: state) + return isVisible ? makeShapeContents(from: shape, with: state) : nil } else if let text = element as? DOM.Text { - return makeTextContents(from: text, with: state) + return isVisible ? makeTextContents(from: text, with: state) : nil } else if let image = element as? DOM.Image { - return try? Builder.makeImageContents(from: image) + return isVisible ? try? Builder.makeImageContents(from: image) : nil } else if let use = element as? DOM.Use { - return try? makeUseLayerContents(from: use, with: state) + return try? makeUseLayerContents(from: use, with: state, ancestors: ancestors) } else if let sw = element as? DOM.Switch, let e = sw.childElements.first { //TODO: select first element that creates non empty Layer - return .layer(makeLayer(from: e, inheriting: state)) + return .layer(makeLayer(from: e, inheriting: state, ancestors: ancestors)) } return nil } func makeClipShapes(for element: DOM.GraphicsElement) -> [ClipShape] { - let attributes = DOM.presentationAttributes(for: element, styles: svg.styles) - guard let clipID = attributes.clipPath?.fragmentID, - let clip = svg.defs.clipPaths.first(where: { $0.id == clipID }) else { return [] } - return clip.childElements.compactMap(makeClipShape) - } - - func makeClipUnits(for element: DOM.GraphicsElement) -> ClipUnits { - let attributes = DOM.presentationAttributes(for: element, styles: svg.styles) - guard let clipID = attributes.clipPath?.fragmentID, - let clip = svg.defs.clipPaths.first(where: { $0.id == clipID }) else { return .userSpaceOnUse } - switch clip.clipPathUnits { - case .objectBoundingBox: return .objectBoundingBox - case .userSpaceOnUse, nil: return .userSpaceOnUse - } + guard case .shapes(let shapes, _) = makeClip(for: element) else { return [] } + return shapes } - func makeClipShape(for element: DOM.GraphicsElement) -> ClipShape? { - guard let shape = Builder.makeShape(from: element) else { - return nil - } - - let transform = Self.createTransforms(from: element.attributes.transform ?? []) - .toMatrix() - - return ClipShape(shape: shape, transform: transform) + func createMaskLayer(for element: DOM.GraphicsElement) -> Layer? { + createMaskLayer(for: element, attributes: DOM.presentationAttributes(for: element, styles: svg.styles)) } - func createMaskLayer(for element: DOM.GraphicsElement) -> Layer? { - guard let maskId = element.attributes.mask?.fragmentID, + func createMaskLayer(for element: DOM.GraphicsElement, attributes: DOM.PresentationAttributes) -> Layer? { + guard let maskId = attributes.mask?.fragmentID, let mask = svg.defs.masks.first(where: { $0.id == maskId }) else { return nil } + // a mask that (indirectly) references itself is dropped + guard references.enter("mask:\(maskId)") else { return nil } + defer { references.leave("mask:\(maskId)") } + let l = Layer() - let maskState = createState(for: mask, inheriting: State()) + // SVG 1.1 §14.4: maskUnits places the mask region, maskContentUnits the contents + let regionUnits = mask.maskUnits ?? .objectBoundingBox + let contentUnits = mask.maskContentUnits ?? .userSpaceOnUse + let bounds = (regionUnits == .objectBoundingBox || contentUnits == .objectBoundingBox) + ? makeBoundingBox(for: element) : nil + + let content = Layer() + if contentUnits == .objectBoundingBox { + // the bounding box of text is unknown here: ignore the mask + guard let bounds else { return nil } + // an empty bounding box masks everything away + guard bounds.width > 0, bounds.height > 0 else { return l } + content.transform = [.matrix(Transform.Matrix(a: bounds.width, b: 0, c: 0, d: bounds.height, + tx: bounds.x, ty: bounds.y))] + } + + var maskState = createState(for: mask, inheriting: State()) + // SVG 1.1 §11.5: `display` does not apply to ``; only its children can be hidden + maskState.display = .inline mask.childElements.forEach { let contents = Layer.Contents.layer(makeLayer(from: $0, inheriting: maskState)) - l.appendContents(contents) + content.appendContents(contents) + } + + if let region = makeMaskRegion(for: mask, bounds: bounds) { + // a zero or negative region disables rendering of the element + guard region.width > 0, region.height > 0 else { return l } + let clipped = Layer() + clipped.clip = [ClipShape(shape: .rect(within: region, radii: .zero), transform: .identity)] + clipped.appendContents(.layer(content)) + l.appendContents(.layer(clipped)) + } else if content.transform.isEmpty { + content.contents.forEach(l.appendContents) + } else { + l.appendContents(.layer(content)) } return l } - func makeFilters(for state: State) -> [Filter] { - guard let filterId = state.filter?.fragmentID, - let filter = svg.defs.filters.first(where: { $0.id == filterId }) else { return [] } - return filter.effects + // `filter` is not inherited: it applies once, to the element that references it + func makeFilter(for element: DOM.GraphicsElement) -> DOM.Filter? { + let attributes = DOM.presentationAttributes(for: element, styles: svg.styles) + guard let filterId = attributes.filter?.fragmentID else { return nil } + return svg.defs.filters.first(where: { $0.id == filterId }) + } + + func makeFilterRegion(for filter: DOM.Filter) -> FilterRegion { + FilterRegion( + x: filter.x.map { Float($0) }, + y: filter.y.map { Float($0) }, + width: filter.width.map { Float($0) }, + height: filter.height.map { Float($0) }, + units: filter.filterUnits == .userSpaceOnUse ? .userSpaceOnUse : .objectBoundingBox, + primitiveUnits: filter.primitiveUnits == .objectBoundingBox ? .objectBoundingBox : .userSpaceOnUse + ) } } } @@ -225,20 +364,25 @@ extension LayerTree.Builder { let stroke: LayerTree.StrokeAttributes.Stroke if state.strokeWidth > 0.0 { - switch state.stroke { + switch resolveFallback(state.stroke) { case .color(let c): let color = LayerTree.Color .create(from: c, current: state.color) .withAlpha(state.strokeOpacity).maybeNone() stroke = .color(color) case .url(let gradientId): - if let gradient = makeLinearGradient(for: gradientId) { + switch makeGradientPaint(for: gradientId) { + case .linear(let gradient): stroke = .linearGradient(gradient) - } else if let gradient = makeRadialGradient(for: gradientId) { + case .radial(let gradient): stroke = .radialGradient(gradient) - } else { + case .color(let color): + stroke = .color(color.withAlpha(state.strokeOpacity).maybeNone()) + case nil: stroke = .color(.none) } + case .urlWithFallback: + stroke = .color(.none) // resolved to .url or .color above } } else { stroke = .color(.none) @@ -248,48 +392,190 @@ extension LayerTree.Builder { width: state.strokeWidth, cap: state.strokeLineCap, join: state.strokeLineJoin, - miterLimit: state.strokeLineMiterLimit) + miterLimit: state.strokeLineMiterLimit, + dashArray: makeDashArray(with: state), + dashOffset: makeDashOffset(with: state)) + } + + /// Length of the viewport diagonal / sqrt(2), the reference for percentages (SVG 1.1 §7.10). + var viewportDiagonal: LayerTree.Float { + let w: LayerTree.Float + let h: LayerTree.Float + if let viewBox = svg.viewBox { + w = viewBox.width + h = viewBox.height + } else { + w = LayerTree.Float(svg.width) + h = LayerTree.Float(svg.height) + } + return ((w * w + h * h) / 2).squareRoot() + } + + func makeDashLength(_ length: DOM.DashLength) -> LayerTree.Float { + switch length { + case .absolute(let value): return value + case .percentage(let value): return value / 100 * viewportDiagonal + } + } + + func makeDashOffset(with state: State) -> LayerTree.Float { + let offset = makeDashLength(state.strokeDashOffset) + return offset.isFinite ? offset : 0 + } + + /// SVG 1.1 §11.4: odd-length lists repeat to even length; a zero sum renders solid. + func makeDashArray(with state: State) -> [LayerTree.Float] { + var lengths = state.strokeDashArray.map(makeDashLength) + guard !lengths.isEmpty, lengths.allSatisfy({ $0 >= 0 && $0.isFinite }), lengths.reduce(0, +) > 0 else { + return [] + } + if lengths.count % 2 == 1 { + lengths += lengths + } + return lengths + } + + /// SVG 1.1 §11.2: a paint server that resolves wins; otherwise the fallback colour (`none` paints nothing). + func resolveFallback(_ paint: DOM.Fill) -> DOM.Fill { + guard case .urlWithFallback(let url, let color) = paint else { return paint } + let resolves = url.fragmentID.map { id in + svg.defs.patterns.contains(where: { $0.id == id }) || makeGradientPaint(for: url) != nil + } ?? false + return resolves ? .url(url) : .color(color) } func makeFillAttributes(with state: State) -> LayerTree.FillAttributes { + var state = state + state.fill = resolveFallback(state.fill) let fill = LayerTree.Color .create(from: state.fill.makeColor(), current: state.color) .withAlpha(state.fillOpacity).maybeNone() if case .url(let patternId) = state.fill, let element = svg.defs.patterns.first(where: { $0.id == patternId.fragmentID }) { - let pattern = makePattern(for: element) + // a pattern that (indirectly) paints itself is dropped + guard let pattern = makePatternGuarded(for: element) else { + return LayerTree.FillAttributes(color: .none, rule: state.fillRule) + } return LayerTree.FillAttributes(pattern: pattern, rule: state.fillRule, opacity: state.fillOpacity) } else if case .url(let gradientId) = state.fill, - let element = svg.defs.linearGradients.first(where: { $0.id == gradientId.fragmentID }), - let gradient = makeGradient(for: element) { - return LayerTree.FillAttributes(linear: gradient, rule: state.fillRule, opacity: state.fillOpacity) - } else if case .url(let gradientId) = state.fill, - let element = svg.defs.radialGradients.first(where: { $0.id == gradientId.fragmentID }), - let gradient = makeGradient(for: element) { - return LayerTree.FillAttributes(radial: gradient, rule: state.fillRule, opacity: state.fillOpacity) + let paint = makeGradientPaint(for: gradientId) { + switch paint { + case .linear(let gradient): + return LayerTree.FillAttributes(linear: gradient, rule: state.fillRule, opacity: state.fillOpacity) + case .radial(let gradient): + return LayerTree.FillAttributes(radial: gradient, rule: state.fillRule, opacity: state.fillOpacity) + case .color(let color): + return LayerTree.FillAttributes(color: color.withAlpha(state.fillOpacity).maybeNone(), rule: state.fillRule) + } } else { return LayerTree.FillAttributes(color: fill, rule: state.fillRule) } } - func makeLinearGradient(for gradientId: URL) -> LayerTree.LinearGradient? { - guard let element = svg.defs.linearGradients.first(where: { $0.id == gradientId.fragmentID }), - let gradient = makeGradient(for: element) else { + /// What a gradient paints: the gradient itself, or a single colour in the degenerate cases. + enum GradientPaint { + case linear(LayerTree.LinearGradient) + case radial(LayerTree.RadialGradient) + case color(LayerTree.Color) + } + + /// A `` or ``; href chains may mix both kinds. + enum GradientElement { + case linear(DOM.LinearGradient) + case radial(DOM.RadialGradient) + + var id: String { + switch self { + case .linear(let e): return e.id + case .radial(let e): return e.id + } + } + + var href: URL? { + switch self { + case .linear(let e): return e.href + case .radial(let e): return e.href + } + } + + var stops: [(offset: DOM.Float, color: DOM.Color, opacity: DOM.Float)] { + switch self { + case .linear(let e): return e.stops.map { ($0.offset, $0.color, $0.opacity) } + case .radial(let e): return e.stops.map { ($0.offset, $0.color, $0.opacity) } + } + } + + var gradientUnits: DOM.LinearGradient.Units? { + switch self { + case .linear(let e): return e.gradientUnits + case .radial(let e): return e.gradientUnits + } + } + + var gradientTransform: [DOM.Transform]? { + switch self { + case .linear(let e): return e.gradientTransform + case .radial(let e): return e.gradientTransform + } + } + + var spreadMethod: DOM.LinearGradient.SpreadMethod? { + switch self { + case .linear(let e): return e.spreadMethod + case .radial(let e): return e.spreadMethod + } + } + + var linear: DOM.LinearGradient? { + if case .linear(let e) = self { return e } return nil } - return gradient - } - func makeRadialGradient(for gradientId: URL) -> LayerTree.RadialGradient? { - guard let element = svg.defs.radialGradients.first(where: { $0.id == gradientId.fragmentID }), - let gradient = makeGradient(for: element) else { + var radial: DOM.RadialGradient? { + if case .radial(let e) = self { return e } return nil } - return gradient } + func makeGradientElement(id: String?) -> GradientElement? { + guard let id else { return nil } + return gradients.elements(in: svg.defs)[id] + } + + /// nil when the url does not name a gradient. Resolved once per gradient id. + func makeGradientPaint(for gradientId: URL) -> GradientPaint? { + guard let id = gradientId.fragmentID else { return nil } + if let paint = gradients.paints[id] { + return paint + } + let paint = makeGradientElement(id: id).map(makeGradientPaint) + gradients.paints[id] = paint + return paint + } + + final class GradientCache { + var paints = [String: GradientPaint?]() + private var index: [String: GradientElement]? + + /// Every gradient by id, built once (a linear gradient wins over a radial one with the same id, + /// the first of its kind over later ones), so each href hop is a lookup, not a scan of the defs. + func elements(in defs: DOM.SVG.Defs) -> [String: GradientElement] { + if let index { return index } + var elements = [String: GradientElement]() + for element in defs.radialGradients.reversed() { elements[element.id] = .radial(element) } + for element in defs.linearGradients.reversed() { elements[element.id] = .linear(element) } + index = elements + return elements + } + } + + /// Most href hops followed from one gradient (SVG sets no limit; this bounds hostile chains). + static var maxGradientHops: Int { 64 } + func makeTextAttributes(with state: State) -> LayerTree.TextAttributes { + var state = state + state.fill = resolveFallback(state.fill) let fill = LayerTree.Color .create(from: state.fill.makeColor(), current: state.color) .withAlpha(state.fillOpacity).maybeNone() @@ -361,81 +647,168 @@ 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 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 + func makePatternGuarded(for element: DOM.Pattern) -> LayerTree.Pattern? { + let key = "pattern:\(element.id)" + guard references.enter(key) else { return nil } + defer { references.leave(key) } + return makePattern(for: element) } - func makeGradient(for element: DOM.LinearGradient) -> LayerTree.LinearGradient? { - let x1 = element.x1 ?? 0 - let y1 = element.y1 ?? 0 - let x2 = element.x2 ?? 1 - let y2 = element.y2 ?? 0 + func makePattern(for element: DOM.Pattern) -> LayerTree.Pattern { + // SVG 1.1 §13.3: attributes not set on this element, and its children when it has none, + // are inherited along the xlink:href chain. + let chain = makePatternChain(for: element) + func inherited(_ value: (DOM.Pattern) -> T?) -> T? { + chain.lazy.compactMap(value).first + } - var stops = [LayerTree.Gradient.Stop]() - if let id = element.href?.fragmentID, - let reference = svg.defs.linearGradients.first(where: { $0.id == id }) { - stops = makeGradientStops(for: reference) - } else { - stops = makeGradientStops(for: element) + let units: LayerTree.PatternUnits = inherited(\.patternUnits) == .userSpaceOnUse ? .userSpaceOnUse : .objectBoundingBox + + // Percentages are fractions of the bounding box under objectBoundingBox, and of the + // viewport (in user units) under userSpaceOnUse. + let viewport = svg.viewBox.map { LayerTree.Size($0.width, $0.height) } + ?? LayerTree.Size(LayerTree.Float(svg.width), LayerTree.Float(svg.height)) + func geometry(_ key: String, _ value: (DOM.Pattern) -> DOM.Coordinate?, viewport length: LayerTree.Float) -> LayerTree.Float { + guard let source = chain.first(where: { value($0) != nil }), + let coordinate = value(source) else { return 0 } + if units == .userSpaceOnUse && source.percentageAttributes.contains(key) { + return coordinate * length + } + return coordinate } - guard stops.count > 1 else { - return nil + + let frame = LayerTree.Rect(x: geometry("x", \.x, viewport: viewport.width), + y: geometry("y", \.y, viewport: viewport.height), + width: geometry("width", \.width, viewport: viewport.width), + height: geometry("height", \.height, viewport: viewport.height)) + let contentUnits: LayerTree.PatternUnits = inherited(\.patternContentUnits) == .objectBoundingBox ? .objectBoundingBox : .userSpaceOnUse + let pattern = LayerTree.Pattern(frame: frame, contentUnits: contentUnits, units: units) + if let viewBox = inherited(\.viewBox) { + pattern.viewBox = LayerTree.Rect(x: viewBox.x, y: viewBox.y, width: viewBox.width, height: viewBox.height) } + pattern.preserveAspectRatio = inherited(\.preserveAspectRatio) ?? .default + pattern.transform = Self.createTransforms(from: inherited(\.patternTransform) ?? []).toMatrix() + let children = chain.first(where: { !$0.childElements.isEmpty })?.childElements ?? [] + pattern.contents = children.compactMap { .layer(makeLayer(from: $0, inheriting: .init())) } + return pattern + } - var gradient = LayerTree.LinearGradient( - gradient: .init(stops: stops), - start: Point(x1, y1), - end: Point(x2, y2) - ) + /// The pattern followed by the patterns it references through href; a cycle, a reference + /// that is not a pattern, or a chain deeper than `ReferenceGuard.maxDepth` ends the chain. + func makePatternChain(for element: DOM.Pattern) -> [DOM.Pattern] { + var chain = [element] + var visited: Set = [element.id] + var entered = [String]() + defer { entered.forEach(references.leave) } + var current = element + while let id = current.href?.fragmentID, + !visited.contains(id), + let next = svg.defs.patterns.first(where: { $0.id == id }), + references.enter("patternHref:\(id)") { + entered.append("patternHref:\(id)") + visited.insert(id) + chain.append(next) + current = next + } + return chain + } - gradient.units = Self.createUnits(from: element.gradientUnits) - gradient.transform = Self.createTransforms(from: element.gradientTransform) - return gradient + /// The gradient followed by the gradients it references through href, of either kind; a cycle, + /// a reference that is not a gradient, or more than `maxGradientHops` hops ends it, keeping what + /// was collected. Gradient hrefs only point at other gradients, so they never re-enter ``, + /// mask or pattern expansion and do not spend the document-wide `ReferenceGuard` budget. + func makeGradientChain(for element: GradientElement) -> [GradientElement] { + var chain = [element] + var visited: Set = [element.id] + var current = element + while chain.count <= Self.maxGradientHops, + let id = current.href?.fragmentID, + !visited.contains(id), + let next = makeGradientElement(id: id) { + visited.insert(id) + chain.append(next) + current = next + } + return chain } - func makeGradient(for element: DOM.RadialGradient) -> LayerTree.RadialGradient? { - var stops = [LayerTree.Gradient.Stop]() - if let id = element.href?.fragmentID, - let reference = svg.defs.radialGradients.first(where: { $0.id == id }) { - stops = makeGradientStops(for: reference) - } else { - stops = makeGradientStops(for: element) + func makeGradientPaint(for element: GradientElement) -> GradientPaint { + // SVG 1.1 §13.2.2, §13.2.3: attributes not set on this element, and its stops when it has + // none, are inherited along the xlink:href chain. Geometry only comes from elements of the + // same kind; units, transform, spreadMethod and stops from either kind. + let chain = makeGradientChain(for: element) + func inherited(_ value: (GradientElement) -> T?) -> T? { + chain.lazy.compactMap(value).first } + + // SVG 1.1 §13.2.4: no stops paints none, a single stop paints its colour. + let stops = makeGradientStops(chain.first(where: { !$0.stops.isEmpty })?.stops ?? []) + guard let last = stops.last else { + return .color(.none) + } + let lastColor = last.color.withMultiplyingAlpha(last.opacity) guard stops.count > 1 else { - return nil + return .color(lastColor) } - let cx = element.cx ?? 0.5 - let cy = element.cy ?? 0.5 - var gradient = LayerTree.RadialGradient( - gradient: .init(stops: stops), - center: LayerTree.Point(element.fx ?? cx, element.fy ?? cy), - radius: LayerTree.Float(element.fr ?? 0), - endCenter: LayerTree.Point(cx, cy), - endRadius: LayerTree.Float(element.r ?? 0.5) - ) - gradient.units = Self.createUnits(from: element.gradientUnits) - gradient.transform = Self.createTransforms(from: element.gradientTransform) - return gradient - } + let units = Self.createUnits(from: inherited(\.gradientUnits)) + let transform = Self.createTransforms(from: inherited(\.gradientTransform) ?? []) + let spread = Self.createSpread(from: inherited(\.spreadMethod)) - func makeGradientStops(for element: DOM.LinearGradient) -> [LayerTree.Gradient.Stop] { - return element.stops.map { - LayerTree.Gradient.Stop(offset: $0.offset, - color: LayerTree.Color.create(from: $0.color, current: .none), - opacity: $0.opacity) + switch element { + case .linear: + let linear = chain.compactMap(\.linear) + func coordinate(_ value: (DOM.LinearGradient) -> DOM.Coordinate?, _ initial: DOM.Coordinate) -> LayerTree.Float { + linear.lazy.compactMap(value).first ?? initial + } + let start = Point(coordinate(\.x1, 0), coordinate(\.y1, 0)) + let end = Point(coordinate(\.x2, 1), coordinate(\.y2, 0)) + // SVG 1.1 §13.2.2: a zero-length vector paints the last stop + guard start != end else { + return .color(lastColor) + } + var gradient = LayerTree.LinearGradient(gradient: .init(stops: stops), start: start, end: end) + gradient.units = units + gradient.transform = transform + gradient.spread = spread + return .linear(gradient) + + case .radial: + let radial = chain.compactMap(\.radial) + func coordinate(_ value: (DOM.RadialGradient) -> DOM.Coordinate?) -> LayerTree.Float? { + radial.lazy.compactMap(value).first + } + let cx = coordinate(\.cx) ?? 0.5 + let cy = coordinate(\.cy) ?? 0.5 + let r = coordinate(\.r) ?? 0.5 + // SVG 1.1 §13.2.3: a zero radius paints the last stop + guard r > 0 else { + return .color(lastColor) + } + // fx and fy default to the (possibly inherited) centre + var gradient = LayerTree.RadialGradient( + gradient: .init(stops: stops), + center: Point(coordinate(\.fx) ?? cx, coordinate(\.fy) ?? cy), + radius: max(0, coordinate(\.fr) ?? 0), + endCenter: Point(cx, cy), + endRadius: r + ) + gradient.units = units + gradient.transform = transform + gradient.spread = spread + return .radial(gradient) } } - func makeGradientStops(for element: DOM.RadialGradient) -> [LayerTree.Gradient.Stop] { - return element.stops.map { - LayerTree.Gradient.Stop(offset: $0.offset, - color: LayerTree.Color.create(from: $0.color, current: .none), - opacity: $0.opacity) + /// SVG 1.1 §13.2.4: offsets are clamped to 0...1 and never decrease. + func makeGradientStops(_ stops: [(offset: DOM.Float, color: DOM.Color, opacity: DOM.Float)]) -> [LayerTree.Gradient.Stop] { + var previous: LayerTree.Float = 0 + return stops.map { + let offset = max(previous, min(1, $0.offset.isFinite ? $0.offset : 0)) + previous = offset + return LayerTree.Gradient.Stop(offset: offset, + color: LayerTree.Color.create(from: $0.color, current: .none), + opacity: $0.opacity) } } @@ -443,6 +816,7 @@ extension LayerTree.Builder { struct State { var opacity: DOM.Float var display: DOM.DisplayMode + var visibility: DOM.Visibility var color: DOM.Color var stroke: DOM.Fill @@ -451,14 +825,13 @@ extension LayerTree.Builder { var strokeLineCap: DOM.LineCap var strokeLineJoin: DOM.LineJoin var strokeLineMiterLimit: DOM.Float - var strokeDashArray: [DOM.Float] + var strokeDashArray: [DOM.DashLength] + var strokeDashOffset: DOM.DashLength var fill: DOM.Fill var fillOpacity: DOM.Float var fillRule: DOM.FillRule - var filter: DOM.URL? - var fontFamily: [DOM.FontFamily] var fontSize: DOM.Float var textAnchor: DOM.TextAnchor @@ -468,6 +841,7 @@ extension LayerTree.Builder { //default root SVG element state opacity = 1.0 display = .inline + visibility = .visible color = .keyword(.black) stroke = .color(.none) @@ -477,6 +851,7 @@ extension LayerTree.Builder { strokeLineJoin = .miter strokeLineMiterLimit = 4.0 strokeDashArray = [] + strokeDashOffset = .absolute(0) fill = .color(.keyword(.black)) fillOpacity = 1.0 @@ -499,6 +874,7 @@ extension LayerTree.Builder { state.opacity = attributes.opacity ?? 1.0 state.display = attributes.display ?? existing.display + state.visibility = attributes.visibility ?? existing.visibility state.color = attributes.color ?? existing.color state.stroke = attributes.stroke ?? existing.stroke @@ -507,13 +883,12 @@ extension LayerTree.Builder { state.strokeLineCap = attributes.strokeLineCap ?? existing.strokeLineCap state.strokeLineJoin = attributes.strokeLineJoin ?? existing.strokeLineJoin state.strokeDashArray = attributes.strokeDashArray ?? existing.strokeDashArray + state.strokeDashOffset = attributes.strokeDashOffset ?? existing.strokeDashOffset state.fill = attributes.fill ?? existing.fill state.fillOpacity = attributes.fillOpacity ?? existing.fillOpacity state.fillRule = attributes.fillRule ?? existing.fillRule - state.filter = attributes.filter ?? existing.filter - state.fontFamily = attributes.fontFamily ?? existing.fontFamily state.fontSize = attributes.fontSize ?? existing.fontSize state.textAnchor = attributes.textAnchor ?? existing.textAnchor @@ -573,6 +948,17 @@ extension LayerTree.Builder { } } + static func createSpread(from spread: DOM.LinearGradient.SpreadMethod?) -> LayerTree.Gradient.Spread { + switch spread { + case .reflect: + return .reflect + case .repeat: + return .repeat + case .pad, nil: + return .pad + } + } + static func createTransforms(from transforms: [DOM.Transform]) -> [LayerTree.Transform] { return transforms.flatMap{ createTransform(for: $0) } } @@ -588,6 +974,8 @@ private extension DOM.Fill { return c case .url: return .none + case .urlWithFallback(_, let c): + return c } } } @@ -604,3 +992,56 @@ private extension DOM.SVG { return sources } } + + +extension LayerTree.Builder { + + /// Bounds the work done resolving ``, mask and pattern references: a reference already + /// being resolved is refused, as is nesting deeper than `maxDepth` (each level costs native stack, + /// and Backdrop renders on secondary threads) or more than `maxReferences` expansions in one + /// document (non-cyclic fan-out such as 2 uses per level is exponential). + /// + /// Any new walk over a reference chain that re-enters ``, mask or pattern expansion must call this. + /// Gradient `href` chains do not: they are bounded by `maxGradientHops`. + final class ReferenceGuard { + static let maxDepth = 16 + static let maxReferences = 20_000 + private var active = Set() + private var depth = 0 + private var total = 0 + + func enter(_ key: String) -> Bool { + guard depth < Self.maxDepth, + total < Self.maxReferences, + active.insert(key).inserted else { return false } + depth += 1 + total += 1 + return true + } + + func leave(_ key: String) { + active.remove(key) + depth -= 1 + } + } + + /// True when following the `` elements inside `root` (transitively) reaches an id in `forbidden`. + func containsReferenceCycle(from root: DOM.GraphicsElement, reaching forbidden: Set) -> Bool { + var visited = Set() + var pending = [root] + while let element = pending.popLast() { + if let use = element as? DOM.Use, let id = use.href.fragmentID { + if forbidden.contains(id) { + return true + } + if visited.insert(id).inserted, let target = svg.firstGraphicsElement(with: id) { + pending.append(target) + } + } + if let container = element as? any ContainerElement { + pending.append(contentsOf: container.childElements) + } + } + return false + } +} diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift b/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift index 79b33154..ac55536d 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift @@ -45,6 +45,7 @@ extension LayerTree { private var hasLoggedFilterWarning = false private var hasLoggedGradientWarning = false private var hasLoggedMaskWarning = false + private var hasLoggedSpreadWarning = false private var paths: [LayerTree.Shape: P.Types.Path] = [:] private var images: [LayerTree.Image: P.Types.Image] = [:] @@ -69,12 +70,19 @@ extension LayerTree { let state = makeCommandState(for: layer, colorConverter: colorConverter) //guard state.hasContents else { continue } - stack.append(.endLayer(layer, state)) - - if state.hasFilters { + if let filterLayer = state.filterLayer, filterLayer.region.isEmpty { + // an empty filter region clips everything away (matches Chrome / Safari) + continue + } + if state.hasFilters && state.filterLayer == nil && layer.hasUnsupportedFilters { + if options.contains(.hideUnsupportedFilters) { + continue + } logUnsupportedFilters(layer.filters) } + stack.append(.endLayer(layer, state)) + if state.hasOpacity || state.hasTransform || state.hasClip || state.hasMask { commands.append(.pushState) } @@ -87,6 +95,11 @@ extension LayerTree { commands.append(.pushTransparencyLayer) } + // filter is applied before clipping, masking and opacity + if let filterLayer = state.filterLayer { + commands.append(.pushFilterLayer(filterLayer)) + } + //push render of all of the layer contents in reverse order for contents in layer.contents.reversed() { switch makeRenderContents(for: contents, colorConverter: colorConverter) { @@ -101,6 +114,10 @@ extension LayerTree { commands.append(contentsOf: cmd) case let .endLayer(layer, state): + if state.filterLayer != nil { + commands.append(.popFilterLayer) + } + //render apply mask if state.hasMask { commands.append(contentsOf: renderCommands(forMask: layer.mask)) @@ -134,6 +151,7 @@ extension LayerTree { var hasMask: Bool var hasFilters: Bool var colorConverter: any ColorConverter + var filterLayer: LayerTree.FilterLayer? } func makeCommandState(for layer: Layer, colorConverter: any ColorConverter) -> CommandState { @@ -156,7 +174,8 @@ extension LayerTree { hasContents: hasContents, hasMask: hasMask, hasFilters: hasFilters, - colorConverter: colorConverter + colorConverter: colorConverter, + filterLayer: hasFilters ? makeFilterLayer(for: layer, colorConverter: colorConverter) : nil ) } @@ -207,27 +226,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 resolvedPattern.contents { - patternCommands.append(contentsOf: renderCommands(for: contents, colorConverter: colorConverter)) - } + if let (resolvedPattern, contentTransform) = Self.resolvePattern(fillPattern, in: provider.getBounds(from: shape)) { + var patternCommands = [RendererCommand]() + if contentTransform != .identity { + patternCommands.append(.pushState) + patternCommands.append(.concatenate(transform: provider.createTransform(from: contentTransform))) + } + for contents in resolvedPattern.contents { + patternCommands.append(contentsOf: renderCommands(for: contents, colorConverter: colorConverter)) + } + if contentTransform != .identity { + patternCommands.append(.popState) + } - 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)) + 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)) + } case .linearGradient(let gradient): if canRenderGradient(gradient.gradient) { commands.append(.pushState) @@ -237,6 +253,7 @@ extension LayerTree { let pathBounds = provider.getBounds(from: shape) commands.append(contentsOf: renderCommands(forLinear: gradient, endpoints: pathBounds.endpoints, + covering: pathBounds, opacity: fill.opacity, colorConverter: colorConverter)) commands.append(.popState) @@ -249,6 +266,7 @@ extension LayerTree { let pathBounds = provider.getBounds(from: shape) commands.append(contentsOf: renderCommands(forRadial: gradient, in: pathBounds, + covering: pathBounds, opacity: fill.opacity, colorConverter: colorConverter)) commands.append(.popState) @@ -264,12 +282,22 @@ extension LayerTree { let join = provider.createLineJoin(from: stroke.join) let limit = provider.createFloat(from: stroke.miterLimit) + let dash = renderCommands(forDash: stroke) + + if !dash.isEmpty { + commands.append(.pushState) + } commands.append(.setLineCap(cap)) commands.append(.setLineJoin(join)) commands.append(.setLine(width: width)) commands.append(.setLineMiter(limit: limit)) + commands.append(contentsOf: dash) commands.append(.setStroke(color: color)) commands.append(.stroke(path)) + if !dash.isEmpty { + commands.append(contentsOf: renderCommands(forDashResetOf: stroke)) + commands.append(.popState) + } case .linearGradient(let gradient): if let endpoints = shape.gradientEndpoints, canRenderGradient(gradient.gradient) { let width = provider.createFloat(from: stroke.width) @@ -282,12 +310,15 @@ extension LayerTree { commands.append(.setLineJoin(join)) commands.append(.setLine(width: width)) commands.append(.setLineMiter(limit: limit)) + commands.append(contentsOf: renderCommands(forDash: stroke)) commands.append(.clipStrokeOutline(path)) commands.append(contentsOf: renderCommands(forLinear: gradient, endpoints: endpoints, + covering: shape.bounds?.outset(by: stroke.coverage), opacity: fill.opacity, colorConverter: colorConverter)) + commands.append(contentsOf: renderCommands(forDashResetOf: stroke)) commands.append(.popState) } case .radialGradient(let gradient): @@ -302,12 +333,15 @@ extension LayerTree { commands.append(.setLineJoin(join)) commands.append(.setLine(width: width)) commands.append(.setLineMiter(limit: limit)) + commands.append(contentsOf: renderCommands(forDash: stroke)) commands.append(.clipStrokeOutline(path)) commands.append(contentsOf: renderCommands(forRadial: gradient, in: pathBounds, + covering: pathBounds.outset(by: stroke.coverage), opacity: fill.opacity, colorConverter: colorConverter)) + commands.append(contentsOf: renderCommands(forDashResetOf: stroke)) commands.append(.popState) } default: @@ -317,14 +351,53 @@ extension LayerTree { return commands } + func renderCommands(forDash stroke: StrokeAttributes) -> [RendererCommand] { + guard !stroke.dashArray.isEmpty else { return [] } + return [.setLineDash(phase: provider.createFloat(from: stroke.dashOffset), + lengths: stroke.dashArray.map(provider.createFloat))] + } + + /// The optimizer may strip a lone push/pop pair (CGText), so a dash must be reset explicitly. + func renderCommands(forDashResetOf stroke: StrokeAttributes) -> [RendererCommand] { + guard !stroke.dashArray.isEmpty else { return [] } + return [.setLineDash(phase: provider.createFloat(from: 0), lengths: [])] + } + func renderCommands(for image: Image) -> [RendererCommand] { guard let renderImage = makeCachedImage(from: image) else { return [] } let size = provider.createSize(from: renderImage) guard size.width > 0 && size.height > 0 else { return [] } + let (dest, clip) = makeImagePlacement(for: image, bitmapSize: size) + let draw = RendererCommand.draw(image: renderImage, in: provider.createRect(from: dest)) + guard let clip else { return [draw] } + return [.pushState, + .setClip(path: makeCachedPath(from: .rect(within: clip, radii: .zero)), rule: provider.createFillRule(from: .nonzero)), + draw, + .popState] + } + + /// Where the bitmap is drawn and, for `slice` overflowing its frame, the rect that clips it. + /// With both width and height the bitmap is fitted to the frame per preserveAspectRatio (SVG 1.1 §7.8). + func makeImagePlacement(for image: Image, bitmapSize size: LayerTree.Size) -> (dest: LayerTree.Rect, clip: LayerTree.Rect?) { let frame = makeImageFrame(for: image, bitmapSize: size) - let rect = provider.createRect(from: frame) - return [.draw(image: renderImage, in: rect)] + guard image.width != nil, image.height != nil, frame.width > 0, frame.height > 0 else { + return (frame, nil) + } + let fit = image.preserveAspectRatio.fit( + contentWidth: size.width, contentHeight: size.height, + viewportWidth: frame.width, viewportHeight: frame.height + ) + let dest = LayerTree.Rect( + x: frame.x + fit.tx, + y: frame.y + fit.ty, + width: size.width * fit.sx, + height: size.height * fit.sy + ) + // a meet or equal-aspect fit only differs from the frame by rounding noise + let epsilon = 1e-4 * max(frame.width, frame.height) + let overflows = image.preserveAspectRatio.align != .none && (dest.width > frame.width + epsilon || dest.height > frame.height + epsilon) + return (dest, overflows ? frame : nil) } private func makeCachedPath(from shape: LayerTree.Shape) -> P.Types.Path { @@ -471,10 +544,11 @@ extension LayerTree { func renderCommands(forLinear gradient: LayerTree.LinearGradient, endpoints: (start: LayerTree.Point, end: LayerTree.Point), + covering area: LayerTree.Rect?, opacity: LayerTree.Float, colorConverter: any ColorConverter) -> [RendererCommand] { - let pathStart: LayerTree.Point - let pathEnd: LayerTree.Point + var pathStart: LayerTree.Point + var pathEnd: LayerTree.Point switch gradient.units { case .objectBoundingBox: let width = endpoints.end.x - endpoints.start.x @@ -493,7 +567,21 @@ extension LayerTree { commands.append(contentsOf: renderCommands(forTransforms: gradient.transform)) } - let converted = gradient.gradient.convertColor(using: colorConverter) + var stops = gradient.gradient + if gradient.spread != .pad, let area, + let periods = Self.spreadPeriods(start: pathStart, end: pathEnd, transform: gradient.transform, covering: area) { + if periods.count <= Self.maxSpreadPeriods(stopCount: stops.stops.count) { + let vector = LayerTree.Point(pathEnd.x - pathStart.x, pathEnd.y - pathStart.y) + stops = stops.spread(gradient.spread, periods: periods) + pathEnd = pathStart.offset(vector, times: LayerTree.Float(periods.upperBound + 1)) + pathStart = pathStart.offset(vector, times: LayerTree.Float(periods.lowerBound)) + } else { + logUnsupportedSpread() + stops = stops.averaged() + } + } + + let converted = stops.convertColor(using: colorConverter) let gradient = provider.createGradient(from: converted) let start = provider.createPoint(from: pathStart) let end = provider.createPoint(from: pathEnd) @@ -505,12 +593,13 @@ extension LayerTree { func renderCommands(forRadial gradient: RadialGradient, in bounds: LayerTree.Rect, + covering area: LayerTree.Rect?, opacity: LayerTree.Float, colorConverter: any ColorConverter) -> [RendererCommand] { let startCenter: LayerTree.Point let startRadius: LayerTree.Float - let endCenter: LayerTree.Point - let endRadius: LayerTree.Float + var endCenter: LayerTree.Point + var endRadius: LayerTree.Float switch gradient.units { case .objectBoundingBox: @@ -537,7 +626,24 @@ extension LayerTree { commands.append(contentsOf: renderCommands(forTransforms: gradient.transform)) } - let converted = gradient.gradient.convertColor(using: colorConverter) + var stops = gradient.gradient + if gradient.spread != .pad, let area, + let periods = Self.spreadPeriods(startCenter: startCenter, startRadius: startRadius, + endCenter: endCenter, endRadius: endRadius, + transform: gradient.transform, covering: area) { + if periods.count <= Self.maxSpreadPeriods(stopCount: stops.stops.count) { + let vector = LayerTree.Point(endCenter.x - startCenter.x, endCenter.y - startCenter.y) + let count = LayerTree.Float(periods.upperBound + 1) + stops = stops.spread(gradient.spread, periods: periods) + endCenter = startCenter.offset(vector, times: count) + endRadius = startRadius + (endRadius - startRadius) * count + } else { + logUnsupportedSpread() + stops = stops.averaged() + } + } + + let converted = stops.convertColor(using: colorConverter) let gradient = provider.createGradient(from: converted) let apha = provider.createFloat(from: opacity) commands.append(.setAlpha(apha)) @@ -555,9 +661,253 @@ extension LayerTree { extension LayerTree.CommandGenerator { - func logUnsupportedFilters(_ filters: [LayerTree.Filter]) { + /// Most stops a `reflect` or `repeat` gradient expands to. + static var maxSpreadStops: Int { 4096 } + + /// Most periods drawn for a gradient of `stopCount` stops (each period may gain two stops when + /// completed to 0...1); a gradient needing more paints its average colour instead. + static func maxSpreadPeriods(stopCount: Int) -> Int { + max(1, maxSpreadStops / (stopCount + 2)) + } + + /// The periods of a linear gradient (0 being start...end) needed to cover `area`, which is in + /// the space the gradient's transform is applied to. nil when they cannot be computed. + static func spreadPeriods(start: LayerTree.Point, end: LayerTree.Point, + transform: [LayerTree.Transform], + covering area: LayerTree.Rect) -> ClosedRange? { + let dx = end.x - start.x + let dy = end.y - start.y + let length = dx * dx + dy * dy + guard length > 0, let inverse = transform.toMatrix().inverted() else { return nil } + let offsets = area.corners.map { + let p = inverse.transform(point: $0) + return ((p.x - start.x) * dx + (p.y - start.y) * dy) / length + } + guard let lower = offsets.min(), let upper = offsets.max() else { return nil } + return makePeriods(lower: lower, upper: upper, includingZero: false) + } + + /// The periods of a radial gradient needed to cover `area`. Only computed when the focal circle + /// lies inside the end circle; otherwise (and inside the focal circle) the gradient pads. + static func spreadPeriods(startCenter: LayerTree.Point, startRadius: LayerTree.Float, + endCenter: LayerTree.Point, endRadius: LayerTree.Float, + transform: [LayerTree.Transform], + covering area: LayerTree.Rect) -> ClosedRange? { + // the circle at t has centre c0 + t·Δc and radius r0 + t·Δr; a point p = c0 + q is on it when + // (Δc·Δc − Δr²)t² − 2(q·Δc + r0Δr)t + (q·q − r0²) = 0. With the focal circle inside + // (Δr > |Δc|) the circles nest, so p is covered from the larger root on. + let dx = endCenter.x - startCenter.x + let dy = endCenter.y - startCenter.y + let dr = endRadius - startRadius + let a = dx * dx + dy * dy - dr * dr + guard a < 0, dr > 0, let inverse = transform.toMatrix().inverted() else { return nil } + let offsets = area.corners.map { corner -> LayerTree.Float in + let p = inverse.transform(point: corner) + let qx = p.x - startCenter.x + let qy = p.y - startCenter.y + let b = -2 * (qx * dx + qy * dy + startRadius * dr) + let c = qx * qx + qy * qy - startRadius * startRadius + let discriminant = max(0, b * b - 4 * a * c) + return (-b - discriminant.squareRoot()) / (2 * a) + } + guard let upper = offsets.max() else { return nil } + return makePeriods(lower: 0, upper: upper) + } + + /// The whole periods spanning lower...upper; a radial gradient always includes period 0. + static func makePeriods(lower: LayerTree.Float, upper: LayerTree.Float, includingZero: Bool = true) -> ClosedRange? { + guard lower.isFinite, upper.isFinite else { return nil } + // bounded so the conversion to Int cannot trap; anything this large is averaged anyway + let limit: LayerTree.Float = 1_000_000 + let floor = lower.rounded(.down) + let ceil = upper.rounded(.up) + let first = Int(max(-limit, includingZero ? min(0, floor) : floor)) + let last = Int(min(limit, includingZero ? max(1, ceil) : ceil)) - 1 + return first...max(first, last) + } + + // Resolves the layer's filter into its user space; nil when a primitive is unsupported + // or the filter region cannot be resolved (e.g. text-only contents under objectBoundingBox), + // in which case the contents are drawn unfiltered. + func makeFilterLayer(for layer: LayerTree.Layer, + colorConverter: any ColorConverter = DefaultColorConverter()) -> LayerTree.FilterLayer? { + guard !layer.filters.isEmpty, + !layer.hasUnsupportedFilters else { return nil } + + let region = layer.filterRegion + let bounds = makeBounds(for: layer) + + // Filter Effects 1 §5.1, SVG 1.1 §15.7.2: filter region + let rect: LayerTree.Rect + switch region.units { + case .objectBoundingBox: + guard let bounds else { return nil } + rect = LayerTree.Rect( + x: bounds.x + (region.x ?? -0.1) * bounds.width, + y: bounds.y + (region.y ?? -0.1) * bounds.height, + width: (region.width ?? 1.2) * bounds.width, + height: (region.height ?? 1.2) * bounds.height + ) + case .userSpaceOnUse: + rect = LayerTree.Rect( + x: region.x ?? -0.1 * size.width, + y: region.y ?? -0.1 * size.height, + width: region.width ?? 1.2 * size.width, + height: region.height ?? 1.2 * size.height + ) + } + + // primitive values map to user space as units.origin + value × units.size + var units = LayerTree.Rect(x: 0, y: 0, width: 1, height: 1) + if region.primitiveUnits == .objectBoundingBox { + guard let bounds else { return nil } + units = bounds + } + + guard rect.x.isFinite, rect.y.isFinite, rect.width.isFinite, rect.height.isFinite else { return nil } + + let width = max(rect.width, 0) + let height = max(rect.height, 0) + let filterRegion = LayerTree.Rect(x: rect.x, y: rect.y, width: width, height: height) + + // the primary tree only, inputs renumbered to positions within it + let inputs = LayerTree.FilterLayer.makeInputs(for: layer.filters) + var positions = [Int: Int]() + var primitives = [LayerTree.FilterLayer.Primitive]() + let tree = LayerTree.FilterLayer.primaryTree(of: inputs) + guard tree.count <= LayerTree.FilterLayer.maxPrimitives else { return nil } + for index in tree { + let primitive = layer.filters[index] + guard let effect = makeFilterEffect(primitive.effect, scale: units.size, colorConverter: colorConverter) else { + return nil + } + let sources = inputs[index].map { input -> LayerTree.FilterLayer.Input in + guard case .primitive(let source) = input else { return input } + return positions[source].map { .primitive($0) } ?? .transparent + } + let subregion = makeSubregion(for: primitive, inputs: sources, resolved: primitives, + region: filterRegion, units: units) + positions[index] = primitives.count + primitives.append(LayerTree.FilterLayer.Primitive( + effect: effect, + inputs: sources, + subregion: subregion, + colorInterpolation: primitive.colorInterpolation == .linearRGB ? .linearRGB : .sRGB + )) + } + + return LayerTree.FilterLayer(region: filterRegion, primitives: primitives) + } + + // Filter Effects 1 §9.4: x, y, width and height default to the union of the subregions of the inputs, or to + // the filter region when there is no input or one is a standard input. The subregion never exceeds the + // filter region (SVG 1.1 §15.7.3); zero or negative sizes disable the primitive (empty rect). + func makeSubregion(for primitive: LayerTree.FilterPrimitive, + inputs: [LayerTree.FilterLayer.Input], + resolved: [LayerTree.FilterLayer.Primitive], + region: LayerTree.Rect, + units: LayerTree.Rect) -> LayerTree.Rect { + var rect: LayerTree.Rect? = inputs.isEmpty ? region : nil + for input in inputs { + guard case .primitive(let source) = input else { + rect = region + break + } + let other = resolved[source].subregion + guard !other.isEmpty else { continue } + rect = rect.map { $0.union(other) } ?? other + } + guard let rect else { return .zero } + // the default is already within the region: keep it exact + guard primitive.x != nil || primitive.y != nil || primitive.width != nil || primitive.height != nil else { + return rect + } + + var x = primitive.x.map { units.x + LayerTree.Float($0) * units.width } ?? rect.x + var y = primitive.y.map { units.y + LayerTree.Float($0) * units.height } ?? rect.y + var width = primitive.width.map { LayerTree.Float($0) * units.width } ?? rect.width + var height = primitive.height.map { LayerTree.Float($0) * units.height } ?? rect.height + if !x.isFinite { x = rect.x } + if !y.isFinite { y = rect.y } + if !width.isFinite { width = rect.width } + if !height.isFinite { height = rect.height } + guard width > 0, height > 0 else { return .zero } + + let minX = max(x, region.minX) + let minY = max(y, region.minY) + let maxX = min(x + width, region.maxX) + let maxY = min(y + height, region.maxY) + guard maxX > minX, maxY > minY else { return .zero } + return LayerTree.Rect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } + + // The effect in user units; nil when unsupported. + func makeFilterEffect(_ effect: LayerTree.Filter, + scale: LayerTree.Size, + colorConverter: any ColorConverter) -> LayerTree.FilterLayer.Effect? { + // renderers clamp to their pixel limits; keep the values finite + let maximum = LayerTree.Float.greatestFiniteMagnitude + switch effect { + case let .gaussianBlur(stdDeviation: x, stdDeviationY: y): + // Filter Effects 1 §9.14: a negative value disables the primitive, zero disables one direction. + let y = y ?? x + guard x >= 0, y >= 0 else { + return .gaussianBlur(stdDeviation: 0, stdDeviationY: 0) + } + return .gaussianBlur(stdDeviation: min(x * scale.width, maximum), + stdDeviationY: min(y * scale.height, maximum)) + case .unsupported: + return nil + case let .offset(dx: dx, dy: dy): + return .offset(dx: max(-maximum, min(dx * scale.width, maximum)), + dy: max(-maximum, min(dy * scale.height, maximum))) + case let .flood(color: color, opacity: opacity): + // Filter Effects 1 §9.13: flood-opacity multiplies the alpha of flood-color + let flood = LayerTree.Color.create(from: color, current: .none).withAlpha(opacity) + return .flood(colorConverter.createColor(from: flood)) + case .composite(let op): + return .composite(op) + case .merge: + return .merge + case .blend(let mode): + return .blend(mode) + case .colorMatrix(let matrix): + return .colorMatrix(matrix.values) + } + } + + // Geometry bounding box of the layer contents in the layer's user space; stroke excluded. + // nil when the contents include text, which is not measured: the filter is then dropped + // rather than clipping the text away. + func makeBounds(for layer: LayerTree.Layer) -> LayerTree.Rect? { + guard !layer.containsText else { return nil } + var points = [LayerTree.Point]() + for contents in layer.contents { + switch contents { + case .shape(let shape, _, _): + if let rect = shape.bounds { + points.append(contentsOf: rect.corners) + } + case .image(let image): + if let width = image.width, let height = image.height { + points.append(contentsOf: LayerTree.Rect(x: image.origin.x, y: image.origin.y, width: width, height: height).corners) + } + case .text: + break + case .layer(let child): + if let rect = makeBounds(for: child) { + let matrix = child.transform.toMatrix() + points.append(contentsOf: rect.corners.map { matrix.transform(point: $0) }) + } + } + } + guard !points.isEmpty else { return nil } + return .makeBounds(between: points) + } + + func logUnsupportedFilters(_ filters: [LayerTree.FilterPrimitive]) { guard !hasLoggedFilterWarning else { return } - let name = filters.map(\.name).joined(separator: ", ") + let name = filters.map(\.effect.name).joined(separator: ", ") let hint: String if options.contains(.commandLine) { @@ -580,6 +930,12 @@ extension LayerTree.CommandGenerator { hasLoggedGradientWarning = true } + func logUnsupportedSpread() { + guard !hasLoggedSpreadWarning else { return } + print("Warning:", "spreadMethod needs more than \(Self.maxSpreadStops) gradient stops; painting the average colour", to: &.standardError) + hasLoggedSpreadWarning = true + } + func logUnsupportedMask() { guard !hasLoggedMaskWarning else { return } print("Warning:", "PDF does not support transparency masks", to: &.standardError) @@ -589,6 +945,10 @@ extension LayerTree.CommandGenerator { private extension LayerTree.Rect { + func outset(by amount: LayerTree.Float) -> LayerTree.Rect { + LayerTree.Rect(x: x - amount, y: y - amount, width: width + amount * 2, height: height + amount * 2) + } + func getPoint(offset: LayerTree.Point) -> LayerTree.Point { return LayerTree.Point(origin.x + size.width * offset.x, origin.y + size.height * offset.y) @@ -601,6 +961,76 @@ private extension LayerTree.Rect { } +extension LayerTree.Gradient { + + /// The stops of the given periods of this gradient laid end to end over 0...1, every odd period + /// mirrored for `reflect` (SVG 1.1 §13.2.2 spreadMethod). + func spread(_ spread: Spread, periods: ClosedRange) -> LayerTree.Gradient { + guard spread != .pad, !stops.isEmpty else { return self } + let period = completedPeriod + let mirrored = period.reversed().map { Stop(offset: 1 - $0.offset, color: $0.color, opacity: $0.opacity) } + let count = LayerTree.Float(periods.count) + var result = [Stop]() + for (index, k) in periods.enumerated() { + let source = spread == .reflect && k % 2 != 0 ? mirrored : period + for stop in source { + result.append(Stop(offset: (LayerTree.Float(index) + stop.offset) / count, + color: stop.color, opacity: stop.opacity)) + } + } + return LayerTree.Gradient(stops: result) + } + + /// The stops completed so the first and last colours fill 0 and 1. + var completedPeriod: [Stop] { + var period = stops + if let first = period.first, first.offset > 0 { + period.insert(Stop(offset: 0, color: first.color, opacity: first.opacity), at: 0) + } + if let last = period.last, last.offset < 1 { + period.append(Stop(offset: 1, color: last.color, opacity: last.opacity)) + } + return period + } + + /// A flat gradient of the average colour of one period (premultiplied, the same for `reflect`), + /// for spreads too fine to draw. + func averaged() -> LayerTree.Gradient { + let period = completedPeriod + var sum: (r: LayerTree.Float, g: LayerTree.Float, b: LayerTree.Float, a: LayerTree.Float) = (0, 0, 0, 0) + // the gradient's own space (P3 when any stop is P3); the other stops are converted into it + let space = colorSpace + for (lhs, rhs) in zip(period, period.dropFirst()) { + let weight = (rhs.offset - lhs.offset) / 2 + for stop in [lhs, rhs] { + let c = stop.premultiplied(in: space) + sum = (sum.r + c.r * weight, sum.g + c.g * weight, sum.b + c.b * weight, sum.a + c.a * weight) + } + } + let color: LayerTree.Color = sum.a > 0 + ? .rgba(r: sum.r / sum.a, g: sum.g / sum.a, b: sum.b / sum.a, a: sum.a, space: space) + : .none + return LayerTree.Gradient(stops: [Stop(offset: 0, color: color, opacity: 1), + Stop(offset: 1, color: color, opacity: 1)]) + } +} + +private extension LayerTree.Gradient.Stop { + func premultiplied(in space: LayerTree.ColorSpace) -> (r: LayerTree.Float, g: LayerTree.Float, b: LayerTree.Float, a: LayerTree.Float) { + switch color { + case .none: + return (0, 0, 0, 0) + case let .rgba(r, g, b, a, source): + let alpha = a * opacity + let (r, g, b) = LayerTree.Color.convert(r: r, g: g, b: b, from: source, to: space) + return (r * alpha, g * alpha, b * alpha, alpha) + case let .gray(white, a): + let alpha = a * opacity + return (white * alpha, white * alpha, white * alpha, alpha) + } + } +} + private extension LayerTree.Gradient { func convertColor(using converter: any ColorConverter) -> LayerTree.Gradient { let stops: [LayerTree.Gradient.Stop] = stops.map { stop in @@ -612,6 +1042,33 @@ private extension LayerTree.Gradient { } } +private extension LayerTree.StrokeAttributes { + /// How far the stroke may reach beyond the path's bounds: half the width, √2 times that at + /// square caps, up to the miter limit at miter joins. + var coverage: LayerTree.Float { + let cap: LayerTree.Float = self.cap == .square ? LayerTree.Float(2).squareRoot() : 1 + let join: LayerTree.Float = self.join == .miter ? max(1, miterLimit) : 1 + return width / 2 * max(cap, join) + } +} + +private extension LayerTree.Point { + func offset(_ vector: LayerTree.Point, times factor: LayerTree.Float) -> LayerTree.Point { + LayerTree.Point(x + vector.x * factor, y + vector.y * factor) + } +} + +private extension LayerTree.Transform.Matrix { + func inverted() -> Self? { + let determinant = a * d - b * c + guard determinant != 0, determinant.isFinite else { return nil } + return Self(a: d / determinant, b: -b / determinant, + c: -c / determinant, d: a / determinant, + tx: (c * ty - d * tx) / determinant, + ty: (b * tx - a * ty) / determinant) + } +} + private extension LayerTree.Shape { var gradientEndpoints: (start: LayerTree.Point, end: LayerTree.Point)? { @@ -635,14 +1092,39 @@ private extension LayerTree.Shape { private extension LayerTree.Filter { var name: String { switch self { - case .gaussianBlur: + case .gaussianBlur(_, _): return "" + case .unsupported(let name): + return "<\(name)>" + case .offset: + return "" + case .flood: + return "" + case .composite: + return "" + case .merge: + return "" + case .blend: + return "" + case .colorMatrix: + return "" } } } private extension LayerTree.Rect { + var isEmpty: Bool { + width <= 0 || height <= 0 + } + + var corners: [LayerTree.Point] { + [origin, + LayerTree.Point(maxX, minY), + LayerTree.Point(maxX, maxY), + LayerTree.Point(minX, maxY)] + } + var gradientEndpoints: (start: LayerTree.Point, end: LayerTree.Point) { let start = LayerTree.Point(midX, minY) let end = LayerTree.Point(midX, maxY) @@ -664,3 +1146,91 @@ private extension LayerTree.Rect { ) } } + +extension LayerTree.CommandGenerator { + + /// Resolves a pattern against the bounding box of the element it fills (SVG 1.1 §13.3). + /// + /// Returns a pattern whose `frame` is the tile in pattern space (user units) and whose `transform` + /// is the patternTransform, plus the transform that maps the pattern contents into that tile + /// (tile origin, then viewBox or objectBoundingBox content units). Returns nil when the + /// pattern disables rendering: a zero or negative tile, an empty bounding box with + /// objectBoundingBox units, an empty viewBox, or a non-finite or non-invertible patternTransform. + /// `bounds` is only evaluated when objectBoundingBox units need it. + static func resolvePattern(_ pattern: LayerTree.Pattern, in boundingBox: @autoclosure () -> LayerTree.Rect) -> (LayerTree.Pattern, LayerTree.Transform.Matrix)? { + let t = pattern.transform + let determinant = t.a * t.d - t.b * t.c + guard [t.a, t.b, t.c, t.d, t.tx, t.ty].allSatisfy(\.isFinite), + determinant.isFinite, determinant != 0 else { return nil } + + let needsBounds = pattern.units == .objectBoundingBox || + (pattern.viewBox == nil && pattern.contentUnits == .objectBoundingBox) + let bounds = needsBounds ? boundingBox() : .zero + + var tile = pattern.frame + if pattern.units == .objectBoundingBox { + tile = LayerTree.Rect( + x: bounds.x + pattern.frame.x * bounds.width, + y: bounds.y + pattern.frame.y * bounds.height, + width: pattern.frame.width * bounds.width, + height: pattern.frame.height * bounds.height + ) + } + guard [tile.x, tile.y, tile.width, tile.height].allSatisfy(\.isFinite), + tile.width > 0, tile.height > 0 else { return nil } + + var contentTransform = LayerTree.Transform.Matrix.identity + if let viewBox = pattern.viewBox { + // the viewBox is fitted into the tile per the pattern's preserveAspectRatio (SVG 1.1 §7.8) + guard viewBox.width > 0, viewBox.height > 0 else { return nil } + let fit = pattern.preserveAspectRatio.fit( + contentWidth: viewBox.width, contentHeight: viewBox.height, + viewportWidth: tile.width, viewportHeight: tile.height + ) + // a tiny viewBox in a large tile can overflow the scale: no pattern, not an infinite matrix + guard [fit.sx, fit.sy, fit.tx, fit.ty].allSatisfy(\.isFinite) else { return nil } + contentTransform = LayerTree.Transform.Matrix( + a: fit.sx, b: 0, c: 0, d: fit.sy, + tx: fit.tx - viewBox.x * fit.sx, + ty: fit.ty - viewBox.y * fit.sy + ) + } else if pattern.contentUnits == .objectBoundingBox { + guard bounds.width > 0, bounds.height > 0 else { return nil } + contentTransform = LayerTree.Transform.Matrix(a: bounds.width, b: 0, c: 0, d: bounds.height, tx: 0, ty: 0) + } + contentTransform = contentTransform.concatenated( + LayerTree.Transform.translate(tx: tile.x, ty: tile.y).toMatrix() + ) + + let resolved = LayerTree.Pattern(frame: tile) + resolved.transform = pattern.transform + resolved.contents = pattern.contents + return (resolved, contentTransform) + } +} + + +extension LayerTree.Color { + + /// Display P3 shares sRGB's transfer curve: convert through linear light. Clamped to 0...1. + static func convert(r: LayerTree.Float, g: LayerTree.Float, b: LayerTree.Float, + from: LayerTree.ColorSpace, to: LayerTree.ColorSpace) -> (LayerTree.Float, LayerTree.Float, LayerTree.Float) { + guard from != to else { return (r, g, b) } + func linear(_ v: LayerTree.Float) -> LayerTree.Float { + v <= 0.04045 ? v / 12.92 : LayerTree.Float(pow(Double((v + 0.055) / 1.055), 2.4)) + } + func encode(_ v: LayerTree.Float) -> LayerTree.Float { + let c = min(1, max(0, v)) + return c <= 0.0031308 ? c * 12.92 : LayerTree.Float(1.055 * pow(Double(c), 1 / 2.4) - 0.055) + } + let (lr, lg, lb) = (linear(r), linear(g), linear(b)) + if from == .p3 { + return (encode(1.2249401 * lr - 0.2249404 * lg), + encode(-0.0420569 * lr + 1.0420571 * lg), + encode(-0.0196376 * lr - 0.0786361 * lg + 1.0982735 * lb)) + } + return (encode(0.8224621 * lr + 0.1775380 * lg), + encode(0.0331941 * lr + 0.9668058 * lg), + encode(0.0170827 * lr + 0.0723974 * lg + 0.9105199 * lb)) + } +} diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.CommandOptimizer.swift b/SwiftDraw/Sources/LayerTree/LayerTree.CommandOptimizer.swift index 51670c6d..af069c18 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.CommandOptimizer.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.CommandOptimizer.swift @@ -87,16 +87,33 @@ extension LayerTree { } else { return nil } + case .setLineDash(phase: let phase, lengths: let lengths): + if state.top.lineDashPhase != phase || state.top.lineDashLengths != lengths { + state.top.lineDashPhase = phase + state.top.lineDashLengths = lengths + } else { + return nil + } case .setBlend(mode: let mode): if state.top.blendMode != mode { state.top.blendMode = mode } else { return nil } + case .setFillPattern: + // a pattern replaces the fill colour: a later setFill of the same colour is not redundant + state.top.fill = nil case .pushState: state.push(state.top) case .popState: state.pop() + case .pushFilterLayer: + // filter layers draw into a fresh graphics state + state.push(State()) + case .popFilterLayer: + // renderers isolate filter layers, but do not rely on it: the outer state is unknown again + state.pop() + state.top = State() default: break } @@ -133,6 +150,8 @@ extension LayerTree { var lineJoin: T.LineJoin? var lineWidth: T.Float? var lineMiter: T.Float? + var lineDashPhase: T.Float? + var lineDashLengths: [T.Float]? var blendMode: T.BlendMode? } } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Filter.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Filter.swift new file mode 100644 index 00000000..c21788b6 --- /dev/null +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Filter.swift @@ -0,0 +1,254 @@ +// +// LayerTree.Filter.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +import Foundation +import SwiftDrawDOM + +extension LayerTree { + + // The element's region and units, resolved against the layer when commands are generated. + // nil values take the spec defaults: -10% / -10% / 120% / 120%. + struct FilterRegion: Hashable { + var x: Float? + var y: Float? + var width: Float? + var height: Float? + var units: FilterUnits = .objectBoundingBox + var primitiveUnits: FilterUnits = .userSpaceOnUse + } + + enum FilterUnits: Hashable { + case userSpaceOnUse + case objectBoundingBox + } + + // A filter resolved into the user space of the layer it applies to. + // Renderers draw the layer contents offscreen (SourceGraphic), evaluate the primitives in order, then + // composite the result of the last one clipped to region. + struct FilterLayer: Hashable { + // each primitive is at least one pass over the whole region: a longer primary tree is drawn + // unfiltered, so a hostile document cannot stall every frame + static let maxPrimitives = 64 + + var region: Rect + // the primary filter primitive tree (Filter Effects 1 §9.3); inputs only refer to earlier primitives + var primitives: [Primitive] + + var effects: [Effect] { + primitives.map(\.effect) + } + + // A filter primitive resolved into user space (SD12) + struct Primitive: Hashable { + var effect: Effect + var inputs: [Input] + // Filter Effects 1 §9.4: a hard clip on the result, within the filter region; + // an empty subregion disables the primitive (transparent black) + var subregion: Rect + var colorInterpolation: ColorInterpolation + } + + enum Effect: Hashable { + // user units, both values explicit; zero does not blur that direction + case gaussianBlur(stdDeviation: Float, stdDeviationY: Float) + // user units + case offset(dx: Float, dy: Float) + // flood-color with flood-opacity applied + case flood(Color) + // inputs[0] composited onto inputs[1] + case composite(DOM.Filter.CompositeOperator) + // inputs composited bottom to top + case merge + // inputs[0] blended onto inputs[1] + case blend(DOM.Filter.BlendMode) + // 4x5 row-major, applied to non-premultiplied values + case colorMatrix([Float]) + } + + enum Input: Hashable { + case sourceGraphic + case sourceAlpha + // BackgroundImage, BackgroundAlpha, FillPaint and StrokePaint + case transparent + // the result of an earlier primitive + case primitive(Int) + } + + // Filter Effects 1 §10: `auto` is drawn as sRGB, like Chrome + enum ColorInterpolation: Hashable { + case sRGB + case linearRGB + } + } +} + +extension LayerTree.FilterLayer { + + // A chain of effects from SourceGraphic, each primitive over the whole region in sRGB. + init(region: LayerTree.Rect, effects: [Effect]) { + self.region = region + self.primitives = effects.enumerated().map { index, effect in + Primitive(effect: effect, + inputs: [index == 0 ? .sourceGraphic : .primitive(index - 1)], + subregion: region, + colorInterpolation: .sRGB) + } + } + + // SD2's filters: blurs chained from SourceGraphic, each over the whole region. + // Renderers may blur these in place instead of evaluating the primitive tree. + var isBlurChain: Bool { + guard !primitives.isEmpty else { return false } + for (index, primitive) in primitives.enumerated() { + guard case .gaussianBlur = primitive.effect, + primitive.inputs == [index == 0 ? .sourceGraphic : .primitive(index - 1)], + primitive.subregion == region else { return false } + } + return true + } + + // Filter Effects 1 §9.2: a missing `in`, a forward reference and a reference to a result that does not + // exist all take the previous result, or SourceGraphic for the first primitive. A repeated `result` + // name refers to the closest preceding primitive. The background and paint inputs are not available + // and are transparent black (SVG 1.1 §15.6 without enable-background: new). + static func makeInputs(for primitives: [LayerTree.FilterPrimitive]) -> [[Input]] { + var named = [String: Int]() + var inputs = [[Input]]() + for (index, primitive) in primitives.enumerated() { + let previous: Input = index == 0 ? .sourceGraphic : .primitive(index - 1) + inputs.append(primitive.inputs.map { input in + switch input { + case .sourceGraphic: + return .sourceGraphic + case .sourceAlpha: + return .sourceAlpha + case .backgroundImage, .backgroundAlpha, .fillPaint, .strokePaint: + return .transparent + case .result(let name): + return named[name].map { .primitive($0) } ?? previous + case nil: + return previous + } + }) + if let result = primitive.result { + named[result] = index + } + } + return inputs + } + + // Filter Effects 1 §9.3: only the tree rooted at the last primitive contributes; ascending indices + static func primaryTree(of inputs: [[Input]]) -> [Int] { + guard !inputs.isEmpty else { return [] } + var isReached = [Bool](repeating: false, count: inputs.count) + isReached[inputs.count - 1] = true + for index in inputs.indices.reversed() where isReached[index] { + for case .primitive(let source) in inputs[index] { + isReached[source] = true + } + } + return inputs.indices.filter { isReached[$0] } + } +} + +extension LayerTree.Layer { + + // a primitive SwiftDraw cannot render in the primary tree; primitives outside it are never drawn + var hasUnsupportedFilters: Bool { + let inputs = LayerTree.FilterLayer.makeInputs(for: filters) + return LayerTree.FilterLayer.primaryTree(of: inputs).contains { !filters[$0].effect.isSupported } + } + + var containsText: Bool { + contents.contains { + switch $0 { + case .text: + return true + case .layer(let layer): + return layer.containsText + case .shape, .image: + return false + } + } + } +} + +extension LayerTree.Filter { + + var isSupported: Bool { + switch self { + case .gaussianBlur(_, _): + return true + case .unsupported: + return false + case .offset, .flood, .composite, .merge, .blend, .colorMatrix: + return true + } + } +} + +extension DOM.Filter.ColorMatrix { + + static let identity: [LayerTree.Float] = [1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0] + + // Filter Effects 1 §9.6: the 4x5 matrix of each type, row-major; a wrong number of values is a pass through. + // The luminance coefficients are those of Filter Effects 1, their complements exact so that + // saturate(1) and hueRotate(0) are the identity. + var values: [LayerTree.Float] { + switch self { + case .matrix(let values): + return values.count == 20 ? values.map { LayerTree.Float($0) } : Self.identity + case .saturate(let value): + let s = LayerTree.Float(value) + return [0.2126 + 0.7874 * s, 0.7152 - 0.7152 * s, 0.0722 - 0.0722 * s, 0, 0, + 0.2126 - 0.2126 * s, 0.7152 + 0.2848 * s, 0.0722 - 0.0722 * s, 0, 0, + 0.2126 - 0.2126 * s, 0.7152 - 0.7152 * s, 0.0722 + 0.9278 * s, 0, 0, + 0, 0, 0, 1, 0] + case .hueRotate(let degrees): + let radians = Double(degrees) * .pi / 180 + let c = LayerTree.Float(cos(radians)) + let s = LayerTree.Float(sin(radians)) + return [0.2126 + c * 0.7874 - s * 0.2126, 0.7152 - c * 0.7152 - s * 0.7152, 0.0722 - c * 0.0722 + s * 0.9278, 0, 0, + 0.2126 - c * 0.2126 + s * 0.143, 0.7152 + c * 0.2848 + s * 0.140, 0.0722 - c * 0.0722 - s * 0.283, 0, 0, + 0.2126 - c * 0.2126 - s * 0.7874, 0.7152 - c * 0.7152 + s * 0.7152, 0.0722 + c * 0.9278 + s * 0.0722, 0, 0, + 0, 0, 0, 1, 0] + case .luminanceToAlpha: + return [0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0] + } + } +} diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Gradient.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Gradient.swift index 6c2f4dca..1b2b7429 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Gradient.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Gradient.swift @@ -65,6 +65,12 @@ extension LayerTree { case objectBoundingBox } + enum Spread: Hashable { + case pad + case reflect + case `repeat` + } + var isOpaque: Bool { stops.allSatisfy(\.isOpaque) } @@ -76,6 +82,7 @@ extension LayerTree { var end: Point var units: Gradient.Units = .objectBoundingBox var transform: [Transform] = [] + var spread: Gradient.Spread = .pad } struct RadialGradient: Hashable { @@ -86,10 +93,11 @@ extension LayerTree { var endRadius: Float var units: Gradient.Units = .objectBoundingBox var transform: [Transform] = [] + var spread: Gradient.Spread = .pad } } -private extension LayerTree.Color { +extension LayerTree.Color { var isP3: Bool { switch self { case .rgba(r: _, g: _, b: _, a: _, space: .p3): diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Image.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Image.swift index db0a9c72..057e49cc 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Image.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Image.swift @@ -30,6 +30,7 @@ // import Foundation +import SwiftDrawDOM extension LayerTree { struct Image: Hashable { @@ -38,6 +39,7 @@ extension LayerTree { var origin: Point = .zero var width: LayerTree.Float? var height: LayerTree.Float? + var preserveAspectRatio: DOM.PreserveAspectRatio = .default enum Bitmap: Hashable { case jpeg(Data) diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift index 5f3057c8..1b7d6f94 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift @@ -39,7 +39,11 @@ extension LayerTree { var clipRule: FillRule? var clipUnits: ClipUnits = .userSpaceOnUse var mask: Layer? - var filters: [Filter] = [] + /// The mask stands for a clip path one clipping path cannot express (SD8): renderers + /// without masks may draw the content unclipped instead of dropping it. + var maskIsClip = false + var filters: [FilterPrimitive] = [] + var filterRegion = FilterRegion() enum Contents: Hashable { case shape(Shape, StrokeAttributes, FillAttributes) @@ -82,7 +86,9 @@ extension LayerTree { clip.hash(into: &hasher) clipUnits.hash(into: &hasher) mask.hash(into: &hasher) + maskIsClip.hash(into: &hasher) filters.hash(into: &hasher) + filterRegion.hash(into: &hasher) } static func ==(lhs: Layer, rhs: Layer) -> Bool { @@ -94,7 +100,9 @@ extension LayerTree { lhs.clipRule == rhs.clipRule && lhs.clipUnits == rhs.clipUnits && lhs.mask == rhs.mask && - lhs.filters == rhs.filters + lhs.maskIsClip == rhs.maskIsClip && + lhs.filters == rhs.filters && + lhs.filterRegion == rhs.filterRegion } } @@ -104,6 +112,8 @@ extension LayerTree { var cap: LineCap var join: LineJoin var miterLimit: Float + var dashArray: [Float] = [] + var dashOffset: Float = 0 enum Stroke: Hashable { case color(Color) diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift index eea4f573..35131add 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Pattern.swift @@ -29,6 +29,8 @@ // 3. This notice may not be removed or altered from any source distribution. // +import SwiftDrawDOM + extension LayerTree { enum PatternUnits: Hashable { @@ -42,20 +44,35 @@ extension LayerTree { var contentUnits: PatternUnits var contents: [LayerTree.Layer.Contents] - init(frame: LayerTree.Rect, contentUnits: PatternUnits = .userSpaceOnUse) { + /// patternUnits: the coordinate system of `frame` + var units: PatternUnits + var viewBox: LayerTree.Rect? + var preserveAspectRatio: DOM.PreserveAspectRatio = .default + /// patternTransform: maps the pattern tile space into the user space of the filled element + var transform: LayerTree.Transform.Matrix + + init(frame: LayerTree.Rect, contentUnits: PatternUnits = .userSpaceOnUse, units: PatternUnits = .userSpaceOnUse) { self.frame = frame self.contentUnits = contentUnits self.contents = [] + self.units = units + self.viewBox = nil + self.transform = .identity } func hash(into hasher: inout Hasher) { frame.hash(into: &hasher) contentUnits.hash(into: &hasher) contents.hash(into: &hasher) + units.hash(into: &hasher) + viewBox.hash(into: &hasher) + preserveAspectRatio.hash(into: &hasher) + transform.hash(into: &hasher) } static func == (lhs: LayerTree.Pattern, rhs: LayerTree.Pattern) -> Bool { - return lhs.frame == rhs.frame && lhs.contentUnits == rhs.contentUnits && lhs.contents == rhs.contents + return lhs.frame == rhs.frame && lhs.contentUnits == rhs.contentUnits && lhs.contents == rhs.contents && + lhs.units == rhs.units && lhs.viewBox == rhs.viewBox && lhs.preserveAspectRatio == rhs.preserveAspectRatio && lhs.transform == rhs.transform } } } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.swift b/SwiftDraw/Sources/LayerTree/LayerTree.swift index 55d4086f..6f733394 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.swift @@ -40,6 +40,7 @@ extension LayerTree { typealias LineJoin = DOM.LineJoin typealias FillRule = DOM.FillRule typealias Filter = DOM.Filter.Effect + typealias FilterPrimitive = DOM.Filter.Primitive enum Error: Swift.Error { case unsupported(any Sendable) diff --git a/SwiftDraw/Sources/Renderer/Renderer.CGText.swift b/SwiftDraw/Sources/Renderer/Renderer.CGText.swift index c15995c6..e35f9eea 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.CGText.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.CGText.swift @@ -146,6 +146,13 @@ struct CGTextProvider: RendererTypeProvider { .map { " \($0)" } .joined(separator: "\n") + var matrix = "ctx.ctm.concatenating(baseCTM.inverted())" + if pattern.transform != .identity { + let t = pattern.transform + let patternTransform = "CGAffineTransform(a: \(createFloat(from: t.a)), b: \(createFloat(from: t.b)), c: \(createFloat(from: t.c)), d: \(createFloat(from: t.d)), tx: \(createFloat(from: t.tx)), ty: \(createFloat(from: t.ty)))" + matrix = "\(patternTransform).concatenating(\(matrix))" + } + return """ let patternDraw1: CGPatternDrawPatternCallback = { _, ctx in \(lines) @@ -154,7 +161,7 @@ struct CGTextProvider: RendererTypeProvider { let pattern1 = CGPattern( info: nil, bounds: \(createRect(from: pattern.frame)), - matrix: ctx.ctm.concatenating(baseCTM.inverted()), + matrix: \(matrix), xStep: \(formatter.format(pattern.frame.width)), yStep: \(formatter.format(pattern.frame.height)), tiling: .constantSpacing, @@ -219,7 +226,7 @@ struct CGTextProvider: RendererTypeProvider { #if canImport(CoreGraphics) return CGProvider().getBounds(from: shape) #else - return .zero + return shape.path.bounds #endif } } @@ -505,6 +512,11 @@ public final class CGTextRenderer: Renderer { lines.append("ctx.setMiterLimit(\(formatter.format(miterLimit)))") } + func setLineDash(phase: LayerTree.Float, lengths: [LayerTree.Float]) { + let values = lengths.map { formatter.format($0) }.joined(separator: ", ") + lines.append("ctx.setLineDash(phase: \(formatter.format(phase)), lengths: [\(values)])") + } + func setClip(path: [LayerTree.Shape], rule: String) { let identifier = createOrGetPath(path) lines.append("ctx.addPath(\(identifier))") @@ -563,11 +575,8 @@ public final class CGTextRenderer: Renderer { } func draw(image: LayerTree.Image, in rect: String) { - lines.append("ctx.saveGState()") - lines.append("ctx.translateBy(x: 0, y: image.height)") - lines.append("ctx.scaleBy(x: 1, y: -1)") - lines.append("ctx.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)") - lines.append("ctx.restoreGState()") + // the generated code embeds no bitmaps: it cannot draw, nor letterbox (the bitmap size is unknown here) + lines.append("// warning: image dropped, is not supported in generated code") } func draw(linear gradient: LayerTree.Gradient, from start: String, to end: String) { @@ -592,6 +601,16 @@ public final class CGTextRenderer: Renderer { """) } + // Filters are not generated: the contents are drawn unfiltered, in their own graphics state. + func pushFilterLayer(_ filter: LayerTree.FilterLayer) { + lines.append("// warning: filter dropped (\(filter.effects.count) effect(s)), contents drawn unfiltered") + lines.append("ctx.saveGState()") + } + + func popFilterLayer() { + lines.append("ctx.restoreGState()") + } + func makeSwiftUI() -> String { """ import SwiftUI diff --git a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift index b6eedc46..23fbdd08 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift @@ -32,6 +32,7 @@ #if canImport(CoreGraphics) import Foundation import CoreText +import Accelerate #if canImport(UIKit) import UIKit #elseif canImport(AppKit) @@ -61,10 +62,13 @@ struct CGTransformingPattern: Hashable { var bounds: CGRect var contents: [RendererCommand] + /// patternTransform: pattern space to the user space of the filled element + var transform: CGAffineTransform - init(bounds: CGRect, contents: [RendererCommand]) { + init(bounds: CGRect, contents: [RendererCommand], transform: CGAffineTransform = .identity) { self.bounds = bounds self.contents = contents + self.transform = transform } func draw(_ ctx: CGContext) { @@ -255,7 +259,9 @@ struct CGProvider: RendererTypeProvider { func createPattern(from pattern: LayerTree.Pattern, contents: [RendererCommand]) -> CGTransformingPattern { let bounds = createRect(from: pattern.frame) - return CGTransformingPattern(bounds: bounds, contents: contents) + return CGTransformingPattern(bounds: bounds, + contents: contents, + transform: createTransform(from: pattern.transform)) } func createFillRule(from rule: LayerTree.FillRule) -> CGPathFillRule { @@ -324,12 +330,21 @@ private extension CGImage { struct CGRenderer: Renderer { typealias Types = CGTypes - let ctx: CGContext - let baseCTM: CGAffineTransform + private let rootContext: CGContext + private let rootCTM: CGAffineTransform + private let filterLayers = CGFilterLayerStack() + let maxFilterLayerPixels: Int - init(context: CGContext) { - self.ctx = context - self.baseCTM = ctx.ctm + // drawing goes into the innermost offscreen filter layer, if any + var ctx: CGContext { filterLayers.context ?? rootContext } + + // offscreen filter layers are bitmaps whose base space is the identity + var baseCTM: CGAffineTransform { filterLayers.context == nil ? rootCTM : .identity } + + init(context: CGContext, maxFilterLayerPixels: Int = CGRenderer.defaultMaxFilterLayerPixels) { + self.rootContext = context + self.rootCTM = context.ctm + self.maxFilterLayerPixels = maxFilterLayerPixels } func pushState() { @@ -374,7 +389,7 @@ struct CGRenderer: Renderer { var alpha : CGFloat = 1.0 let cgPattern = CGPattern.make(bounds: pattern.bounds, - matrix: ctx.ctm.concatenating(baseCTM.inverted()), + matrix: pattern.transform.concatenating(ctx.ctm.concatenating(baseCTM.inverted())), step: pattern.bounds.size, tiling: .constantSpacingMinimalDistortion, isColored: true, @@ -402,6 +417,10 @@ struct CGRenderer: Renderer { ctx.setMiterLimit(miterLimit) } + func setLineDash(phase: CGFloat, lengths: [CGFloat]) { + ctx.setLineDash(phase: phase, lengths: lengths) + } + func setClip(path: CGPath, rule: CGPathFillRule) { ctx.addPath(path) ctx.clip(using: rule) @@ -458,6 +477,330 @@ struct CGRenderer: Renderer { options: [.drawsAfterEndLocation, .drawsBeforeStartLocation] ) } + + // The following commands are drawn into a bitmap covering the filter region in device pixels, + // intersected with the visible clip grown by how far the filter moves pixels (blur extent, offsets). + // The result is composited back clipped to the region. Over maxFilterLayerPixels the bitmap is rendered + // at a reduced scale (deviations and offsets scaled to match) and drawn back up, so large exports stay + // filtered without unbounded allocations. SD2's blur chains are blurred in place; other filters evaluate + // their primitive tree (SD12), which keeps several bitmaps alive: the budget is shared between them. + func pushFilterLayer(_ filter: LayerTree.FilterLayer) { + let parent = ctx + let region = CGRect(x: CGFloat(filter.region.x), + y: CGFloat(filter.region.y), + width: CGFloat(filter.region.width), + height: CGFloat(filter.region.height)) + let toDevice = parent.userSpaceToDeviceSpaceTransform + let isBlurChain = filter.isBlurChain + let deviations = isBlurChain ? filter.effects.compactMap { $0.deviceStdDeviation(toDevice) } : [] + let visible = parent.boundingBoxOfClipPath + + parent.saveGState() + parent.clip(to: region) + + // empty clip: draw nothing and allocate nothing + guard !visible.isNull, !visible.isEmpty else { + parent.clip(to: .zero) + filterLayers.entries.append(CGFilterLayer()) + return + } + + // blurs too small to change a pixel (every box size 1) are a pass-through: + // draw straight into the parent, clipped to the region + let isVector = parent.bitsPerPixel == 0 + let oversample: CGFloat = isVector ? 2 : 1 + if isBlurChain && !deviations.contains(where: { CGContext.isVisibleBlur($0, scale: oversample) }) { + filterLayers.entries.append(CGFilterLayer()) + return + } + + var deviceRect = region.applying(toDevice) + if !visible.isInfinite { + let spread = filter.deviceSpread(toDevice) + deviceRect = deviceRect.intersection(visible.applying(toDevice).insetBy(dx: -spread.width, dy: -spread.height)) + } + + // the offscreen itself, plus the bitmaps of the primitive tree + let maxPixels = isBlurChain ? maxFilterLayerPixels : maxFilterLayerPixels / (filter.peakBitmapCount + 1) + + // PDF and other vector contexts: filtered groups are rasterized at 2x (2 px per point) + guard var layer = CGFilterLayer.make(deviceRect: deviceRect, + deviations: deviations, + maxPixels: maxPixels, + oversample: oversample, + colorSpace: parent.colorSpace) else { + // empty clip or region: draw nothing and allocate nothing + parent.clip(to: .zero) + filterLayers.entries.append(CGFilterLayer()) + return + } + if !isBlurChain { + layer.filter = filter + layer.toDevice = toDevice + } + + layer.offscreen?.concatenate(toDevice) + // input outside the region is excluded (it matters under rotation or skew); the edge is anti-aliased + // once, by the parent clip at composite time + layer.offscreen?.setShouldAntialias(false) + layer.offscreen?.clip(to: region) + layer.offscreen?.setShouldAntialias(true) + filterLayers.entries.append(layer) + } + + func popFilterLayer() { + guard let layer = filterLayers.entries.popLast() else { return } + let parent = ctx + + if let offscreen = layer.offscreen { + if let filter = layer.filter { + offscreen.applyFilter(filter, toPixels: layer.pixelTransform) + } else { + for deviation in layer.deviations { + offscreen.applyGaussianBlur(CGSize(width: deviation.width * layer.scale, + height: deviation.height * layer.scale)) + } + } + if let image = offscreen.makeImage() { + let size = CGSize(width: CGFloat(image.width) / layer.scale, + height: CGFloat(image.height) / layer.scale) + parent.saveGState() + parent.concatenate(parent.userSpaceToDeviceSpaceTransform.inverted()) + if layer.scale == 1 { + parent.interpolationQuality = .none + } + parent.draw(image, in: CGRect(origin: layer.deviceRect.origin, size: size)) + parent.restoreGState() + } + } + + // removes the region clip set by pushFilterLayer + parent.restoreGState() + } + + static let defaultMaxFilterLayerPixels = 16_777_216 + static let maxDeviceSpread: CGFloat = 100_000 +} + +final class CGFilterLayerStack { + var entries = [CGFilterLayer]() + + var context: CGContext? { + entries.last(where: { $0.offscreen != nil })?.offscreen + } +} + +struct CGFilterLayer { + // nil when contents are drawn directly into the parent context + var offscreen: CGContext? + var deviceRect: CGRect = .zero + var deviations: [CGSize] = [] + // offscreen pixels per device pixel + var scale: CGFloat = 1 + // a primitive tree to evaluate when popped, with the user space it was resolved in (SD12); + // nil for blur chains, blurred in place by deviations + var filter: LayerTree.FilterLayer? + var toDevice: CGAffineTransform = .identity + + // user space to offscreen pixels: x right, y down from the first row in memory + var pixelTransform: CGAffineTransform { + let height = CGFloat(offscreen?.height ?? 0) + return toDevice + .concatenating(CGAffineTransform(translationX: -deviceRect.minX, y: -deviceRect.minY)) + .concatenating(CGAffineTransform(scaleX: scale, y: scale)) + .concatenating(CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: height)) + } + + // nil when the device rect is empty or not finite + static func make(deviceRect: CGRect, + deviations: [CGSize], + maxPixels: Int, + oversample: CGFloat, + colorSpace: CGColorSpace?) -> CGFilterLayer? { + guard !deviceRect.isNull, !deviceRect.isInfinite, + deviceRect.minX.isFinite, deviceRect.minY.isFinite, + deviceRect.width.isFinite, deviceRect.height.isFinite else { return nil } + let rect = deviceRect.integral + guard rect.width >= 1, rect.height >= 1 else { return nil } + + let scale = makeScale(size: rect.size, maxPixels: maxPixels, oversample: oversample) + let width = max(1, Int((rect.width * scale).rounded(.up))) + let height = max(1, Int((rect.height * scale).rounded(.up))) + guard let offscreen = CGContext.makeFilterLayer(width: width, height: height, colorSpace: colorSpace) else { + return nil + } + + offscreen.scaleBy(x: scale, y: scale) + offscreen.translateBy(x: -rect.minX, y: -rect.minY) + return CGFilterLayer(offscreen: offscreen, deviceRect: rect, deviations: deviations, scale: scale) + } + + // largest scale ≤ oversample that keeps the bitmap within maxPixels and each side within maxSide + static func makeScale(size: CGSize, maxPixels: Int, oversample: CGFloat) -> CGFloat { + let maxSide = CGFloat(maxPixels).squareRoot() * 4 + let pixels = size.width * size.height + var scale = oversample + scale = min(scale, (CGFloat(maxPixels) / pixels).squareRoot()) + scale = min(scale, maxSide / size.width, maxSide / size.height) + return scale + } +} + +private extension LayerTree.FilterLayer.Effect { + + // stdDeviation converted to device pixels along the device axes; nil when it does not blur. + // Exact for scale and 90° rotations, an approximation under skew or other rotations. + func deviceStdDeviation(_ toDevice: CGAffineTransform) -> CGSize? { + guard case let .gaussianBlur(stdDeviation: x, stdDeviationY: y) = self else { return nil } + let sx = CGFloat(x) + let sy = CGFloat(y) + let width = hypot(sx * toDevice.a, sy * toDevice.c) + let height = hypot(sx * toDevice.b, sy * toDevice.d) + let size = CGSize(width: width.isFinite ? width : 0, height: height.isFinite ? height : 0) + return size.width > 0 || size.height > 0 ? size : nil + } +} + +private extension LayerTree.FilterLayer { + + // How far the filter can move a pixel, in device pixels: three deviations per blur plus each offset. + // Every path through the primitive tree is covered by the sum over all primitives. + func deviceSpread(_ toDevice: CGAffineTransform) -> CGSize { + var width: CGFloat = 0 + var height: CGFloat = 0 + for effect in effects { + switch effect { + case .gaussianBlur: + if let deviation = effect.deviceStdDeviation(toDevice) { + width += 3 * deviation.width + height += 3 * deviation.height + } + case let .offset(dx: dx, dy: dy): + let vector = CGSize(width: CGFloat(dx), height: CGFloat(dy)).applying(toDevice) + width += abs(vector.width) + height += abs(vector.height) + case .flood, .composite, .merge, .blend, .colorMatrix: + break + } + } + return CGSize(width: width.isFinite ? min(width, CGRenderer.maxDeviceSpread) : CGRenderer.maxDeviceSpread, + height: height.isFinite ? min(height, CGRenderer.maxDeviceSpread) : CGRenderer.maxDeviceSpread) + } +} + +extension CGContext { + + static func makeFilterLayer(width: Int, height: Int, colorSpace: CGColorSpace?) -> CGContext? { + let sRGB = CGColorSpace(name: CGColorSpace.sRGB)! + let space = colorSpace.flatMap { $0.model == .rgb ? $0 : nil } ?? sRGB + let info = CGImageAlphaInfo.premultipliedLast.rawValue + return CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: space, bitmapInfo: info) ?? + CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: sRGB, bitmapInfo: info) + } + + // Filter Effects 1 §9.16 / SVG 1.1 §15.17: three successive box blurs approximate the gaussian. + // Box sizes must be odd for vImage, so an even size d becomes d+1, d-1, d+1 (near-identical variance). + func applyGaussianBlur(_ deviation: CGSize) { + guard let data else { return } + Self.applyGaussianBlur(deviation, data: data, width: width, height: height, bytesPerRow: bytesPerRow) + } + + static func applyGaussianBlur(_ deviation: CGSize, data: UnsafeMutableRawPointer, width: Int, height: Int, bytesPerRow: Int) { + // a box wider than the image only spreads it further towards transparent: clamp per axis + let sizesX = Self.boxSizes(for: deviation.width, limit: width | 1) + let sizesY = Self.boxSizes(for: deviation.height, limit: height | 1) + guard sizesX.contains(where: { $0 > 1 }) || sizesY.contains(where: { $0 > 1 }) else { return } + + let byteCount = bytesPerRow * height + guard let temp = calloc(byteCount, 1) else { return } + defer { free(temp) } + + var src = vImage_Buffer(data: data, height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: bytesPerRow) + var dst = vImage_Buffer(data: temp, height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: bytesPerRow) + var background: [UInt8] = [0, 0, 0, 0] + for pass in 0..<3 { + let error = vImageBoxConvolve_ARGB8888(&src, &dst, nil, 0, 0, + UInt32(sizesY[pass]), UInt32(sizesX[pass]), + &background, vImage_Flags(kvImageBackgroundColorFill)) + guard error == kvImageNoError else { return } + swap(&src, &dst) + } + // after three passes the result is in the temporary buffer + memcpy(data, src.data, byteCount) + } + + // SD12: evaluates the primitive tree on the offscreen's pixels, SourceGraphic being what was drawn, + // and leaves the result in their place. + func applyFilter(_ filter: LayerTree.FilterLayer, toPixels transform: CGAffineTransform) { + let width = self.width + let height = self.height + let bytesPerRow = self.bytesPerRow + let colorSpace = self.colorSpace + guard let data = self.data, width > 0, height > 0 else { return } + let rowBytes = width * 4 + + var pixels = [UInt8](repeating: 0, count: rowBytes * height) + pixels.withUnsafeMutableBytes { buffer in + guard let base = buffer.baseAddress else { return } + for row in 0.. SIMD4 { + guard let ctx = CGContext.makeFilterLayer(width: 1, height: 1, colorSpace: colorSpace), + let data = ctx.data else { + return FilterBitmap.components(of: color) + } + ctx.setFillColor(CGProvider().createColor(from: color)) + ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + let pixel = data.assumingMemoryBound(to: UInt8.self) + let alpha = Float(pixel[3]) + guard alpha > 0 else { return .zero } + return SIMD4(Float(pixel[0]) / alpha, Float(pixel[1]) / alpha, Float(pixel[2]) / alpha, alpha / 255) + } + + static func isVisibleBlur(_ deviation: CGSize, scale: CGFloat) -> Bool { + boxSizes(for: deviation.width * scale) != [1, 1, 1] || boxSizes(for: deviation.height * scale) != [1, 1, 1] + } + + // limit is odd: the largest box size worth applying + static func boxSizes(for deviation: CGFloat, limit: Int = .max) -> [Int] { + guard deviation.isFinite, deviation > 0 else { return [1, 1, 1] } + let factor: CGFloat = 3 * (2 * CGFloat.pi).squareRoot() / 4 + let value = Swift.min(deviation * factor + 0.5, CGFloat(Swift.min(limit, 1 << 24))) + let d = Int(value.rounded(.down)) + guard d > 1 else { return [1, 1, 1] } + return d % 2 == 1 ? [d, d, d] : [d + 1, d - 1, d + 1] + } } #endif diff --git a/SwiftDraw/Sources/Renderer/Renderer.LayerTree.swift b/SwiftDraw/Sources/Renderer/Renderer.LayerTree.swift index 62a5c3fe..4ae258ce 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.LayerTree.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.LayerTree.swift @@ -124,6 +124,6 @@ struct LayerTreeProvider: RendererTypeProvider { } func getBounds(from shape: LayerTree.Shape) -> LayerTree.Rect { - return LayerTree.Rect(x: 0, y: 0, width: 0, height: 0) + return shape.path.bounds } } diff --git a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+StrokeScale.swift b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+StrokeScale.swift index 54d4d41b..7b9c1742 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+StrokeScale.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+StrokeScale.swift @@ -90,6 +90,17 @@ enum StrokeWidthScaler { element.style.strokeWidth = multiply(value, by: scale) count += 1 } + if let value = element.importantStyle.strokeWidth { + element.importantStyle.strokeWidth = multiply(value, by: scale) + count += 1 + } + // stylesheet values matched at parse time are copies of the rules counted in scaleStyles + if let value = element.matchedStyle?.attributes.strokeWidth { + element.matchedStyle?.attributes.strokeWidth = multiply(value, by: scale) + } + if let value = element.matchedStyle?.importantAttributes.strokeWidth { + element.matchedStyle?.importantAttributes.strokeWidth = multiply(value, by: scale) + } } private static func scaleDefs(_ defs: inout DOM.SVG.Defs, by scale: SFSymbolRenderer.StrokeWidthScale, count: inout Int) { @@ -119,6 +130,19 @@ enum StrokeWidthScaler { count += 1 } } + // the same values in source order, as the cascade reads them + // (complex-selector rules are not in `attributes`, so they are counted here) + for ruleIndex in styles[sheetIndex].rules.indices { + let isComplex = styles[sheetIndex].rules[ruleIndex].selector.simple == nil + if let value = styles[sheetIndex].rules[ruleIndex].attributes.strokeWidth { + styles[sheetIndex].rules[ruleIndex].attributes.strokeWidth = multiply(value, by: scale) + if isComplex { count += 1 } + } + if let value = styles[sheetIndex].rules[ruleIndex].importantAttributes.strokeWidth { + styles[sheetIndex].rules[ruleIndex].importantAttributes.strokeWidth = multiply(value, by: scale) + if isComplex { count += 1 } + } + } } } diff --git a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift index b0d1bd63..320b2780 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift @@ -231,7 +231,10 @@ extension SFSymbolRenderer { let isSFSymbolLayer = containsAcceptedName(layer.class) guard isSFSymbolLayer || layer.opacity > 0 else { return [] } - guard layer.mask == nil else { + if layer.mask != nil && layer.maskIsClip { + // a clip drawn as a mask (text, mixed clip-rules, nested clip-path): keep the content unclipped + print("Warning:", "clip-path unsupported in SF Symbols, drawn unclipped.", to: &.standardError) + } else if layer.mask != nil { print("Warning:", "mask unsupported in SF Symbols.", to: &.standardError) return [] } diff --git a/SwiftDraw/Sources/Renderer/Renderer.swift b/SwiftDraw/Sources/Renderer/Renderer.swift index c0adf0eb..c731af5e 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.swift @@ -91,6 +91,7 @@ protocol Renderer { func setLine(cap: Types.LineCap) func setLine(join: Types.LineJoin) func setLine(miterLimit: Types.Float) + func setLineDash(phase: Types.Float, lengths: [Types.Float]) func setClip(path: Types.Path, rule: Types.FillRule) func setAlpha(_ alpha: Types.Float) func setBlend(mode: Types.BlendMode) @@ -101,6 +102,9 @@ protocol Renderer { func draw(image: Types.Image, in rect: Types.Rect) func draw(linear gradient: Types.Gradient, from start: Types.Point, to end: Types.Point) func draw(radial gradient: Types.Gradient, startCenter: Types.Point, startRadius: Types.Float, endCenter: Types.Point, endRadius: Types.Float) + + func pushFilterLayer(_ filter: LayerTree.FilterLayer) + func popFilterLayer() } extension Renderer { @@ -136,6 +140,8 @@ extension Renderer { setLine(join: j) case .setLineMiter(limit: let l): setLine(miterLimit: l) + case .setLineDash(phase: let p, lengths: let l): + setLineDash(phase: p, lengths: l) case .setClip(path: let p, rule: let r): setClip(path: p, rule: r) case .setAlpha(let a): @@ -154,6 +160,10 @@ extension Renderer { draw(linear: g, from: start, to: end) case let .drawRadialGradient(g, startCenter, startRadius, endCenter, endRadius): draw(radial: g, startCenter: startCenter, startRadius: startRadius, endCenter: endCenter, endRadius: endRadius) + case .pushFilterLayer(let f): + pushFilterLayer(f) + case .popFilterLayer: + popFilterLayer() } } @@ -180,6 +190,7 @@ enum RendererCommand: @unchecked Sendable { case setLineCap(Types.LineCap) case setLineJoin(Types.LineJoin) case setLineMiter(limit: Types.Float) + case setLineDash(phase: Types.Float, lengths: [Types.Float]) case setClip(path: Types.Path, rule: Types.FillRule) case setAlpha(Types.Float) case setBlend(mode: Types.BlendMode) @@ -194,6 +205,10 @@ enum RendererCommand: @unchecked Sendable { case pushTransparencyLayer case popTransparencyLayer + + // draws the following commands offscreen until popFilterLayer, then composites the filtered result + case pushFilterLayer(LayerTree.FilterLayer) + case popFilterLayer } diff --git a/SwiftDraw/Sources/SVG.swift b/SwiftDraw/Sources/SVG.swift index b4a23240..d67a6e43 100644 --- a/SwiftDraw/Sources/SVG.swift +++ b/SwiftDraw/Sources/SVG.swift @@ -160,7 +160,7 @@ extension SVG { } init(dom: DOM.SVG, options: Options) { - self.size = CGSize(width: dom.width, height: dom.height) + self.size = CGSize(width: CGFloat(dom.width), height: CGFloat(dom.height)) //To create the draw commands; // - XML is parsed into DOM.SVG @@ -188,7 +188,7 @@ public struct SVG: Sendable { public let size: CGSize init(dom: DOM.SVG, options: Options) { - size = CGSize(width: dom.width, height: dom.height) + size = CGSize(width: CGFloat(dom.width), height: CGFloat(dom.height)) } public struct Options: OptionSet { diff --git a/SwiftDraw/Sources/Utilities/FilterBitmap.swift b/SwiftDraw/Sources/Utilities/FilterBitmap.swift new file mode 100644 index 00000000..351f85f4 --- /dev/null +++ b/SwiftDraw/Sources/Utilities/FilterBitmap.swift @@ -0,0 +1,758 @@ +// +// FilterBitmap.swift +// SwiftDraw +// +// Created by Misoservices on 9/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import Foundation +import SwiftDrawDOM + +// A premultiplied RGBA image, 8 bits per channel, rows top to bottom without padding: the offscreen of a +// filter layer, on which the filter primitives are evaluated (SD12). Platform independent. +// +// Values are stored in the encoding of the drawing (sRGB). Primitives in linearRGB (Filter Effects 1 §10) +// decode to linear light, compute, and encode again per pixel, so storage never quantizes linear values. +// Blur and offset only move pixels and work on the stored values. +struct FilterBitmap: Equatable { + var width: Int + var height: Int + var pixels: [UInt8] + + // transparent black + init(width: Int, height: Int) { + self.width = max(0, width) + self.height = max(0, height) + self.pixels = [UInt8](repeating: 0, count: self.width * self.height * 4) + } + + init(width: Int, height: Int, pixels: [UInt8]) { + precondition(pixels.count == width * height * 4) + self.width = width + self.height = height + self.pixels = pixels + } + + // r, g, b, a + func pixel(x: Int, y: Int) -> [UInt8] { + let i = (y * width + x) * 4 + return Array(pixels[i.. Void + // straight (non-premultiplied) RGBA of a colour in the encoding of the bitmaps + var color: (LayerTree.Color) -> SIMD4 = FilterBitmap.components(of:) + } + + // The result of the last primitive, given SourceGraphic: the layer contents drawn over the filter region. + func evaluate(source: FilterBitmap, environment: Environment) -> FilterBitmap { + let width = source.width + let height = source.height + let lastUse = lastUses + + var results = [FilterBitmap?](repeating: nil, count: primitives.count) + var sourceAlpha: FilterBitmap? + + for (index, primitive) in primitives.enumerated() { + func input(_ n: Int) -> FilterBitmap { + guard n < primitive.inputs.count else { return FilterBitmap(width: width, height: height) } + switch primitive.inputs[n] { + case .sourceGraphic: + return source + case .sourceAlpha: + if sourceAlpha == nil { + sourceAlpha = FilterBitmap.sourceAlpha(of: source) + } + return sourceAlpha! + case .transparent: + return FilterBitmap(width: width, height: height) + case .primitive(let other): + // the last reader takes the bitmap: it can then be changed in place without a copy + if lastUse[other] == index && primitive.inputs.count == 1 { + defer { results[other] = nil } + return results[other] ?? FilterBitmap(width: width, height: height) + } + return results[other] ?? FilterBitmap(width: width, height: height) + } + } + + var result: FilterBitmap + let isLinear = primitive.colorInterpolation == .linearRGB + if primitive.subregion.width <= 0 || primitive.subregion.height <= 0 { + // Filter Effects 1 §9.4: a disabled primitive is transparent black + result = FilterBitmap(width: width, height: height) + } else { + switch primitive.effect { + case let .gaussianBlur(stdDeviation: x, stdDeviationY: y): + result = input(0) + let deviation = environment.transform.deviation(x, y) + environment.blur(&result, deviation.x, deviation.y) + case let .offset(dx: dx, dy: dy): + let vector = environment.transform.vector(dx, dy) + result = FilterBitmap.offset(input(0), dx: vector.x, dy: vector.y) + case .flood(let color): + result = FilterBitmap.flood(width: width, height: height, color: environment.color(color)) + case .composite(let op): + result = FilterBitmap.composite(input(0), input(1), op: op, linear: isLinear) + case .merge: + result = FilterBitmap.merge(primitive.inputs.indices.map(input), width: width, height: height, linear: isLinear) + case .blend(let mode): + result = FilterBitmap.blend(input(0), input(1), mode: mode, linear: isLinear) + case .colorMatrix(let matrix): + result = FilterBitmap.colorMatrix(input(0), matrix: matrix, linear: isLinear) + } + result.clip(to: primitive.subregion, transform: environment.transform) + } + results[index] = result + + for case .primitive(let other) in primitive.inputs where lastUse[other] == index { + results[other] = nil + } + if lastUse.sourceAlpha == index { + sourceAlpha = nil + } + } + + return results.last.flatMap { $0 } ?? FilterBitmap(width: width, height: height) + } + + // An upper bound of the bitmaps alive at once while evaluating, SourceGraphic included, + // so renderers can keep the total within their pixel budget. + var peakBitmapCount: Int { + let lastUse = lastUses + var peak = 1 + for (index, primitive) in primitives.enumerated() { + // results still needed, SourceGraphic, SourceAlpha, transparent inputs and the result itself + var alive = (0..= index }.count + 2 + if lastUse.sourceAlpha >= index && primitives.prefix(index + 1).contains(where: { $0.inputs.contains(.sourceAlpha) }) { + alive += 1 + } + alive += primitive.inputs.filter { $0 == .transparent }.count + peak = max(peak, alive) + } + return peak + } + + // the index of the last primitive reading each result (-1 when unread), and SourceAlpha + private var lastUses: LastUses { + var uses = LastUses(primitives: [Int](repeating: -1, count: primitives.count)) + for (index, primitive) in primitives.enumerated() { + for input in primitive.inputs { + switch input { + case .primitive(let other): + uses.primitives[other] = index + case .sourceAlpha: + uses.sourceAlpha = index + case .sourceGraphic, .transparent: + break + } + } + } + return uses + } + + private struct LastUses { + var primitives: [Int] + var sourceAlpha = -1 + + subscript(index: Int) -> Int { + primitives[index] + } + } +} + +private extension LayerTree.Transform.Matrix { + + // stdDeviation along the pixel axes; exact for scale and 90° rotations, an approximation otherwise + func deviation(_ x: Float, _ y: Float) -> (x: Float, y: Float) { + let dx = (a * x).hypot(c * y) + let dy = (b * x).hypot(d * y) + return (dx.isFinite ? dx : 0, dy.isFinite ? dy : 0) + } + + func vector(_ x: Float, _ y: Float) -> (x: Float, y: Float) { + let vx = a * x + c * y + let vy = b * x + d * y + return (vx.isFinite ? vx : 0, vy.isFinite ? vy : 0) + } + + func invertedMatrix() -> Self? { + let determinant = a * d - b * c + guard determinant != 0, determinant.isFinite else { return nil } + return Self(a: d / determinant, b: -b / determinant, + c: -c / determinant, d: a / determinant, + tx: (c * ty - d * tx) / determinant, + ty: (b * tx - a * ty) / determinant) + } +} + +private extension Float { + func hypot(_ other: Float) -> Float { + (self * self + other * other).squareRoot() + } +} + +// MARK: - Primitives + +extension FilterBitmap { + + // Filter Effects 1 §9.2: the alpha of SourceGraphic with black colour channels + static func sourceAlpha(of source: FilterBitmap) -> FilterBitmap { + var result = FilterBitmap(width: source.width, height: source.height) + source.pixels.withUnsafeBufferPointer { src in + result.pixels.withUnsafeMutableBufferPointer { dst in + var i = 3 + while i < src.count { + dst[i] = src[i] + i += 4 + } + } + } + return result + } + + // Filter Effects 1 §9.13: the whole bitmap, later clipped to the subregion. + // color-interpolation-filters does not apply to feFlood (Filter Effects 1 §10). + static func flood(width: Int, height: Int, color: SIMD4) -> FilterBitmap { + var result = FilterBitmap(width: width, height: height) + var premultiplied = color + premultiplied.x *= color.w + premultiplied.y *= color.w + premultiplied.z *= color.w + // not stored through a pointer into a local array: optimized builds (Swift 6.4, -O) kept only the + // first byte, so every flood was transparent in Release while Debug tests passed + let bytes = quantize(premultiplied, linear: false) + let pixel: [UInt8] = [bytes.x, bytes.y, bytes.z, bytes.w] + guard pixel != [0, 0, 0, 0] else { return result } + result.pixels.withUnsafeMutableBufferPointer { dst in + var i = 0 + while i < dst.count { + dst[i] = pixel[0] + dst[i + 1] = pixel[1] + dst[i + 2] = pixel[2] + dst[i + 3] = pixel[3] + i += 4 + } + } + return result + } + + // Filter Effects 1 §9.18: shifted by a vector in pixels; uncovered pixels are transparent black and + // fractional offsets are interpolated bilinearly. + static func offset(_ source: FilterBitmap, dx: Float, dy: Float) -> FilterBitmap { + let width = source.width + let height = source.height + // offsets beyond the bitmap leave nothing + guard abs(dx) < Float(width), abs(dy) < Float(height) else { + return FilterBitmap(width: width, height: height) + } + + let ix = Int(dx.rounded(.down)) + let iy = Int(dy.rounded(.down)) + var fx = dx - Float(ix) + var fy = dy - Float(iy) + // within a thousandth of a pixel the offset is whole: copy exactly + if fx < 0.001 { fx = 0 } + if fy < 0.001 { fy = 0 } + var sx = ix + var sy = iy + if fx > 0.999 { fx = 0; sx += 1 } + if fy > 0.999 { fy = 0; sy += 1 } + + // 8-bit fixed point weights: a destination pixel (x, y) reads the source at (x - sx, y - sy) and, + // weighted wx and wy, at the pixel before along each axis + let wx = Int((fx * 256).rounded()) + let wy = Int((fy * 256).rounded()) + let w11 = (256 - wx) * (256 - wy) + let w01 = wx * (256 - wy) + let w10 = (256 - wx) * wy + let w00 = wx * wy + + var result = FilterBitmap(width: width, height: height) + source.pixels.withUnsafeBufferPointer { src in + result.pixels.withUnsafeMutableBufferPointer { dst in + guard let srcBase = src.baseAddress, let dstBase = dst.baseAddress else { return } + let raw = UnsafeRawPointer(srcBase) + let rowBytes = width * 4 + // destination columns with a source column inside the bitmap + let xStart = Swift.max(0, sx) + let xEnd = Swift.min(width, width + sx + (wx > 0 ? 1 : 0)) + guard xStart < xEnd else { return } + + // a whole pixel offset copies row segments + if wx == 0 && wy == 0 { + for y in 0..= 0 && y - sy < height { + memcpy(dstBase + y * rowBytes + xStart * 4, + srcBase + (y - sy) * rowBytes + (xStart - sx) * 4, + (xEnd - xStart) * 4) + } + return + } + + for y in 0..= 0 && y1 < height + let hasRow0 = wy > 0 && y0 >= 0 && y0 < height + guard hasRow1 || hasRow0 else { continue } + let row1 = y1 * rowBytes + let row0 = y0 * rowBytes + for x in xStart.. 0 && x0 >= 0 + let o = y * rowBytes + x * 4 + + // transparent or uniform neighbourhoods need no weighting + let p11 = hasRow1 && hasColumn1 ? raw.loadUnaligned(fromByteOffset: row1 + x1 * 4, as: UInt32.self) : 0 + let p01 = hasRow1 && hasColumn0 ? raw.loadUnaligned(fromByteOffset: row1 + x0 * 4, as: UInt32.self) : 0 + let p10 = hasRow0 && hasColumn1 ? raw.loadUnaligned(fromByteOffset: row0 + x1 * 4, as: UInt32.self) : 0 + let p00 = hasRow0 && hasColumn0 ? raw.loadUnaligned(fromByteOffset: row0 + x0 * 4, as: UInt32.self) : 0 + let isUniform = (wx == 0 || p01 == p11) && (wy == 0 || (p10 == p11 && (wx == 0 || p00 == p11))) + if isUniform { + if p11 != 0 { + memcpy(dstBase + o, srcBase + row1 + x1 * 4, 4) + } + continue + } + for c in 0..<4 { + var sum = 0 + if hasRow1 { + if hasColumn1 { sum += Int(src[row1 + x1 * 4 + c]) * w11 } + if hasColumn0 { sum += Int(src[row1 + x0 * 4 + c]) * w01 } + } + if hasRow0 { + if hasColumn1 { sum += Int(src[row0 + x1 * 4 + c]) * w10 } + if hasColumn0 { sum += Int(src[row0 + x0 * 4 + c]) * w00 } + } + dst[o + c] = UInt8(truncatingIfNeeded: (sum + 32768) >> 16) + } + } + } + } + } + return result + } + + // Filter Effects 1 §9.8: `a` (in) is the source and `b` (in2) the destination of the Porter-Duff operator + // (Compositing and Blending 1 §9.1); arithmetic is clamped to 0...1 with colour never above alpha. + static func composite(_ a: FilterBitmap, _ b: FilterBitmap, op: DOM.Filter.CompositeOperator, linear: Bool) -> FilterBitmap { + switch op { + case .over: + return combine(a, b, linear: linear) { s, d in s + d * (1 - s.w) } + case .in: + return combine(a, b, linear: linear) { s, d in s * d.w } + case .out: + return combine(a, b, linear: linear) { s, d in s * (1 - d.w) } + case .atop: + return combine(a, b, linear: linear) { s, d in s * d.w + d * (1 - s.w) } + case .xor: + return combine(a, b, linear: linear) { s, d in s * (1 - d.w) + d * (1 - s.w) } + case .lighter: + return combine(a, b, linear: linear) { s, d in s + d } + case let .arithmetic(k1: k1, k2: k2, k3: k3, k4: k4): + let k1 = Float(k1), k2 = Float(k2), k3 = Float(k3), k4 = Float(k4) + return combine(a, b, linear: linear) { i1, i2 in + k1 * i1 * i2 + k2 * i1 + k3 * i2 + SIMD4(repeating: k4) + } + } + } + + // Filter Effects 1 §9.16: the inputs composited with `over`, the first at the bottom + static func merge(_ inputs: [FilterBitmap], width: Int, height: Int, linear: Bool) -> FilterBitmap { + guard var result = inputs.first else { return FilterBitmap(width: width, height: height) } + for input in inputs.dropFirst() { + result = combine(input, result, linear: linear) { s, d in s + d * (1 - s.w) } + } + return result + } + + // Filter Effects 1 §9.5, Compositing and Blending 1 §5.8 and §10: `a` (in) is the source Cs, + // `b` (in2) the backdrop Cb, blended then composited with source-over. + static func blend(_ a: FilterBitmap, _ b: FilterBitmap, mode: DOM.Filter.BlendMode, linear: Bool) -> FilterBitmap { + combine(a, b, linear: linear) { s, d in + let alphaS = s.w + let alphaB = d.w + var result = s * (1 - alphaB) + d * (1 - alphaS) + guard alphaS > 0, alphaB > 0 else { return result } + let cs = SIMD3(s.x, s.y, s.z) / alphaS + let cb = SIMD3(d.x, d.y, d.z) / alphaB + let mixed = blendColor(cb, cs, mode: mode) * (alphaS * alphaB) + result.x += mixed.x + result.y += mixed.y + result.z += mixed.z + result.w += alphaS * alphaB + return result + } + } + + // Filter Effects 1 §9.6: on non-premultiplied values, clamped to 0...1 + static func colorMatrix(_ source: FilterBitmap, matrix: [Float], linear: Bool) -> FilterBitmap { + guard matrix.count == 20 else { return source } + let m = matrix + return map(source, linear: linear) { p in + var c = p + if c.w > 0 { + c.x /= c.w + c.y /= c.w + c.z /= c.w + } + var out = SIMD4( + m[0] * c.x + m[1] * c.y + m[2] * c.z + m[3] * c.w + m[4], + m[5] * c.x + m[6] * c.y + m[7] * c.z + m[8] * c.w + m[9], + m[10] * c.x + m[11] * c.y + m[12] * c.z + m[13] * c.w + m[14], + m[15] * c.x + m[16] * c.y + m[17] * c.z + m[18] * c.w + m[19] + ) + out.replace(with: 0, where: out .!= out) + out.clamp(lowerBound: .zero, upperBound: SIMD4(repeating: 1)) + out.x *= out.w + out.y *= out.w + out.z *= out.w + return out + } + } + + // Filter Effects 1 §9.4: pixels whose centre falls outside the subregion become transparent black + mutating func clip(to rect: LayerTree.Rect, transform: LayerTree.Transform.Matrix) { + let corners = [ + LayerTree.Point(rect.minX, rect.minY), + LayerTree.Point(rect.maxX, rect.minY), + LayerTree.Point(rect.maxX, rect.maxY), + LayerTree.Point(rect.minX, rect.maxY) + ].map(transform.transform(point:)) + let isAxisAligned = (transform.b == 0 && transform.c == 0) || (transform.a == 0 && transform.d == 0) + + if isAxisAligned { + let xs = corners.map(\.x) + let ys = corners.map(\.y) + // whole columns and rows with their centre inside + let minX = Swift.max(0, Swift.min(width, Int((xs.min()! - 0.5).rounded(.up).clamped(-1, Float(width))))) + let maxX = Swift.max(minX, Swift.min(width, Int((xs.max()! - 0.5).rounded(.up).clamped(-1, Float(width))))) + let minY = Swift.max(0, Swift.min(height, Int((ys.min()! - 0.5).rounded(.up).clamped(-1, Float(height))))) + let maxY = Swift.max(minY, Swift.min(height, Int((ys.max()! - 0.5).rounded(.up).clamped(-1, Float(height))))) + guard minX > 0 || minY > 0 || maxX < width || maxY < height else { return } + let w = width + pixels.withUnsafeMutableBufferPointer { dst in + for y in 0..= maxY { + for i in row..<(row + w * 4) { dst[i] = 0 } + continue + } + for i in row..<(row + minX * 4) { dst[i] = 0 } + for i in (row + maxX * 4)..<(row + w * 4) { dst[i] = 0 } + } + } + return + } + + // rotated or skewed: test each pixel centre in user space + guard let inverse = transform.invertedMatrix() else { + pixels = [UInt8](repeating: 0, count: pixels.count) + return + } + let w = width + let h = height + pixels.withUnsafeMutableBufferPointer { dst in + for y in 0..= rect.maxX || p.y < rect.minY || p.y >= rect.maxY else { continue } + let i = (y * w + x) * 4 + dst[i] = 0 + dst[i + 1] = 0 + dst[i + 2] = 0 + dst[i + 3] = 0 + } + } + } + } + + // straight RGBA of a colour, sRGB and Display P3 components alike (renderers convert between spaces) + static func components(of color: LayerTree.Color) -> SIMD4 { + switch color { + case .none: + return .zero + case let .rgba(r: r, g: g, b: b, a: a, space: _): + return SIMD4(r, g, b, a) + case let .gray(white: white, a: a): + return SIMD4(white, white, white, a) + } + } +} + +// MARK: - Pixel access + +private extension FilterBitmap { + + // Runs of equal pixels (transparent areas, solid fills) are computed once. + static func combine(_ a: FilterBitmap, _ b: FilterBitmap, linear: Bool, + _ op: (SIMD4, SIMD4) -> SIMD4) -> FilterBitmap { + precondition(a.pixels.count == b.pixels.count) + var result = FilterBitmap(width: a.width, height: a.height) + a.pixels.withUnsafeBufferPointer { pa in + b.pixels.withUnsafeBufferPointer { pb in + result.pixels.withUnsafeMutableBufferPointer { dst in + guard let baseA = pa.baseAddress, let baseB = pb.baseAddress, let out = dst.baseAddress else { return } + let rawA = UnsafeRawPointer(baseA) + let rawB = UnsafeRawPointer(baseB) + var keyA: UInt32 = 0 + var keyB: UInt32 = 0 + var value = quantize(op(.zero, .zero), linear: linear) + var i = 0 + while i < dst.count { + let pixelA = rawA.loadUnaligned(fromByteOffset: i, as: UInt32.self) + let pixelB = rawB.loadUnaligned(fromByteOffset: i, as: UInt32.self) + if pixelA != keyA || pixelB != keyB { + keyA = pixelA + keyB = pixelB + value = quantize(op(load(baseA, i, linear: linear), load(baseB, i, linear: linear)), linear: linear) + } + if value != .zero { + out[i] = value.x + out[i + 1] = value.y + out[i + 2] = value.z + out[i + 3] = value.w + } + i += 4 + } + } + } + } + return result + } + + static func map(_ source: FilterBitmap, linear: Bool, _ op: (SIMD4) -> SIMD4) -> FilterBitmap { + var result = FilterBitmap(width: source.width, height: source.height) + source.pixels.withUnsafeBufferPointer { src in + result.pixels.withUnsafeMutableBufferPointer { dst in + guard let base = src.baseAddress, let out = dst.baseAddress else { return } + let raw = UnsafeRawPointer(base) + var key: UInt32 = 0 + var value = quantize(op(.zero), linear: linear) + var i = 0 + while i < dst.count { + let pixel = raw.loadUnaligned(fromByteOffset: i, as: UInt32.self) + if pixel != key { + key = pixel + value = quantize(op(load(base, i, linear: linear)), linear: linear) + } + if value != .zero { + out[i] = value.x + out[i + 1] = value.y + out[i + 2] = value.z + out[i + 3] = value.w + } + i += 4 + } + } + } + return result + } + + // premultiplied, 0...1, in linear light when linear + @inline(__always) + static func load(_ p: UnsafePointer, _ i: Int, linear: Bool) -> SIMD4 { + let a = p[i + 3] + guard linear, a > 0 else { + return SIMD4(Float(p[i]), Float(p[i + 1]), Float(p[i + 2]), Float(a)) / 255 + } + let alpha = Float(a) / 255 + return SIMD4(decode(p[i], a) * alpha, decode(p[i + 1], a) * alpha, decode(p[i + 2], a) * alpha, alpha) + } + + @inline(__always) + static func store(_ value: SIMD4, _ p: UnsafeMutablePointer, _ i: Int, linear: Bool) { + let bytes = quantize(value, linear: linear) + p[i] = bytes.x + p[i + 1] = bytes.y + p[i + 2] = bytes.z + p[i + 3] = bytes.w + } + + // clamped to 0...1 with colour never above alpha (premultiplied), then rounded + @inline(__always) + static func quantize(_ value: SIMD4, linear: Bool) -> SIMD4 { + var v = value + v.replace(with: 0, where: v .!= v) + v.clamp(lowerBound: .zero, upperBound: SIMD4(repeating: 1)) + v.x = Swift.min(v.x, v.w) + v.y = Swift.min(v.y, v.w) + v.z = Swift.min(v.z, v.w) + if linear && v.w > 0 { + v.x = encode(v.x / v.w) * v.w + v.y = encode(v.y / v.w) * v.w + v.z = encode(v.z / v.w) * v.w + } + let rounded = v * 255 + 0.5 + return SIMD4(UInt8(rounded.x), UInt8(rounded.y), UInt8(rounded.z), UInt8(rounded.w)) + } + + // a premultiplied channel to its straight value in linear light + @inline(__always) + static func decode(_ c: UInt8, _ a: UInt8) -> Float { + let straight = Swift.min(255, (Int(c) * 255 + Int(a) / 2) / Int(a)) + return linearTable[straight] + } + + // linear light 0...1 to the sRGB encoding 0...1 + @inline(__always) + static func encode(_ linear: Float) -> Float { + let index = Int(linear * Float(encodingTable.count - 1) + 0.5) + return encodingTable[Swift.max(0, Swift.min(encodingTable.count - 1, index))] + } + + // sRGB transfer function (IEC 61966-2-1) + static let linearTable: [Float] = (0...255).map { value in + let c = Float(value) / 255 + return c <= 0.04045 ? c / 12.92 : Float(pow(Double((c + 0.055) / 1.055), 2.4)) + } + + static let encodingTable: [Float] = (0..<8192).map { index in + let l = Float(index) / 8191 + return l <= 0.0031308 ? l * 12.92 : 1.055 * Float(pow(Double(l), 1 / 2.4)) - 0.055 + } +} + +private extension Float { + func clamped(_ lower: Float, _ upper: Float) -> Float { + guard !isNaN else { return lower } + return Swift.max(lower, Swift.min(upper, self)) + } +} + +// MARK: - Blend modes + +private extension FilterBitmap { + + // Compositing and Blending 1 §10: B(Cb, Cs) on straight colours + static func blendColor(_ cb: SIMD3, _ cs: SIMD3, mode: DOM.Filter.BlendMode) -> SIMD3 { + switch mode { + case .normal: + return cs + case .multiply: + return cb * cs + case .screen: + return cb + cs - cb * cs + case .overlay: + return separable(cb, cs) { b, s in hardLight(s, b) } + case .darken: + return pointwiseMin(cb, cs) + case .lighten: + return pointwiseMax(cb, cs) + case .colorDodge: + return separable(cb, cs) { b, s in + if b == 0 { return 0 } + if s >= 1 { return 1 } + return Swift.min(1, b / (1 - s)) + } + case .colorBurn: + return separable(cb, cs) { b, s in + if b >= 1 { return 1 } + if s <= 0 { return 0 } + return 1 - Swift.min(1, (1 - b) / s) + } + case .hardLight: + return separable(cb, cs, hardLight) + case .softLight: + return separable(cb, cs) { b, s in + if s <= 0.5 { + return b - (1 - 2 * s) * b * (1 - b) + } + let d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : b.squareRoot() + return b + (2 * s - 1) * (d - b) + } + case .difference: + return separable(cb, cs) { b, s in abs(b - s) } + case .exclusion: + return cb + cs - 2 * cb * cs + case .hue: + return setLum(setSat(cs, sat(cb)), lum(cb)) + case .saturation: + return setLum(setSat(cb, sat(cs)), lum(cb)) + case .color: + return setLum(cs, lum(cb)) + case .luminosity: + return setLum(cb, lum(cs)) + } + } + + static func separable(_ cb: SIMD3, _ cs: SIMD3, _ f: (Float, Float) -> Float) -> SIMD3 { + SIMD3(f(cb.x, cs.x), f(cb.y, cs.y), f(cb.z, cs.z)) + } + + static func hardLight(_ b: Float, _ s: Float) -> Float { + if s <= 0.5 { + return b * 2 * s + } + let t = 2 * s - 1 + return b + t - b * t + } + + static func lum(_ c: SIMD3) -> Float { + 0.3 * c.x + 0.59 * c.y + 0.11 * c.z + } + + static func clipColor(_ c: SIMD3) -> SIMD3 { + let l = lum(c) + let n = c.min() + let x = c.max() + var c = c + if n < 0 { + c = SIMD3(repeating: l) + (c - SIMD3(repeating: l)) * l / (l - n) + } + if x > 1 { + c = SIMD3(repeating: l) + (c - SIMD3(repeating: l)) * (1 - l) / (x - l) + } + return c + } + + static func setLum(_ c: SIMD3, _ l: Float) -> SIMD3 { + clipColor(c + SIMD3(repeating: l - lum(c))) + } + + static func sat(_ c: SIMD3) -> Float { + c.max() - c.min() + } + + static func setSat(_ c: SIMD3, _ s: Float) -> SIMD3 { + let maximum = c.max() + let minimum = c.min() + guard maximum > minimum else { return .zero } + return (c - SIMD3(repeating: minimum)) * s / (maximum - minimum) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.Builder.ReferenceCycleTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.Builder.ReferenceCycleTests.swift new file mode 100644 index 00000000..84df2c02 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.Builder.ReferenceCycleTests.swift @@ -0,0 +1,252 @@ +// +// LayerTree.Builder.ReferenceCycleTests.swift +// SwiftDraw +// Created by Simon Whitty on 21/11/18. +// Copyright 2020 WhileLoop Pty Ltd. All rights reserved. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. + +import SwiftDrawDOM +import XCTest +import Foundation +@testable import SwiftDraw + +final class LayerTreeBuilderReferenceCycleTests: XCTestCase { + + private struct Stats { + var layers = 0 + var shapes = 0 + var maskedLayers = 0 + var patternFills = 0 + var nesting = 0 + } + + private func makeSVG(_ body: String) throws -> DOM.SVG { + try DOM.SVG.parse(xml: """ + + \(body) + + """) + } + + private func stats(_ body: String) throws -> Stats { + let layer = LayerTree.Builder(svg: try makeSVG(body)).makeLayer() + return Self.stats(of: layer) + } + + private static func stats(of layer: LayerTree.Layer) -> Stats { + var stats = Stats() + collect(layer, depth: 1, into: &stats) + return stats + } + + private static func collect(_ layer: LayerTree.Layer, depth: Int, into stats: inout Stats) { + stats.layers += 1 + stats.nesting = max(stats.nesting, depth) + if let mask = layer.mask { + stats.maskedLayers += 1 + collect(mask, depth: depth + 1, into: &stats) + } + collect(layer.contents, depth: depth + 1, into: &stats) + } + + private static func collect(_ contents: [LayerTree.Layer.Contents], depth: Int, into stats: inout Stats) { + for content in contents { + switch content { + case .layer(let l): + collect(l, depth: depth, into: &stats) + case .shape(_, _, let fill): + stats.shapes += 1 + if case .pattern(let pattern) = fill.fill { + stats.patternFills += 1 + collect(pattern.contents, depth: depth + 1, into: &stats) + } + case .image, .text: + break + } + } + } + + /// ` ... ` + private static func chain(count: Int) -> String { + var svg = "" + for i in 0.."## + } + svg += ##""## + return svg + } + + // MARK: - cycles + + func testUseOfOwnAncestorDrawsNoExtraCopy() throws { + // rendered directly: the group draws once, the nested use is the error + let s = try stats(##""##) + XCTAssertEqual(s.shapes, 1) + } + + func testUseOfItselfDrawsNothing() throws { + let s = try stats(##""##) + XCTAssertEqual(s.shapes, 0) + } + + func testMutualCycleRenderedDirectlyDrawsEachGroupOnce() throws { + let s = try stats(##""" + + + """##) + XCTAssertEqual(s.shapes, 2) + } + + func testUseOfCyclicTargetDrawsNothing() throws { + let s = try stats(##""" + + + """##) + XCTAssertEqual(s.shapes, 0) + } + + func testIndirectCycleThroughTargetDrawsNothing() throws { + let s = try stats(##""" + + + + + + """##) + XCTAssertEqual(s.shapes, 0) + } + + // MARK: - valid references still draw + + func testDiamondDrawsSharedTargetTwice() throws { + let s = try stats(##""" + + + + + + + + """##) + XCTAssertEqual(s.shapes, 2) + } + + func testRepeatedSiblingUsesAreAllDrawn() throws { + let s = try stats(##""" + + + """##) + XCTAssertEqual(s.shapes, 3) + } + + func testChainUnderTheCapIsDrawnFully() throws { + let s = try stats(##"\##(Self.chain(count: 5))"##) + XCTAssertEqual(s.shapes, 1) + } + + // MARK: - caps + + func testLongChainStopsAtTheCapWithoutCrashing() throws { + let s = try stats(##"\##(Self.chain(count: 100))"##) + XCTAssertEqual(s.shapes, 0) + XCTAssertLessThanOrEqual(s.layers, 4 * LayerTree.Builder.ReferenceGuard.maxDepth + 8) + } + + func testFanOutIsBoundedByTheReferenceBudget() throws { + var body = "" + for i in 0..<30 { + body += ##""## + } + body += ##""## + let s = try stats(body) + XCTAssertLessThanOrEqual(s.layers, 10 * LayerTree.Builder.ReferenceGuard.maxReferences) + } + + func testCapDepthFitsASmallSecondaryThreadStack() throws { + let maxDepth = LayerTree.Builder.ReferenceGuard.maxDepth + nonisolated(unsafe) let svg = try makeSVG(##"\##(Self.chain(count: maxDepth - 1))"##) + nonisolated(unsafe) var result = Stats() + let done = DispatchSemaphore(value: 0) + let thread = Thread { + result = Self.stats(of: LayerTree.Builder(svg: svg).makeLayer()) + done.signal() + } + thread.stackSize = 512 * 1024 + thread.start() + XCTAssertEqual(done.wait(timeout: .now() + 30), .success) + XCTAssertEqual(result.shapes, 1) + } + + // MARK: - mask, pattern, gradient + + func testMaskReferencingItselfIsDroppedInsideTheMask() throws { + let s = try stats(##""" + + + """##) + XCTAssertEqual(s.maskedLayers, 1) + } + + func testPatternPaintedWithItselfIsDroppedInsideThePattern() throws { + let s = try stats(##""" + + + + + """##) + XCTAssertEqual(s.patternFills, 1) + } + + func testPatternHrefCycleStillPaints() throws { + let s = try stats(##""" + + + + """##) + XCTAssertEqual(s.patternFills, 1) + XCTAssertEqual(s.shapes, 2) // the filled rect and the rect inherited from #b + } + + func testPatternHrefChainStopsAtTheCap() throws { + var body = "" + for i in 0..<100 { + body += ##""## + } + body += ##""## + let s = try stats(body) + // the content lives past the cap, so it is not inherited; the document still renders + XCTAssertEqual(s.patternFills, 1) + XCTAssertEqual(s.shapes, 1) + } + + func testGradientHrefCycleDoesNotRecurse() throws { + let s = try stats(##""" + + + + """##) + XCTAssertEqual(s.shapes, 1) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift index 3c041bab..f7c05c76 100644 --- a/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift +++ b/SwiftDraw/Tests/LayerTree/LayerTree.BuilderTests.swift @@ -144,6 +144,142 @@ final class LayerTreeBuilderTests: XCTestCase { XCTAssertEqual(pattern.contentUnits, LayerTree.PatternUnits.userSpaceOnUse) } + func testDOMPatternDefaultsToObjectBoundingBoxUnits() { + let builder = LayerTree.Builder(svg: DOM.SVG(width: 100, height: 100)) + + var element = DOM.Pattern(id: "p", width: 0.5, height: 0.25) + element.x = 0.1 + + let pattern = builder.makePattern(for: element) + XCTAssertEqual(pattern.units, .objectBoundingBox) + XCTAssertEqual(pattern.contentUnits, .userSpaceOnUse) + XCTAssertEqual(pattern.frame, LayerTree.Rect(x: 0.1, y: 0, width: 0.5, height: 0.25)) + XCTAssertEqual(pattern.transform, .identity) + XCTAssertNil(pattern.viewBox) + } + + func testDOMPatternPercentagesResolveAgainstViewportInUserSpace() { + let svg = DOM.SVG(width: 200, height: 100) + svg.viewBox = .init(x: 0, y: 0, width: 400, height: 50) + + var element = DOM.Pattern(id: "p", width: 0.5, height: 1) + element.x = 0.1 + element.y = 4 + element.patternUnits = .userSpaceOnUse + element.percentageAttributes = ["x", "width", "height"] + + let pattern = LayerTree.Builder(svg: svg).makePattern(for: element) + // x 10% and width 50% of the viewBox width 400, height 100% of 50; y is a plain number + XCTAssertEqual(pattern.frame, LayerTree.Rect(x: 40, y: 4, width: 200, height: 50)) + } + + func testDOMPatternPercentagesStayFractionsInObjectBoundingBox() { + var element = DOM.Pattern(id: "p", width: 0.5, height: 1) + element.percentageAttributes = ["width", "height"] + let pattern = LayerTree.Builder(svg: DOM.SVG(width: 200, height: 100)).makePattern(for: element) + XCTAssertEqual(pattern.frame, LayerTree.Rect(x: 0, y: 0, width: 0.5, height: 1)) + } + + func testDOMPatternInheritedPercentageKeepsItsUnit() { + var base = DOM.Pattern(id: "base", width: 0.25, height: 8) + base.percentageAttributes = ["width"] + var derived = DOM.Pattern(id: "derived") + derived.href = URL(string: "#base") + derived.patternUnits = .userSpaceOnUse + + let svg = DOM.SVG(width: 100, height: 100) + svg.defs.patterns = [base, derived] + let pattern = LayerTree.Builder(svg: svg).makePattern(for: derived) + XCTAssertEqual(pattern.frame.size, LayerTree.Size(25, 8)) + } + + func testDOMPatternMissingSizeIsEmpty() { + let builder = LayerTree.Builder(svg: DOM.SVG(width: 100, height: 100)) + let pattern = builder.makePattern(for: DOM.Pattern(id: "p")) + XCTAssertEqual(pattern.frame, .zero) + } + + func testDOMPatternViewBoxAndTransform() { + let builder = LayerTree.Builder(svg: DOM.SVG(width: 100, height: 100)) + + var element = DOM.Pattern(id: "p", width: 10, height: 10) + element.viewBox = .init(x: 0, y: 0, width: 20, height: 20) + element.patternTransform = [.translate(tx: 5, ty: 0), .scale(sx: 2, sy: 2)] + + let pattern = builder.makePattern(for: element) + XCTAssertEqual(pattern.viewBox, LayerTree.Rect(x: 0, y: 0, width: 20, height: 20)) + XCTAssertEqual(pattern.transform, .init(a: 2, b: 0, c: 0, d: 2, tx: 5, ty: 0)) + } + + func testDOMPatternInheritsAttributesAndContentThroughHref() { + // Inkscape: + var base = DOM.Pattern(id: "base", width: 8, height: 4) + base.patternUnits = .userSpaceOnUse + base.x = 1 + base.patternTransform = [.scale(sx: 3, sy: 3)] + base.childElements = [DOM.Circle(cx: 2, cy: 2, r: 1)] + + var middle = DOM.Pattern(id: "middle") + middle.href = URL(string: "#base") + middle.y = 2 + + var derived = DOM.Pattern(id: "derived") + derived.href = URL(string: "#middle") + derived.patternTransform = [.translate(tx: 10, ty: 20)] + + let svg = DOM.SVG(width: 100, height: 100) + svg.defs.patterns = [base, middle, derived] + let builder = LayerTree.Builder(svg: svg) + + let pattern = builder.makePattern(for: derived) + XCTAssertEqual(pattern.units, .userSpaceOnUse) + XCTAssertEqual(pattern.frame, LayerTree.Rect(x: 1, y: 2, width: 8, height: 4)) + XCTAssertEqual(pattern.transform, .init(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 20)) + XCTAssertEqual(pattern.contents, builder.makePattern(for: base).contents) + XCTAssertEqual(pattern.contents.count, 1) + } + + func testDOMPatternOwnChildrenOverrideHref() { + var base = DOM.Pattern(id: "base", width: 8, height: 8) + base.childElements = [DOM.Circle(cx: 2, cy: 2, r: 1), DOM.Circle(cx: 4, cy: 4, r: 1)] + + var derived = DOM.Pattern(id: "derived") + derived.href = URL(string: "#base") + derived.childElements = [DOM.Circle(cx: 1, cy: 1, r: 1)] + + let svg = DOM.SVG(width: 100, height: 100) + svg.defs.patterns = [base, derived] + + let pattern = LayerTree.Builder(svg: svg).makePattern(for: derived) + XCTAssertEqual(pattern.contents.count, 1) + XCTAssertEqual(pattern.frame.size, LayerTree.Size(8, 8)) + } + + func testDOMPatternHrefCycleTerminates() { + var a = DOM.Pattern(id: "a") + a.href = URL(string: "#b") + var b = DOM.Pattern(id: "b", width: 5, height: 5) + b.href = URL(string: "#a") + var selfRef = DOM.Pattern(id: "self", width: 3, height: 3) + selfRef.href = URL(string: "#self") + + let svg = DOM.SVG(width: 100, height: 100) + svg.defs.patterns = [a, b, selfRef] + let builder = LayerTree.Builder(svg: svg) + + XCTAssertEqual(builder.makePatternChain(for: a).map(\.id), ["a", "b"]) + XCTAssertEqual(builder.makePattern(for: a).frame.size, LayerTree.Size(5, 5)) + XCTAssertEqual(builder.makePatternChain(for: selfRef).map(\.id), ["self"]) + } + + func testDOMPatternHrefToMissingPattern() { + var element = DOM.Pattern(id: "p", width: 2, height: 2) + element.href = URL(string: "#missing") + let builder = LayerTree.Builder(svg: DOM.SVG(width: 100, height: 100)) + XCTAssertEqual(builder.makePatternChain(for: element).map(\.id), ["p"]) + XCTAssertEqual(builder.makePattern(for: element).frame.size, LayerTree.Size(2, 2)) + } + func testStrokeAttributes() { var state = LayerTree.Builder.State() state.stroke = .color(.rgbf(1.0, 0.0, 0.0, 1.0)) @@ -202,8 +338,8 @@ extension LayerTree.Builder { static func makeTransform( viewBox: DOM.SVG.ViewBox?, - width: DOM.Length, - height: DOM.Length + width: DOM.Coordinate, + height: DOM.Coordinate ) -> [LayerTree.Transform] { makeTransform( x: nil, diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.CSSCascadeTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.CSSCascadeTests.swift new file mode 100644 index 00000000..ed200c04 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.CSSCascadeTests.swift @@ -0,0 +1,217 @@ +// +// LayerTree.CSSCascadeTests.swift +// SwiftDraw +// +// Added by Misoservices for the Backdrop fork of SwiftDraw (altered source version). +// Copyright 2026 Misoservices +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw +import Foundation + +final class LayerTreeCSSCascadeTests: XCTestCase { + + private struct NoChildLayer: Error {} + + private func makeLayer(_ body: String) throws -> LayerTree.Layer { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + return LayerTree.Builder(svg: svg).makeLayer() + } + + private func commandStream(_ body: String) throws -> [RendererCommand] { + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: try makeLayer(body), colorConverter: .default) + } + + // the layer of the first element drawn under the root + private func firstChild(_ body: String) throws -> LayerTree.Layer { + let root = try makeLayer(body) + for case .layer(let l) in root.contents { + return l + } + throw NoChildLayer() + } + + func testTransformFromStyleSheet() throws { + let layer = try firstChild(#""#) + XCTAssertEqual(layer.transform, [.translate(tx: 10, ty: 20)]) + } + + func testTransformFromStyleAttribute() throws { + let layer = try firstChild(#""#) + XCTAssertEqual(layer.transform, [.rotate(radians: .pi / 2)]) + } + + func testStyleSheetTransformOverridesAttribute() throws { + let layer = try firstChild(#""#) + XCTAssertEqual(layer.transform, [.scale(sx: 2, sy: 2)]) + } + + func testMaskFromStyleSheet() throws { + let layer = try firstChild(""" + + + + """) + XCTAssertNotNil(layer.mask) + } + + // clip-rule applies to the children (SVG 1.1 §14.3.5), set by a stylesheet too + func testClipRuleFromStyleSheet() throws { + let svg = """ + + + + """ + XCTAssertEqual(try firstChild(svg).clipRule, .evenodd) + XCTAssertTrue(try commandStream(svg).contains { + if case .setClip(path: _, rule: .evenodd) = $0 { return true } + return false + }) + } + + func testClipChildTransformFromStyleSheet() throws { + let layer = try firstChild(""" + + + + """) + XCTAssertEqual(layer.clip.first?.transform, .init(a: 1, b: 0, c: 0, d: 1, tx: 3, ty: 4)) + } + + func testStyleSheetFontSizeBeatsPresentationAttribute() throws { + let root = try makeLayer(#"Hi"#) + let sizes = root.contents.compactMap { contents -> LayerTree.Float? in + if case let .text(_, _, att) = contents { return att.size } + return nil + } + XCTAssertEqual(sizes, [30]) + } + + func testDescendantSelectorFillsCommandStream() throws { + let commands = try commandStream(""" + + + """) + let fills = commands.compactMap { command -> LayerTree.Color? in + if case let .setFill(color: c) = command { return c } + return nil + } + XCTAssertEqual(fills, [.rgba(r: 1, g: 0, b: 0, a: 1, space: .srgb), .black]) + } + + func testOneBadDeclarationKeepsTheSheet() throws { + let commands = try commandStream(""" + + + """) + let fills = commands.compactMap { command -> LayerTree.Color? in + if case let .setFill(color: c) = command { return c } + return nil + } + XCTAssertEqual(fills, [.black, .rgba(r: 0, g: 0, b: 1, a: 1, space: .srgb)]) + } + + private func fills(_ body: String) throws -> [LayerTree.Color] { + try commandStream(body).compactMap { + if case let .setFill(color: c) = $0 { return c } + return nil + } + } + + func testUseKeepsTheStyleOfItsOriginalElement() throws { + let colors = try fills(""" + + + + """) + // the referenced rect matches in ; the circle inherits fill from the styled + XCTAssertEqual(colors, [.rgba(r: 1, g: 0, b: 0, a: 1, space: .srgb), .rgba(r: 0, g: 0, b: 1, a: 1, space: .srgb)]) + } + + func testRulesApplyInsideNestedSVG() throws { + let colors = try fills(""" + + + + """) + // (a nested 's children are currently emitted twice, a parser bug outside SD10) + XCTAssertEqual(colors.first, .black) + XCTAssertGreaterThan(colors.count, 1) + XCTAssertTrue(colors.dropFirst().allSatisfy { $0 == .rgba(r: 1, g: 0, b: 0, a: 1, space: .srgb) }) + } + + func testNoneTransformOverridesLowerRule() throws { + let root = try makeLayer(#""#) + // without a transform the rect is a plain shape, not a transformed layer + XCTAssertEqual(root.contents.count, 1) + guard case .shape = root.contents.first else { + return XCTFail("expected an untransformed shape, got \(root.contents)") + } + } + + func testNoneMaskOverridesLowerRule() throws { + let root = try makeLayer(""" + + + + """) + // without a mask the rect is a plain shape, not a masked layer + XCTAssertEqual(root.contents.count, 1) + guard case .shape = root.contents.first else { + return XCTFail("expected an unmasked shape, got \(root.contents)") + } + } + + func testStylesheetStrokeWidthIsScaledForSFSymbols() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + """) + StrokeWidthScaler.scale(svg, by: .init(multiplier: 0.5)) + let group = try XCTUnwrap(svg.childElements.first as? DOM.Group) + XCTAssertEqual(DOM.presentationAttributes(for: group.childElements[0], styles: svg.styles).strokeWidth, 2) + XCTAssertEqual(DOM.presentationAttributes(for: group.childElements[1], styles: svg.styles).strokeWidth, 3) + XCTAssertEqual(svg.styles[0].rules[0].attributes.strokeWidth, 2) + } + + func testComplexSelectorStrokeWidthCountsForSFSymbols() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + """) + // the renderer warns "no effect" when this is 0 + XCTAssertEqual(StrokeWidthScaler.scale(svg, by: .init(multiplier: 0.5)), 1) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.ClipMaskTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.ClipMaskTests.swift new file mode 100644 index 00000000..4a5dd721 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.ClipMaskTests.swift @@ -0,0 +1,514 @@ +// +// LayerTree.ClipMaskTests.swift +// SwiftDraw +// +// Created by Simon Whitty on 13/12/18. +// Copyright 2020 WhileLoop Pty Ltd. All rights reserved. +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM + +import XCTest +@testable import SwiftDraw +import Foundation + +/// SD8: clip paths and masks to spec (SVG 1.1 §14.3.5, §14.4). Asserts on the command stream. +final class LayerTreeClipMaskTests: XCTestCase { + + typealias Command = RendererCommand + + private func commands(_ body: String) throws -> [Command] { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default) + } + + private func clips(_ commands: [Command]) -> [(bounds: LayerTree.Rect, rule: LayerTree.FillRule)] { + commands.compactMap { + guard case let .setClip(path: shapes, rule: rule) = $0 else { return nil } + return (bounds(shapes), rule) + } + } + + private func fills(_ commands: [Command]) -> [(bounds: LayerTree.Rect, rule: LayerTree.FillRule)] { + commands.compactMap { + guard case let .fill(shapes, rule: rule) = $0 else { return nil } + return (bounds(shapes), rule) + } + } + + private func bounds(_ shapes: [LayerTree.Shape]) -> LayerTree.Rect { + shapes.map(\.path.bounds).reduce(nil as LayerTree.Rect?) { acc, b in acc.map { $0.union(b) } ?? b } ?? .zero + } + + private func transparencyLayers(_ commands: [Command]) -> Int { + commands.filter { if case .pushTransparencyLayer = $0 { return true } else { return false } }.count + } + + private func assertRect(_ rect: LayerTree.Rect?, _ x: Float, _ y: Float, _ w: Float, _ h: Float, + file: StaticString = #filePath, line: UInt = #line) { + guard let rect else { return XCTFail("no rect", file: file, line: line) } + XCTAssertEqual(rect.x, x, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(rect.y, y, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(rect.width, w, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(rect.height, h, accuracy: 0.001, file: file, line: line) + } + + // MARK: - clipPathUnits + + func testClipPathUnitsObjectBoundingBox() throws { + let c = try commands(""" + + + """) + XCTAssertEqual(clips(c).count, 1) + assertRect(clips(c).first?.bounds, 35, 35, 30, 30) + } + + func testClipPathUnitsObjectBoundingBoxOfGroup() throws { + // group bounds: union of (0,0,20,20) and (60,40,40,40 translated by 0,20) = (0,0,100,100) + let c = try commands(""" + + + + + + """) + assertRect(clips(c).first?.bounds, 0, 0, 50, 100) + } + + func testClipPathUnitsObjectBoundingBoxIgnoresElementTransform() throws { + // the clip lives in the element's user space, after its own transform + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 10, 10, 10, 10) + } + + func testClipPathUnitsObjectBoundingBoxAppliesClipPathTransform() throws { + let c = try commands(""" + + + """) + // the bbox mapping comes first, then the transform in user units (Blink, Gecko, resvg) + assertRect(clips(c).first?.bounds, 0.5, 0, 20, 40) + } + + func testClipPathUnitsObjectBoundingBoxOfEmptyBoxClipsEverything() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 0, 0, 0, 0) + } + + func testClipPathUnitsUserSpaceOnUse() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 5, 5, 10, 10) + } + + func testInvalidClipPathUnitsKeepsDocument() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 5, 5, 10, 10) + XCTAssertEqual(fills(c).count, 1) + } + + // MARK: - clip-rule + + func testClipRuleOnClipPathChild() throws { + let c = try commands(""" + + + """) + XCTAssertEqual(clips(c).first?.rule, .evenodd) + } + + func testClipRuleInheritedFromClipPath() throws { + let c = try commands(""" + + + """) + XCTAssertEqual(clips(c).first?.rule, .evenodd) + } + + func testClipRuleOnChildOverridesClipPath() throws { + let c = try commands(""" + + + """) + XCTAssertEqual(clips(c).first?.rule, .nonzero) + } + + func testClipRuleOnReferencingElementIsNotUsed() throws { + // clip-rule is inherited by the contents from the , not from the element it clips + let c = try commands(""" + + + """) + XCTAssertEqual(clips(c).first?.rule, .nonzero) + } + + func testClipRuleFromStyleAttribute() throws { + let c = try commands(""" + + + """) + XCTAssertEqual(clips(c).first?.rule, .evenodd) + } + + func testMixedClipRulesBecomeMask() throws { + let c = try commands(""" + + + + + + """) + XCTAssertTrue(clips(c).isEmpty) + XCTAssertGreaterThan(transparencyLayers(c), 0) + let maskFills = fills(c).filter { $0.bounds.width == 10 } + XCTAssertEqual(maskFills.map(\.rule), [.evenodd, .nonzero]) + } + + // MARK: - and inside + + func testUseInsideClipPath() throws { + let c = try commands(""" + + + + """) + // rect → translate(1,2) → translate(20,30) → scale(2) + assertRect(clips(c).first?.bounds, 42, 64, 20, 20) + } + + func testUseInsideClipPathInheritsClipRule() throws { + let c = try commands(""" + + + + """) + XCTAssertEqual(clips(c).first?.rule, .evenodd) + } + + func testUseOfGroupInsideClipPathIsIgnored() throws { + let c = try commands(""" + + + + """) + assertRect(clips(c).first?.bounds, 0, 0, 0, 0) + } + + func testTextInsideClipPathBecomesMask() throws { + let svg = try DOM.SVG.parse(xml: """ + + Hi + + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let clipped = try XCTUnwrap(findLayer(in: layer) { $0.mask != nil }) + XCTAssertTrue(clipped.clip.isEmpty) + let texts = allContents(of: try XCTUnwrap(clipped.mask)).compactMap { c -> LayerTree.TextAttributes? in + if case let .text(_, _, att) = c { return att } else { return nil } + } + XCTAssertEqual(texts.map(\.color), [.white]) + } + + func testEmptyClipPathClipsEverything() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 0, 0, 0, 0) + } + + func testHiddenClipPathChildIsIgnored() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 0, 0, 10, 10) + } + + // MARK: - clip-path on + + func testClipPathOnClipPathIntersects() throws { + let c = try commands(""" + + + + """) + // the outer clip is drawn as a mask, clipped by the inner one + XCTAssertGreaterThan(transparencyLayers(c), 0) + assertRect(clips(c).first?.bounds, 5, 5, 10, 10) + XCTAssertEqual(fills(c).map(\.bounds.width), [50, 10]) + } + + func testSelfReferencingClipPathKeepsDocument() throws { + let c = try commands(""" + + + """) + XCTAssertFalse(fills(c).isEmpty) + } + + func testClipAndMaskTogether() throws { + let svg = try DOM.SVG.parse(xml: """ + + + Hi + + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let masked = try XCTUnwrap(findLayer(in: layer) { $0.mask != nil }) + // the clip masks the mask's own contents + let inner = try XCTUnwrap(findLayer(in: try XCTUnwrap(masked.mask)) { $0.mask != nil }) + XCTAssertFalse(allContents(of: try XCTUnwrap(inner.mask)).isEmpty) + } + + // MARK: - masks + + func testMaskRegionDefaultsToBoundingBoxPlusTenPercent() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 5, 16, 60, 48) + } + + func testMaskRegionObjectBoundingBox() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 10, 40, 25, 20) + } + + func testMaskRegionUserSpaceOnUse() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 5, 10, 20, 50) + } + + func testMaskContentUnitsObjectBoundingBox() throws { + let c = try commands(""" + + + """) + // the content rect is scaled by the bounding box: (10,20,25,40) + let scale = c.compactMap { cmd -> LayerTree.Transform? in + if case let .concatenate(transform: t) = cmd { return t } else { return nil } + } + XCTAssertEqual(scale, [.matrix(.init(a: 50, b: 0, c: 0, d: 40, tx: 10, ty: 20))]) + XCTAssertEqual(fills(c).map(\.bounds.width), [50, 0.5]) + } + + func testMaskZeroRegionHidesElement() throws { + let c = try commands(""" + + + """) + // the element is drawn into a transparency layer, but the mask draws nothing into its + // destination-in layer, so nothing survives + let blend = try XCTUnwrap(c.firstIndex { if case .setBlend = $0 { return true } else { return false } }) + XCTAssertEqual(fills(Array(c[.. + + """) + // the mask draws nothing between its destination-in blend and the end of the stream + let blend = try XCTUnwrap(c.lastIndex { if case .setBlend = $0 { return true } else { return false } }) + XCTAssertTrue(fills(Array(c[blend...])).isEmpty) + XCTAssertGreaterThan(transparencyLayers(c), 0) + } + + func testInvalidMaskAttributesKeepDocument() throws { + let c = try commands(""" + + + """) + assertRect(clips(c).first?.bounds, 5, 16, 60, 48) + } + + // MARK: - review round 1 + + func testUseOfTextInsideClipPathBecomesMask() throws { + let svg = try DOM.SVG.parse(xml: """ + + Hi + + + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let clipped = try XCTUnwrap(findLayer(in: layer) { $0.mask != nil }) + XCTAssertTrue(clipped.maskIsClip) + let mask = try XCTUnwrap(clipped.mask) + let texts = allContents(of: mask).compactMap { c -> LayerTree.TextAttributes? in + if case let .text(_, _, att) = c { return att } else { return nil } + } + XCTAssertEqual(texts.map(\.color), [.white]) + // the offset reaches the text's layer + XCTAssertNotNil(findLayer(in: mask) { $0.transform == [.matrix(.init(a: 1, b: 0, c: 0, d: 1, tx: 5, ty: 0))] }) + } + + func testMutualClipCycleKeepsDocument() throws { + let c = try commands(""" + + + + """) + // a → b → (a dropped): b clips a, both are drawn as masks + XCTAssertEqual(fills(c).map(\.bounds.width), [50, 10, 20]) + } + + func testClipPathInsideMaskContents() throws { + let c = try commands(""" + + + + + + """) + XCTAssertEqual(clips(c).map(\.bounds.width), [100, 10]) + } + + func testManyElementsSharingOneClipAllRender() throws { + // above what the old document-wide budget (20,000 references, three per element here) allowed + let count = 7_000 + let rects = (0.."## }.joined() + let c = try commands(""" + + + \(rects) + """) + XCTAssertEqual(clips(c).count, count) + XCTAssertEqual(fills(c).count, count) + } + + func testGroupWithTextHasUnknownBoundingBox() throws { + // a bbox measured without the text would cut it away: the region and the bbox clip are skipped + let svg = try DOM.SVG.parse(xml: """ + + + + Hi + Hi + + """) + let builder = LayerTree.Builder(svg: svg) + XCTAssertNil(builder.makeBoundingBox(for: svg.childElements[0])) + let layer = builder.makeLayer() + let masked = try XCTUnwrap(findLayer(in: layer) { $0.mask != nil }) + XCTAssertNil(findLayer(in: try XCTUnwrap(masked.mask)) { !$0.clip.isEmpty }) + XCTAssertNil(findLayer(in: layer) { !$0.clip.isEmpty }) + } + + func testMoveOnlyPathDoesNotCollapseGroupBoundingBox() throws { + let c = try commands(""" + + + + + + """) + assertRect(clips(c).first?.bounds, 0, 0, 20, 40) + } + + // MARK: - helpers + + func testBoundingBoxOfUseFanOutStaysBounded() throws { + // 4 uses per level over 12 levels is 4^12 walks: measuring gives up instead and the box is unknown + var defs = ##""## + for level in stride(from: 11, through: 0, by: -1) { + defs += ##""## + String(repeating: ##""##, count: 4) + "" + } + let svg = try DOM.SVG.parse(xml: """ + + \(defs) + + """) + let builder = LayerTree.Builder(svg: svg) + let use = try XCTUnwrap(svg.firstGraphicsElement(with: "u")) + XCTAssertNil(builder.makeBoundingBox(for: use)) + XCTAssertEqual(builder.measurement.steps, LayerTree.Builder.MeasurementBudget.maxSteps) + } + + func testClipPathChainFitsASmallSecondaryThreadStack() throws { + // each nested clip-path costs native stack: the chain is cut at ReferenceGuard.maxDepth + let chain = (0..<2_000).map { + ##""## + }.joined() + nonisolated(unsafe) let svg = try DOM.SVG.parse(xml: """ + + \(chain) + + """) + nonisolated(unsafe) var result = [Command]() + let done = DispatchSemaphore(value: 0) + let thread = Thread { + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + result = generator.renderCommands(for: layer, colorConverter: .default) + done.signal() + } + thread.stackSize = 512 * 1024 + thread.start() + XCTAssertEqual(done.wait(timeout: .now() + 30), .success) + XCTAssertFalse(fills(result).isEmpty) + } + + private func findLayer(in layer: LayerTree.Layer, where predicate: (LayerTree.Layer) -> Bool) -> LayerTree.Layer? { + if predicate(layer) { return layer } + for case let .layer(child) in layer.contents { + if let found = findLayer(in: child, where: predicate) { return found } + } + return nil + } + + private func allContents(of layer: LayerTree.Layer) -> [LayerTree.Layer.Contents] { + layer.contents.flatMap { c -> [LayerTree.Layer.Contents] in + if case let .layer(child) = c { return allContents(of: child) } + return [c] + } + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.CommandGeneratorTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.CommandGeneratorTests.swift index d43dfae7..a768f6ab 100644 --- a/SwiftDraw/Tests/LayerTree/LayerTree.CommandGeneratorTests.swift +++ b/SwiftDraw/Tests/LayerTree/LayerTree.CommandGeneratorTests.swift @@ -71,6 +71,130 @@ final class LayerTreeCommandGeneratorTests: XCTestCase { let commands = generator.renderCommands(forTransforms: [matrix, scale, translate, rotate]) XCTAssertEqual(commands.count, 4) } + + // MARK: - Patterns (SVG 1.1 §13.3) + + typealias Generator = LayerTree.CommandGenerator + + func testPatternUserSpaceTileIsOffsetByXY() throws { + let pattern = LayerTree.Pattern(frame: .init(x: 3, y: 4, width: 10, height: 20)) + let (resolved, content) = try XCTUnwrap(Generator.resolvePattern(pattern, in: .init(x: 50, y: 50, width: 100, height: 100))) + XCTAssertEqual(resolved.frame, .init(x: 3, y: 4, width: 10, height: 20)) + XCTAssertEqual(resolved.transform, .identity) + // contents are drawn relative to the tile origin + XCTAssertEqual(content, .init(a: 1, b: 0, c: 0, d: 1, tx: 3, ty: 4)) + } + + func testPatternObjectBoundingBoxTile() throws { + let pattern = LayerTree.Pattern(frame: .init(x: 0.1, y: 0, width: 0.25, height: 0.5), units: .objectBoundingBox) + let (resolved, content) = try XCTUnwrap(Generator.resolvePattern(pattern, in: .init(x: 20, y: 40, width: 200, height: 100))) + XCTAssertEqual(resolved.frame, .init(x: 40, y: 40, width: 50, height: 50)) + XCTAssertEqual(content, .init(a: 1, b: 0, c: 0, d: 1, tx: 40, ty: 40)) + } + + func testPatternObjectBoundingBoxContentUnits() throws { + let pattern = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 10, height: 10), contentUnits: .objectBoundingBox) + let (_, content) = try XCTUnwrap(Generator.resolvePattern(pattern, in: .init(x: 20, y: 40, width: 200, height: 100))) + // content scaled by the bounding box size, origin at the tile (not the bounding box) + XCTAssertEqual(content, .init(a: 200, b: 0, c: 0, d: 100, tx: 0, ty: 0)) + } + + func testPatternViewBoxMeetsAndOverridesContentUnits() throws { + let pattern = LayerTree.Pattern(frame: .init(x: 5, y: 0, width: 40, height: 20), contentUnits: .objectBoundingBox) + pattern.viewBox = .init(x: 10, y: 10, width: 10, height: 10) + let (_, content) = try XCTUnwrap(Generator.resolvePattern(pattern, in: .init(x: 0, y: 0, width: 100, height: 100))) + // xMidYMid meet: scale 2, centred horizontally (40 - 20) / 2 = 10, viewBox origin -20 + XCTAssertEqual(content, .init(a: 2, b: 0, c: 0, d: 2, tx: 5 + 10 - 20, ty: -20)) + } + + func testPatternTransformIsCarried() throws { + let pattern = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 10, height: 10)) + pattern.transform = .init(a: 0, b: 1, c: -1, d: 0, tx: 7, ty: 8) + let (resolved, _) = try XCTUnwrap(Generator.resolvePattern(pattern, in: .zero)) + XCTAssertEqual(resolved.transform, pattern.transform) + } + + func testPatternWithEmptyTilePaintsNothing() { + let bounds = LayerTree.Rect(x: 0, y: 0, width: 10, height: 10) + XCTAssertNil(Generator.resolvePattern(LayerTree.Pattern(frame: .zero), in: bounds)) + XCTAssertNil(Generator.resolvePattern(LayerTree.Pattern(frame: .init(x: 0, y: 0, width: -1, height: 5)), in: bounds)) + XCTAssertNil(Generator.resolvePattern(LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 1, height: 1), units: .objectBoundingBox), in: .zero)) + + let emptyViewBox = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5)) + emptyViewBox.viewBox = .init(x: 0, y: 0, width: 0, height: 5) + XCTAssertNil(Generator.resolvePattern(emptyViewBox, in: bounds)) + } + + func testPatternWithDegenerateTransformPaintsNothing() { + let bounds = LayerTree.Rect(x: 0, y: 0, width: 10, height: 10) + let scaledToZero = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5)) + scaledToZero.transform = .init(a: 0, b: 0, c: 0, d: 0, tx: 0, ty: 0) + XCTAssertNil(Generator.resolvePattern(scaledToZero, in: bounds)) + + let collapsed = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5)) + collapsed.transform = .init(a: 1, b: 2, c: 2, d: 4, tx: 0, ty: 0) + XCTAssertNil(Generator.resolvePattern(collapsed, in: bounds)) + + let infinite = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5)) + infinite.transform = .init(a: .infinity, b: 0, c: 0, d: 1, tx: 0, ty: 0) + XCTAssertNil(Generator.resolvePattern(infinite, in: bounds)) + + let nanTile = LayerTree.Pattern(frame: .init(x: .nan, y: 0, width: 5, height: 5)) + XCTAssertNil(Generator.resolvePattern(nanTile, in: bounds)) + } + + func testPatternBoundsOnlyEvaluatedWhenNeeded() { + var evaluated = 0 + func bounds() -> LayerTree.Rect { + evaluated += 1 + return .init(x: 0, y: 0, width: 10, height: 10) + } + + let userSpace = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5)) + _ = Generator.resolvePattern(userSpace, in: bounds()) + XCTAssertEqual(evaluated, 0) + + let viewBoxOverridesContentUnits = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5), contentUnits: .objectBoundingBox) + viewBoxOverridesContentUnits.viewBox = .init(x: 0, y: 0, width: 1, height: 1) + _ = Generator.resolvePattern(viewBoxOverridesContentUnits, in: bounds()) + XCTAssertEqual(evaluated, 0) + + _ = Generator.resolvePattern(LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 5, height: 5), contentUnits: .objectBoundingBox), in: bounds()) + XCTAssertEqual(evaluated, 1) + + _ = Generator.resolvePattern(LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 1, height: 1), units: .objectBoundingBox), in: bounds()) + XCTAssertEqual(evaluated, 2) + } + + func testPatternCommandsFromInkscapeStyleDocument() throws { + let svg = try DOM.SVG.parse(xml: #""" + + + + + + + + + + + + """#) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + let commands = generator.renderCommands(for: layer, colorConverter: .default) + + let patterns = commands.compactMap { command -> LayerTree.Pattern? in + if case .setFillPattern(let p) = command { return p } + return nil + } + // the pattern without width/height paints nothing, and the document still renders + XCTAssertEqual(patterns.count, 1) + XCTAssertEqual(patterns.first?.frame, .init(x: 2, y: 3, width: 8, height: 8)) + XCTAssertEqual(patterns.first?.transform, .init(a: 1, b: 0, c: 0, d: 1, tx: 10, ty: 0)) + XCTAssertEqual(patterns.first?.contents.count, 1) + XCTAssertEqual(commands.filter { if case .fill = $0 { return true } else { return false } }.count, 1) + } } private extension LayerTree.CommandGenerator { diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.DashTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.DashTests.swift new file mode 100644 index 00000000..40a79882 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.DashTests.swift @@ -0,0 +1,155 @@ +// +// LayerTree.DashTests.swift +// SwiftDraw +// +// Created by Simon Whitty on 13/12/18. +// Copyright 2020 WhileLoop Pty Ltd. All rights reserved. +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw +import Foundation + +final class LayerTreeDashTests: XCTestCase { + + private func commandStream(_ body: String) throws -> [RendererCommand] { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default) + } + + private func dashes(_ svgBody: String, attributes: String = "") throws -> [(phase: Float, lengths: [Float])] { + let svg = try DOM.SVG.parse(xml: """ + \(svgBody) + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default).compactMap { + if case let .setLineDash(phase: p, lengths: l) = $0, !l.isEmpty { return (p, l) } + return nil + } + } + + func testBasicDashAndOffset() throws { + let d = try dashes(#""#) + XCTAssertEqual(d.count, 1) + XCTAssertEqual(d[0].lengths, [4, 2]) + XCTAssertEqual(d[0].phase, 1) + } + + func testOddListRepeats() throws { + let d = try dashes(#""#) + XCTAssertEqual(d[0].lengths, [5, 3, 2, 5, 3, 2]) + } + + func testNegativeInvalidatesAttribute() throws { + XCTAssertTrue(try dashes(#""#).isEmpty) + } + + func testGarbageDoesNotDropDocument() throws { + XCTAssertTrue(try dashes(#""#).isEmpty) + } + + func testAllZeroIsSolid() throws { + XCTAssertTrue(try dashes(#""#).isEmpty) + } + + func testNoneOverridesInherited() throws { + let d = try dashes(#""#) + XCTAssertEqual(d.count, 1) + XCTAssertEqual(d[0].lengths, [4, 2]) + } + + func testStyleAttributeAndGroupInheritance() throws { + let d = try dashes(#""#) + XCTAssertEqual(d[0].lengths, [6, 3]) + XCTAssertEqual(d[0].phase, 2) + } + + func testPercentageRelativeToViewportDiagonal() throws { + // width=height=100 => normalized diagonal = 100 + let d = try dashes(#""#) + XCTAssertEqual(d[0].lengths[0], 10, accuracy: 0.001) + XCTAssertEqual(d[0].lengths[1], 5, accuracy: 0.001) + } + + func testSolidStrokeEmitsNoDashCommand() throws { + XCTAssertTrue(try dashes(#""#).isEmpty) + } + + func testDashIsBracketedByPushAndPop() throws { + let commands = try commandStream(#""#) + let names = commands.map { String(describing: $0).prefix(while: { $0 != "(" }) } + let dash = try XCTUnwrap(names.firstIndex(of: "setLineDash")) + let stroke = try XCTUnwrap(names.firstIndex(of: "stroke")) + XCTAssertEqual(names[dash - 5], "pushState") // push, cap, join, width, miter, dash + XCTAssertGreaterThan(stroke, dash) + // explicit reset after the stroke, then pop + guard case let .setLineDash(phase: phase, lengths: lengths) = commands[stroke + 1] else { + return XCTFail("expected dash reset after stroke") + } + XCTAssertEqual(phase, 0) + XCTAssertEqual(lengths, []) + XCTAssertEqual(names[stroke + 2], "popState") + } + + func testPercentageNonSquareViewport() throws { + let d = try dashes(#""#, + attributes: #"viewBox="0 0 200 100""#) + // viewBox 200x100: sqrt((200² + 100²) / 2) = 158.11 + XCTAssertEqual(d[0].lengths[0], 15.811, accuracy: 0.01) + } + + func testPercentageUsesViewBoxNotWidthHeight() throws { + let d = try dashes(#""#, + attributes: #"viewBox="0 0 50 50""#) + XCTAssertEqual(d[0].lengths[0], 5, accuracy: 0.001) + } + + func testNegativeOffsetIsKept() throws { + let d = try dashes(#""#) + XCTAssertEqual(d[0].phase, -3) + } + + func testInvalidChildDasharrayInheritsFromDashedGroup() throws { + let d = try dashes(#""#) + XCTAssertEqual(d.count, 1) + XCTAssertEqual(d[0].lengths, [4, 2]) + } + + func testStyleNoneOverridesGroup() throws { + let d = try dashes(#""#) + XCTAssertTrue(d.isEmpty) + } + + func testNonFiniteValuesAreInvalid() throws { + XCTAssertTrue(try dashes(#""#).isEmpty) + XCTAssertTrue(try dashes(#""#).isEmpty) + let d = try dashes(#""#) + XCTAssertEqual(d[0].phase, 0) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.FilterGraphTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.FilterGraphTests.swift new file mode 100644 index 00000000..c4cdf979 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.FilterGraphTests.swift @@ -0,0 +1,478 @@ +// +// LayerTree.FilterGraphTests.swift +// SwiftDraw +// +// Created by Misoservices on 9/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +@testable import SwiftDrawDOM +@testable import SwiftDraw +import XCTest + +// SD12: filter primitive trees resolved into LayerTree.FilterLayer +final class LayerTreeFilterGraphTests: XCTestCase { + + typealias Primitive = LayerTree.FilterLayer.Primitive + + // Inkscape 1.x Filters ▸ Shadows and Glows ▸ Drop Shadow + func testInkscapeDropShadow() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + + + """#) + + let region = filter.region + XCTAssertEqual(region.x, 0, accuracy: 0.001) + XCTAssertEqual(region.y, 5, accuracy: 0.001) + XCTAssertEqual(region.width, 120, accuracy: 0.001) + XCTAssertEqual(region.height, 60, accuracy: 0.001) + XCTAssertEqual(filter.primitives, [ + Primitive(effect: .flood(.rgba(r: 0, g: 0, b: 0, a: 0.498039, space: .srgb)), + inputs: [], subregion: region, colorInterpolation: .sRGB), + Primitive(effect: .composite(.in), inputs: [.primitive(0), .sourceGraphic], + subregion: region, colorInterpolation: .sRGB), + Primitive(effect: .gaussianBlur(stdDeviation: 3, stdDeviationY: 3), inputs: [.primitive(1)], + subregion: region, colorInterpolation: .sRGB), + Primitive(effect: .offset(dx: 6, dy: 6), inputs: [.primitive(2)], + subregion: region, colorInterpolation: .sRGB), + Primitive(effect: .composite(.over), inputs: [.sourceGraphic, .primitive(3)], + subregion: region, colorInterpolation: .sRGB) + ]) + XCTAssertFalse(filter.isBlurChain) + } + + // Illustrator's AI_Shadow: no color-interpolation-filters, so linearRGB + func testIllustratorShadow() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + + + + """#) + + XCTAssertEqual(filter.effects, [ + .gaussianBlur(stdDeviation: 2, stdDeviationY: 2), + .offset(dx: 4, dy: 4), + .merge + ]) + XCTAssertEqual(filter.primitives.map(\.inputs), [[.sourceAlpha], [.primitive(0)], [.primitive(1), .sourceGraphic]]) + XCTAssertEqual(Set(filter.primitives.map(\.colorInterpolation)), [.linearRGB]) + } + + // Figma's drop shadow: a transparent BackgroundImageFix flood, a hard alpha from SourceAlpha, implicit inputs + func testFigmaDropShadow() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + + + + + + """#) + + XCTAssertEqual(filter.region, LayerTree.Rect(x: 0, y: 0, width: 108, height: 108)) + XCTAssertEqual(filter.effects, [ + .flood(.rgba(r: 0, g: 0, b: 0, a: 0, space: .srgb)), + .colorMatrix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 0]), + .offset(dx: 0, dy: 4), + .gaussianBlur(stdDeviation: 2, stdDeviationY: 2), + .composite(.out), + .colorMatrix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.25, 0]), + .blend(.normal), + .blend(.normal) + ]) + XCTAssertEqual(filter.primitives.map(\.inputs), [ + [], + [.sourceAlpha], + [.primitive(1)], + [.primitive(2)], + [.primitive(3), .primitive(1)], + [.primitive(4)], + [.primitive(5), .primitive(0)], + [.sourceGraphic, .primitive(6)] + ]) + } + + // Filter Effects 1 §9.2: missing, forward and unknown references take the previous result + func testInputReferences() { + func primitive(_ inputs: [DOM.Filter.Input?], result: String? = nil) -> DOM.Filter.Primitive { + DOM.Filter.Primitive(effect: .merge, inputs: inputs, result: result) + } + let inputs = LayerTree.FilterLayer.makeInputs(for: [ + primitive([nil], result: "a"), + primitive([.result("later")], result: "a"), + primitive([.result("a"), .result("missing")], result: "later"), + primitive([.backgroundImage, .backgroundAlpha, .fillPaint, .strokePaint]), + primitive([.sourceGraphic, .sourceAlpha, .result("later"), nil]) + ]) + + XCTAssertEqual(inputs, [ + [.sourceGraphic], + [.primitive(0)], + // the closest preceding "a" + [.primitive(1), .primitive(1)], + [.transparent, .transparent, .transparent, .transparent], + [.sourceGraphic, .sourceAlpha, .primitive(2), .primitive(3)] + ]) + } + + // Filter Effects 1 §9.3: only the tree rooted at the last primitive is evaluated + func testOnlyThePrimaryTreeIsKept() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + + + """#) + + XCTAssertEqual(filter.effects, [ + .flood(.rgba(r: 0, g: Float(128) / 255, b: 0, a: 1, space: .srgb)), + .composite(.in) + ]) + XCTAssertEqual(filter.primitives.map(\.inputs), [[], [.sourceAlpha, .primitive(0)]]) + } + + // an unsupported primitive outside the primary tree is never drawn, so the filter still applies + func testUnsupportedPrimitiveOutsidePrimaryTree() throws { + let commands = try makeCommands(#""" + + + + + + + + """#, options: .hideUnsupportedFilters) + + XCTAssertEqual(commands.filterLayers.first?.effects, [.offset(dx: 2, dy: 0)]) + XCTAssertEqual(commands.names, ["pushFilterLayer", "setFillColor", "fillPath", "popFilterLayer"]) + } + + func testUnsupportedPrimitiveInPrimaryTreeDrawsUnfiltered() throws { + let commands = try makeCommands(#""" + + + + + + + + """#) + + XCTAssertEqual(commands.names, ["setFillColor", "fillPath"]) + } + + // Filter Effects 1 §9.4: x, y, width, height in user units, the rest from the default subregion, + // all within the filter region + func testSubregionInUserSpace() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + """#) + + XCTAssertEqual(filter.primitives.map(\.subregion), [ + LayerTree.Rect(x: 10, y: 0, width: 30, height: 100), + // 50% of the viewport height, clipped to the filter region + LayerTree.Rect(x: 80, y: 0, width: 20, height: 30), + LayerTree.Rect(x: 10, y: 0, width: 90, height: 100) + ]) + } + + func testSubregionInBoundingBoxUnits() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + """#) + + let flood = LayerTree.Rect(x: 50, y: 110, width: 100, height: 20) + XCTAssertEqual(filter.primitives.map(\.subregion), [flood, flood]) + XCTAssertEqual(filter.primitives[1].effect, .offset(dx: 20, dy: 20)) + } + + // the default subregion of a primitive reading only results is the union of theirs + func testSubregionDefaultsToUnionOfInputs() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + + """#) + + XCTAssertEqual(filter.primitives.map(\.subregion), [ + LayerTree.Rect(x: 10, y: 10, width: 10, height: 10), + LayerTree.Rect(x: 50, y: 60, width: 10, height: 10), + LayerTree.Rect(x: 10, y: 10, width: 50, height: 60), + // SourceGraphic is a standard input: the filter region + LayerTree.Rect(x: 0, y: 0, width: 100, height: 100) + ]) + } + + // zero or negative sizes, or no overlap with the filter region, disable the primitive + func testEmptySubregionDisablesPrimitive() throws { + let filter = try makeFilterLayer(#""" + + + + + + + + + + """#) + + XCTAssertEqual(filter.primitives.map(\.subregion), [.zero, .zero, .zero, .zero]) + } + + func testFloodColorAndOpacity() throws { + let filter = try makeFilterLayer(#""" + + + + + """#) + + XCTAssertEqual(filter.effects, [.flood(.rgba(r: 1, g: 0, b: 0, a: 0.25, space: .srgb))]) + } + + func testColorMatrixTypesResolveToMatrices() { + let identity = DOM.Filter.ColorMatrix.identity + XCTAssertEqual(DOM.Filter.ColorMatrix.saturate(1).values, identity) + XCTAssertEqual(DOM.Filter.ColorMatrix.matrix([1, 2]).values, identity) + for (value, expected) in zip(DOM.Filter.ColorMatrix.hueRotate(0).values, identity) { + XCTAssertEqual(value, expected, accuracy: 0.0001) + } + for (value, expected) in zip(DOM.Filter.ColorMatrix.hueRotate(360).values, identity) { + XCTAssertEqual(value, expected, accuracy: 0.0001) + } + // saturate(0) is a grey: each row is the luminance + XCTAssertEqual(Array(DOM.Filter.ColorMatrix.saturate(0).values[0..<3]), [0.2126, 0.7152, 0.0722]) + XCTAssertEqual(Array(DOM.Filter.ColorMatrix.saturate(0).values[5..<8]), [0.2126, 0.7152, 0.0722]) + XCTAssertEqual(DOM.Filter.ColorMatrix.luminanceToAlpha.values, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0]) + } + + func testFloodGoesThroughColorConverter() throws { + let svg = try DOM.SVG.parse(xml: #""" + + + + + """#) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let filtered = try XCTUnwrap(layer.allLayers.first { !$0.filters.isEmpty }) + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .init(100, 100), options: .default) + let filter = try XCTUnwrap(generator.makeFilterLayer(for: filtered, colorConverter: .luminance)) + + guard case .flood(.gray(white: 0, a: let alpha)) = try XCTUnwrap(filter.effects.first) else { + return XCTFail("expected a luminance flood") + } + XCTAssertEqual(alpha, 1, accuracy: 0.0001) + } + + // SD2's blur chains keep their in-place path; anything else is a primitive tree + func testBlurChain() throws { + let chain = try makeFilterLayer(#""" + + + + + + + + """#) + let alpha = try makeFilterLayer(#""" + + + + + """#) + let subregion = try makeFilterLayer(#""" + + + + + """#) + + XCTAssertTrue(chain.isBlurChain) + XCTAssertFalse(alpha.isBlurChain) + XCTAssertFalse(subregion.isBlurChain) + } + + func testPrimaryTreeIsCapped() throws { + func chain(_ count: Int) -> String { + let offsets = String(repeating: #""#, count: count) + return #""# + + offsets + #""# + } + let max = LayerTree.FilterLayer.maxPrimitives + XCTAssertEqual(try makeCommands(chain(max)).filterLayers.first?.primitives.count, max) + // over the cap: drawn unfiltered, like an unsupported primitive + XCTAssertTrue(try makeCommands(chain(max + 1)).filterLayers.isEmpty) + } + + func testPeakBitmapCount() { + let region = LayerTree.Rect(x: 0, y: 0, width: 10, height: 10) + func primitive(_ inputs: [LayerTree.FilterLayer.Input]) -> Primitive { + Primitive(effect: .merge, inputs: inputs, subregion: region, colorInterpolation: .sRGB) + } + // source and one result at a time + XCTAssertEqual(LayerTree.FilterLayer(region: region, effects: [.offset(dx: 1, dy: 1), .offset(dx: 1, dy: 1)]).peakBitmapCount, 3) + // two results kept for the merge, SourceAlpha alive until its last reader + let tree = LayerTree.FilterLayer(region: region, primitives: [ + primitive([.sourceAlpha]), + primitive([.sourceAlpha]), + primitive([.primitive(0), .primitive(1), .transparent]) + ]) + XCTAssertEqual(tree.peakBitmapCount, 5) + } + + // every filter of the samples resolves: none is drawn unfiltered or hidden as unsupported + func testSamplesResolveEveryFilter() throws { + let samples = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Samples.bundle") + // read from the source tree, which a device or emulator run does not have + try XCTSkipUnless(FileManager.default.fileExists(atPath: samples.path), "Samples.bundle not reachable") + for (name, count) in [("filter-drop-shadow", 3), ("filter-primitives", 8)] { + let xml = try String(contentsOf: samples.appendingPathComponent("\(name).svg"), encoding: .utf8) + let filters = try makeCommands(xml, options: .hideUnsupportedFilters).filterLayers + XCTAssertEqual(filters.count, count, name) + XCTAssertFalse(filters.contains { $0.region.width <= 0 || $0.region.height <= 0 }, name) + } + } + + // the merge's region-wide result keeps the whole region in the command stream + func testFilterLayerCommandsForGraph() throws { + let commands = try makeCommands(#""" + + + + + + + + """#) + + XCTAssertEqual(commands.names, [ + "pushState", "setAlpha", "pushTransparencyLayer", + "pushFilterLayer", "setFillColor", "fillPath", "popFilterLayer", + "popTransparencyLayer", "popState" + ]) + } +} + +private extension LayerTreeFilterGraphTests { + + func makeCommands(_ xml: String, options: SVG.Options = .default) throws -> [RendererCommand] { + let svg = try DOM.SVG.parse(xml: xml) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), + size: .init(svg.width, svg.height), + options: options) + let commands = generator.renderCommands(for: layer, colorConverter: .default) + return LayerTree.CommandOptimizer().optimizeCommands(commands) + } + + // the first filter layer in the command stream + func makeFilterLayer(_ xml: String) throws -> LayerTree.FilterLayer { + try XCTUnwrap(makeCommands(xml).filterLayers.first) + } +} + +private extension Array where Element == RendererCommand { + + var filterLayers: [LayerTree.FilterLayer] { + compactMap { + if case .pushFilterLayer(let filter) = $0 { return filter } + return nil + } + } + + var names: [String] { + let renderer = MockRenderer() + renderer.perform(self) + return renderer.operations + } +} + +private extension LayerTree.Layer { + + var allLayers: [LayerTree.Layer] { + [self] + contents.flatMap { contents -> [LayerTree.Layer] in + if case .layer(let layer) = contents { return layer.allLayers } + return [] + } + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.FilterTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.FilterTests.swift new file mode 100644 index 00000000..1241bafe --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.FilterTests.swift @@ -0,0 +1,314 @@ +// +// LayerTree.FilterTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDrawDOM +@testable import SwiftDraw +import XCTest + +final class LayerTreeFilterTests: XCTestCase { + + func testBlurGeneratesFilterLayerWithDefaultRegion() throws { + let commands = try makeCommands(#""" + + + + + """#) + + let filter = try XCTUnwrap(commands.filterLayers.first) + XCTAssertEqual(commands.filterLayers.count, 1) + XCTAssertEqual(filter.region, LayerTree.Rect(x: 21, y: 21, width: 48, height: 48)) + XCTAssertEqual(filter.effects, [.gaussianBlur(stdDeviation: 5, stdDeviationY: 5)]) + XCTAssertEqual(commands.names, ["pushFilterLayer", "setFillColor", "fillPath", "popFilterLayer"]) + } + + func testBlurTwoValuesAndUserSpaceRegion() throws { + let commands = try makeCommands(#""" + + + + + + + """#) + + let filter = try XCTUnwrap(commands.filterLayers.first) + XCTAssertEqual(filter.region, LayerTree.Rect(x: 0, y: 10, width: 90, height: 80)) + XCTAssertEqual(filter.effects, [.gaussianBlur(stdDeviation: 4, stdDeviationY: 2)]) + } + + func testPrimitiveUnitsObjectBoundingBoxScalesDeviation() throws { + let commands = try makeCommands(#""" + + + + + + + """#) + + let filter = try XCTUnwrap(commands.filterLayers.first) + XCTAssertEqual(filter.region, LayerTree.Rect(x: 0, y: 0, width: 50, height: 20)) + XCTAssertEqual(filter.effects, [.gaussianBlur(stdDeviation: 5, stdDeviationY: 2)]) + } + + func testNegativeDeviationDisablesBlur() throws { + let commands = try makeCommands(#""" + + + + + """#) + + XCTAssertEqual(commands.filterLayers.first?.effects, [.gaussianBlur(stdDeviation: 0, stdDeviationY: 0)]) + } + + func testFilterRegionIncludesChildTransforms() throws { + let commands = try makeCommands(#""" + + + + + + + + """#) + + XCTAssertEqual(commands.filterLayers.first?.region, LayerTree.Rect(x: 0, y: 0, width: 40, height: 50)) + } + + func testFilterIsNotInheritedByChildren() throws { + let commands = try makeCommands(#""" + + + + + + + + """#) + + XCTAssertEqual(commands.filterLayers.count, 1) + } + + func testEmptyFilterRegionDrawsNothing() throws { + let commands = try makeCommands(#""" + + + + + """#) + + XCTAssertEqual(commands.names, []) + } + + func testUnsupportedPrimitiveDrawsUnfiltered() throws { + let commands = try makeCommands(#""" + + + + + + + + """#) + + XCTAssertEqual(commands.names, ["setFillColor", "fillPath"]) + } + + func testUnsupportedPrimitiveIsHiddenWithOption() throws { + let commands = try makeCommands(#""" + + + + + + """#, options: .hideUnsupportedFilters) + + XCTAssertEqual(commands.names, ["setFillColor", "fillPath"]) + } + + func testFilterIsAppliedBeforeOpacityClipAndMask() throws { + let commands = try makeCommands(#""" + + + + + + + """#) + + XCTAssertEqual(commands.names, [ + "pushState", "setAlpha", "pushTransparencyLayer", "setClip", "pushTransparencyLayer", + "pushFilterLayer", "setFillColor", "fillPath", "popFilterLayer", + // the mask is clipped to its region, SVG 1.1 §14.4 (SD8) + "setBlendMode", "pushTransparencyLayer", "setBlendMode", + "pushState", "setClip", "setFillColor", "fillPath", "popState", + "popTransparencyLayer", "popTransparencyLayer", + "popTransparencyLayer", "popState" + ]) + } + + func testNestedFilterLayers() throws { + let commands = try makeCommands(#""" + + + + + + + + """#) + + XCTAssertEqual(commands.names, [ + "pushFilterLayer", "pushFilterLayer", "setFillColor", "fillPath", "popFilterLayer", + "setFillColor", "fillPath", "popFilterLayer" + ]) + } + + // text has no measured bounds: drawn unfiltered, neither warned about nor hidden as unsupported + func testTextWithObjectBoundingBoxBlurIsDrawnUnfiltered() throws { + let svg = try DOM.SVG.parse(xml: #""" + + + Hi + + """#) + let root = LayerTree.Builder(svg: svg).makeLayer() + let layer = try XCTUnwrap(root.allLayers.first { !$0.filters.isEmpty }) + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .init(100, 100), options: .hideUnsupportedFilters) + + XCTAssertFalse(layer.hasUnsupportedFilters) + XCTAssertNil(generator.makeFilterLayer(for: layer)) + } + + // a group mixing shapes and text has unknown bounds: draw it unfiltered rather than clip the text away + func testGroupWithTextIsDrawnUnfiltered() throws { + let commands = try makeCommands(#""" + + + + + Hello + + + """#, options: .hideUnsupportedFilters) + + XCTAssertTrue(commands.filterLayers.isEmpty) + XCTAssertEqual(commands.names.prefix(2), ["setFillColor", "fillPath"]) + } + + func testGroupWithTextKeepsUserSpaceFilter() throws { + let commands = try makeCommands(#""" + + + + + + + Hello + + + """#) + + XCTAssertEqual(commands.filterLayers.first?.region, LayerTree.Rect(x: 0, y: 0, width: 100, height: 100)) + } + + func testHugeDeviationStaysFinite() throws { + let commands = try makeCommands(#""" + + + + + """#) + + let effect = try XCTUnwrap(commands.filterLayers.first?.effects.first) + guard case let .gaussianBlur(x, y) = effect else { return XCTFail() } + XCTAssertTrue(x.isFinite) + XCTAssertTrue(y.isFinite) + } + + // renderers isolate a filter layer, but the optimizer does not elide state after it + func testOptimizerResetsStateAroundFilterLayer() { + let filter = LayerTree.FilterLayer(region: .init(x: 0, y: 0, width: 10, height: 10), + effects: [.gaussianBlur(stdDeviation: 1, stdDeviationY: 1)]) + let commands: [RendererCommand] = [ + .setFill(color: .black), + .pushFilterLayer(filter), + .setFill(color: .black), + .popFilterLayer, + .setFill(color: .black) + ] + + let optimized = LayerTree.CommandOptimizer().optimizeCommands(commands) + XCTAssertEqual(optimized.names, ["setFillColor", "pushFilterLayer", "setFillColor", "popFilterLayer", "setFillColor"]) + } +} + +private extension LayerTreeFilterTests { + + func makeCommands(_ xml: String, options: SVG.Options = .default) throws -> [RendererCommand] { + let svg = try DOM.SVG.parse(xml: xml) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), + size: .init(100, 100), + options: options) + let commands = generator.renderCommands(for: layer, colorConverter: .default) + return LayerTree.CommandOptimizer().optimizeCommands(commands) + } +} + +private extension Array where Element == RendererCommand { + + var filterLayers: [LayerTree.FilterLayer] { + compactMap { + if case .pushFilterLayer(let filter) = $0 { return filter } + return nil + } + } + + var names: [String] { + let renderer = MockRenderer() + renderer.perform(self) + return renderer.operations + } +} + +private extension LayerTree.Layer { + + var allLayers: [LayerTree.Layer] { + [self] + contents.flatMap { contents -> [LayerTree.Layer] in + if case .layer(let layer) = contents { return layer.allLayers } + return [] + } + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.GradientTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.GradientTests.swift new file mode 100644 index 00000000..02886095 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.GradientTests.swift @@ -0,0 +1,564 @@ +// +// LayerTree.GradientTests.swift +// SwiftDraw +// +// Created by Simon Whitty on 13/12/18. +// Copyright 2020 WhileLoop Pty Ltd. All rights reserved. +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw +import Foundation + +final class LayerTreeGradientTests: XCTestCase { + + private typealias Command = RendererCommand + + private func commands(_ body: String) throws -> [Command] { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default) + } + + private func paint(_ body: String, id: String = "g") throws -> LayerTree.Builder.GradientPaint? { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + return LayerTree.Builder(svg: svg).makeGradientPaint(for: URL(string: "#\(id)")!) + } + + private func linear(_ body: String) throws -> LayerTree.LinearGradient? { + if case .linear(let g) = try paint(body) { return g } + return nil + } + + private func radial(_ body: String) throws -> LayerTree.RadialGradient? { + if case .radial(let g) = try paint(body) { return g } + return nil + } + + private func color(_ body: String) throws -> LayerTree.Color? { + if case .color(let c) = try paint(body) { return c } + return nil + } + + private let stops = #""# + private let red = LayerTree.Color.rgba(r: 1, g: 0, b: 0, a: 1, space: .srgb) + private let blue = LayerTree.Color.rgba(r: 0, g: 0, b: 1, a: 1, space: .srgb) + + // MARK: - href inheritance (SVG 1.1 §13.2.2, §13.2.3) + + func testMultiHopInheritsStopsAndAttributes() throws { + let g = try linear(""" + + \(stops) + + + + + """) + XCTAssertEqual(g?.gradient.stops.map(\.color), [red, blue]) + XCTAssertEqual(g?.start, LayerTree.Point(0.25, 0)) + XCTAssertEqual(g?.end, LayerTree.Point(0.75, 0.5)) + XCTAssertEqual(g?.units, .userSpaceOnUse) + XCTAssertEqual(g?.spread, .reflect) + XCTAssertEqual(g?.transform, [.translate(tx: 5, ty: 0)]) + } + + func testOwnAttributesOverrideReferenced() throws { + let g = try linear(""" + + \(stops) + + + + + """) + XCTAssertEqual(g?.gradient.stops.map(\.color), [blue, red]) + XCTAssertEqual(g?.start.x, 0.1) + XCTAssertEqual(g?.units, .objectBoundingBox) + XCTAssertEqual(g?.spread, .pad) + } + + func testCrossKindInheritsStopsAndCommonAttributesOnly() throws { + let g = try radial(""" + + \(stops) + + + """) + XCTAssertEqual(g?.gradient.stops.map(\.color), [red, blue]) + XCTAssertEqual(g?.units, .userSpaceOnUse) + XCTAssertEqual(g?.spread, .repeat) + XCTAssertEqual(g?.endRadius, 20) + XCTAssertEqual(g?.endCenter, LayerTree.Point(0.5, 0.5)) + } + + func testRadialGeometryInheritsThroughLinear() throws { + let g = try radial(""" + + \(stops) + + + + """) + XCTAssertEqual(g?.endCenter, LayerTree.Point(10, 25)) + XCTAssertEqual(g?.endRadius, 30) + XCTAssertEqual(g?.center, LayerTree.Point(12, 25)) + } + + func testLinearReferencingRadialUsesDefaultGeometry() throws { + let g = try linear(""" + + \(stops) + + + """) + XCTAssertEqual(g?.start, LayerTree.Point(0, 0)) + XCTAssertEqual(g?.end, LayerTree.Point(1, 0)) + } + + func testHrefCycleEndsChain() throws { + let g = try linear(""" + + \(stops) + + + + """) + XCTAssertEqual(g?.start, LayerTree.Point(0.2, 0)) + XCTAssertEqual(g?.end, LayerTree.Point(0.8, 0.5)) + } + + func testHrefCycleWithoutStopsPaintsNone() throws { + XCTAssertEqual(try color(""" + + + + + """), LayerTree.Color.none) + } + + func testSelfReferenceKeepsDocument() throws { + let cmds = try commands(""" + \(stops) + + """) + XCTAssertTrue(cmds.contains { if case .drawLinearGradient = $0 { return true }; return false }) + } + + private func chain(hops: Int) -> String { + var defs = #"\#(stops)"# + for i in 1.." + } + defs += "" + return "\(defs)" + } + + func testFortyHopChainResolves() throws { + let g = try linear(chain(hops: 40)) + XCTAssertEqual(g?.start.x, 0.5) + XCTAssertEqual(g?.end.x, 0.75) + XCTAssertEqual(g?.gradient.stops.count, 2) + } + + func testLongChainIsBounded() throws { + // cut after maxGradientHops: the stops at the far end are out of reach, so it paints none + XCTAssertEqual(try color(chain(hops: LayerTree.Builder.maxGradientHops + 10)), LayerTree.Color.none) + } + + func testManyShapesSharingAnHrefdGradientAllPaint() throws { + // more paints than ReferenceGuard.maxReferences: gradient hrefs must not spend that budget + let count = LayerTree.Builder.ReferenceGuard.maxReferences + 100 + let rects = String(repeating: #""#, count: count) + let cmds = try commands(""" + \(stops) + \(rects) + """) + let drawn = cmds.filter { if case .drawLinearGradient = $0 { return true }; return false } + XCTAssertEqual(drawn.count, count) + } + + func testHrefChainResolvesInsideDeepUse() throws { + // nesting spends ReferenceGuard depth; the gradient chain must still resolve + var body = "\(stops)" + body += "" + body += "" + for i in 1...15 { + body += "" + } + let cmds = try commands(body) + XCTAssertGreaterThan(cmds.filter { if case .drawLinearGradient = $0 { return true }; return false }.count, 1) + XCTAssertFalse(cmds.contains { if case .setFill = $0 { return true }; return false }) + } + + // MARK: - degenerate gradients (SVG 1.1 §13.2.2-§13.2.4) + + func testZeroStopsPaintsNone() throws { + XCTAssertEqual(try color(#""#), LayerTree.Color.none) + let cmds = try commands(#""#) + XCTAssertFalse(cmds.contains { if case .fill = $0 { return true }; return false }) + } + + func testSingleStopPaintsSolidColour() throws { + XCTAssertEqual(try color(#""#), + .rgba(r: 1, g: 0, b: 0, a: 0.5, space: .srgb)) + + let cmds = try commands(""" + + + + """) + XCTAssertTrue(cmds.contains { if case .setFill(color: .rgba(r: 0, g: 0, b: 1, a: 0.5, space: .srgb)) = $0 { return true }; return false }) + XCTAssertTrue(cmds.contains { if case .setStroke(color: .rgba(r: 0, g: 0, b: 1, a: 1, space: .srgb)) = $0 { return true }; return false }) + } + + func testSingleStopInheritedThroughHref() throws { + XCTAssertEqual(try color(""" + + + + + """), red) + } + + func testZeroLengthVectorPaintsLastStop() throws { + XCTAssertEqual(try color(#"\#(stops)"#), blue) + } + + func testZeroRadiusPaintsLastStop() throws { + XCTAssertEqual(try color(#"\#(stops)"#), blue) + } + + func testStopOffsetsAreClampedAndMonotonic() throws { + let g = try linear(""" + + + + + """) + XCTAssertEqual(g?.gradient.stops.map(\.offset), [0, 0.6, 0.6, 1]) + } + + // MARK: - focal point + + func testFocalPointDefaultsToCentre() throws { + let g = try radial(#"\#(stops)"#) + XCTAssertEqual(g?.center, LayerTree.Point(0.25, 0.3)) + XCTAssertEqual(g?.endCenter, LayerTree.Point(0.2, 0.3)) + } + + // MARK: - spreadMethod (SVG 1.1 §13.2.2) + + func testSpreadMethodParsing() throws { + XCTAssertEqual(try linear(#"\#(stops)"#)?.spread, .repeat) + XCTAssertEqual(try linear(#"\#(stops)"#)?.spread, .reflect) + XCTAssertEqual(try linear(#"\#(stops)"#)?.spread, .pad) + XCTAssertEqual(try linear(#"\#(stops)"#)?.spread, .pad) + } + + private func drawnLinear(_ body: String) throws -> (gradient: LayerTree.Gradient, start: LayerTree.Point, end: LayerTree.Point)? { + for case let .drawLinearGradient(g, from: s, to: e) in try commands(body) { + return (g, s, e) + } + return nil + } + + private func drawnRadial(_ body: String) throws -> (gradient: LayerTree.Gradient, center: LayerTree.Point, radius: Float, endCenter: LayerTree.Point, endRadius: Float)? { + for case let .drawRadialGradient(g, startCenter: c, startRadius: r, endCenter: ec, endRadius: er) in try commands(body) { + return (g, c, r, ec, er) + } + return nil + } + + func testPadDrawsSinglePeriod() throws { + let d = try drawnLinear(""" + \(stops) + + """) + XCTAssertEqual(d?.start, LayerTree.Point(0, 0)) + XCTAssertEqual(d?.end, LayerTree.Point(25, 0)) + XCTAssertEqual(d?.gradient.stops.count, 2) + } + + func testRepeatLinearCoversShape() throws { + let d = try drawnLinear(""" + \(stops) + + """) + XCTAssertEqual(d?.start, LayerTree.Point(0, 0)) + XCTAssertEqual(d?.end, LayerTree.Point(100, 0)) + XCTAssertEqual(d?.gradient.stops.map(\.offset), [0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1]) + XCTAssertEqual(d?.gradient.stops.map(\.color), [red, blue, red, blue, red, blue, red, blue]) + } + + func testReflectLinearMirrorsOddPeriods() throws { + let d = try drawnLinear(""" + \(stops) + + """) + XCTAssertEqual(d?.end, LayerTree.Point(100, 0)) + XCTAssertEqual(d?.gradient.stops.map(\.offset), [0, 0.5, 0.5, 1]) + XCTAssertEqual(d?.gradient.stops.map(\.color), [red, blue, blue, red]) + } + + func testRepeatExtendsBeforeStart() throws { + let d = try drawnLinear(""" + \(stops) + + """) + // periods -2...1: odd period -1 is mirrored + XCTAssertEqual(d?.start, LayerTree.Point(0, 0)) + XCTAssertEqual(d?.end, LayerTree.Point(100, 0)) + XCTAssertEqual(d?.gradient.stops.map(\.color), [red, blue, blue, red, red, blue, blue, red]) + } + + func testRepeatCompletesPartialStops() throws { + let d = try drawnLinear(""" + + + + + """) + XCTAssertEqual(d?.gradient.stops.map(\.offset), [0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1]) + } + + func testRepeatHonoursGradientTransform() throws { + // scaled ×2: one period spans 50 units, so two periods cover the rect + let d = try drawnLinear(""" + \(stops) + + """) + XCTAssertEqual(d?.end, LayerTree.Point(50, 0)) + XCTAssertEqual(d?.gradient.stops.count, 4) + } + + func testRepeatJustUnderTheCapExpands() throws { + // 2 stops: up to 4096 / 4 = 1024 periods + let d = try drawnLinear(""" + \(stops) + + """) + XCTAssertEqual(LayerTree.CommandGenerator.maxSpreadPeriods(stopCount: 2), 1024) + XCTAssertEqual(d?.gradient.stops.count, 2000) + XCTAssertEqual(d?.end, LayerTree.Point(100, 0)) + } + + func testRepeatBeyondTheCapPaintsAverageColour() throws { + let d = try drawnLinear(""" + + + + + """) + // red→blue over the first half, blue pad over the second: 1/4 red, 3/4 blue + guard let d, case let .rgba(r, g, b, a, _) = d.gradient.stops[0].color else { return XCTFail("no gradient") } + XCTAssertEqual(d.gradient.stops.count, 2) + XCTAssertEqual(d.gradient.stops[0].color, d.gradient.stops[1].color) + XCTAssertEqual(r, 0.25, accuracy: 0.001) + XCTAssertEqual(g, 0, accuracy: 0.001) + XCTAssertEqual(b, 0.75, accuracy: 0.001) + XCTAssertEqual(a, 1, accuracy: 0.001) + } + + func testAverageColourIsPremultiplied() throws { + let d = try drawnLinear(""" + + + + + """) + // transparent blue contributes no hue + guard let d, case let .rgba(r, _, b, a, _) = d.gradient.stops[0].color else { return XCTFail("no gradient") } + XCTAssertEqual(r, 1, accuracy: 0.001) + XCTAssertEqual(b, 0, accuracy: 0.001) + XCTAssertEqual(a, 0.5, accuracy: 0.001) + } + + func testRepeatFarFromItsVectorStillExpands() throws { + // 10 periods per unit, shape at x = 20 000: only the periods under the shape are drawn, + // not every period from 0 (which would exceed the cap and fall back to the average colour) + let d = try drawnLinear(""" + \(stops) + + """) + XCTAssertGreaterThan(d?.gradient.stops.count ?? 0, 1000) + } + + func testMakePeriodsLinearHasNoPeriodZeroFloor() { + typealias Generator = LayerTree.CommandGenerator + XCTAssertEqual(Generator.makePeriods(lower: 10_000.3, upper: 10_000.7, includingZero: false), 10_000...10_000) + XCTAssertEqual(Generator.makePeriods(lower: -0.5, upper: 2.2, includingZero: false), -1...2) + XCTAssertEqual(Generator.makePeriods(lower: 5, upper: 5, includingZero: false), 5...5) + XCTAssertEqual(Generator.makePeriods(lower: 10_000.3, upper: 10_000.7), 0...10_000) + } + + func testAverageOfMixedColourSpacesConvertsIntoTheGradientSpace() throws { + // sRGB mid grey and P3 mid grey are the same colour: the average stays that grey + let d = try drawnLinear(""" + + + + + """) + guard let d, case let .rgba(r, g, b, _, space) = d.gradient.stops[0].color else { return XCTFail("no gradient") } + XCTAssertEqual(space, .p3) + XCTAssertEqual(r, 0.50196, accuracy: 0.002) + XCTAssertEqual(g, 0.50196, accuracy: 0.002) + XCTAssertEqual(b, 0.50196, accuracy: 0.002) + } + + func testColourConversionBetweenSpaces() { + let grey = LayerTree.Color.convert(r: 0.5, g: 0.5, b: 0.5, from: .p3, to: .srgb) + XCTAssertEqual(grey.0, 0.5, accuracy: 0.002) + XCTAssertEqual(grey.2, 0.5, accuracy: 0.002) + let red = LayerTree.Color.convert(r: 1, g: 0, b: 0, from: .srgb, to: .p3) + XCTAssertEqual(red.0, 0.917, accuracy: 0.005) // sRGB red is inside P3 + XCTAssertEqual(red.1, 0.2, accuracy: 0.01) + XCTAssertEqual(LayerTree.Color.convert(r: 0.2, g: 0.3, b: 0.4, from: .srgb, to: .srgb).1, 0.3) + } + + func testObjectBoundingBoxRepeatWithTransform() throws { + let d = try drawnLinear(""" + \(stops) + + """) + // one period is 25 units, shifted by 10: x -10...90 is periods -1...3 + XCTAssertEqual(d?.start, LayerTree.Point(-25, 0)) + XCTAssertEqual(d?.end, LayerTree.Point(100, 0)) + XCTAssertEqual(d?.gradient.stops.count, 10) + } + + func testObjectBoundingBoxReflectWithTransform() throws { + let d = try drawnLinear(""" + \(stops) + + """) + // scaled by 0.5: x 0...100 maps to 0...200 in gradient space, four periods of 50 + XCTAssertEqual(d?.end, LayerTree.Point(200, 0)) + XCTAssertEqual(d?.gradient.stops.map(\.color), [red, blue, blue, red, red, blue, blue, red]) + } + + func testObjectBoundingBoxRadialRepeatWithTransform() throws { + let d = try drawnRadial(""" + \(stops) + + """) + // centre (20, 20), radius 10; scale(2) maps the rect to 0...20, farthest corner 28.3 away + XCTAssertEqual(d?.endCenter, LayerTree.Point(20, 20)) + XCTAssertEqual(d?.endRadius, 30) + XCTAssertEqual(d?.gradient.stops.count, 6) + } + + func testObjectBoundingBoxRadialReflect() throws { + let d = try drawnRadial(""" + \(stops) + + """) + // corners -5...35: farthest from (20, 20) is 35.4, four periods of 10 + XCTAssertEqual(d?.endRadius, 40) + XCTAssertEqual(d?.gradient.stops.map(\.color), [red, blue, blue, red, red, blue, blue, red]) + } + + func testRadialStrokeWithSpread() throws { + let d = try drawnRadial(""" + \(stops) + + """) + // bounds 30...70 outset by 8 (miter limit 4): farthest corner 39.6 away, four periods + XCTAssertEqual(d?.endRadius, 40) + XCTAssertEqual(d?.gradient.stops.count, 8) + } + + func testRadialPeriodsAreExactWithFocalNearEdge() throws { + let d = try drawnRadial(""" + \(stops) + + """) + // the farthest corners, (100, 0) and (100, 100), are covered from t = 5.54: six periods, + // not the ~110 that a bound of (distance − r0) / (Δr − |Δc|) gives + XCTAssertEqual(d?.gradient.stops.count, 12) + } + + func testSquareCapCoverage() throws { + func end(_ cap: String) throws -> LayerTree.Point? { + try drawnLinear(""" + \(stops) + + """)?.end + } + // a square cap reaches 2·√2 = 2.83 beyond y = 37.5, past the fourth period + XCTAssertEqual(try end("round"), LayerTree.Point(0, 40)) + XCTAssertEqual(try end("square"), LayerTree.Point(0, 50)) + } + + func testRepeatRadialCoversShape() throws { + let d = try drawnRadial(""" + \(stops) + + """) + // the farthest corner is 50 from the centre: five periods + XCTAssertEqual(d?.center, LayerTree.Point(0, 0)) + XCTAssertEqual(d?.radius, 0) + XCTAssertEqual(d?.endRadius, 50) + XCTAssertEqual(d?.gradient.stops.count, 10) + } + + func testReflectRadialWithFocalPoint() throws { + let d = try drawnRadial(""" + \(stops) + + """) + guard let d else { return XCTFail("no radial gradient") } + let periods = Float(d.gradient.stops.count / 2) + XCTAssertGreaterThan(periods, 1) + XCTAssertEqual(d.endRadius, 20 * periods) + XCTAssertEqual(d.endCenter, LayerTree.Point(5 + 5 * periods, 0)) + XCTAssertEqual(d.gradient.stops.map(\.color).prefix(4), [red, blue, blue, red]) + } + + func testRadialFocalOutsideCirclePads() throws { + let d = try drawnRadial(""" + \(stops) + + """) + XCTAssertEqual(d?.endRadius, 5) + XCTAssertEqual(d?.gradient.stops.count, 2) + } + + func testRepeatStrokeCoversStrokeWidth() throws { + let d = try drawnLinear(""" + \(stops) + + """) + // the line spans y 10...30, the stroke reaches 2 beyond (miter limit 4 → 8): periods 0...3 + XCTAssertEqual(d?.start, LayerTree.Point(0, 0)) + XCTAssertEqual(d?.end, LayerTree.Point(0, 40)) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.InvalidElementTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.InvalidElementTests.swift new file mode 100644 index 00000000..5cd2dabe --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.InvalidElementTests.swift @@ -0,0 +1,62 @@ +// +// LayerTree.InvalidElementTests.swift +// SwiftDraw +// +// Created by Misoservices on 08/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw +import Foundation + +// SD15: a document with invalid elements still draws the valid ones +final class LayerTreeInvalidElementTests: XCTestCase { + + func testValidSiblingsOfInvalidElementsStillProduceCommands() throws { + let dom = try DOM.SVG.parse(data: Data(""" + + + + + + + + """.utf8)) + let layer = LayerTree.Builder(svg: dom).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + let commands = generator.renderCommands(for: layer, colorConverter: .default) + let fills = commands.compactMap { command -> LayerTree.Color? in + if case let .setFill(color: color) = command { return color } + return nil + } + XCTAssertEqual(fills, [ + .rgba(r: 1, g: 0, b: 0, a: 1, space: .srgb), + .rgba(r: 0, g: 0, b: 1, a: 1, space: .srgb) + ]) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.PaintFallbackTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.PaintFallbackTests.swift new file mode 100644 index 00000000..301994f3 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.PaintFallbackTests.swift @@ -0,0 +1,143 @@ +// +// CGPatternTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDraw +@testable import SwiftDrawDOM +import XCTest + +/// Pattern fills reset the optimizer's tracked fill, and `fill="url(#id) fallback"` (SVG 1.1 §11.2). +final class LayerTreePaintFallbackTests: XCTestCase { + + func testOptimizerKeepsSetFillAfterPattern() { + let commands: [RendererCommand] = [ + .setFill(color: .black), + .setFillPattern(LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 2, height: 2))), + .setFill(color: .black) + ] + let optimized = LayerTree.CommandOptimizer().optimizeCommands(commands) + XCTAssertEqual(optimized.names, ["setFillColor", "setFillPattern", "setFillColor"]) + } + + func testOptimizerStillDropsRepeatedFill() { + let commands: [RendererCommand] = [ + .setFill(color: .black), + .setFill(color: .black) + ] + let optimized = LayerTree.CommandOptimizer().optimizeCommands(commands) + XCTAssertEqual(optimized.names, ["setFillColor"]) + } + + func testPatternThenSameColourEndToEnd() throws { + let commands = try makeCommands(fill: "#000000", then: "url(#pat)", defs: pattern) + XCTAssertEqual(commands.names.filter { $0 == "setFillColor" }.count, 2) + XCTAssertEqual(commands.names.filter { $0 == "setFillPattern" }.count, 1) + } + + func testResolvingServerWinsOverFallback() throws { + let commands = try makeCommands(fill: "url(#g) #ff0000", defs: gradient) + XCTAssertTrue(commands.names.contains("drawLinearGradient")) + } + + func testMissingServerUsesFallbackColour() throws { + let commands = try makeCommands(fill: "url(#missing) #ff0000") + XCTAssertEqual(commands.fillColors, [.init(255, 0, 0)]) + } + + func testMissingServerWithNoneFallbackPaintsNothing() throws { + let commands = try makeCommands(fill: "url(#missing) none") + XCTAssertFalse(commands.names.contains("fill")) + } + + func testMissingServerWithoutFallbackPaintsNothing() throws { + let commands = try makeCommands(fill: "url(#missing)") + XCTAssertFalse(commands.names.contains("fill")) + } + + func testFallbackInStyleAttribute() throws { + let commands = try makeCommands(fill: nil, style: "fill:url(#missing) #ff0000") + XCTAssertEqual(commands.fillColors, [.init(255, 0, 0)]) + } + + func testStrokeFallback() throws { + let svg = try DOM.SVG.parse(xml: #""" + + + + """#) + let commands = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .init(10, 10), options: .default) + .renderCommands(for: LayerTree.Builder(svg: svg).makeLayer(), colorConverter: .default) + XCTAssertTrue(commands.contains { if case .setStroke(let c) = $0 { return c == .init(0, 255, 0) }; return false }) + } + + func testUnreadableFallbackIsDroppedNotTheServer() throws { + let commands = try makeCommands(fill: "url(#g) garbage", defs: gradient) + XCTAssertTrue(commands.names.contains("drawLinearGradient")) + } + + var pattern: String { + #""# + } + + var gradient: String { + #""# + } + + func makeCommands(fill first: String?, then second: String? = nil, style: String? = nil, defs: String = "") throws -> [RendererCommand] { + func rect(_ fill: String?, _ style: String?) -> String { + "" + } + let svg = try DOM.SVG.parse(xml: """ + + \(defs) + \(rect(first, style)) + \(second.map { rect($0, nil) } ?? "") + \(second != nil ? rect(first, nil) : "") + + """) + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .init(10, 10), options: .default) + let commands = generator.renderCommands(for: LayerTree.Builder(svg: svg).makeLayer(), colorConverter: .default) + return LayerTree.CommandOptimizer().optimizeCommands(commands) + } +} + +private extension Array where Element == RendererCommand { + + var names: [String] { + let renderer = MockRenderer() + renderer.perform(self) + return renderer.operations + } + + var fillColors: [LayerTree.Color] { + compactMap { if case .setFill(let c) = $0 { return c }; return nil } + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.PreserveAspectRatioTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.PreserveAspectRatioTests.swift new file mode 100644 index 00000000..9836eb97 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.PreserveAspectRatioTests.swift @@ -0,0 +1,394 @@ +// +// LayerTree.PreserveAspectRatioTests.swift +// SwiftDraw +// +// Copyright 2020 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import XCTest +@testable import SwiftDraw +@testable import SwiftDrawDOM +import Foundation + +final class LayerTreePreserveAspectRatioTests: XCTestCase { + + private typealias PAR = DOM.PreserveAspectRatio + + // MARK: parsing + + private func parse(_ value: String?) -> PAR? { + XMLParser().parsePreserveAspectRatio(value) + } + + func testParsesAllAlignments() { + let names = ["xMinYMin", "xMidYMin", "xMaxYMin", "xMinYMid", "xMidYMid", "xMaxYMid", "xMinYMax", "xMidYMax", "xMaxYMax"] + let aligns: [PAR.Align] = [.xMinYMin, .xMidYMin, .xMaxYMin, .xMinYMid, .xMidYMid, .xMaxYMid, .xMinYMax, .xMidYMax, .xMaxYMax] + for (name, align) in zip(names, aligns) { + XCTAssertEqual(parse(name), PAR(align: align, meetOrSlice: .meet)) + XCTAssertEqual(parse("\(name) slice"), PAR(align: align, meetOrSlice: .slice)) + XCTAssertEqual(parse(" \(name) meet "), PAR(align: align, meetOrSlice: .meet)) + } + XCTAssertEqual(parse("none"), PAR(align: .none, meetOrSlice: .meet)) + XCTAssertEqual(parse("defer xMinYMax slice"), PAR(align: .xMinYMax, meetOrSlice: .slice)) + } + + func testInvalidValueIsIgnored() { + XCTAssertNil(parse(nil)) + XCTAssertNil(parse("")) + XCTAssertNil(parse("xMidYMid crop")) + XCTAssertNil(parse("middle")) + XCTAssertNil(parse("xMidYMid meet slice")) + // the document still parses + XCTAssertNoThrow(try DOM.SVG.parse(xml: #""#)) + } + + func testParsedOnSVGAndImage() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + """) + XCTAssertEqual(svg.preserveAspectRatio, PAR(align: .xMinYMax, meetOrSlice: .slice)) + XCTAssertEqual((svg.childElements[0] as? DOM.SVG)?.preserveAspectRatio, PAR(align: .none)) + XCTAssertEqual((svg.childElements[1] as? DOM.Image)?.preserveAspectRatio, PAR(align: .xMaxYMin)) + } + + // MARK: viewBox transform of the root + + private func transform(_ par: String?, viewBox: String = "0 0 100 50", width: Int = 200, height: Int = 200) throws -> [LayerTree.Transform] { + let attribute = par.map { #"preserveAspectRatio="\#($0)""# } ?? "" + let svg = try DOM.SVG.parse(xml: """ + + """) + return LayerTree.Builder(svg: svg).makeLayer().transform + } + + func testDefaultIsMeetMidMid() throws { + // 100x50 into 200x200: scale 2, centred vertically ((200 - 100) / 2) + XCTAssertEqual(try transform(nil), [.translate(tx: 0, ty: 50), .scale(sx: 2, sy: 2)]) + } + + func testNoneStretches() throws { + XCTAssertEqual(try transform("none"), [.scale(sx: 2, sy: 4)]) + } + + func testMeetAlignments() throws { + XCTAssertEqual(try transform("xMinYMin"), [.scale(sx: 2, sy: 2)]) + XCTAssertEqual(try transform("xMaxYMax meet"), [.translate(tx: 0, ty: 100), .scale(sx: 2, sy: 2)]) + XCTAssertEqual(try transform("xMidYMid", viewBox: "0 0 50 100"), [.translate(tx: 50, ty: 0), .scale(sx: 2, sy: 2)]) + XCTAssertEqual(try transform("xMaxYMin", viewBox: "0 0 50 100"), [.translate(tx: 100, ty: 0), .scale(sx: 2, sy: 2)]) + } + + func testSliceFillsViewport() throws { + // 100x50 into 200x200: scale 4 (covers), 400 wide, overflow of 200 shared by the alignment + XCTAssertEqual(try transform("xMinYMin slice"), [.scale(sx: 4, sy: 4)]) + XCTAssertEqual(try transform("xMidYMid slice"), [.translate(tx: -100, ty: 0), .scale(sx: 4, sy: 4)]) + XCTAssertEqual(try transform("xMaxYMax slice"), [.translate(tx: -200, ty: 0), .scale(sx: 4, sy: 4)]) + } + + func testViewBoxOriginIsKept() throws { + XCTAssertEqual(try transform("xMinYMin", viewBox: "10 20 100 50"), + [.scale(sx: 2, sy: 2), .translate(tx: -10, ty: -20)]) + } + + func testMatchingAspectIsUnchanged() throws { + XCTAssertEqual(try transform(nil, viewBox: "0 0 100 100"), [.scale(sx: 2, sy: 2)]) + XCTAssertEqual(try transform("xMaxYMax slice", viewBox: "0 0 100 100"), [.scale(sx: 2, sy: 2)]) + } + + func testEmptyViewBoxFallsBackToViewport() throws { + XCTAssertEqual(try transform(nil, viewBox: "0 0 0 10"), []) + } + + // MARK: nested svg + + func testNestedSVGTransformAndClip() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + """) + let root = LayerTree.Builder(svg: svg).makeLayer() + let layers: [LayerTree.Layer] = root.contents.compactMap { + if case .layer(let l) = $0 { return l } + return nil + } + XCTAssertEqual(layers.count, 2) + + // slice: scale 4 so the 10x10 box covers 40x20; the viewport is the top 5 units of it + XCTAssertEqual(layers[0].transform, [.translate(tx: 10, ty: 20), .scale(sx: 4, sy: 4)]) + XCTAssertEqual(layers[0].clip.first?.shape, .rect(within: .init(x: 0, y: 0, width: 10, height: 5), radii: .zero)) + + // meet (default): scale 2, centred horizontally; the viewport is wider than the viewBox + XCTAssertEqual(layers[1].transform, [.translate(tx: 10, ty: 0), .scale(sx: 2, sy: 2)]) + XCTAssertEqual(layers[1].clip.first?.shape, .rect(within: .init(x: -5, y: 0, width: 20, height: 10), radii: .zero)) + } + + // MARK: image + + private func placement(_ par: PAR, width: Float? = 100, height: Float? = 100, bitmap: LayerTree.Size = .init(50, 100)) -> (dest: LayerTree.Rect, clip: LayerTree.Rect?) { + var image = LayerTree.Image(bitmap: .png(Data([1]))) + image.origin = .init(10, 20) + image.width = width + image.height = height + image.preserveAspectRatio = par + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.makeImagePlacement(for: image, bitmapSize: bitmap) + } + + func testImageMeetCentresInFrame() { + let p = placement(PAR()) + XCTAssertEqual(p.dest, .init(x: 35, y: 20, width: 50, height: 100)) + XCTAssertNil(p.clip) + XCTAssertEqual(placement(PAR(align: .xMinYMid)).dest, .init(x: 10, y: 20, width: 50, height: 100)) + XCTAssertEqual(placement(PAR(align: .xMaxYMid)).dest, .init(x: 60, y: 20, width: 50, height: 100)) + } + + func testImageSliceCoversAndClipsToFrame() { + let p = placement(PAR(align: .xMidYMid, meetOrSlice: .slice)) + XCTAssertEqual(p.dest, .init(x: 10, y: -30, width: 100, height: 200)) + XCTAssertEqual(p.clip, .init(x: 10, y: 20, width: 100, height: 100)) + XCTAssertEqual(placement(PAR(align: .xMinYMin, meetOrSlice: .slice)).dest, .init(x: 10, y: 20, width: 100, height: 200)) + XCTAssertEqual(placement(PAR(align: .xMaxYMax, meetOrSlice: .slice)).dest, .init(x: 10, y: -80, width: 100, height: 200)) + } + + func testImageNoneStretches() { + let p = placement(PAR(align: .none)) + XCTAssertEqual(p.dest, .init(x: 10, y: 20, width: 100, height: 100)) + XCTAssertNil(p.clip) + } + + func testImageWithOneDimensionKeepsItsAspectRatio() { + XCTAssertEqual(placement(PAR(), width: 100, height: nil).dest, .init(x: 10, y: 20, width: 100, height: 200)) + XCTAssertEqual(placement(PAR(), width: nil, height: nil).dest, .init(x: 10, y: 20, width: 50, height: 100)) + } + + func testImageWithPreserveAspectRatioStillDraws() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + let commands = generator.renderCommands(for: layer, colorConverter: .default) + // LayerTreeProvider reports the frame as the bitmap size, so the fit is the identity here + XCTAssertTrue(commands.contains { if case .draw = $0 { return true }; return false }) + } + + // MARK: ViewBoxFit + + func testFitIsUniformUnlessNone() { + let meet = PAR().fit(contentWidth: 10, contentHeight: 20, viewportWidth: 100, viewportHeight: 100) + XCTAssertEqual(meet.sx, 5) + XCTAssertEqual(meet.sy, 5) + let none = PAR(align: .none).fit(contentWidth: 10, contentHeight: 20, viewportWidth: 100, viewportHeight: 100) + XCTAssertEqual(none.sx, 10) + XCTAssertEqual(none.sy, 5) + } + + // MARK: table over alignments + + func testEveryAlignmentAndModeOnBothAxes() { + let aligns: [(String, Float, Float)] = [ + ("xMinYMin", 0, 0), ("xMidYMin", 0.5, 0), ("xMaxYMin", 1, 0), + ("xMinYMid", 0, 0.5), ("xMidYMid", 0.5, 0.5), ("xMaxYMid", 1, 0.5), + ("xMinYMax", 0, 1), ("xMidYMax", 0.5, 1), ("xMaxYMax", 1, 1) + ] + // content 40x10 and 10x40 into 100x100: the wide one is limited by x, the tall one by y + for (content, size) in [("wide", (w: Float(40), h: Float(10))), ("tall", (w: Float(10), h: Float(40)))] { + for (name, fx, fy) in aligns { + for mode in ["meet", "slice"] { + let par = parse("\(name) \(mode)")! + let fit = par.fit(contentWidth: size.w, contentHeight: size.h, viewportWidth: 100, viewportHeight: 100) + // meet is limited by the long side (100 / 40), slice by the short side (100 / 10) + let expected: Float = mode == "meet" ? 2.5 : 10 + XCTAssertEqual(fit.sx, expected, "\(content) \(name) \(mode)") + XCTAssertEqual(fit.sy, expected, "\(content) \(name) \(mode)") + XCTAssertEqual(fit.tx, (100 - size.w * expected) * fx, accuracy: 1e-4, "\(content) \(name) \(mode)") + XCTAssertEqual(fit.ty, (100 - size.h * expected) * fy, accuracy: 1e-4, "\(content) \(name) \(mode)") + } + } + } + } + + func testNoneSliceStillStretches() { + let par = parse("none slice")! + XCTAssertEqual(par.align, .none) + let fit = par.fit(contentWidth: 10, contentHeight: 20, viewportWidth: 100, viewportHeight: 100) + XCTAssertEqual(fit.sx, 10) + XCTAssertEqual(fit.sy, 5) + XCTAssertEqual(fit.tx, 0) + XCTAssertEqual(fit.ty, 0) + } + + func testBareDeferAndWhitespaceVariants() { + XCTAssertNil(parse("defer")) + XCTAssertEqual(parse("\t xMaxYMin\n\r slice "), PAR(align: .xMaxYMin, meetOrSlice: .slice)) + XCTAssertEqual(parse("defer\txMinYMax"), PAR(align: .xMinYMax, meetOrSlice: .meet)) + } + + // MARK: degenerate sizes + + private func isFinite(_ rect: LayerTree.Rect) -> Bool { + [rect.x, rect.y, rect.width, rect.height].allSatisfy(\.isFinite) + } + + func testDegenerateViewportsFallBackToIdentity() { + let box = DOM.SVG.ViewBox(x: 0, y: 0, width: 100, height: 50) + for (w, h): (DOM.Coordinate, DOM.Coordinate) in [(0, 100), (100, 0), (-10, 100), (100, -10), (0, 0)] { + let fit = LayerTree.Builder.makeViewBoxFit(viewBox: box, width: w, height: h, preserveAspectRatio: nil) + XCTAssertEqual([fit.sx, fit.sy, fit.tx, fit.ty], [1, 1, 0, 0], "\(w)x\(h)") + let clip = LayerTree.Builder.makeViewportClip(viewBox: box, width: w, height: h, preserveAspectRatio: nil) + XCTAssertTrue(isFinite(clip), "\(w)x\(h)") + } + } + + func testNonFiniteViewBoxFallsBackToViewport() { + let size = Float.infinity + for box in [DOM.SVG.ViewBox(x: 0, y: 0, width: size, height: 10), + DOM.SVG.ViewBox(x: size, y: 0, width: 10, height: 10), + DOM.SVG.ViewBox(x: 0, y: 0, width: .nan, height: 10), + // tiny box: the scale overflows to infinity + DOM.SVG.ViewBox(x: 0, y: 0, width: Float.leastNonzeroMagnitude, height: 10)] { + let fit = LayerTree.Builder.makeViewBoxFit(viewBox: box, width: 100, height: 100, preserveAspectRatio: nil) + XCTAssertTrue([fit.sx, fit.sy, fit.tx, fit.ty].allSatisfy(\.isFinite), "\(box)") + XCTAssertGreaterThan(fit.sx, 0) + let clip = LayerTree.Builder.makeViewportClip(viewBox: box, width: 100, height: 100, preserveAspectRatio: nil) + XCTAssertTrue(isFinite(clip), "\(box)") + } + } + + // MARK: root slice + + func testRootSliceGetsAClipInViewBoxSpace() throws { + let svg = try DOM.SVG.parse(xml: """ + + """) + let root = LayerTree.Builder(svg: svg).makeLayer() + XCTAssertEqual(root.transform, [.scale(sx: 2, sy: 2)]) + XCTAssertEqual(root.clip.first?.shape, .rect(within: .init(x: 0, y: 0, width: 100, height: 50), radii: .zero)) + } + + func testRootMeetAndNoneAreNotClipped() throws { + for par in ["xMidYMid meet", "none", "xMinYMin slice".replacingOccurrences(of: "slice", with: "meet")] { + let svg = try DOM.SVG.parse(xml: """ + + """) + XCTAssertTrue(LayerTree.Builder(svg: svg).makeLayer().clip.isEmpty, par) + } + } + + // MARK: nested svg with a viewBox origin + + func testNestedSVGWithViewBoxOrigin() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + """) + let root = LayerTree.Builder(svg: svg).makeLayer() + guard case .layer(let nested)? = root.contents.first else { return XCTFail() } + // scale 2, centred horizontally: (40 - 20) / 2 = 10 + XCTAssertEqual(nested.transform, [.translate(tx: 20, ty: 20), .scale(sx: 2, sy: 2), .translate(tx: -5, ty: -5)]) + // the viewport in viewBox space: x from 5 - 10 / 2 = 0, 20 wide, 10 tall + XCTAssertEqual(nested.clip.first?.shape, .rect(within: .init(x: 0, y: 5, width: 20, height: 10), radii: .zero)) + } + + // MARK: image epsilon + + func testImageMeetAndEqualAspectDoNotClip() { + XCTAssertNil(placement(PAR(), bitmap: .init(100, 100)).clip) + XCTAssertNil(placement(PAR(align: .xMinYMin, meetOrSlice: .slice), bitmap: .init(100, 100)).clip) + // a bitmap whose ratio matches up to rounding noise + XCTAssertNil(placement(PAR(align: .xMidYMid, meetOrSlice: .slice), bitmap: .init(333.3333, 333.33334)).clip) + XCTAssertNotNil(placement(PAR(align: .xMidYMid, meetOrSlice: .slice), bitmap: .init(50, 100)).clip) + } + + // MARK: pattern + + func testPatternViewBoxHonoursPreserveAspectRatio() throws { + typealias Generator = LayerTree.CommandGenerator + func content(_ par: PAR) throws -> LayerTree.Transform.Matrix { + let pattern = LayerTree.Pattern(frame: .init(x: 5, y: 0, width: 40, height: 20)) + pattern.viewBox = .init(x: 10, y: 10, width: 10, height: 10) + pattern.preserveAspectRatio = par + return try XCTUnwrap(Generator.resolvePattern(pattern, in: .init(x: 0, y: 0, width: 100, height: 100))).1 + } + // meet: scale 2, free space 20 in x + XCTAssertEqual(try content(PAR(align: .xMinYMin)), .init(a: 2, b: 0, c: 0, d: 2, tx: 5 - 20, ty: -20)) + XCTAssertEqual(try content(PAR(align: .xMaxYMax)), .init(a: 2, b: 0, c: 0, d: 2, tx: 5 + 20 - 20, ty: -20)) + // slice: scale 4, 20 of overflow in y + XCTAssertEqual(try content(PAR(align: .xMinYMid, meetOrSlice: .slice)), .init(a: 4, b: 0, c: 0, d: 4, tx: 5 - 40, ty: -10 - 40)) + // none: stretched + XCTAssertEqual(try content(PAR(align: .none)), .init(a: 4, b: 0, c: 0, d: 2, tx: 5 - 40, ty: -20)) + } + + func testPatternFitOverflowIsDropped() { + typealias Generator = LayerTree.CommandGenerator + let pattern = LayerTree.Pattern(frame: .init(x: 0, y: 0, width: 1e30, height: 1e30)) + pattern.viewBox = .init(x: 0, y: 0, width: 1e-30, height: 1e-30) + XCTAssertNil(Generator.resolvePattern(pattern, in: .init(x: 0, y: 0, width: 100, height: 100))) + } + + func testRootClipPathIntersectsTheSliceViewport() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + """) + let root = LayerTree.Builder(svg: svg).makeLayer() + // the viewport clips the outer layer, the root's own clip-path stays on the inner one + XCTAssertFalse(root.clip.isEmpty) + guard case .layer(let inner)? = root.contents.first else { return XCTFail("no inner layer") } + XCTAssertFalse(inner.clip.isEmpty) + XCTAssertNotEqual(root.clip, inner.clip) + XCTAssertTrue(inner.transform.isEmpty) + XCTAssertFalse(root.transform.isEmpty) + } + + func testPatternParsesAndInheritsPreserveAspectRatio() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + + + """) + XCTAssertEqual(svg.defs.patterns.first?.preserveAspectRatio, PAR(align: .xMaxYMax, meetOrSlice: .slice)) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + let commands = generator.renderCommands(for: layer, colorConverter: .default) + // "b" has no preserveAspectRatio of its own: it inherits "a" (the resolved pattern only keeps the fit) + XCTAssertNil(svg.defs.patterns.last?.preserveAspectRatio) + let b = try XCTUnwrap(svg.defs.patterns.last) + let pattern = LayerTree.Builder(svg: svg).makePattern(for: b) + XCTAssertEqual(pattern.preserveAspectRatio, PAR(align: .xMaxYMax, meetOrSlice: .slice)) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.TolerantParsingTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.TolerantParsingTests.swift new file mode 100644 index 00000000..56f95bb0 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.TolerantParsingTests.swift @@ -0,0 +1,64 @@ +// +// LayerTree.TolerantParsingTests.swift +// SwiftDraw +// +// Created by Misoservices on 08/10/26. +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw +import Foundation + +final class LayerTreeTolerantParsingTests: XCTestCase { + + private func fillColors(_ svg: String) throws -> [LayerTree.Color] { + let dom = try DOM.SVG.parse(data: Data(svg.utf8)) + let layer = LayerTree.Builder(svg: dom).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default).compactMap { + if case let .setFill(color: color) = $0 { return color } + return nil + } + } + + private func alphas(_ colors: [LayerTree.Color]) -> [Float] { + colors.compactMap { + if case let .rgba(_, _, _, a, _) = $0 { return a } + return nil + } + } + + func testHSLAAndHexAlphaReachCommandStream() throws { + let colors = try fillColors(""" + + + + + """) + let a = alphas(colors) + XCTAssertEqual(a.count, 2) + XCTAssertEqual(a[0], 0.25, accuracy: 0.001) + XCTAssertEqual(a[1], 128.0 / 255, accuracy: 0.001) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.UnitsTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.UnitsTests.swift new file mode 100644 index 00000000..2c003d88 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.UnitsTests.swift @@ -0,0 +1,116 @@ +// +// LayerTree.UnitsTests.swift +// SwiftDraw +// +// Added by Misoservices for the Backdrop fork of SwiftDraw (altered source version). +// Copyright 2026 Misoservices +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +import XCTest +@testable import SwiftDraw +@testable import SwiftDrawDOM + +// SD13 — SVG 1.1 §7.10: units and percentages reach the renderers in user units +final class LayerTreeUnitsTests: XCTestCase { + + typealias Command = RendererCommand + + private func commands(_ xml: String) throws -> [Command] { + let svg = try DOM.SVG.parse(xml: xml) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default) + } + + private func fills(_ commands: [Command]) -> [LayerTree.Rect] { + commands.compactMap { + guard case let .fill(shapes, rule: _) = $0 else { return nil } + return shapes.map(\.path.bounds).reduce(nil as LayerTree.Rect?) { acc, b in acc.map { $0.union(b) } ?? b } + } + } + + private func transforms(_ commands: [Command]) -> [String] { + commands.compactMap { + switch $0 { + case let .translate(tx, ty): return "translate(\(tx), \(ty))" + case let .scale(sx, sy): return "scale(\(sx), \(sy))" + case .concatenate: return "concatenate" + default: return nil + } + } + } + + func testPercentagesAndUnitsReachTheFillCommand() throws { + let commands = try commands(""" + + + + + """) + let fills = fills(commands) + XCTAssertEqual(fills.count, 2) + XCTAssertEqual(fills.first?.x ?? 0, 20, accuracy: 0.001) + XCTAssertEqual(fills.first?.y ?? 0, 96, accuracy: 0.001) + XCTAssertEqual(fills.first?.width ?? 0, 100, accuracy: 0.001) + XCTAssertEqual(fills.first?.height ?? 0, 96, accuracy: 0.001) + // r = 2em = 32 at the initial 16px font-size + XCTAssertEqual(fills.last?.midX ?? 0, 100, accuracy: 0.01) + XCTAssertEqual(fills.last?.width ?? 0, 64, accuracy: 0.01) + } + + func testFractionalRootWithMatchingViewBoxHasNoTransform() throws { + // stored as Int, 145.11934 became 145 and the viewBox was letterboxed by a fraction of a unit + let svg = try DOM.SVG.parse(xml: """ + + + """) + XCTAssertEqual(svg.width, 145.11934) + XCTAssertEqual(LayerTree.Builder(svg: svg).makeLayer().transform, []) + } + + func testRootWithOnlyWidthScalesTheViewBoxUniformly() throws { + // browsers draw width="200" viewBox="0 0 100 50" at 200x100, a uniform x2 + let svg = try DOM.SVG.parse(xml: """ + + """) + XCTAssertEqual(svg.width, 200) + XCTAssertEqual(svg.height, 100) + XCTAssertEqual(LayerTree.Builder(svg: svg).makeLayer().transform, [.scale(sx: 2, sy: 2)]) + } + + func testNestedSVGPercentagesUseTheEnclosingViewport() throws { + // nested viewport: x=50 y=10 100x80; its 10x10 viewBox meets at x8, centred 10 units right + let commands = try commands(""" + + + + + """) + XCTAssertEqual(transforms(commands), ["translate(60.0, 10.0)", "scale(8.0, 8.0)"]) + XCTAssertEqual(fills(commands), [LayerTree.Rect(x: 0, y: 0, width: 5, height: 10)]) + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.VisibilityCascadeTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.VisibilityCascadeTests.swift new file mode 100644 index 00000000..06b06480 --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.VisibilityCascadeTests.swift @@ -0,0 +1,182 @@ +// +// CGPatternTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDraw +@testable import SwiftDrawDOM +import XCTest + +/// SD16: visibility and cascade follow-ups of SD7 and SD8 (SVG 1.1 §11.6.2, §14.3.5, §7.11). +final class LayerTreeVisibilityCascadeTests: XCTestCase { + + func testHiddenLeafWithFilterPaintsNothing() throws { + let layer = try makeLayer(""" + + + """) + XCTAssertNil(findLayer(in: layer) { !$0.filters.isEmpty }) + XCTAssertTrue(allContents(of: layer).isEmpty) + } + + func testVisibleChildOfHiddenGroupStillPaints() throws { + let layer = try makeLayer(""" + + """) + XCTAssertEqual(allContents(of: layer).count, 1) + } + + func testRootWithDisplayNoneHasNoViewBoxTransform() throws { + let svg = try DOM.SVG.parse(xml: """ + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + XCTAssertTrue(layer.transform.isEmpty) + XCTAssertTrue(layer.contents.isEmpty) + } + + func testHiddenClipPathHidesItsChildrenUnlessTheySayVisible() throws { + let hidden = try makeLayer(""" + + + """) + // nothing contributes: the clip is the empty shape, which clips everything + XCTAssertEqual(findLayer(in: hidden) { !$0.clip.isEmpty }?.clip, [.empty]) + + let shown = try makeLayer(""" + + + """) + XCTAssertEqual(findLayer(in: shown) { !$0.clip.isEmpty }?.clip.count, 1) + XCTAssertNotEqual(findLayer(in: shown) { !$0.clip.isEmpty }?.clip, [.empty]) + } + + func testClipRuleSetOnClipPathByCSS() throws { + let layer = try makeLayer(""" + + + + """) + XCTAssertEqual(findLayer(in: layer) { !$0.clip.isEmpty }?.clipRule, .evenodd) + } + + func testClipPathOnAClipPathChildClipsThatChild() throws { + let layer = try makeLayer(""" + + + + """) + // one clipping path cannot express it: it becomes an alpha mask whose member carries its own clip + let masked = try XCTUnwrap(findLayer(in: layer) { $0.mask != nil }) + XCTAssertTrue(masked.maskIsClip) + let mask = try XCTUnwrap(masked.mask) + XCTAssertNotNil(findLayer(in: mask) { !$0.clip.isEmpty }) + } + + func testBoundingBoxReadsDisplayAndTransformFromTheCascade() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + """) + let box = try XCTUnwrap(LayerTree.Builder(svg: svg).makeBoundingBox(for: svg.childElements[0])) + XCTAssertEqual(box, LayerTree.Rect(x: 0, y: 0, width: 60, height: 10)) + } + + func testHiddenGeometryStaysInTheBoundingBox() throws { + // SVG 2 keeps `visibility: hidden` geometry in the bounding box + let svg = try DOM.SVG.parse(xml: """ + + + + """) + let box = try XCTUnwrap(LayerTree.Builder(svg: svg).makeBoundingBox(for: svg.childElements[0])) + XCTAssertEqual(box, LayerTree.Rect(x: 0, y: 0, width: 50, height: 10)) + } + + func testUnionBeyondTheFloatRangeIsNotKnown() { + let big = LayerTree.Float.greatestFiniteMagnitude * 0.75 + let a = LayerTree.Rect(x: -big, y: 0, width: 1, height: 1) + let b = LayerTree.Rect(x: big, y: 0, width: 1, height: 1) + guard case .empty = LayerTree.Builder.Bounds(a.union(b)) else { return XCTFail("expected empty") } + } + + func testLayerEqualityAndHashSeeMaskIsClip() { + let a = LayerTree.Layer() + let b = LayerTree.Layer() + XCTAssertEqual(a, b) + b.maskIsClip = true + XCTAssertNotEqual(a, b) + } + + func testGroupWithTextKeepsTheTextWhenTheBoundingBoxIsUnknown() throws { + let layer = try makeLayer(""" + + Hi + """) + XCTAssertTrue(allContents(of: layer).contains { if case .text = $0 { return true }; return false }) + } + + func testFilterAndMaskTypeClipBothSurvive() throws { + let layer = try makeLayer(""" + + + + """) + let filtered = try XCTUnwrap(findLayer(in: layer) { !$0.filters.isEmpty }) + XCTAssertTrue(filtered.maskIsClip) + XCTAssertNotNil(filtered.mask) + } + + // MARK: helpers + + private func makeLayer(_ body: String) throws -> LayerTree.Layer { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + return LayerTree.Builder(svg: svg).makeLayer() + } + + private func findLayer(in layer: LayerTree.Layer, where predicate: (LayerTree.Layer) -> Bool) -> LayerTree.Layer? { + if predicate(layer) { return layer } + for case let .layer(child) in layer.contents { + if let found = findLayer(in: child, where: predicate) { return found } + } + return nil + } + + private func allContents(of layer: LayerTree.Layer) -> [LayerTree.Layer.Contents] { + layer.contents.flatMap { c -> [LayerTree.Layer.Contents] in + if case let .layer(child) = c { return allContents(of: child) } + return [c] + } + } +} diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.VisibilityTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.VisibilityTests.swift new file mode 100644 index 00000000..59149fbb --- /dev/null +++ b/SwiftDraw/Tests/LayerTree/LayerTree.VisibilityTests.swift @@ -0,0 +1,330 @@ +// +// LayerTree.VisibilityTests.swift +// SwiftDraw +// +// Created by Simon Whitty on 13/12/18. +// Copyright 2020 WhileLoop Pty Ltd. All rights reserved. +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw +import Foundation + +final class LayerTreeVisibilityTests: XCTestCase { + + private func commands(_ body: String) throws -> [RendererCommand] { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: LayerTreeProvider(), size: .zero, options: .default) + return generator.renderCommands(for: layer, colorConverter: .default) + } + + private func fillCount(_ body: String) throws -> Int { + try commands(body).filter { + if case .fill = $0 { return true } + return false + }.count + } + + /// The `clip` of every layer that has one, in tree order. + private func clipShapes(_ body: String) throws -> [[LayerTree.ClipShape]] { + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + func collect(_ layer: LayerTree.Layer) -> [[LayerTree.ClipShape]] { + var result = layer.clip.isEmpty ? [] : [layer.clip] + for case .layer(let child) in layer.contents { + result += collect(child) + } + return result + } + return collect(LayerTree.Builder(svg: svg).makeLayer()) + } + + func testDisplayNoneDrawsNothing() throws { + XCTAssertEqual(try fillCount(#""#), 0) + XCTAssertEqual(try fillCount(#""#), 0) + } + + func testDisplayNoneDropsSubtreeEvenIfChildSaysInline() throws { + let body = #""# + XCTAssertEqual(try fillCount(body), 1) + } + + func testDisplayNoneLeavesNoTransformClipOrOpacity() throws { + let body = #""" + + + """# + let cmds = try commands(body) + XCTAssertFalse(cmds.contains { if case .fill = $0 { return true } else { return false } }) + XCTAssertFalse(cmds.contains { if case .setClip = $0 { return true } else { return false } }) + XCTAssertFalse(cmds.contains { if case .setAlpha = $0 { return true } else { return false } }) + XCTAssertFalse(cmds.contains { if case .translate = $0 { return true } else { return false } }) + } + + func testDisplayNoneOnUseAndOnReferencedElement() throws { + let body = #""" + + + """# + XCTAssertEqual(try fillCount(body), 0) + XCTAssertEqual(try fillCount(##""##), 1) + } + + func testDisplayNoneRootDoesNotCrash() throws { + let svg = try DOM.SVG.parse(xml: #""#) + let layer = LayerTree.Builder(svg: svg).makeLayer() + XCTAssertTrue(layer.contents.isEmpty) + } + + func testDisplayNoneChildOfClipPathDoesNotContribute() throws { + let body = #""" + + + """# + let clips = try commands(body).filter { if case .setClip = $0 { return true } else { return false } } + XCTAssertEqual(clips.count, 1) + // the hidden child is not part of the clip itself + let clip = try XCTUnwrap(clipShapes(body).first) + XCTAssertEqual(clip.count, 1) + XCTAssertEqual(clip[0].shape, .rect(within: .init(x: 0, y: 0, width: 5, height: 5), radii: .zero)) + } + + func testClipPathWithOnlyHiddenChildrenClipsEverything() throws { + let body = #""" + + + """# + let clip = try XCTUnwrap(clipShapes(body).first) + XCTAssertEqual(clip.map(\.shape), [.rect(within: .zero, radii: .zero)]) + } + + func testDisplayDoesNotApplyToMask() throws { + let body = #""" + + + """# + XCTAssertEqual(try fillCount(body), 2) + } + + func testDisplayNoneOnChildOfMaskStillHidesIt() throws { + let body = #""" + + + """# + XCTAssertEqual(try fillCount(body), 2) + } + + func testDisplayNoneOnClipPathElementStillClips() throws { + let body = #""" + + + """# + XCTAssertEqual(try clipShapes(body).first?.count, 1) + XCTAssertEqual(try fillCount(body), 1) + } + + func testDefinitionsInsideDisplayNoneGroupAreStillUsed() throws { + let body = #""" + + + + + + + + + """# + let cmds = try commands(body) + XCTAssertEqual(try XCTUnwrap(clipShapes(body).first).count, 1) + XCTAssertTrue(cmds.contains { if case .drawLinearGradient = $0 { return true } else { return false } }) + XCTAssertTrue(cmds.contains { if case .setFillPattern = $0 { return true } else { return false } }) + } + + func testDisplayAndVisibilityFromClassAndStyleSheet() throws { + let body = #""" + + + + + + """# + XCTAssertEqual(try fillCount(body), 2) + } + + func testHiddenTextAndImageDrawNothing() throws { + // the layer tree, not the commands: LayerTreeProvider draws no path for text, visible or not + let png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + func contents(_ attribute: String) throws -> (text: Int, image: Int) { + let svg = try DOM.SVG.parse(xml: """ + + hi + + """) + func count(_ layer: LayerTree.Layer) -> (text: Int, image: Int) { + layer.contents.reduce(into: (0, 0)) { total, content in + switch content { + case .text: total.text += 1 + case .image: total.image += 1 + case .layer(let child): + let c = count(child) + total.text += c.text + total.image += c.image + case .shape: break + } + } + } + return count(LayerTree.Builder(svg: svg).makeLayer()) + } + let visible = try contents("") + XCTAssertEqual(visible.text, 1) + XCTAssertEqual(visible.image, 1) + for attribute in [#"visibility="hidden""#, #"display="none""#] { + let hidden = try contents(attribute) + XCTAssertEqual(hidden.text, 0, attribute) + XCTAssertEqual(hidden.image, 0, attribute) + } + } + + func testUseOfDisplayNoneElementWithOpacityAndMaskDrawsNothing() throws { + let body = #""" + + + + + + """# + XCTAssertEqual(try commands(body).count, 0) + } + + func testDisplayNoneElementBuildsNoLayerState() throws { + let cmds = try commands(#""#) + XCTAssertEqual(cmds.count, 0) + } + + func testForeignObjectWithSVGNamespaceChild() throws { + // is not rendered, so an SVG-namespace child inside it goes with it; the sibling draws + let body = #""" + + + """# + XCTAssertEqual(try fillCount(body), 1) + } + + func testDisplayDoesNotApplyToGradientPatternOrFilter() throws { + // SVG 1.1 §11.5: display does not apply to gradients, or + let body = #""" + + + + + + + """# + let cmds = try commands(body) + XCTAssertTrue(cmds.contains { if case .drawLinearGradient = $0 { return true } else { return false } }) + XCTAssertTrue(cmds.contains { if case .setFillPattern = $0 { return true } else { return false } }) + let svg = try DOM.SVG.parse(xml: """ + \(body) + """) + func hasFilter(_ layer: LayerTree.Layer) -> Bool { + !layer.filters.isEmpty || layer.contents.contains { + if case .layer(let child) = $0 { return hasFilter(child) } else { return false } + } + } + XCTAssertTrue(hasFilter(LayerTree.Builder(svg: svg).makeLayer())) + } + + func testClipPathWithOnlyAUseChildStillDraws() throws { + // a child this builder cannot clip with is not a hidden one: the element must not vanish + let body = #""" + + + + """# + XCTAssertEqual(try fillCount(body), 1) + XCTAssertFalse(try clipShapes(body).contains { $0 == [LayerTree.ClipShape(shape: .rect(within: .zero, radii: .zero), transform: .identity)] }) + } + + func testVisibilityParsesInDOM() throws { + let svg = try DOM.SVG.parse(xml: #""" + + + + + + + """#) + let values = svg.childElements.map { DOM.presentationAttributes(for: $0, styles: svg.styles).visibility } + XCTAssertEqual(values, [.hidden, .collapse, nil, nil]) + } + + func testVisibilityHiddenDrawsNothing() throws { + XCTAssertEqual(try fillCount(#""#), 0) + XCTAssertEqual(try fillCount(#""#), 0) + XCTAssertEqual(try fillCount(#""#), 0) + } + + func testVisibilityInheritsAndChildMayShowAgain() throws { + let body = #""" + + + + + + """# + XCTAssertEqual(try fillCount(body), 2) + } + + func testInvalidVisibilityIsIgnored() throws { + XCTAssertEqual(try fillCount(#""#), 0) + XCTAssertEqual(try fillCount(#""#), 1) + } + + func testVisibilityHiddenGroupStillAppliesToUse() throws { + let body = ##""## + XCTAssertEqual(try fillCount(body), 1) + } + + func testForeignNamespaceElementDropsSubtree() throws { + let body = #""" + + + """# + XCTAssertEqual(try fillCount(body), 1) + } + + func testForeignNamespaceDoesNotLeakIntoDOM() throws { + let svg = try DOM.SVG.parse(xml: """ + + + + + """) + XCTAssertEqual(svg.childElements.count, 1) + } +} diff --git a/SwiftDraw/Tests/Renderer/CGClipMaskTests.swift b/SwiftDraw/Tests/Renderer/CGClipMaskTests.swift new file mode 100644 index 00000000..125fe139 --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGClipMaskTests.swift @@ -0,0 +1,187 @@ +// +// CGClipMaskTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDraw + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import XCTest + +/// Pixel checks of clip paths and masks through the CoreGraphics renderer (SVG 1.1 §14.3.5, §14.4). +/// Every drawing is 16×16 on white, with a red element whose clip or mask leaves only some columns red. +final class CGClipMaskTests: XCTestCase { + + func testClipPathUnitsObjectBoundingBox() throws { + // bbox (0,0,16,16): clip to x in 8...16 + let colors = try render(#""" + + + """#) + XCTAssertEqual(colors(4), .white) + XCTAssertEqual(colors(12), .red) + } + + func testClipRuleFromClipPathContents() throws { + // outer 0...16, hole 4...12; evenodd on the path cuts the hole + let colors = try render(#""" + + + """#) + XCTAssertEqual(colors(2), .red) + XCTAssertEqual(colors(8), .white) + XCTAssertEqual(colors(14), .red) + } + + func testMixedClipRulesUnion() throws { + let colors = try render(#""" + + + + + + """#) + XCTAssertEqual(colors(2), .red) + XCTAssertEqual(colors(8), .white) + XCTAssertEqual(colors(14), .red) + } + + func testUseInsideClipPath() throws { + let colors = try render(#""" + + + + """#) + XCTAssertEqual(colors(4), .white) + XCTAssertEqual(colors(10), .red) + XCTAssertEqual(colors(14), .white) + } + + func testClipPathOnClipPath() throws { + let colors = try render(#""" + + + + """#) + XCTAssertEqual(colors(2), .white) + XCTAssertEqual(colors(6), .red) + XCTAssertEqual(colors(12), .white) + } + + func testMaskContentUnitsObjectBoundingBox() throws { + let colors = try render(#""" + + + """#) + XCTAssertEqual(colors(4), .white) + XCTAssertEqual(colors(10), .red) + XCTAssertEqual(colors(14), .white) + } + + func testMaskRegionClipsMask() throws { + let colors = try render(#""" + + + """#) + XCTAssertEqual(colors(2), .red) + XCTAssertEqual(colors(8), .white) + } + + func testMaskZeroRegionHidesElement() throws { + let colors = try render(#""" + + + """#) + XCTAssertEqual(colors(2), .white) + XCTAssertEqual(colors(8), .white) + XCTAssertEqual(colors(14), .white) + } + + func testFilterWithMaskTypeClip() throws { + // mixed clip-rules make the clip a mask. A 2-px red bar blurred with σ = 3 spreads to a faint pink, + // so its pixels are no longer solid red; with the filter ignored they would be + let colors = try render(#""" + + + + + + + """#) + XCTAssertNotEqual(colors(1), .red) + XCTAssertNotEqual(colors(2), .red) + XCTAssertEqual(colors(8), .white) + XCTAssertEqual(colors(13), .white) + } + + func testClipPathWithoutShapesClipsEverything() throws { + let colors = try render(#""" + + + + + """#) + XCTAssertEqual(colors(2), .white) + XCTAssertEqual(colors(8), .white) + XCTAssertEqual(colors(13), .white) + } + + enum Pixel: Equatable { + case red + case white + case other + } + + func render(_ body: String) throws -> (Int) -> Pixel { + let xml = """ + + + \(body) + + """ + let svg = try XCTUnwrap(SVG(xml: xml)) + let image = svg.rasterize(with: CGSize(width: 16, height: 16), scale: 1) + let canvas = NSBitmapImageRep(pixelsWide: 16, pixelsHigh: 16) + canvas.lockFocus() + image.draw(in: NSRect(x: 0, y: 0, width: 16, height: 16)) + canvas.unlockFocus() + + return { i in + guard let color = canvas.colorAt(x: i, y: 8)?.usingColorSpace(.deviceRGB) else { return .other } + let (r, g, b) = (color.redComponent, color.greenComponent, color.blueComponent) + if r > 0.9, g < 0.1, b < 0.1 { return .red } + if r > 0.9, g > 0.9, b > 0.9 { return .white } + return .other + } + } +} + +#endif diff --git a/SwiftDraw/Tests/Renderer/CGPaintFallbackTests.swift b/SwiftDraw/Tests/Renderer/CGPaintFallbackTests.swift new file mode 100644 index 00000000..8963c9a3 --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGPaintFallbackTests.swift @@ -0,0 +1,103 @@ +// +// CGPatternTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDraw + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import XCTest + +/// Pixel checks for the pattern-fill state leak and the paint fallback (SVG 1.1 §11.2) through CoreGraphics. +final class CGPaintFallbackTests: XCTestCase { + + // a solid colour A, a pattern, then colour A again: the last shape must be solid A, not the pattern + func testSolidColourAfterPatternIsNotPatterned() throws { + let pixels = try render(defs: #""#, + body: #""" + + + + """#) + XCTAssertEqual(pixels(2), .red) + XCTAssertEqual(pixels(10), .blue) + XCTAssertEqual(pixels(14), .red) + } + + func testMissingServerPaintsFallbackColour() throws { + let pixels = try render(defs: "", body: #""#) + XCTAssertEqual(pixels(8), .red) + } + + func testResolvingServerWinsOverFallback() throws { + let pixels = try render(defs: #""#, + body: #""#) + XCTAssertEqual(pixels(8), .blue) + } + + func testNoneFallbackPaintsNothing() throws { + let pixels = try render(defs: "", body: #""#) + XCTAssertEqual(pixels(8), .white) + } +} + +private extension CGPaintFallbackTests { + + enum Pixel: Equatable { case red, blue, white, other } + + /// Draws on white and samples the middle row. + func render(defs: String, body: String) throws -> (Int) -> Pixel { + let xml = """ + + \(defs) + + \(body) + + """ + let svg = try XCTUnwrap(SVG(xml: xml)) + let image = svg.rasterize(with: CGSize(width: 16, height: 16), scale: 1) + let canvas = NSBitmapImageRep(pixelsWide: 16, pixelsHigh: 16) + canvas.lockFocus() + image.draw(in: NSRect(x: 0, y: 0, width: 16, height: 16)) + canvas.unlockFocus() + + return { i in + guard let color = canvas.colorAt(x: i, y: 8)?.usingColorSpace(.deviceRGB) else { return .other } + let (r, g, b) = (color.redComponent, color.greenComponent, color.blueComponent) + if r > 0.9, g < 0.1, b < 0.1 { return .red } + if r < 0.1, g < 0.1, b > 0.9 { return .blue } + if r > 0.9, g > 0.9, b > 0.9 { return .white } + return .other + } + } +} + +#endif diff --git a/SwiftDraw/Tests/Renderer/CGPatternTests.swift b/SwiftDraw/Tests/Renderer/CGPatternTests.swift new file mode 100644 index 00000000..29db1d8a --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGPatternTests.swift @@ -0,0 +1,140 @@ +// +// CGPatternTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDraw + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import XCTest + +/// Pixel checks of `` through the CoreGraphics renderer (SVG 1.1 §13.3). +/// Every drawing is 16×16 and uses full-height (or full-width) stripes, so only one axis matters. +final class CGPatternTests: XCTestCase { + + func testPatternXOffsetsTiles() throws { + // tiles at x = 4 + 8k, red in the first half of each tile + let colors = try render(pattern: #"\#(stripe)"#) + XCTAssertEqual(colors(1), .white) + XCTAssertEqual(colors(5), .red) + XCTAssertEqual(colors(9), .white) + XCTAssertEqual(colors(13), .red) + } + + func testPatternYOffsetsTiles() throws { + // horizontal stripes: tiles at y = 4 + 8k, red in the top half of each tile + let colors = try render(pattern: #""#, column: true) + XCTAssertEqual(colors(1), .white) + XCTAssertEqual(colors(5), .red) + XCTAssertEqual(colors(9), .white) + XCTAssertEqual(colors(13), .red) + } + + func testPatternTransformMovesTiles() throws { + let colors = try render(pattern: #"\#(stripe)"#) + XCTAssertEqual(colors(1), .white) + XCTAssertEqual(colors(3), .red) + XCTAssertEqual(colors(7), .white) + XCTAssertEqual(colors(11), .red) + } + + func testPatternHrefInheritsAttributesAndContent() throws { + let colors = try render(pattern: #""" + \#(stripe) + + """#) + XCTAssertEqual(colors(1), .white) + XCTAssertEqual(colors(3), .red) + XCTAssertEqual(colors(7), .white) + XCTAssertEqual(colors(11), .red) + } + + func testPatternUnitsDefaultToObjectBoundingBox() throws { + // width 0.5 of the 16 wide bounding box: tiles of 8 + let colors = try render(pattern: #"\#(stripe)"#) + XCTAssertEqual(colors(1), .red) + XCTAssertEqual(colors(5), .white) + XCTAssertEqual(colors(9), .red) + XCTAssertEqual(colors(13), .white) + } + + func testPatternViewBoxScalesContent() throws { + // viewBox 0 0 8 16 into a 16×16 tile: meet scale 1, content centred at x = 4 + let colors = try render(pattern: #"\#(stripe)"#) + XCTAssertEqual(colors(1), .white) + XCTAssertEqual(colors(5), .red) + XCTAssertEqual(colors(9), .white) + } + + func testPatternWithoutSizePaintsNothingAndKeepsDocument() throws { + let colors = try render(pattern: #"\#(stripe)"#) + XCTAssertEqual(colors(1), .white) + XCTAssertEqual(colors(5), .white) + } +} + +private extension CGPatternTests { + + enum Pixel: Equatable { + case red + case white + case other + } + + var stripe: String { #""# } + + /// Samples the middle row, or the middle column when `column` is true. + func render(pattern: String, column: Bool = false) throws -> (Int) -> Pixel { + let xml = """ + + \(pattern) + + + + """ + let svg = try XCTUnwrap(SVG(xml: xml)) + let image = svg.rasterize(with: CGSize(width: 16, height: 16), scale: 1) + let canvas = NSBitmapImageRep(pixelsWide: 16, pixelsHigh: 16) + canvas.lockFocus() + image.draw(in: NSRect(x: 0, y: 0, width: 16, height: 16)) + canvas.unlockFocus() + + return { i in + guard let color = canvas.colorAt(x: column ? 8 : i, y: column ? i : 8)?.usingColorSpace(.deviceRGB) else { return .other } + let (r, g, b) = (color.redComponent, color.greenComponent, color.blueComponent) + if r > 0.9, g < 0.1, b < 0.1 { return .red } + if r > 0.9, g > 0.9, b > 0.9 { return .white } + return .other + } + } +} + +#endif diff --git a/SwiftDraw/Tests/Renderer/CGRenderer+CSSCascadeTests.swift b/SwiftDraw/Tests/Renderer/CGRenderer+CSSCascadeTests.swift new file mode 100644 index 00000000..b96368f9 --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGRenderer+CSSCascadeTests.swift @@ -0,0 +1,90 @@ +// +// CGRenderer+CSSCascadeTests.swift +// SwiftDraw +// +// Added by Misoservices for the Backdrop fork of SwiftDraw (altered source version). +// Copyright 2026 Misoservices +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +#if canImport(CoreGraphics) +@testable import SwiftDraw +import CoreGraphics +import XCTest + +final class CGRendererCSSCascadeTests: XCTestCase { + + // a stylesheet transform and a descendant selector reach the pixels + func testStyleSheetTransformAndSelectorDrawPixels() throws { + let bitmap = try render(#""" + + + + + """#) + + // the red square moved from x 0…10 to x 20…30 + XCTAssertEqual(bitmap.rgba(x: 5, y: 5), [0, 0, 0, 0]) + XCTAssertEqual(bitmap.rgba(x: 25, y: 5), [255, 0, 0, 255]) + // the second rect keeps the type rule + XCTAssertEqual(bitmap.rgba(x: 5, y: 15), [0, 0, 255, 255]) + } +} + +private struct Bitmap { + var width: Int + var bytes: [UInt8] + + // y is measured from the top, like SVG + func rgba(x: Int, y: Int) -> [UInt8] { + let i = (y * width + x) * 4 + return Array(bytes[i.. Bitmap { + let svg = try XCTUnwrap(SVG(xml: xml)) + let width = Int(svg.size.width) + let height = Int(svg.size.height) + var bytes = [UInt8](repeating: 0, count: width * height * 4) + let space = CGColorSpace(name: CGColorSpace.sRGB)! + bytes.withUnsafeMutableBytes { buffer in + let ctx = CGContext(data: buffer.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + ctx.translateBy(x: 0, y: CGFloat(height)) + ctx.scaleBy(x: 1, y: -1) + CGRenderer(context: ctx).perform(svg.commands) + } + return Bitmap(width: width, bytes: bytes) + } +} +#endif diff --git a/SwiftDraw/Tests/Renderer/CGRenderer+FilterPrimitivesTests.swift b/SwiftDraw/Tests/Renderer/CGRenderer+FilterPrimitivesTests.swift new file mode 100644 index 00000000..fb4df0df --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGRenderer+FilterPrimitivesTests.swift @@ -0,0 +1,326 @@ +// +// CGRenderer+FilterPrimitivesTests.swift +// SwiftDraw +// +// Created by Misoservices on 9/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +#if canImport(CoreGraphics) +@testable import SwiftDraw +import CoreGraphics +import XCTest + +// SD12: filter primitive trees rendered by CoreGraphics +final class CGRendererFilterPrimitivesTests: XCTestCase { + + // Inkscape's drop shadow: a half black copy of the shape, offset, under the shape + func testInkscapeDropShadow() throws { + let bitmap = try render(#""" + + + + + + + + + + + """#) + + XCTAssertEqual(bitmap.rgba(x: 40, y: 40), [255, 0, 0, 255]) + // the shadow alone, below and right of the shape + XCTAssertEqual(bitmap.rgba(x: 65, y: 65), [0, 0, 0, 127]) + XCTAssertEqual(bitmap.rgba(x: 25, y: 65), [0, 0, 0, 0]) + XCTAssertEqual(bitmap.rgba(x: 75, y: 75), [0, 0, 0, 0]) + } + + // Illustrator's AI_Shadow: blurred SourceAlpha offset under SourceGraphic, in linearRGB + func testIllustratorShadow() throws { + let bitmap = try render(#""" + + + + + + + + + + + + """#) + + XCTAssertEqual(bitmap.rgba(x: 40, y: 40), [0, 0, 255, 255]) + // the shadow alone, more than three deviations inside its edges + XCTAssertEqual(bitmap.rgba(x: 50, y: 62), [0, 0, 0, 255]) + // the blurred edge of the shadow + XCTAssertEqual(bitmap.alpha(x: 70, y: 62), 0.45, accuracy: 0.15) + XCTAssertEqual(bitmap.alpha(x: 80, y: 80), 0) + } + + // Figma's drop shadow: hard alpha, offset, blur, the shape cut out, a 25% black colour matrix + func testFigmaDropShadow() throws { + let bitmap = try render(#""" + + + + + + + + + + + + + + """#) + + XCTAssertEqual(bitmap.rgba(x: 40, y: 40), [0, 255, 0, 255]) + XCTAssertEqual(bitmap.alpha(x: 40, y: 65), 0.25, accuracy: 0.02) + XCTAssertEqual(bitmap.rgba(x: 40, y: 65).prefix(3), [0, 0, 0]) + XCTAssertEqual(bitmap.alpha(x: 40, y: 75), 0) + XCTAssertEqual(bitmap.alpha(x: 10, y: 40), 0) + } + + // Filter Effects 1 §9.4 example: the flood fills its subregion only, hiding the shape + func testFloodSubregion() throws { + let bitmap = try render(#""" + + + + + + + """#) + + assertPixel(bitmap.rgba(x: 100, y: 100), [0, 96, 0, 191]) + assertPixel(bitmap.rgba(x: 60, y: 140), [0, 96, 0, 191]) + // the circle itself is gone + XCTAssertEqual(bitmap.alpha(x: 30, y: 100), 0) + XCTAssertEqual(bitmap.alpha(x: 100, y: 30), 0) + } + + // Filter Effects 1 §10: linearRGB by default, sRGB when asked + func testColorInterpolationFilters() throws { + func draw(_ space: String) throws -> Bitmap { + try render(#""" + + + + + + + + """#) + } + + let linear = try draw("") + let srgb = try draw(#"color-interpolation-filters="sRGB""#) + XCTAssertEqual(Double(linear.rgba(x: 50, y: 50)[0]), 188, accuracy: 2) + XCTAssertEqual(Double(srgb.rgba(x: 50, y: 50)[0]), 128, accuracy: 2) + XCTAssertEqual(linear.rgba(x: 50, y: 50)[3], 255) + } + + func testColorMatrixSaturate() throws { + let bitmap = try render(#""" + + + + + + + """#) + + XCTAssertEqual(bitmap.rgba(x: 25, y: 25), [54, 54, 54, 255]) + // a hue rotation keeps greys + assertPixel(bitmap.rgba(x: 75, y: 25), [128, 128, 128, 255]) + } + + func testBlendMultiplyOntoFlood() throws { + let bitmap = try render(#""" + + + + + + + + """#) + + XCTAssertEqual(bitmap.rgba(x: 50, y: 50), [255, 0, 0, 255]) + } + + // the visible area grows by the offset: content clipped away can be moved into view + func testOffsetBringsContentIntoTheClip() throws { + let bitmap = try render(#""" + + + + + + + + + + """#) + + XCTAssertEqual(bitmap.alpha(x: 70, y: 20), 1) + XCTAssertEqual(bitmap.alpha(x: 20, y: 20), 0) + XCTAssertEqual(bitmap.alpha(x: 95, y: 20), 0) + } + + // offsets are in user space: scaled to device pixels, turned with the shape + func testOffsetFollowsScaleAndRotation() throws { + let scaled = try render(#""" + + + + + """#, scale: 2) + XCTAssertEqual(scaled.alpha(x: 30, y: 10), 1) + XCTAssertEqual(scaled.alpha(x: 10, y: 10), 0) + + let rotated = try render(#""" + + + + + """#) + // rotate(90) maps the rect to x 70...90, y 40...60 and +x to +y + XCTAssertEqual(rotated.alpha(x: 80, y: 80), 1) + XCTAssertEqual(rotated.alpha(x: 80, y: 50), 0) + } + + // a disconnected branch, and primitives after it, follow the primary tree only + func testOnlyPrimaryTreeIsDrawn() throws { + let bitmap = try render(#""" + + + + + + + + + """#) + + assertPixel(bitmap.rgba(x: 50, y: 50), [0, 0, 128, 128]) + } + + // the pixel budget is shared by the bitmaps of the tree: still drawn, at a reduced scale + func testOverPixelCapStillFilters() throws { + let bitmap = try render(#""" + + + + + + + + """#, maxFilterLayerPixels: 2_000) + + XCTAssertGreaterThan(bitmap.alpha(x: 30, y: 30), 0.9) + XCTAssertGreaterThan(bitmap.rgba(x: 30, y: 30)[0], 200) + XCTAssertGreaterThan(bitmap.alpha(x: 60, y: 60), 0.9) + XCTAssertLessThan(bitmap.rgba(x: 60, y: 60)[0], 50) + XCTAssertEqual(bitmap.alpha(x: 90, y: 90), 0) + } + + func testHugeOffsetDoesNotTrap() throws { + let bitmap = try render(#""" + + + + + + """#) + + XCTAssertEqual(bitmap.alpha(x: 30, y: 30), 0) + XCTAssertEqual(bitmap.alpha(x: 70, y: 70), 1) + } + + // the flood colour goes through the same colour matching as a fill + func testFloodMatchesFillColor() throws { + let bitmap = try render(#""" + + + + + + """#) + + XCTAssertEqual(bitmap.rgba(x: 25, y: 25), bitmap.rgba(x: 75, y: 25)) + } +} + +private struct Bitmap { + var width: Int + var height: Int + var bytes: [UInt8] + + // premultiplied; y is measured from the top, like SVG + func rgba(x: Int, y: Int) -> [UInt8] { + let i = (y * width + x) * 4 + return Array(bytes[i.. Double { + Double(rgba(x: x, y: y)[3]) / 255 + } +} + +private extension CGRendererFilterPrimitivesTests { + + // colours other than primaries may move by a step through colour matching and rounding + func assertPixel(_ pixel: [UInt8], _ expected: [UInt8], accuracy: Int = 2, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(pixel.count, expected.count, file: file, line: line) + for (value, target) in zip(pixel, expected) { + XCTAssertEqual(Int(value), Int(target), accuracy: accuracy, "\(pixel) is not \(expected)", file: file, line: line) + } + } + + func render(_ xml: String, scale: CGFloat = 1, maxFilterLayerPixels: Int = CGRenderer.defaultMaxFilterLayerPixels) throws -> Bitmap { + let svg = try XCTUnwrap(SVG(xml: xml)) + let width = Int(svg.size.width * scale) + let height = Int(svg.size.height * scale) + var bytes = [UInt8](repeating: 0, count: width * height * 4) + let space = CGColorSpace(name: CGColorSpace.sRGB)! + bytes.withUnsafeMutableBytes { buffer in + let ctx = CGContext(data: buffer.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + // SVG coordinates are y-down + ctx.translateBy(x: 0, y: CGFloat(height)) + ctx.scaleBy(x: scale, y: -scale) + CGRenderer(context: ctx, maxFilterLayerPixels: maxFilterLayerPixels).perform(svg.commands) + } + return Bitmap(width: width, height: height, bytes: bytes) + } +} +#endif diff --git a/SwiftDraw/Tests/Renderer/CGRenderer+FilterTests.swift b/SwiftDraw/Tests/Renderer/CGRenderer+FilterTests.swift new file mode 100644 index 00000000..c24d81ee --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGRenderer+FilterTests.swift @@ -0,0 +1,305 @@ +// +// CGRenderer+FilterTests.swift +// SwiftDraw +// +// Created by Misoservices on 8/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +#if canImport(CoreGraphics) +@testable import SwiftDraw +import CoreGraphics +import XCTest + +final class CGRendererFilterTests: XCTestCase { + + // 100x100 bitmap, black square 25...75 blurred by stdDeviation 5, filter region 20...80 + func testGaussianBlurSoftensEdges() throws { + let bitmap = try render(#""" + + + + + """#) + + // centre is opaque, the edge is half covered, the blur spreads outside the shape + XCTAssertGreaterThan(bitmap.alpha(x: 50, y: 50), 0.95) + XCTAssertEqual(bitmap.alpha(x: 25, y: 50), 0.5, accuracy: 0.15) + XCTAssertGreaterThan(bitmap.alpha(x: 22, y: 50), 0.05) + XCTAssertLessThan(bitmap.alpha(x: 28, y: 50), 0.95) + // nothing is drawn outside the filter region + XCTAssertEqual(bitmap.alpha(x: 10, y: 50), 0) + XCTAssertEqual(bitmap.alpha(x: 50, y: 90), 0) + } + + func testGaussianBlurHonoursDeviceScale() throws { + let bitmap = try render(#""" + + + + + """#, scale: 2) + + // same picture as above in 2x device pixels + XCTAssertGreaterThan(bitmap.alpha(x: 50, y: 50), 0.95) + XCTAssertEqual(bitmap.alpha(x: 25, y: 50), 0.5, accuracy: 0.15) + XCTAssertGreaterThan(bitmap.alpha(x: 22, y: 50), 0.05) + XCTAssertEqual(bitmap.alpha(x: 10, y: 50), 0) + } + + func testVerticalOnlyBlur() throws { + let bitmap = try render(#""" + + + + + """#) + + // left edge stays hard, top edge is soft + XCTAssertEqual(bitmap.alpha(x: 24, y: 50), 0) + XCTAssertGreaterThan(bitmap.alpha(x: 26, y: 50), 0.95) + XCTAssertEqual(bitmap.alpha(x: 50, y: 25), 0.5, accuracy: 0.15) + } + + func testUnfilteredDrawingAfterFilterLayer() throws { + let bitmap = try render(#""" + + + + + + """#) + + XCTAssertEqual(bitmap.alpha(x: 61, y: 61), 1) + XCTAssertEqual(bitmap.alpha(x: 59, y: 61), 0) + } + + func testBoxSizes() { + XCTAssertEqual(CGContext.boxSizes(for: 0), [1, 1, 1]) + XCTAssertEqual(CGContext.boxSizes(for: 2), [5, 3, 5]) + XCTAssertEqual(CGContext.boxSizes(for: 5), [9, 9, 9]) + XCTAssertEqual(CGContext.boxSizes(for: .infinity), [1, 1, 1]) + XCTAssertEqual(CGContext.boxSizes(for: .nan), [1, 1, 1]) + XCTAssertEqual(CGContext.boxSizes(for: 1e300, limit: 201), [201, 201, 201]) + XCTAssertEqual(CGContext.boxSizes(for: 1e6, limit: 1024 | 1), [1025, 1025, 1025]) + XCTAssertFalse(CGContext.isVisibleBlur(CGSize(width: 0.3, height: 0.3), scale: 1)) + XCTAssertTrue(CGContext.isVisibleBlur(CGSize(width: 0, height: 2), scale: 1)) + } + + // the clip-path applies to the blurred result: a hard edge at the clip, the blur kept inside it + func testBlurThenClip() throws { + let bitmap = try render(#""" + + + + + + + """#) + + // edge at x=51 (outside the clip): blurred first, so x=49 stays dark and x=50 is cut + XCTAssertGreaterThan(bitmap.alpha(x: 49, y: 25), 0.65) + XCTAssertEqual(bitmap.alpha(x: 50, y: 25), 0) + // edge at x=49 (inside the clip): the blur falls off before the clip + XCTAssertEqual(bitmap.alpha(x: 49, y: 75), 0.4, accuracy: 0.15) + XCTAssertEqual(bitmap.alpha(x: 50, y: 75), 0) + } + + // blurred groups are rasterized at 2x in PDF contexts + func testPDFContextSmoke() throws { + let svg = try XCTUnwrap(SVG(xml: #""" + + + + + """#)) + let data = NSMutableData() + var box = CGRect(x: 0, y: 0, width: 100, height: 100) + let consumer = try XCTUnwrap(CGDataConsumer(data: data as CFMutableData)) + let ctx = try XCTUnwrap(CGContext(consumer: consumer, mediaBox: &box, nil)) + ctx.beginPDFPage(nil) + ctx.draw(svg) + ctx.endPDFPage() + ctx.closePDF() + + XCTAssertGreaterThan(data.length, 1000) + } + + // shape near the top: catches a vertically flipped composite + func testOffCentreBlurIsNotFlipped() throws { + let bitmap = try render(#""" + + + + + """#) + + XCTAssertGreaterThan(bitmap.alpha(x: 50, y: 20), 0.9) + XCTAssertEqual(bitmap.alpha(x: 50, y: 10), 0.5, accuracy: 0.15) + XCTAssertEqual(bitmap.alpha(x: 50, y: 30), 0.5, accuracy: 0.15) + XCTAssertEqual(bitmap.alpha(x: 50, y: 80), 0) + } + + func testBlurWithOpacityAndClip() throws { + let bitmap = try render(#""" + + + + + + """#) + + XCTAssertEqual(bitmap.alpha(x: 40, y: 50), 0.5, accuracy: 0.05) + XCTAssertEqual(bitmap.alpha(x: 60, y: 50), 0) + XCTAssertEqual(bitmap.alpha(x: 20, y: 50), 0.25, accuracy: 0.1) + } + + func testBlurWithMask() throws { + let bitmap = try render(#""" + + + + + + """#) + + XCTAssertGreaterThan(bitmap.alpha(x: 50, y: 40), 0.9) + XCTAssertEqual(bitmap.alpha(x: 50, y: 60), 0) + XCTAssertEqual(bitmap.alpha(x: 20, y: 40), 0.5, accuracy: 0.15) + } + + // stdDeviation "0 5" is vertical in user space, horizontal after rotate(90) + func testBlurFollowsRotation() throws { + let bitmap = try render(#""" + + + + + """#) + + XCTAssertEqual(bitmap.alpha(x: 25, y: 50), 0.5, accuracy: 0.15) + XCTAssertEqual(bitmap.alpha(x: 50, y: 24), 0) + XCTAssertGreaterThan(bitmap.alpha(x: 50, y: 26), 0.95) + } + + func testNestedBlurs() throws { + let bitmap = try render(#""" + + + + + + + + + """#) + + // the inner rect is blurred twice, so softer than the outer one at the same edge offset + XCTAssertLessThan(bitmap.alpha(x: 21, y: 30), bitmap.alpha(x: 61, y: 70)) + XCTAssertGreaterThan(bitmap.alpha(x: 30, y: 30), 0.9) + XCTAssertGreaterThan(bitmap.alpha(x: 70, y: 70), 0.9) + XCTAssertEqual(bitmap.alpha(x: 5, y: 95), 1) + } + + // over the pixel cap the layer is rendered at a reduced scale, still blurred + func testOverPixelCapStillBlurs() throws { + let bitmap = try render(#""" + + + + + """#, maxFilterLayerPixels: 900) + + XCTAssertGreaterThan(bitmap.alpha(x: 50, y: 50), 0.9) + XCTAssertEqual(bitmap.alpha(x: 25, y: 50), 0.5, accuracy: 0.2) + XCTAssertEqual(bitmap.alpha(x: 10, y: 50), 0) + XCTAssertEqual(CGFilterLayer.makeScale(size: CGSize(width: 60, height: 60), maxPixels: 900, oversample: 1), 0.5) + } + + func testHugeValuesDoNotTrap() throws { + let bitmap = try render(#""" + + + + + + + + + """#) + + XCTAssertGreaterThan(bitmap.alpha(x: 60, y: 60), 0.9) + } + + func testEmptyClipDrawsNothing() throws { + let bitmap = try render(#""" + + + + + + + + """#) + + XCTAssertEqual(bitmap.alpha(x: 50, y: 50), 0) + } +} + +private struct Bitmap { + var width: Int + var height: Int + var bytes: [UInt8] + + // y is measured from the top, like SVG + func alpha(x: Int, y: Int) -> Double { + Double(bytes[(y * width + x) * 4 + 3]) / 255 + } +} + +private extension CGRendererFilterTests { + + func render(_ xml: String, scale: CGFloat = 1, maxFilterLayerPixels: Int = CGRenderer.defaultMaxFilterLayerPixels) throws -> Bitmap { + let svg = try XCTUnwrap(SVG(xml: xml)) + let width = Int(svg.size.width * scale) + let height = Int(svg.size.height * scale) + var bytes = [UInt8](repeating: 0, count: width * height * 4) + let space = CGColorSpace(name: CGColorSpace.sRGB)! + bytes.withUnsafeMutableBytes { buffer in + let ctx = CGContext(data: buffer.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + // SVG coordinates are y-down + ctx.translateBy(x: 0, y: CGFloat(height)) + ctx.scaleBy(x: scale, y: -scale) + CGRenderer(context: ctx, maxFilterLayerPixels: maxFilterLayerPixels).perform(svg.commands) + } + return Bitmap(width: width, height: height, bytes: bytes) + } +} +#endif diff --git a/SwiftDraw/Tests/Renderer/CGRenderer+UnitsTests.swift b/SwiftDraw/Tests/Renderer/CGRenderer+UnitsTests.swift new file mode 100644 index 00000000..f4fd6dae --- /dev/null +++ b/SwiftDraw/Tests/Renderer/CGRenderer+UnitsTests.swift @@ -0,0 +1,99 @@ +// +// CGRenderer+UnitsTests.swift +// SwiftDraw +// +// Added by Misoservices for the Backdrop fork of SwiftDraw (altered source version). +// Copyright 2026 Misoservices +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + + +@testable import SwiftDrawDOM +@testable import SwiftDraw + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import CoreGraphics +import XCTest + +// SD13 — SVG 1.1 §7.10: units, percentages and fractional root sizes, in pixels +final class CGRendererUnitsTests: XCTestCase { + + private func render(_ xml: String, pixelsWide: Int, pixelsHigh: Int) throws -> ImageRenderer { + let svg = SVG(dom: try DOM.SVG.parse(xml: xml), options: .default) + let renderer = ImageRenderer(pixelsWide: pixelsWide, pixelsHigh: pixelsHigh) + renderer.renderer.perform(svg.commands) + return renderer + } + + func testPercentageWidthFillsHalfTheViewport() throws { + // `width="50%"` used to be 50 user units + let renderer = try render(""" + + + """, pixelsWide: 200, pixelsHigh: 100) + + XCTAssertFalse(renderer.isSVGRed(x: 50, y: 50)) + XCTAssertFalse(renderer.isSVGRed(x: 98, y: 50)) + XCTAssertTrue(renderer.isSVGRed(x: 102, y: 50)) + XCTAssertTrue(renderer.isSVGRed(x: 195, y: 50)) + } + + func testFractionalRootSizeIsNotLetterboxed() throws { + // truncated to 150, the 150.9-wide viewBox met at x0.994 and left a 0.3px band top and bottom + let renderer = try render(""" + + + """, pixelsWide: 151, pixelsHigh: 100) + + XCTAssertTrue(renderer.isSVGRed(x: 75, y: 0)) + XCTAssertTrue(renderer.isSVGRed(x: 75, y: 99)) + } + + func testRootWithOnlyWidthKeepsTheViewBoxRatio() throws { + // 200x100 like the browsers, not 200x50 with the viewBox centred in it + let renderer = try render(""" + + + """, pixelsWide: 200, pixelsHigh: 100) + + XCTAssertTrue(renderer.isSVGRed(x: 5, y: 5)) + XCTAssertTrue(renderer.isSVGRed(x: 195, y: 95)) + } + + func testMillimetresAndEmResolveToPixels() throws { + // 25.4mm = 96px wide; 3em = 48px high at the initial 16px font-size + let renderer = try render(""" + + + """, pixelsWide: 100, pixelsHigh: 48) + + XCTAssertTrue(renderer.isSVGRed(x: 94, y: 2)) + XCTAssertTrue(renderer.isSVGRed(x: 94, y: 45)) + XCTAssertFalse(renderer.isSVGRed(x: 98, y: 24)) + } +} + +#endif diff --git a/SwiftDraw/Tests/Renderer/CGRendererTests.swift b/SwiftDraw/Tests/Renderer/CGRendererTests.swift index 67877a1d..83e35bc8 100644 --- a/SwiftDraw/Tests/Renderer/CGRendererTests.swift +++ b/SwiftDraw/Tests/Renderer/CGRendererTests.swift @@ -29,6 +29,7 @@ // 3. This notice may not be removed or altered from any source distribution. // +@testable import SwiftDrawDOM @testable import SwiftDraw #if canImport(AppKit) && !targetEnvironment(macCatalyst) @@ -48,6 +49,119 @@ final class CGRendererTests: XCTestCase { XCTAssertEqual(renderer.getColor(x: 1, y: 1), .red) } + func testLineDashLeavesGaps() { + let renderer = ImageRenderer(pixelsWide: 20, pixelsHigh: 4) + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 2)) + path.addLine(to: CGPoint(x: 20, y: 2)) + + renderer.renderer.setStroke(color: .red) + renderer.renderer.setLine(width: 4) + renderer.renderer.setLineDash(phase: 0, lengths: [5, 5]) + renderer.renderer.stroke(path: path) + + XCTAssertEqual(renderer.getColor(x: 2, y: 2), .red) + XCTAssertNotEqual(renderer.getColor(x: 7, y: 2), .red) + XCTAssertEqual(renderer.getColor(x: 12, y: 2), .red) + } + + func testRepeatGradientRepeats() throws { + // red→blue every 10 units: pad would leave x ≥ 10 blue, repeat restarts red at x = 10 and 20 + func redness(_ spread: String, x: Int) throws -> CGFloat { + let renderer = ImageRenderer(pixelsWide: 40, pixelsHigh: 4) + let svg = try DOM.SVG.parse(xml: """ + + + + + + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: CGProvider(), size: LayerTree.Size(40, 4), options: .default) + renderer.renderer.perform(generator.renderCommands(for: layer, colorConverter: .default)) + let color = try XCTUnwrap(renderer.getColor(x: x, y: 2)?.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)) + let c = try XCTUnwrap(color.components) + return c[0] - c[2] + } + XCTAssertGreaterThan(try redness("repeat", x: 1), 0.5) + XCTAssertGreaterThan(try redness("repeat", x: 21), 0.5) + XCTAssertLessThan(try redness("pad", x: 21), -0.5) + // reflect: x = 11 mirrors x = 9 (blue) + XCTAssertLessThan(try redness("reflect", x: 11), -0.5) + XCTAssertGreaterThan(try redness("reflect", x: 19), 0.5) + } + + func testRadialReflectGradientMirrors() throws { + // red at the centre, blue at r = 10, mirrored back to red at r = 20 (pad stays blue) + func redness(_ spread: String, x: Int) throws -> CGFloat { + let renderer = ImageRenderer(pixelsWide: 40, pixelsHigh: 40) + let svg = try DOM.SVG.parse(xml: """ + + + + + + + """) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let generator = LayerTree.CommandGenerator(provider: CGProvider(), size: LayerTree.Size(40, 40), options: .default) + renderer.renderer.perform(generator.renderCommands(for: layer, colorConverter: .default)) + let color = try XCTUnwrap(renderer.getColor(x: x, y: 20)?.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)) + let c = try XCTUnwrap(color.components) + return c[0] - c[2] + } + XCTAssertGreaterThan(try redness("reflect", x: 20), 0.5) + XCTAssertLessThan(try redness("reflect", x: 30), -0.5) + XCTAssertGreaterThan(try redness("reflect", x: 39), 0.5) + XCTAssertLessThan(try redness("pad", x: 39), -0.5) + } + + func testPreserveAspectRatioLetterboxesViewBox() throws { + // 100x100 viewBox in a 200x100 viewport: xMidYMid meet centres it, leaving 50px bands either side + let dom = try DOM.SVG.parse(xml: """ + + + """) + let svg = SVG(dom: dom, options: .default) + let renderer = ImageRenderer(pixelsWide: 200, pixelsHigh: 100) + renderer.renderer.perform(svg.commands) + + XCTAssertTrue(renderer.isSVGRed(x: 100, y: 50)) + XCTAssertTrue(renderer.isSVGRed(x: 55, y: 50)) + XCTAssertFalse(renderer.isSVGRed(x: 45, y: 50)) + XCTAssertFalse(renderer.isSVGRed(x: 155, y: 50)) + } + + func testPreserveAspectRatioSliceFillsViewport() throws { + let dom = try DOM.SVG.parse(xml: """ + + + """) + let svg = SVG(dom: dom, options: .default) + let renderer = ImageRenderer(pixelsWide: 200, pixelsHigh: 100) + renderer.renderer.perform(svg.commands) + + XCTAssertTrue(renderer.isSVGRed(x: 5, y: 5)) + XCTAssertTrue(renderer.isSVGRed(x: 195, y: 95)) + } + + func testRootSliceDoesNotBleedOutsideItsViewport() throws { + // drawn into a larger context, the overflow of a sliced viewBox is clipped to the 200x100 viewport + let dom = try DOM.SVG.parse(xml: """ + + + """) + let svg = SVG(dom: dom, options: .default) + let renderer = ImageRenderer(pixelsWide: 300, pixelsHigh: 200) + renderer.renderer.perform(svg.commands) + + // the context is not flipped, so the 200x100 viewport is the bottom half of the 300x200 bitmap (rows 100...199) + XCTAssertTrue(renderer.isSVGRed(x: 100, y: 150)) + XCTAssertFalse(renderer.isSVGRed(x: 250, y: 150)) + XCTAssertFalse(renderer.isSVGRed(x: 100, y: 50)) + } + func testAlphaClips() { let renderer = ImageRenderer(pixelsWide: 2, pixelsHigh: 2) @@ -71,6 +185,12 @@ final class ImageRenderer { self.renderer = CGRenderer(context: context) } + /// `.red` in this file is a test colour; an SVG `red` fill reads back as pure (1, 0, 0) + func isSVGRed(x: Int, y: Int) -> Bool { + guard let c = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { return false } + return c.redComponent > 0.99 && c.greenComponent < 0.01 && c.blueComponent < 0.01 && c.alphaComponent > 0.99 + } + func getColor(x: Int, y: Int) -> CGColor? { return bitmap.colorAt(x: x, y: y)?.cgColor } diff --git a/SwiftDraw/Tests/Renderer/GradientTests.swift b/SwiftDraw/Tests/Renderer/GradientTests.swift index 7dac74b6..8cb6105b 100644 --- a/SwiftDraw/Tests/Renderer/GradientTests.swift +++ b/SwiftDraw/Tests/Renderer/GradientTests.swift @@ -82,7 +82,9 @@ final class GradientTests: XCTestCase { gradient = try DOMXMLParser().parseLinearGradient(node) XCTAssertEqual(gradient.gradientUnits, .objectBoundingBox) + // since SD16 an unreadable value is dropped, the gradient is kept node.attributes["gradientUnits"] = "invalid" - XCTAssertThrowsError(try DOMXMLParser().parseLinearGradient(node)) + gradient = try DOMXMLParser().parseLinearGradient(node) + XCTAssertNil(gradient.gradientUnits) } } diff --git a/SwiftDraw/Tests/Renderer/MockRenderer.swift b/SwiftDraw/Tests/Renderer/MockRenderer.swift index 91e8a88a..89266817 100644 --- a/SwiftDraw/Tests/Renderer/MockRenderer.swift +++ b/SwiftDraw/Tests/Renderer/MockRenderer.swift @@ -98,6 +98,10 @@ final class MockRenderer: Renderer { operations.append("setLineMiterLimit") } + func setLineDash(phase: LayerTree.Float, lengths: [LayerTree.Float]) { + operations.append("setLineDash") + } + func setClip(path: [LayerTree.Shape], rule: LayerTree.FillRule) { operations.append("setClip") } @@ -137,4 +141,12 @@ final class MockRenderer: Renderer { func draw(radial gradient: LayerTree.Gradient, startCenter: LayerTree.Point, startRadius: LayerTree.Float, endCenter: LayerTree.Point, endRadius: LayerTree.Float) { operations.append("drawRadialGradient") } + + func pushFilterLayer(_ filter: LayerTree.FilterLayer) { + operations.append("pushFilterLayer") + } + + func popFilterLayer() { + operations.append("popFilterLayer") + } } diff --git a/SwiftDraw/Tests/Renderer/Renderer.CGTextTests.swift b/SwiftDraw/Tests/Renderer/Renderer.CGTextTests.swift index e0d57163..adb3ebdd 100644 --- a/SwiftDraw/Tests/Renderer/Renderer.CGTextTests.swift +++ b/SwiftDraw/Tests/Renderer/Renderer.CGTextTests.swift @@ -32,6 +32,38 @@ import XCTest final class RendererCGTextTests: XCTestCase { + func testFilterLayerIsolatesGraphicsState() throws { + let xml = #""" + + + + + + """# + let code = try CGTextRenderer.render(data: Data(xml.utf8), options: .default, api: .uiKit, precision: 2) + let lines = code.components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + + let warning = try XCTUnwrap(lines.firstIndex { $0.hasPrefix("// warning: filter dropped") }) + let save = try XCTUnwrap(lines.firstIndex(of: "ctx.saveGState()")) + let restore = try XCTUnwrap(lines.lastIndex(of: "ctx.restoreGState()")) + let lastFill = try XCTUnwrap(lines.lastIndex { $0.hasPrefix("ctx.setFillColor") }) + XCTAssertLessThan(warning, save) + XCTAssertLessThan(save, restore) + XCTAssertGreaterThan(lastFill, restore) + } + + func testImageIsDroppedWithAWarningNotAnUndeclaredReference() throws { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + let xml = """ + + + + """ + let code = try CGTextRenderer.render(data: Data(xml.utf8), options: .default, api: .uiKit, precision: 2) + XCTAssertFalse(code.contains("ctx.draw(image")) + XCTAssertTrue(code.contains("// warning: image dropped")) + } + func testLinesCode() throws { let code = try CGTextRenderer.render(svgNamed: "lines.svg") XCTAssertEqual( @@ -84,6 +116,23 @@ final class RendererCGTextTests: XCTestCase { ) } + func testPatternCode() throws { + let svg = #""" + + + + + + + + + + """# + let code = try CGTextRenderer.render(data: Data(svg.utf8), options: .default, api: .uiKit, precision: 2) + XCTAssertTrue(code.contains("bounds: CGRect(x: 2, y: 3, width: 8, height: 8)")) + XCTAssertTrue(code.contains("matrix: CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 10.0, ty: 0.0).concatenating(ctx.ctm.concatenating(baseCTM.inverted()))")) + } + func testSwiftUICode() throws { let code = try CGTextRenderer.render(svgNamed: "lines.svg", api: .swiftUI) XCTAssertEqual( @@ -547,6 +596,49 @@ final class RendererCGTextTests: XCTestCase { } } +extension RendererCGTextTests { + + func testDashedStrokeIsResetAfterStroke() throws { + let svg = #""# + let code = try CGTextRenderer.render(data: Data(svg.utf8), options: .default, api: .uiKit, precision: 2) + let lines = code.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + let dash = try XCTUnwrap(lines.firstIndex(of: "ctx.setLineDash(phase: 1, lengths: [4, 2])")) + let stroke = try XCTUnwrap(lines.lastIndex(of: "ctx.strokePath()")) + XCTAssertGreaterThan(stroke, dash) + // the lone push/pop pair is stripped by the optimizer, so the dash must be reset explicitly + XCTAssertEqual(lines[stroke + 1], "ctx.setLineDash(phase: 0, lengths: [])") + } + + func testRepeatGradientCode() throws { + let svg = #""" + + + + + + + """# + let code = try CGTextRenderer.render(data: Data(svg.utf8), options: .default, api: .uiKit, precision: 2) + XCTAssertTrue(code.contains("[0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]"), code) + XCTAssertTrue(code.contains("end: CGPoint(x: 100, y: 0)"), code) + } + + func testDashedGradientStrokesAreReset() throws { + // A stroke-only shape is the lone push/pop pair the optimizer strips, so each gradient stroke + // branch must reset the dash itself or it leaks into the caller's context. + for gradient in [#""#, + #""#] { + let svg = #""# + gradient + + #""# + let code = try CGTextRenderer.render(data: Data(svg.utf8), options: .default, api: .uiKit, precision: 2) + let lines = code.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + let dash = try XCTUnwrap(lines.firstIndex(of: "ctx.setLineDash(phase: 0, lengths: [4, 2])"), gradient) + let reset = try XCTUnwrap(lines.lastIndex(of: "ctx.setLineDash(phase: 0, lengths: [])"), gradient) + XCTAssertGreaterThan(reset, dash, gradient) + } + } +} + private extension CGTextRenderer { static func render(svgNamed name: String, in bundle: Bundle = .test, api: API = .uiKit, precision: Int = 2) throws -> String { @@ -556,3 +648,17 @@ private extension CGTextRenderer { } } + +extension RendererCGTextTests { + + func testRootSliceIsClippedToItsViewport() throws { + let svg = #""# + let code = try CGTextRenderer.render(data: Data(svg.utf8), options: .default, api: .uiKit, precision: 2) + let lines = code.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + // the 200x100 viewport is 100x50 in viewBox space after the 2x scale + let scale = try XCTUnwrap(lines.firstIndex(of: "ctx.scaleBy(x: 2, y: 2)")) + let clip = try XCTUnwrap(lines.firstIndex(where: { $0.hasSuffix("CGRect(x: 0, y: 0, width: 100, height: 50),") })) + XCTAssertGreaterThan(clip, scale) + XCTAssertTrue(lines.contains("ctx.clip()")) + } +} diff --git a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift index c84f0fd1..ad5b2ea7 100644 --- a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift +++ b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift @@ -197,7 +197,7 @@ final class RendererSFSymbolClipPathTests: XCTestCase { // MARK: - Layer-level coverage - /// Independent smoke test: the layer's clipUnits propagates through Builder. + /// objectBoundingBox units are resolved by the Builder (SD8): the clip reaches the renderers in user space. func testBuilder_setsClipUnits_objectBoundingBox() throws { let svg = try DOM.SVG.parse(#""" @@ -212,7 +212,25 @@ final class RendererSFSymbolClipPathTests: XCTestCase { let layer = LayerTree.Builder(svg: svg).makeLayer() let clipped = firstClippedLayer(in: layer) XCTAssertNotNil(clipped) - XCTAssertEqual(clipped?.clipUnits, .objectBoundingBox) + XCTAssertEqual(clipped?.clipUnits, .userSpaceOnUse) + XCTAssertEqual(clipped?.clip.first?.transform, + LayerTree.Transform.Matrix(a: 10, b: 0, c: 0, d: 10, tx: 0, ty: 0)) + } + + /// A clip drawn as a mask (here mixed clip-rules) keeps its content, unclipped, in symbol export. + func testMaskTypeClip_keepsContentUnclipped() throws { + let svg = try DOM.SVG.parse(#""" + + + + + + + + """#) + let paths = SFSymbolRenderer.getPaths(for: svg) ?? [] + XCTAssertEqual(paths.count, 1) + assertBounds(paths[0].path.bounds, equals: .init(x: 0, y: 0, width: 100, height: 100), accuracy: 0.5) } func testBuilder_setsClipUnits_userSpaceOnUseByDefault() throws { diff --git a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift index f792a53b..6db8ff18 100644 --- a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift +++ b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift @@ -101,6 +101,23 @@ final class RendererSFSymbolTests: XCTestCase { ) } + // SD10: `.hierarchical-0:secondary` is one class name, not a class plus a pseudo-class + func testSFSymbolExportKeepsLayerClassRules() throws { + let expected = [ + "sun-horizon.svg": [".monochrome-0 {", ".monochrome-1 {", + ".multicolor-0:systemYellowColor {", ".multicolor-1:emerald {", + ".hierarchical-0:secondary {", ".hierarchical-1:primary {"], + "checkmark.svg": [".multicolor-0:custom {"] + ] + for (file, rules) in expected { + let url = try Bundle.test.url(forResource: file) + let output = try SFSymbolRenderer.render(svg: DOM.SVG.parse(fileURL: url)) + for rule in rules { + XCTAssertTrue(output.contains(rule), "\(file) lost \(rule)") + } + } + } + func testTransparentSFSymboleLayers_AreNot_Removed() throws { let source = try DOM.SVG.parse(#""" diff --git a/SwiftDraw/Tests/Renderer/RendererTests.swift b/SwiftDraw/Tests/Renderer/RendererTests.swift index 4ed96771..199d6116 100644 --- a/SwiftDraw/Tests/Renderer/RendererTests.swift +++ b/SwiftDraw/Tests/Renderer/RendererTests.swift @@ -53,6 +53,7 @@ final class RendererTests: XCTestCase { .setLineCap(.butt), .setLineJoin(.bevel), .setLineMiter(limit: 10), + .setLineDash(phase: 1, lengths: [2, 3]), .setClip(path: .mock, rule: .nonzero), .fill(.mock, rule: .nonzero), .stroke(.mock), @@ -80,6 +81,7 @@ final class RendererTests: XCTestCase { "setLineCap", "setLineJoin", "setLineMiterLimit", + "setLineDash", "setClip", "fillPath", "strokePath", diff --git a/SwiftDraw/Tests/SVGTests.swift b/SwiftDraw/Tests/SVGTests.swift index cc1dea2b..88988ac7 100644 --- a/SwiftDraw/Tests/SVGTests.swift +++ b/SwiftDraw/Tests/SVGTests.swift @@ -60,7 +60,7 @@ struct SVGTests { } @Test - func root_em_ex_dimensions_parse_as_raw_values() { + func root_em_ex_dimensions_resolve_against_font_size() { let ex = #""# let em = #""# let px = #""# @@ -69,8 +69,9 @@ struct SVGTests { let emSVG = SVG(data: Data(em.utf8)) let pxSVG = SVG(data: Data(px.utf8)) - #expect(exSVG?.size == CGSize(width: 2, height: 1)) - #expect(emSVG?.size == CGSize(width: 2, height: 1)) + // SVG 1.1 §7.10: 1em is the initial 16px font-size, 1ex half of it + #expect(exSVG?.size == CGSize(width: 16, height: 8)) + #expect(emSVG?.size == CGSize(width: 32, height: 16)) #expect(pxSVG?.size == CGSize(width: 2, height: 1)) } diff --git a/SwiftDraw/Tests/Test.bundle/checkmark.svg b/SwiftDraw/Tests/Test.bundle/checkmark.svg new file mode 100644 index 00000000..22a4a4f6 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/checkmark.svg @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg b/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg index 2c48fdfe..d2154fcb 100644 --- a/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg +++ b/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg @@ -1,8 +1,8 @@ - + - + diff --git a/SwiftDraw/Tests/Test.bundle/sun-horizon.svg b/SwiftDraw/Tests/Test.bundle/sun-horizon.svg new file mode 100644 index 00000000..1b28a41d --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/sun-horizon.svg @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/SwiftDraw/Tests/Utilities/FilterBitmapTests.swift b/SwiftDraw/Tests/Utilities/FilterBitmapTests.swift new file mode 100644 index 00000000..d44e7b5b --- /dev/null +++ b/SwiftDraw/Tests/Utilities/FilterBitmapTests.swift @@ -0,0 +1,375 @@ +// +// FilterBitmapTests.swift +// SwiftDraw +// +// Created by Misoservices on 9/10/26. +// Copyright 2026 Misoservices. Altered version of SwiftDraw by Simon Whitty. +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/SwiftDraw +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// +// 3. This notice may not be removed or altered from any source distribution. +// + +@testable import SwiftDrawDOM +@testable import SwiftDraw +import XCTest + +// SD12: filter primitives on premultiplied RGBA pixels, platform independent +final class FilterBitmapTests: XCTestCase { + + let red: [UInt8] = [255, 0, 0, 255] + let blue: [UInt8] = [0, 0, 255, 255] + let halfBlue: [UInt8] = [0, 0, 128, 128] + let white: [UInt8] = [255, 255, 255, 255] + let clear: [UInt8] = [0, 0, 0, 0] + + func testSourceAlphaKeepsOnlyAlpha() { + let source = FilterBitmap.make([[red, halfBlue, [10, 20, 30, 40]]]) + XCTAssertEqual(FilterBitmap.sourceAlpha(of: source).row(0), [[0, 0, 0, 255], [0, 0, 0, 128], [0, 0, 0, 40]]) + } + + func testFloodIsPremultiplied() { + let flood = FilterBitmap.flood(width: 2, height: 1, color: SIMD4(1, 0.5, 0, 0.5)) + XCTAssertEqual(flood.row(0), [[128, 64, 0, 128], [128, 64, 0, 128]]) + XCTAssertEqual(FilterBitmap.flood(width: 1, height: 1, color: SIMD4(1, 1, 1, 0)).row(0), [clear]) + } + + func testWholeOffset() { + let source = FilterBitmap.make([[red, blue, clear], + [clear, clear, clear]]) + let shifted = FilterBitmap.offset(source, dx: 1, dy: 1) + XCTAssertEqual(shifted.row(0), [clear, clear, clear]) + XCTAssertEqual(shifted.row(1), [clear, red, blue]) + + let back = FilterBitmap.offset(source, dx: -1, dy: 0) + XCTAssertEqual(back.row(0), [blue, clear, clear]) + } + + func testFractionalOffsetInterpolates() { + let source = FilterBitmap.make([[white, clear, clear]]) + let shifted = FilterBitmap.offset(source, dx: 0.5, dy: 0) + XCTAssertEqual(shifted.row(0), [[128, 128, 128, 128], [128, 128, 128, 128], clear]) + // within a thousandth of a pixel the offset is whole + XCTAssertEqual(FilterBitmap.offset(source, dx: 0.9999, dy: 0).row(0), [clear, white, clear]) + + // inside a solid area the colour is exact, both axes interpolate at its edges + let solid = FilterBitmap.make([[white, white, white], + [white, white, white], + [clear, clear, clear]]) + let diagonal = FilterBitmap.offset(solid, dx: 0.5, dy: 0.5) + XCTAssertEqual(diagonal.row(0), [[64, 64, 64, 64], [128, 128, 128, 128], [128, 128, 128, 128]]) + XCTAssertEqual(diagonal.row(1), [[128, 128, 128, 128], white, white]) + XCTAssertEqual(diagonal.row(2), [[64, 64, 64, 64], [128, 128, 128, 128], [128, 128, 128, 128]]) + } + + func testOffsetBeyondBitmapIsTransparent() { + let source = FilterBitmap.make([[white, white]]) + XCTAssertEqual(FilterBitmap.offset(source, dx: 2, dy: 0).row(0), [clear, clear]) + XCTAssertEqual(FilterBitmap.offset(source, dx: .nan, dy: 0).row(0), [clear, clear]) + } + + // Porter-Duff with `in` as source over `in2` as destination + func testCompositeOperators() { + let a = FilterBitmap.make([[red]]) + let b = FilterBitmap.make([[halfBlue]]) + func composite(_ op: DOM.Filter.CompositeOperator) -> [UInt8] { + FilterBitmap.composite(a, b, op: op, linear: false).pixel(x: 0, y: 0) + } + XCTAssertEqual(composite(.over), red) + XCTAssertEqual(composite(.in), [128, 0, 0, 128]) + XCTAssertEqual(composite(.out), [127, 0, 0, 127]) + XCTAssertEqual(composite(.atop), [128, 0, 0, 128]) + XCTAssertEqual(composite(.xor), [127, 0, 0, 127]) + XCTAssertEqual(composite(.lighter), [255, 0, 128, 255]) + // Figma's inner shadow: in2 - in, clamped + XCTAssertEqual(composite(.arithmetic(k1: 0, k2: -1, k3: 1, k4: 0)), clear) + XCTAssertEqual(FilterBitmap.composite(b, a, op: .arithmetic(k1: 0, k2: -1, k3: 1, k4: 0), linear: false).pixel(x: 0, y: 0), + [127, 0, 0, 127]) + // k4 alone fills transparent pixels; colour never exceeds alpha + XCTAssertEqual(FilterBitmap.composite(FilterBitmap.make([[clear]]), FilterBitmap.make([[clear]]), + op: .arithmetic(k1: 0, k2: 0, k3: 0, k4: 0.5), linear: false).pixel(x: 0, y: 0), + [128, 128, 128, 128]) + XCTAssertEqual(FilterBitmap.composite(a, b, op: .arithmetic(k1: 0, k2: 1, k3: 0, k4: -0.5), linear: false).pixel(x: 0, y: 0), + [128, 0, 0, 128]) + // huge coefficients cannot produce NaN pixels + XCTAssertEqual(FilterBitmap.composite(a, a, op: .arithmetic(k1: 3e38, k2: 3e38, k3: -3e38, k4: 0), linear: false).pixel(x: 0, y: 0).count, 4) + } + + // Filter Effects 1 §9.16: the first node at the bottom + func testMergeOrder() { + let merged = FilterBitmap.merge([FilterBitmap.make([[red]]), FilterBitmap.make([[halfBlue]])], width: 1, height: 1, linear: false) + XCTAssertEqual(merged.pixel(x: 0, y: 0), [127, 0, 128, 255]) + let reversed = FilterBitmap.merge([FilterBitmap.make([[halfBlue]]), FilterBitmap.make([[red]])], width: 1, height: 1, linear: false) + XCTAssertEqual(reversed.pixel(x: 0, y: 0), red) + XCTAssertEqual(FilterBitmap.merge([], width: 1, height: 1, linear: false).pixel(x: 0, y: 0), clear) + } + + // Compositing and Blending 1: `in` is the source, `in2` the backdrop + func testBlendModes() { + let source = FilterBitmap.make([[red]]) + let backdrop = FilterBitmap.make([[[0, 0, 255, 255]]]) + func blend(_ mode: DOM.Filter.BlendMode, _ s: FilterBitmap? = nil, _ b: FilterBitmap? = nil) -> [UInt8] { + FilterBitmap.blend(s ?? source, b ?? backdrop, mode: mode, linear: false).pixel(x: 0, y: 0) + } + XCTAssertEqual(blend(.normal), red) + XCTAssertEqual(blend(.multiply), [0, 0, 0, 255]) + XCTAssertEqual(blend(.screen), [255, 0, 255, 255]) + XCTAssertEqual(blend(.darken), [0, 0, 0, 255]) + XCTAssertEqual(blend(.lighten), [255, 0, 255, 255]) + XCTAssertEqual(blend(.difference), [255, 0, 255, 255]) + XCTAssertEqual(blend(.exclusion), [255, 0, 255, 255]) + // luminosity of white keeps the backdrop's hue at full lightness + XCTAssertEqual(blend(.luminosity, FilterBitmap.make([[white]])), white) + // colour of red on a white backdrop keeps white's luminosity + XCTAssertEqual(blend(.color, nil, FilterBitmap.make([[white]])), white) + + let black = FilterBitmap.make([[[0, 0, 0, 255]]]) + let whiteBitmap = FilterBitmap.make([[white]]) + let grey = FilterBitmap.make([[[128, 128, 128, 255]]]) + // overlay is hard-light with the layers swapped + XCTAssertEqual(blend(.overlay, black, whiteBitmap), white) + XCTAssertEqual(blend(.hardLight, black, whiteBitmap), [0, 0, 0, 255]) + XCTAssertEqual(blend(.softLight, black, whiteBitmap), white) + XCTAssertEqual(blend(.softLight, whiteBitmap, black), [0, 0, 0, 255]) + XCTAssertEqual(blend(.colorDodge, whiteBitmap, grey), white) + XCTAssertEqual(blend(.colorDodge, grey, black), [0, 0, 0, 255]) + XCTAssertEqual(blend(.colorBurn, black, grey), [0, 0, 0, 255]) + XCTAssertEqual(blend(.colorBurn, grey, whiteBitmap), white) + // a hue on a grey backdrop has no saturation left: the grey + XCTAssertEqual(blend(.hue, source, grey), [128, 128, 128, 255]) + // no saturation from a grey source: the luminosity of blue + XCTAssertEqual(blend(.saturation, grey, backdrop), [28, 28, 28, 255]) + + // over a transparent backdrop every mode is the source + for mode in [DOM.Filter.BlendMode.multiply, .screen, .difference, .hue] { + XCTAssertEqual(blend(mode, FilterBitmap.make([[halfBlue]]), FilterBitmap.make([[clear]])), halfBlue) + } + // and a transparent source leaves the backdrop + XCTAssertEqual(blend(.multiply, FilterBitmap.make([[clear]]), FilterBitmap.make([[halfBlue]])), halfBlue) + } + + func testColorMatrix() { + let source = FilterBitmap.make([[red, [0, 0, 0, 3], clear]]) + // Figma's hard alpha: any coverage becomes opaque black + let hardAlpha: [Float] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 0] + XCTAssertEqual(FilterBitmap.colorMatrix(source, matrix: hardAlpha, linear: false).row(0), + [[0, 0, 0, 255], [0, 0, 0, 255], clear]) + + // a grey of red: 0.2126 in sRGB values, 0.2126 of linear light in linearRGB + let grey = DOM.Filter.ColorMatrix.saturate(0).values + XCTAssertEqual(FilterBitmap.colorMatrix(FilterBitmap.make([[red]]), matrix: grey, linear: false).pixel(x: 0, y: 0), [54, 54, 54, 255]) + XCTAssertEqual(FilterBitmap.colorMatrix(FilterBitmap.make([[red]]), matrix: grey, linear: true).pixel(x: 0, y: 0), [127, 127, 127, 255]) + + // the matrix works on straight colour: a half transparent red stays red + let identity = DOM.Filter.ColorMatrix.identity + XCTAssertEqual(FilterBitmap.colorMatrix(FilterBitmap.make([[[128, 0, 0, 128]]]), matrix: identity, linear: true).pixel(x: 0, y: 0), + [128, 0, 0, 128]) + // an alpha offset gives transparent pixels black coverage + let opaque: [Float] = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1] + XCTAssertEqual(FilterBitmap.colorMatrix(FilterBitmap.make([[clear]]), matrix: opaque, linear: false).pixel(x: 0, y: 0), + [0, 0, 0, 255]) + // a wrong matrix size is a pass through + XCTAssertEqual(FilterBitmap.colorMatrix(source, matrix: [1, 2], linear: false), source) + } + + // Filter Effects 1 §10: linearRGB composites in linear light; storage stays sRGB encoded + func testLinearRGBCompositing() { + let halfWhite = FilterBitmap.make([[[128, 128, 128, 128]]]) + let black = FilterBitmap.make([[[0, 0, 0, 255]]]) + XCTAssertEqual(FilterBitmap.composite(halfWhite, black, op: .over, linear: false).pixel(x: 0, y: 0), [128, 128, 128, 255]) + XCTAssertEqual(FilterBitmap.composite(halfWhite, black, op: .over, linear: true).pixel(x: 0, y: 0), [188, 188, 188, 255]) + + // dark colours survive a linearRGB pass unchanged + let dark = FilterBitmap.make([[[3, 5, 1, 255], [2, 1, 0, 128]]]) + XCTAssertEqual(FilterBitmap.composite(dark, FilterBitmap(width: 2, height: 1), op: .over, linear: true), dark) + XCTAssertEqual(FilterBitmap.colorMatrix(dark, matrix: DOM.Filter.ColorMatrix.identity, linear: true), dark) + } + + func testClipToAxisAlignedSubregion() { + var bitmap = FilterBitmap.flood(width: 4, height: 3, color: SIMD4(1, 1, 1, 1)) + // user space at 2x: the subregion 0.5...1.5 × 0.5...1 covers pixels 1...2 × 1 + let transform = LayerTree.Transform.Matrix(a: 2, b: 0, c: 0, d: 2, tx: 0, ty: 0) + bitmap.clip(to: LayerTree.Rect(x: 0.5, y: 0.5, width: 1, height: 0.5), transform: transform) + XCTAssertEqual(bitmap.row(0), [clear, clear, clear, clear]) + XCTAssertEqual(bitmap.row(1), [clear, white, white, clear]) + XCTAssertEqual(bitmap.row(2), [clear, clear, clear, clear]) + + // a subregion covering the bitmap changes nothing + var whole = FilterBitmap.flood(width: 2, height: 2, color: SIMD4(1, 1, 1, 1)) + whole.clip(to: LayerTree.Rect(x: -5, y: -5, width: 20, height: 20), transform: .identity) + XCTAssertEqual(whole, FilterBitmap.flood(width: 2, height: 2, color: SIMD4(1, 1, 1, 1))) + } + + func testClipToRotatedSubregion() { + var bitmap = FilterBitmap.flood(width: 5, height: 5, color: SIMD4(1, 1, 1, 1)) + // 45° about the bitmap centre: a diamond + // Float overloads of cos and sin are missing on Android and Windows + let cosine = Float(cos(Double.pi / 4)) + let sine = Float(sin(Double.pi / 4)) + let rotate = LayerTree.Transform.Matrix(a: cosine, b: sine, c: -sine, d: cosine, tx: 0, ty: 0) + let transform = rotate.concatenated(LayerTree.Transform.Matrix(a: 1, b: 0, c: 0, d: 1, tx: 2.5, ty: 2.5)) + bitmap.clip(to: LayerTree.Rect(x: -1.5, y: -1.5, width: 3, height: 3), transform: transform) + XCTAssertEqual(bitmap.pixel(x: 2, y: 2), white) + XCTAssertEqual(bitmap.pixel(x: 2, y: 0), white) + XCTAssertEqual(bitmap.pixel(x: 0, y: 0), clear) + XCTAssertEqual(bitmap.pixel(x: 4, y: 4), clear) + } + + // Figma's drop shadow on a 3x3 white square, dy=4, no blur + func testEvaluateFigmaDropShadow() { + let source = makeSquare(size: 8, from: 2, to: 4) + let region = LayerTree.Rect(x: 0, y: 0, width: 8, height: 8) + let filter = LayerTree.FilterLayer(region: region, primitives: [ + primitive(.flood(.rgba(r: 0, g: 0, b: 0, a: 0, space: .srgb)), [], region), + primitive(.colorMatrix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 0]), [.sourceAlpha], region), + primitive(.offset(dx: 0, dy: 4), [.primitive(1)], region), + primitive(.gaussianBlur(stdDeviation: 0, stdDeviationY: 0), [.primitive(2)], region), + primitive(.composite(.out), [.primitive(3), .primitive(1)], region), + primitive(.colorMatrix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.25, 0]), [.primitive(4)], region), + primitive(.blend(.normal), [.primitive(5), .primitive(0)], region), + primitive(.blend(.normal), [.sourceGraphic, .primitive(6)], region) + ]) + + let result = filter.evaluate(source: source, environment: .init(transform: .identity, blur: { _, _, _ in })) + XCTAssertEqual(result.pixel(x: 3, y: 3), white) + XCTAssertEqual(result.pixel(x: 3, y: 5), clear) + XCTAssertEqual(result.pixel(x: 3, y: 6), [0, 0, 0, 64]) + XCTAssertEqual(result.pixel(x: 2, y: 7), [0, 0, 0, 64]) + XCTAssertEqual(result.pixel(x: 0, y: 0), clear) + } + + // Inkscape's drop shadow: a half black flood `in` SourceGraphic, offset, SourceGraphic over it + func testEvaluateInkscapeDropShadow() { + let source = makeSquare(size: 8, from: 1, to: 3, color: red) + let region = LayerTree.Rect(x: 0, y: 0, width: 8, height: 8) + let filter = LayerTree.FilterLayer(region: region, primitives: [ + primitive(.flood(.rgba(r: 0, g: 0, b: 0, a: 0.5, space: .srgb)), [], region), + primitive(.composite(.in), [.primitive(0), .sourceGraphic], region), + primitive(.gaussianBlur(stdDeviation: 1, stdDeviationY: 1), [.primitive(1)], region), + primitive(.offset(dx: 2, dy: 2), [.primitive(2)], region), + primitive(.composite(.over), [.sourceGraphic, .primitive(3)], region) + ]) + + var deviations = [(Float, Float)]() + let environment = LayerTree.FilterLayer.Environment( + transform: LayerTree.Transform.Matrix(a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0), + blur: { _, x, y in deviations.append((x, y)) } + ) + let result = filter.evaluate(source: source, environment: environment) + XCTAssertEqual(result.pixel(x: 2, y: 2), red) + XCTAssertEqual(result.pixel(x: 5, y: 5), [0, 0, 0, 128]) + XCTAssertEqual(result.pixel(x: 4, y: 2), clear) + XCTAssertEqual(deviations.map(\.0), [1]) + } + + // user space at 2x, flipped vertically: offsets and deviations follow into pixels + func testEvaluateMapsUserSpaceToPixels() { + let source = makeSquare(size: 8, from: 3, to: 3) + let region = LayerTree.Rect(x: -100, y: -100, width: 200, height: 200) + let filter = LayerTree.FilterLayer(region: region, primitives: [ + primitive(.gaussianBlur(stdDeviation: 3, stdDeviationY: 1), [.sourceGraphic], region), + primitive(.offset(dx: 1, dy: 1), [.primitive(0)], region) + ]) + var deviations = [(Float, Float)]() + let environment = LayerTree.FilterLayer.Environment( + transform: LayerTree.Transform.Matrix(a: 2, b: 0, c: 0, d: -2, tx: 0, ty: 8), + blur: { _, x, y in deviations.append((x, y)) } + ) + + let result = filter.evaluate(source: source, environment: environment) + XCTAssertEqual(deviations.map(\.0), [6]) + XCTAssertEqual(deviations.map(\.1), [2]) + XCTAssertEqual(result.pixel(x: 5, y: 1), white) + XCTAssertEqual(result.pixel(x: 3, y: 3), clear) + } + + func testEvaluateDisabledPrimitiveIsTransparent() { + let source = makeSquare(size: 4, from: 0, to: 3) + let region = LayerTree.Rect(x: 0, y: 0, width: 4, height: 4) + let filter = LayerTree.FilterLayer(region: region, primitives: [ + primitive(.offset(dx: 0, dy: 0), [.sourceGraphic], .zero), + primitive(.merge, [.primitive(0), .transparent], region) + ]) + let result = filter.evaluate(source: source, environment: .init(transform: .identity, blur: { _, _, _ in })) + XCTAssertEqual(result, FilterBitmap(width: 4, height: 4)) + } + + // a result read by later primitives is kept, and the blur may change its input in place + func testEvaluateKeepsSharedResults() { + let source = makeSquare(size: 4, from: 1, to: 2) + let region = LayerTree.Rect(x: 0, y: 0, width: 4, height: 4) + let filter = LayerTree.FilterLayer(region: region, primitives: [ + primitive(.offset(dx: 1, dy: 0), [.sourceGraphic], region), + primitive(.gaussianBlur(stdDeviation: 1, stdDeviationY: 1), [.primitive(0)], region), + primitive(.merge, [.primitive(1), .primitive(0), .sourceGraphic], region) + ]) + // a "blur" that clears its bitmap + let environment = LayerTree.FilterLayer.Environment(transform: .identity, blur: { bitmap, _, _ in + bitmap = FilterBitmap(width: bitmap.width, height: bitmap.height) + }) + let result = filter.evaluate(source: source, environment: environment) + XCTAssertEqual(result.pixel(x: 1, y: 1), white) + XCTAssertEqual(result.pixel(x: 3, y: 1), white) + XCTAssertEqual(result.pixel(x: 0, y: 1), clear) + } + + func testFloodColorFromEnvironment() { + let region = LayerTree.Rect(x: 0, y: 0, width: 2, height: 2) + let filter = LayerTree.FilterLayer(region: region, primitives: [ + primitive(.flood(.rgba(r: 1, g: 0, b: 0, a: 1, space: .p3)), [], LayerTree.Rect(x: 0, y: 0, width: 1, height: 2)) + ]) + let environment = LayerTree.FilterLayer.Environment(transform: .identity, blur: { _, _, _ in }, + color: { _ in SIMD4(0, 1, 0, 1) }) + let result = filter.evaluate(source: FilterBitmap(width: 2, height: 2), environment: environment) + XCTAssertEqual(result.row(0), [[0, 255, 0, 255], clear]) + } +} + +private extension FilterBitmapTests { + + func primitive(_ effect: LayerTree.FilterLayer.Effect, + _ inputs: [LayerTree.FilterLayer.Input], + _ subregion: LayerTree.Rect) -> LayerTree.FilterLayer.Primitive { + LayerTree.FilterLayer.Primitive(effect: effect, inputs: inputs, subregion: subregion, colorInterpolation: .sRGB) + } + + func makeSquare(size: Int, from: Int, to: Int, color: [UInt8]? = nil) -> FilterBitmap { + var rows = [[[UInt8]]]() + for y in 0.. FilterBitmap { + FilterBitmap(width: rows[0].count, height: rows.count, pixels: rows.flatMap { $0.flatMap { $0 } }) + } + + func row(_ y: Int) -> [[UInt8]] { + (0..