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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) |
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,4 @@ fastlane/test_output
iOSInjectionProject/

# End of https://www.gitignore.io/api/swift,macos,carthage,cocoapods
test-output/
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)
Loading