Skip to content
82 changes: 82 additions & 0 deletions .github/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Architecture & Parsing Pipeline

## Overview

```
SVGParser.parse(xml:)
├─ SVGIndex(element:) ← traverses full XML tree FIRST
│ ├─ elements[id] = XMLElement
│ ├─ paints[id] = SVGPaint ← linearGradient, radialGradient, pattern
│ └─ CSSParser ← <style> blocks
└─ parse(element:parentContext:) ← depth-first node tree construction
└─ SVGNodeContext ← merges style + property attributes
└─ parsers[tag]?.parse() → SVGNode
```

`SVGIndex` is built before any `SVGNode` objects exist. Element parsers receive an `SVGNodeContext` that holds the completed index.

## Platform Guards

```swift
#if os(WASI) || os(Linux) // polyfill only — no SwiftUI, no CoreGraphics
#else
import SwiftUI // Apple platforms
#endif

#if canImport(SwiftUI) // SwiftUI view code
#endif

#if !os(WASI) && !os(Linux) // CoreGraphics (CGContext, CGColor, etc.)
#endif
```

Always use these guards when touching model, parser, or rendering code.

## Paint Server Resolution

When a shape has `fill="url(#someId)"`:

1. `SVGHelper.parseFill(_:index:)` → `parseFillInternal`
2. `index.paint(by: "someId")` → returns the pre-built `SVGPaint`
3. At render time, `view.apply(paint: model.fill, model: model)` dispatches on type:
- `SVGLinearGradient` → `LinearGradient` overlay
- `SVGRadialGradient` → `RadialGradient` overlay
- `SVGPattern` → `CGBitmapContext` tile → `ImagePaint` overlay
- `SVGColor` → `.foregroundColor`

`xlink:href` on a paint server (e.g. a gradient or pattern referencing another) is resolved inside `SVGIndex` using `getParentGradient()` / `getParentPattern()`. Property lookup falls back to the parent when an attribute is absent on the child element. Content (stops / tile nodes) is inherited when the child element has no children of its own.

## SVGNode Rendering

Each model class provides its own SwiftUI view inside `#if canImport(SwiftUI)`:

```swift
public class SVGRect: SVGShape {
public func contentView() -> some View { SVGRectView(model: self) }
}

struct SVGRectView: View {
@ObservedObject var model: SVGRect
var body: some View {
RoundedRectangle(...)
.applySVGStroke(stroke: model.stroke)
.applyShapeAttributes(model: model) // fill + node attributes
.frame(...).position(...).offset(...)
}
}
```

`SVGNode.toSwiftUI()` dispatches to the right view via a type switch — add new cases there when adding new node types.

## CGContext Tile Rendering (Patterns)

`SVGPattern` renders its tile using `CGBitmapContext` (thread-safe, no `@MainActor`):

1. Create `CGBitmapContext` at `ceil(width) × ceil(height)` pixels.
2. Flip coordinate system: `translateBy(x:0, y:h); scaleBy(x:1, y:-1)` so y increases downward (matching SVG).
3. Call `node.draw(in: context)` for each tile child — default is no-op; `SVGRect` and `SVGGroup` override it.
4. `context.makeImage()` → `CGImage` → `Image(decorative:scale:1.0)` → `ImagePaint`.

To support additional shapes inside patterns, override `draw(in context: CGContext)` in the shape class under `#if !os(WASI) && !os(Linux)`.
116 changes: 116 additions & 0 deletions .github/docs/implementing-features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Implementing New SVG Features

## Adding a New SVG Element

A renderable element (e.g. `<ellipse>`) needs three things:

### 1. Model class — `Source/Model/Shapes/SVGFoo.swift`

Subclass `SVGShape` (for shapes with fill/stroke) or `SVGNode`. Follow `SVGRect.swift` as a template:
- Store geometry properties under platform guards (`@Published` on Apple, plain `var` on WASI/Linux).
- Override `frame() -> CGRect`.
- Override `serialize(_ serializer:)`.
- Implement `contentView()` + `SVGFooView: View` inside `#if canImport(SwiftUI)`.
- If the element may appear inside a `<pattern>` tile, override `draw(in context: CGContext)` under `#if !os(WASI) && !os(Linux)`.

