Skip to content
Merged
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
199 changes: 199 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Contributing: Implementing SVG Spec Features

This guide walks through the end-to-end process of identifying a missing SVG feature, implementing it, and verifying it with tests. It is written for both humans and AI agents.

---

## 1. Find an uncovered feature

Open [`w3c-coverage.md`](w3c-coverage.md). Each section lists W3C conformance tests with ✅ (covered) or ❌ (not yet covered). Look for a category with low coverage and pick a failing test whose description sounds tractable — prefer tests that:

- Use only elements and attributes (no scripting or DOM interaction)
- Have a clear, deterministic pass criterion ("no red on the page", "one green rectangle")
- Aren't dependent on font rendering, animation, or system state

Read the SVG source file for that test to understand exactly what it exercises:

```
Tests/SVGViewTests/w3c/1.1F2/svg/<test-name>.svg
```

---

## 2. Trace the code path

SVGView's parser pipeline:

```
XML bytes
→ DOMParser (Source/Parser/XML/DOMParser.swift)
→ XMLElement tree
→ SVGParser (Source/Parser/SVG/SVGParser.swift)
looks up element name in `parsers` dictionary
calls the matching SVGElementParser
→ SVGNode model (Source/Model/)
→ Serializer (Source/Serialization/Serializer.swift)
→ .ref snapshot string
```

Key files to check:

| File | What to look at |
|------|----------------|
| `SVGParser.swift` | The `parsers` dictionary — if the element name is missing, add an entry |
| `SVGStructureParsers.swift` | Group/Use/Viewport/Switch parsers |
| `SVGShapeParser.swift` | Shape element parsers |
| `SVGElementParser.swift` | `SVGBaseElementParser` — common attribute handling (transform, opacity, clip, markers) |
| `SVGContext.swift` | `SVGNodeContext` — how style and property attributes are resolved |
| `SVGConstants.swift` | `availableStyleAttributes` — CSS properties SVGView recognises |
| `Source/Model/` | The node model classes — add a new model class here if needed |
| `Source/Serialization/Serializations.swift` | Add serialization for any new model fields |

### Example trace: `<switch>` was missing

1. `struct-cond-01-t.svg` used `<switch>` — not in `parsers` dictionary → silently dropped
2. Added `SVGSwitchParser` to `SVGStructureParsers.swift`
3. Registered `"switch": SVGSwitchParser()` in `SVGParser.swift`

---

## 3. Implement the parser

### Adding a parser for an unknown element

If the element produces a renderable node:

1. Subclass `SVGBaseElementParser` and override `doParse(context:delegate:)`
2. Read attributes from `context.properties` (non-style) or via `context.value(_:)` (style)
3. Return the appropriate `SVGNode` subclass (or a new one if needed)
4. Register the parser in `SVGParser.parsers`

```swift
// SVGStructureParsers.swift
class SVGMyFeatureParser: SVGBaseElementParser {
override func doParse(context: SVGNodeContext, delegate: (XMLElement) -> SVGNode?) -> SVGNode? {
let attrs = context.properties
// parse attributes, call delegate() for child elements
return SVGGroup(contents: parseContents(context: context, delegate: delegate))
}
}

// SVGParser.swift (add to parsers dict)
"myelement": SVGMyFeatureParser(),
```

### Adding support for a new attribute on existing elements

Most style attributes flow through `SVGContext`. To support a new one:

1. Add the attribute name string to `SVGConstants.availableStyleAttributes`
2. Add a property to the relevant `SVGNode` subclass (with `@Published` on non-Linux)
3. Read it in the element's parser via `context.styles["attribute-name"]`
4. Serialize it in `Serializations.swift`

---

## 4. Add the test

### Add to the test file

Tests live in `Tests/SVGViewTests/SVG11Tests.swift` or `SVG12Tests.swift`, organised in nested `@Suite` structs by category:

```swift
@Suite("Struct")
struct Struct: SVGTestHelper {
var dir: String { "1.1F2" }

@Test func myNewTest() throws { try compareToReference("my-test-name") }
}
```

Use the exact W3C file name (without `.svg`) as the argument to `compareToReference`.

### Add to the CLI snapshot list

Open `GenerateReferencesCLI/cli.swift` and add the test name to `v11Refs` (or `v12Refs`):

```swift
static let v11Refs: [String] = [
// ...
"my-test-name",
// ...
]
```

Keep the list alphabetical within each category block.

---

## 5. Generate the reference snapshot

Run the CLI to parse the SVG with the new parser code and write the `.ref` file:

```bash
swift run GenerateReferencesCLI Tests/SVGViewTests/w3c/
```

Or via Make:

```bash
make update-references-snapshots
```

This only works on macOS (the CLI is wrapped in `#if os(macOS)`). The output files land in:

```
Tests/SVGViewTests/w3c/1.1F2/refs/<test-name>.ref
Tests/SVGViewTests/w3c/1.2T/refs/<test-name>.ref
```

**Inspect the generated `.ref` before committing.** The serialized tree should reflect what you expect — correct elements selected, ignored, or transformed. If it looks wrong, fix the parser and re-run.

---

## 6. Run the tests

```bash
# via Xcode / xcodebuild
xcodebuild test -scheme SVGView-Package
```

Or use the Xcode MCP tool:

```
RunAllTests(tabIdentifier: "windowtab3")
```

All previously-passing tests must still pass, and the new ones must pass too.

---

## 7. Update the coverage report

```bash
make w3c-coverage
```

This regenerates `w3c-coverage.md` by counting `.ref` files vs `.svg` files. Commit it alongside the code change.

---

## Checklist

- [ ] Feature traced to a specific missing parser or attribute
- [ ] Parser implemented and registered
- [ ] Test name added to `SVG11Tests.swift` / `SVG12Tests.swift`
- [ ] Test name added to `cli.swift` `v11Refs` / `v12Refs`
- [ ] `.ref` file generated and inspected
- [ ] All tests pass
- [ ] `w3c-coverage.md` regenerated

---

## Tips

- **Start with `requiredExtensions`/`requiredFeatures` tests** — they are fully deterministic and don't depend on fonts, system language, or platform-specific color values.
- **Avoid `systemLanguage` tests** as a first target — the expected output depends on the system locale, making snapshot tests environment-sensitive.
- **The `.ref` file is the ground truth.** If you generate it with a broken parser, the test will pass but cover nothing. Always read the ref and confirm it matches the spec's pass criteria.
- **`SVGBaseElementParser.parse()`** already handles `transform`, `opacity`, `clip-path`, `id`, and markers for every element. Your `doParse()` only needs to handle element-specific attributes.
- **Serialization drives the tests**, not rendering. An element that parses correctly but serializes nothing will not be caught by these tests. Check `Serializations.swift` to ensure new model fields are serialized.
2 changes: 2 additions & 0 deletions GenerateReferencesCLI/cli.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ struct cli: ParsableCommand {
"shapes-rect-04-f",
"shapes-rect-05-f",
"shapes-rect-06-f",
"struct-cond-01-t",
"struct-cond-03-t",
"struct-defs-01-t",
"struct-frag-01-t",
"struct-frag-06-t",
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@ generate-test-cases: # Generate test cases from w3c reference files
update-references-snapshots: # Update .ref from .svg files
swift run GenerateReferencesCLI Tests/SVGViewTests/w3c/

.PHONY: help test generate-test-cases update-references-snapshots
w3c-coverage: # Regenerate w3c-coverage.md
./w3c-coverage.sh

.PHONY: help test generate-test-cases update-references-snapshots w3c-coverage
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This is a fork of [exyte/SVGView](https://github.com/exyte/SVGView) that tailore

This uses `make` heavily for relevant scripts. Run `make` without arguments to see the available commands.

See [CONTRIBUTING.md](CONTRIBUTING.md) for a step-by-step guide on identifying missing SVG spec features and implementing them, including how to write tests and generate reference snapshots.

## To add a new SVG test:
- Update `cli.swift` to include the svg file path
- `make generate-test-cases` to generate the unit test files
Expand Down
71 changes: 71 additions & 0 deletions Source/Parser/SVG/Elements/SVGStructureParsers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,74 @@ class SVGMarkerParser: SVGBaseElementParser {
}
}
}