### 2. Element parser — `Source/Parser/SVG/Elements/`

Create or extend a parser file. Subclass `SVGBaseElementParser` and override `doParse(context:delegate:)`. Use `SVGShapeParser.swift` as a template for shapes. Parse attributes via `SVGHelper` and `context.properties` / `context.styles`.

### 3. Register in `SVGParser.parsers`

```swift
// Source/Parser/SVG/SVGParser.swift
private static let parsers: [String: SVGElementParser] = [
...
"foo": SVGFooParser(),
]
```

Also add a case to `SVGNode.toSwiftUI()` if the new type is a distinct node class.

---

## Adding a New Paint Server

A paint server is resolved from `fill="url(#id)"` or `stroke="url(#id)"`. Follow `SVGPattern` as the most recent example.

### 1. Model class — `Source/Model/Primitives/SVGFooPaint.swift`

Subclass `SVGPaint`. Implement `apply<S>(view: S, model: SVGShape?) -> some View` inside `#if canImport(SwiftUI)` using `@ViewBuilder`. The pattern for overlay masking:

```swift
view
.foregroundColor(.clear)
.overlay(
<fill view>
.frame(width: bounds.width, height: bounds.height)
.offset(x: bounds.minX, y: bounds.minY)
.mask(view)
)
```

### 2. Register in `SVGIndex`

In `Source/Parser/SVG/SVGIndex.swift`:

```swift
// fill(from:) switch:
case "linearGradient", "radialGradient", "pattern", "myNewType":
paints[id] = parseFill(element)

// parseFill() switch:
case "myNewType":
return parseMyNewType(element)
```

Add `parseMyNewType(_ element: XMLElement) -> SVGPaint?`. For `xlink:href` inheritance, look up `paints[id]` and cast — see `getParentGradient()` / `getParentPattern()` for the pattern.

### 3. Wire into `SVGPaint.apply(paint:model:)`

```swift
// Source/Model/Primitives/SVGPaint.swift
case let foo as SVGFooPaint:
foo.apply(view: self, model: model)
```

---

## Test Workflow

1. **Add the test** to the relevant suite in `Tests/SVGViewTests/SVG11Tests.swift` (or `SVG12Tests.swift` / `SVGCustomTests.swift`):
```swift
@Test func myFeatureTest() async throws { try await compareToReference("my-test-name") }
```

2. **Add to the CLI list** in `GenerateReferencesCLI/cli.swift` under `v11Refs` (or `v12Refs` / `customRefs`).

3. **Generate the reference file** — this snapshots the current parser output as the accepted result:
```bash
swift run GenerateReferencesCLI Tests/SVGViewTests/w3c
```
The `.ref` file is written to `Tests/SVGViewTests/w3c/<version>/refs/<name>.ref`.

4. **Run the test** to confirm it passes:
```bash
swift test --filter <TestFunctionName>
```

5. **Update `w3c-coverage.md`** — change `❌` to `✅` for the test entry:
```bash
./w3c-coverage.sh
```

> The test compares `Serializer.serialize(node)` text output — it validates the parsed node structure, not pixel output. The rendered PNG is recorded as an attachment for manual inspection but does not assert.

---

## Key Helpers

| Helper | Location | Purpose |
|--------|----------|---------|
| `SVGHelper.parseCGFloat(_:_:defaultValue:)` | `SVGParserBasics.swift` | Parse numeric attribute |
| `SVGHelper.parseFill(_:index:)` | `SVGParserBasics.swift` | Resolve fill paint (color or `url(#...)`) |
| `SVGHelper.parseTransform(_:)` | `SVGParserPrimitives.swift` | Parse `transform` / `*Transform` attribute |
| `SVGHelper.parseColor(_:_:)` | `SVGParserBasics.swift` | Parse color string to `SVGColor` |
| `SVGParser.parseElements(_:index:)` | `SVGParser.swift` | Parse `XMLElement[]` into `SVGNode[]` (used by pattern tile parsing) |
50 changes: 50 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SVGView — Agent Guidelines

SVGView is a Swift Package that parses SVG files into a model tree (`SVGNode` subclasses) and renders them with SwiftUI. It targets iOS, macOS, tvOS, and watchOS; WASI/Linux builds exclude all SwiftUI/CoreGraphics code.

## Build & Test

```bash
swift build
swift test
swift test --filter <TestName> # run a single test
swift run GenerateReferencesCLI Tests/SVGViewTests/w3c # regenerate .ref snapshots
```

Tests are snapshot-based: the parsed node tree is serialized to text and compared against a `.ref` file. The visual render is recorded as an attachment only — it does not assert.

### Exporting rendered PNGs for debugging

Pass `--attachments-path <dir>` to write all test attachments (rendered PNG, actual/expected text, diff) to a directory on disk:

```bash
mkdir -p /tmp/svgout
swift test --filter <TestName> --attachments-path /tmp/svgout
# Files written: <TestName>-rendered.png, <TestName>-actual.txt, <TestName>-expected.txt, <TestName>-diff.txt
```

To compare a rendered PNG against the W3C reference image, open the reference URL in a browser:
```
https://www.w3.org/Graphics/SVG/Test/20110816/png/<test-name>.png
```

## Key Directories

| Path | Purpose |
|------|---------|
| `Source/Model/` | SVGNode model classes (shapes, groups, primitives) |
| `Source/Parser/SVG/` | Parser pipeline — `SVGParser`, `SVGIndex`, element parsers, context |
| `Source/Model/Primitives/` | Paint servers: `SVGColor`, `SVGLinearGradient`, `SVGRadialGradient`, `SVGPattern` |
| `Source/UI/` | Platform-specific extensions (`MBezierPath`) |
| `Tests/SVGViewTests/` | Snapshot tests + W3C test SVGs and `.ref` files |
| `GenerateReferencesCLI/` | CLI tool to regenerate `.ref` snapshot files |
| `w3c-coverage.md` | ✅/❌ table of W3C test pass/fail status |

## W3C Test Coverage

`w3c-coverage.md` tracks which W3C conformance tests pass. `w3c-coverage.sh` regenerates it by running the test suite. When a new feature is implemented, add a test to `SVG11Tests.swift` and regenerate the corresponding `.ref` file.

## Reference Docs