/// Implements the SVG `<switch>` element (SVG 1.1 §5.8).
///
/// The switch evaluates conditional-processing attributes on each child element
/// in document order and renders the first child for which all conditions are
/// met. Subsequent children are ignored.
class SVGSwitchParser: SVGBaseElementParser {

/// SVG 1.1 feature strings that SVGView supports.
private static let supportedFeatures: Set<String> = [
"http://www.w3.org/TR/SVG11/feature#SVG-static",
"http://www.w3.org/TR/SVG11/feature#CoreAttribute",
"http://www.w3.org/TR/SVG11/feature#Structure",
"http://www.w3.org/TR/SVG11/feature#BasicStructure",
"http://www.w3.org/TR/SVG11/feature#ConditionalProcessing",
"http://www.w3.org/TR/SVG11/feature#Shape",
"http://www.w3.org/TR/SVG11/feature#BasicText",
"http://www.w3.org/TR/SVG11/feature#PaintAttribute",
"http://www.w3.org/TR/SVG11/feature#BasicPaintAttribute",
"http://www.w3.org/TR/SVG11/feature#OpacityAttribute",
"http://www.w3.org/TR/SVG11/feature#GraphicsAttribute",
"http://www.w3.org/TR/SVG11/feature#BasicGraphicsAttribute",
"http://www.w3.org/TR/SVG11/feature#Gradient",
"http://www.w3.org/TR/SVG11/feature#Marker",
"http://www.w3.org/TR/SVG11/feature#Image",
]

override func doParse(context: SVGNodeContext, delegate: (XMLElement) -> SVGNode?) -> SVGNode? {
let children = context.element.contents.compactMap { $0 as? XMLElement }
for child in children {
if conditionsMet(for: child) {
let contents = delegate(child).map { [$0] } ?? []
return SVGGroup(contents: contents)
}
}
return SVGGroup(contents: [])
}

private func conditionsMet(for element: XMLElement) -> Bool {
let attrs = element.attributes

// requiredExtensions: SVGView supports no extensions — any non-empty value skips the child
if let extensions = attrs["requiredExtensions"], !extensions.trimmingCharacters(in: .whitespaces).isEmpty {
return false
}

// requiredFeatures: every space-separated feature URI must be in our supported set
if let features = attrs["requiredFeatures"] {
let required = features.split(separator: " ").map(String.init)
if !required.allSatisfy({ Self.supportedFeatures.contains($0) }) {
return false
}
}

// systemLanguage: at least one comma-separated BCP-47 tag must prefix-match the current locale
if let languages = attrs["systemLanguage"] {
let currentLang: String
if #available(macOS 13, iOS 16, watchOS 9, *) {
currentLang = Locale.current.language.languageCode?.identifier ?? ""
} else {
currentLang = (Locale.current as NSLocale).languageCode
}
let codes = languages.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
if !codes.contains(where: { $0.hasPrefix(currentLang) || currentLang.hasPrefix($0) }) {
return false
}
}

return true
}
}
1 change: 1 addition & 0 deletions Source/Parser/SVG/SVGParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public struct SVGParser {
"path": SVGPathParser(),
"marker": SVGMarkerParser(),
"defs": SVGVDefParser(),
"switch": SVGSwitchParser(),
]

private static func parse(context: SVGNodeContext) -> SVGNode? {
Expand Down
49 changes: 48 additions & 1 deletion Tests/SVGViewTests/BaseTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import Foundation
import Testing
@testable import SVGView

#if canImport(SwiftUI)
import SwiftUI
#endif

protocol SVGTestHelper {
var dir: String { get }
}
Expand All @@ -17,18 +21,21 @@ extension SVGTestHelper {

var dir: String { "1.2T" }

func compareToReference(_ fileName: String) throws {
func compareToReference(_ fileName: String) async throws {
let bundle = Bundle.module
let svgURL = try #require(bundle.url(forResource: fileName, withExtension: "svg", subdirectory: "w3c/\(dir)/svg/"))
let refURL = try #require(bundle.url(forResource: fileName, withExtension: "ref", subdirectory: "w3c/\(dir)/refs/"))

let svgSource = try String(contentsOf: svgURL)
let node = try #require(SVGParser.parse(contentsOf: svgURL))
let content = Serializer.serialize(node)
let reference = try String(contentsOf: refURL)

Attachment.record(Attachment(svgSource, named: "\(fileName).svg"))
Attachment.record(Attachment(content, named: "\(fileName)-actual.txt"))
Attachment.record(Attachment(reference, named: "\(fileName)-expected.txt"))
Attachment.record(Attachment(unifiedDiff(actual: content, expected: reference), named: "\(fileName)-diff.txt"))
await renderedPNGAttachment(node: node, named: "\(fileName)-rendered.png")

#expect(content == reference, "nodeContent is not equal to referenceContent. \(prettyFirstDifferenceBetweenStrings(s1: content, s2: reference))")
}
Expand Down Expand Up @@ -75,6 +82,46 @@ extension SVGTestHelper {
}
return result.reversed()
}

/// Renders `node` to a PNG using `ImageRenderer` and records it as a test attachment.
/// Only runs on platforms that support `ImageRenderer` (macOS 13+, iOS 16+, watchOS 9+).
/// Failures are silently ignored — the render is a diagnostic aid, not part of correctness.
func renderedPNGAttachment(node: SVGNode, named name: String) async {
#if canImport(SwiftUI)
if #available(macOS 13, iOS 16, watchOS 9, *) {
let size = renderSize(for: node)
let png = await MainActor.run { () -> Data? in
let renderer = ImageRenderer(content: SVGView(svg: node).frame(width: size.width, height: size.height))
renderer.scale = 1.0
#if os(macOS)
guard let nsImage = renderer.nsImage,
let tiff = nsImage.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff) else { return nil }
return rep.representation(using: .png, properties: [:])
#elseif os(iOS)
return renderer.uiImage?.pngData()
#else
return nil
#endif
}
if let png {
Attachment.record(Attachment(png, named: name))
}
}
#endif
}

private func renderSize(for node: SVGNode) -> CGSize {
if let viewport = node as? SVGViewport {
if let box = viewport.viewBox, box.width > 0, box.height > 0 {
return CGSize(width: box.width, height: box.height)
}
if let w = viewport.width.ideal, let h = viewport.height.ideal, w > 0, h > 0 {
return CGSize(width: w, height: h)
}
}
return CGSize(width: 480, height: 360)
}
}

/// Find first differing character between two strings
Expand Down
Loading