- [Architecture & parsing pipeline](.github/docs/architecture.md)
- [Implementing new SVG features](.github/docs/implementing-features.md)
2 changes: 2 additions & 0 deletions GenerateReferencesCLI/cli.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ struct cli: ParsableCommand {
"paths-data-20-f",
"pservers-grad-01-b",
"pservers-grad-02-b",
"pservers-grad-03-b",
"pservers-grad-06-b",
"pservers-grad-04-b",
"pservers-grad-05-b",
"pservers-grad-07-b",
Expand Down
11 changes: 11 additions & 0 deletions Source/Model/Nodes/SVGGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ public class SVGGroup: SVGNode {
serializer.add("contents", contents)
}

#if !os(WASI) && !os(Linux)
override func draw(in context: CGContext) {
context.saveGState()
context.concatenate(transform)
for node in contents {
node.draw(in: context)
}
context.restoreGState()
}
#endif

#if canImport(SwiftUI)
public func contentView() -> some View {
SVGGroupView(model: self)
Expand Down
6 changes: 6 additions & 0 deletions Source/Model/Nodes/SVGNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ public class SVGNode: SerializableElement {
return String(describing: type(of: self))
}

#if !os(WASI) && !os(Linux)
func draw(in context: CGContext) {
// default: no-op
}
#endif

}

#if canImport(SwiftUI)
Expand Down
83 changes: 67 additions & 16 deletions Source/Model/Primitives/SVGGradient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,22 @@ public class SVGRadialGradient: SVGGradient {
public let fx: CGFloat
public let fy: CGFloat
public let r: CGFloat
public let gradientTransform: CGAffineTransform

public init(cx: CGFloat = 0.5, cy: CGFloat = 0.5, fx: CGFloat = 0.5, fy: CGFloat = 0.5, r: CGFloat = 0.5, userSpace: Bool = false, stops: [SVGStop] = []) {
public init(cx: CGFloat = 0.5, cy: CGFloat = 0.5, fx: CGFloat = 0.5, fy: CGFloat = 0.5, r: CGFloat = 0.5,
userSpace: Bool = false, gradientTransform: CGAffineTransform = .identity, stops: [SVGStop] = []) {
self.cx = cx
self.cy = cy
self.fx = fx
self.fy = fy
self.r = r
self.gradientTransform = gradientTransform
super.init(
userSpace: userSpace,
stops: stops
)
}

#if canImport(SwiftUI)
public func toSwiftUI(rect: CGRect) -> RadialGradient {
let suiStops = stops.map { stop in Gradient.Stop(color: stop.color.toSwiftUI(), location: stop.offset) }
Expand All @@ -123,21 +126,69 @@ public class SVGRadialGradient: SVGGradient {
return RadialGradient(gradient: Gradient(stops: suiStops), center: UnitPoint(x: ncx, y: ncy), startRadius: 0, endRadius: userSpace ? r : r * s)
}

func apply<S>(view: S, model: SVGShape? = nil) -> some View where S : View {
let frame = model?.frame() ?? CGRect()
@ViewBuilder
func apply<S>(view: S, model: SVGShape? = nil) -> some View where S: View {
let bounds = model?.bounds() ?? CGRect()
let width = bounds.width
let height = bounds.height
let minimum = min(width, height)
return view
.foregroundColor(.clear)
.overlay(
toSwiftUI(rect: frame)
.scaleEffect(CGSize(width: userSpace ? 1 : width/minimum,
height: userSpace ? 1 : height/minimum))
.offset(x: bounds.minX, y: bounds.minY)
.mask(view)
)
let frame = model?.frame() ?? CGRect()
if !gradientTransform.isIdentity, let cgImage = renderGradientImage(shapeFrame: frame) {
let image = Image(decorative: cgImage, scale: 1.0)
view
.foregroundColor(.clear)
.overlay(
image
.resizable()
.frame(width: bounds.width, height: bounds.height)
.offset(x: bounds.minX, y: bounds.minY)
.mask(view)
)
} else {
let minimum = min(bounds.width, bounds.height)
view
.foregroundColor(.clear)
.overlay(
toSwiftUI(rect: frame)
.scaleEffect(CGSize(width: userSpace ? 1 : bounds.width / minimum,
height: userSpace ? 1 : bounds.height / minimum))
.offset(x: bounds.minX, y: bounds.minY)
.mask(view)
)
}
}

private func renderGradientImage(shapeFrame: CGRect) -> CGImage? {
let w = Int(ceil(shapeFrame.width))
let h = Int(ceil(shapeFrame.height))
guard w > 0, h > 0 else { return nil }
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil, width: w, height: h,
bitsPerComponent: 8, bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
// Flip y-axis so that y increases downward (matches SVG coordinate space)
context.translateBy(x: 0, y: CGFloat(h))
context.scaleBy(x: 1, y: -1)
// Translate so that the shape's SVG origin maps to the bitmap origin
context.translateBy(x: -shapeFrame.minX, y: -shapeFrame.minY)
// Apply gradientTransform: maps gradient space → SVG user space
context.concatenate(gradientTransform)
// Build CGGradient from stops
let cgColors = stops.map { stop in
CGColor(red: CGFloat(stop.color.r) / 255.0,
green: CGFloat(stop.color.g) / 255.0,
blue: CGFloat(stop.color.b) / 255.0,
alpha: CGFloat(stop.color.a) / 255.0)
} as CFArray
let locations: [CGFloat] = stops.map(\.offset)
guard let cg = CGGradient(colorsSpace: colorSpace, colors: cgColors, locations: locations) else { return nil }
context.drawRadialGradient(
cg,
startCenter: CGPoint(x: fx, y: fy), startRadius: 0,
endCenter: CGPoint(x: cx, y: cy), endRadius: r,
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation]
)
return context.makeImage()
}
#endif

Expand Down
Loading