Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4ec92b0
Implement coords-dom-01-f
Amnell May 24, 2026
e67b80d
Implement coords-dom-02-f
Amnell May 24, 2026
60cad6a
Begin script support
Amnell May 25, 2026
48f5ba2
Implement stroke and fill support in scripting engine
Amnell May 25, 2026
6b3046b
Implement stroke-dasharray and stroke-dashoffset in script runner
Amnell May 25, 2026
778b513
Implement linecap, linejoin and miterlimit in ScriptRunner
Amnell May 25, 2026
da38a27
Add fill-rule support in ScriptRunner
Amnell May 25, 2026
0448493
Add support for currentColor in ScriptRunner
Amnell May 25, 2026
3be9d31
Harden currentcolor
Amnell May 25, 2026
ae64a3b
Gitignore .vscode
Amnell May 25, 2026
6e53e86
Add currentColor runtime behavior and regression controls
Amnell May 25, 2026
2b001af
Harden currentColor inheritance and override precedence
Amnell May 25, 2026
f15b1d1
Add text currentColor parity and relink controls
Amnell May 25, 2026
9e96a3e
Add text stroke currentColor relink controls
Amnell May 25, 2026
b80efe2
Add mixed-node currentColor inheritance controls
Amnell May 25, 2026
16ed2a5
Add style source precedence currentColor controls
Amnell May 25, 2026
2fe2f51
Add opacity hardening for currentColor controls
Amnell May 25, 2026
071229e
Add currentColor mutation ordering controls
Amnell May 25, 2026
a724a0c
Add currentColor opacity clamping controls
Amnell May 25, 2026
1a2160b
Add invalid opacity currentColor controls
Amnell May 25, 2026
4b18399
Add invalid color currentColor controls
Amnell May 25, 2026
62e2371
Add currentColor edge color token controls
Amnell May 25, 2026
c62a294
Add malformed currentColor token controls
Amnell May 25, 2026
48e9cb9
Add function-notation currentColor controls
Amnell May 25, 2026
571bed8
Add CSS-wide keyword currentColor controls
Amnell May 25, 2026
ca9c621
Implment coords-dom-03-f
Amnell May 25, 2026
d0d1c7a
Implement coords-dom-04-f DOM scripting support
Amnell May 25, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,4 @@ iOSInjectionProject/

# End of https://www.gitignore.io/api/swift,macos,carthage,cocoapods
test-output/
/.vscode
65 changes: 65 additions & 0 deletions GenerateReferencesCLI/cli.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ struct cli: ParsableCommand {
"color-prop-05-t",
"coords-coord-01-t",
"coords-coord-02-t",
"coords-dom-01-f",
"coords-dom-02-f",
"coords-dom-03-f",
"coords-dom-04-f",
"coords-trans-01-b",
"coords-trans-02-t",
"coords-trans-03-t",
Expand Down Expand Up @@ -153,6 +157,67 @@ struct cli: ParsableCommand {
"viewport-01",
"viewport-02",
"graph-01",
"script-gating-01",
"script-gating-02",
"script-gating-03",
"script-gating-04",
"script-stroke-01",
"script-stroke-02",
"script-stroke-03",
"script-stroke-04",
"script-stroke-05",
"script-stroke-06",
"script-fillrule-01",
"script-fillrule-02",
"script-currentcolor-01",
"script-currentcolor-02",
"script-currentcolor-03",
"script-currentcolor-04",
"script-currentcolor-05",
"script-currentcolor-06",
"script-currentcolor-07",
"script-currentcolor-08",
"script-currentcolor-09",
"script-currentcolor-10",
"script-currentcolor-11",
"script-currentcolor-12",
"script-currentcolor-13",
"script-currentcolor-14",
"script-currentcolor-15",
"script-currentcolor-16",
"script-currentcolor-17",
"script-currentcolor-18",
"script-currentcolor-19",
"script-currentcolor-20",
"script-currentcolor-21",
"script-currentcolor-22",
"script-currentcolor-23",
"script-currentcolor-24",
"script-currentcolor-25",
"script-currentcolor-26",
"script-currentcolor-27",
"script-currentcolor-28",
"script-currentcolor-29",
"script-currentcolor-30",
"script-currentcolor-31",
"script-currentcolor-32",
"script-currentcolor-33",
"script-currentcolor-34",
"script-currentcolor-35",
"script-currentcolor-36",
"script-currentcolor-37",
"script-currentcolor-38",
"script-currentcolor-39",
"script-currentcolor-40",
"script-currentcolor-41",
"script-currentcolor-42",
"script-currentcolor-43",
"script-currentcolor-44",
"script-currentcolor-45",
"script-currentcolor-46",
"script-currentcolor-47",
"script-currentcolor-48",
"script-currentcolor-49",
]

mutating func run() throws {
Expand Down
2 changes: 2 additions & 0 deletions Source/Model/Nodes/SVGGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ public class SVGGroup: SVGNode {

#if !os(WASI) && !os(Linux)
override func draw(in context: CGContext) {
guard opaque else { return }
context.saveGState()
context.concatenate(transform)
for node in contents {
guard node.opaque else { continue }
node.draw(in: context)
}
context.restoreGState()
Expand Down
10 changes: 9 additions & 1 deletion Source/Model/Nodes/SVGNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ public class SVGNode: SerializableElement {

#if os(WASI) || os(Linux)
public var transform: CGAffineTransform = CGAffineTransform.identity
public var scriptTransforms: [CGAffineTransform] = []
public var opaque: Bool
public var opacity: Double
public var currentColor: SVGColor?
public var hasExplicitCurrentColor: Bool = false
public var clip: SVGNode?
public var mask: SVGNode?
public var id: String?
Expand All @@ -19,8 +22,11 @@ public class SVGNode: SerializableElement {
public var markerEnd: String?
#else
@Published public var transform: CGAffineTransform = CGAffineTransform.identity
@Published public var scriptTransforms: [CGAffineTransform] = []
@Published public var opaque: Bool
@Published public var opacity: Double
@Published public var currentColor: SVGColor?
@Published public var hasExplicitCurrentColor: Bool = false
@Published public var clip: SVGNode?
@Published public var mask: SVGNode?
@Published public var id: String?
Expand All @@ -30,10 +36,12 @@ public class SVGNode: SerializableElement {
#endif


public init(transform: CGAffineTransform = .identity, opaque: Bool = true, opacity: Double = 1, clip: SVGNode? = nil, mask: SVGNode? = nil, id: String? = nil, markerStart: String? = nil, markerMid: String? = nil, markerEnd: String? = nil) {
public init(transform: CGAffineTransform = .identity, scriptTransforms: [CGAffineTransform] = [], opaque: Bool = true, opacity: Double = 1, currentColor: SVGColor? = nil, clip: SVGNode? = nil, mask: SVGNode? = nil, id: String? = nil, markerStart: String? = nil, markerMid: String? = nil, markerEnd: String? = nil) {
self.transform = transform
self.scriptTransforms = scriptTransforms
self.opaque = opaque
self.opacity = opacity
self.currentColor = currentColor
self.clip = clip
self.mask = mask
self.id = id
Expand Down
4 changes: 4 additions & 0 deletions Source/Model/Nodes/SVGShape.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ public class SVGShape: SVGNode {
#if os(WASI) || os(Linux)
public var fill: SVGPaint?
public var stroke: SVGStroke?
public var fillUsesCurrentColor: Bool = false
public var strokeUsesCurrentColor: Bool = false
#else
@Published public var fill: SVGPaint?
@Published public var stroke: SVGStroke?
@Published public var fillUsesCurrentColor: Bool = false
@Published public var strokeUsesCurrentColor: Bool = false
#endif

override func serialize(_ serializer: Serializer) {
Expand Down
4 changes: 4 additions & 0 deletions Source/Model/Nodes/SVGText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ public class SVGText: SVGNode {
public var font: SVGFont?
public var fill: SVGPaint?
public var stroke: SVGStroke?
public var fillUsesCurrentColor: Bool = false
public var strokeUsesCurrentColor: Bool = false
public var textAnchor: Anchor = .leading
#else
@Published public var text: String
@Published public var font: SVGFont?
@Published public var fill: SVGPaint?
@Published public var stroke: SVGStroke?
@Published public var fillUsesCurrentColor: Bool = false
@Published public var strokeUsesCurrentColor: Bool = false
@Published public var textAnchor: Anchor = .leading
#endif

Expand Down
2 changes: 1 addition & 1 deletion Source/Model/Shapes/SVGCircle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ struct SVGCircleView: View {
public var body: some View {
Circle()
.applySVGStroke(stroke: model.stroke)
.applyShapeAttributes(model: model)
.frame(width: 2 * model.r, height: 2 * model.r)
.position(x: model.cx, y: model.cy)
.applyShapeAttributes(model: model)
}
}
#endif
3 changes: 2 additions & 1 deletion Source/Model/Shapes/SVGRect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ public class SVGRect: SVGShape {

#if !os(WASI) && !os(Linux)
override func draw(in context: CGContext) {
guard opaque else { return }
guard let color = fill as? SVGColor else { return }
context.saveGState()
context.setFillColor(CGColor(
red: CGFloat(color.r) / 255,
green: CGFloat(color.g) / 255,
blue: CGFloat(color.b) / 255,
alpha: CGFloat(color.opacity)
alpha: CGFloat(color.opacity * opacity)
))
context.fill(CGRect(x: x, y: y, width: width, height: height))
context.restoreGState()
Expand Down
11 changes: 11 additions & 0 deletions Source/Parser/SVG/Elements/SVGElementParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ class SVGBaseElementParser: SVGElementParser {
guard let node = doParse(context: context, delegate: delegate) else { return nil }
let transform = SVGHelper.parseTransform(context.properties["transform"] ?? "")
node.transform = node.transform.concatenating(transform)
node.scriptTransforms = SVGHelper.parseTransformOperations(context.properties["transform"] ?? "")
node.opacity = SVGHelper.parseOpacity(context.properties, "opacity")
if let visibility = context.style("visibility")?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), visibility == "hidden" {
node.opaque = false
}
if let display = context.style("display")?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), display == "none" {
node.opaque = false
}
if let colorValue = context.style("color") {
node.currentColor = SVGHelper.parseColor(colorValue, context.styles)
}
node.hasExplicitCurrentColor = context.hasElementStyle("color")

if let clipId = SVGHelper.parseUse(context.properties["clip-path"]),
let clipNode = context.index.element(by: clipId),
Expand Down
2 changes: 2 additions & 0 deletions Source/Parser/SVG/Elements/SVGShapeParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class SVGShapeParser: SVGBaseElementParser {
guard let locus = parseLocus(context: context) else { return nil }
locus.fill = SVGHelper.parseFill(context.styles, context.index)
locus.stroke = SVGHelper.parseStroke(context.styles, index: context.index)
locus.fillUsesCurrentColor = context.style("fill")?.lowercased() == "currentcolor"
locus.strokeUsesCurrentColor = context.style("stroke")?.lowercased() == "currentcolor"
return locus
}

Expand Down
5 changes: 4 additions & 1 deletion Source/Parser/SVG/Elements/SVGTextParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ class SVGTextParser: SVGBaseElementParser {

if let textNode = context.element.contents.first as? XMLText {
let trimmed = textNode.text.trimmingCharacters(in: .whitespacesAndNewlines).processingWhitespaces()
return SVGText(text: trimmed, font: font, fill: SVGHelper.parseFill(context.styles, context.index), stroke: SVGHelper.parseStroke(context.styles, index: context.index), textAnchor: textAnchor, transform: transform)
let text = SVGText(text: trimmed, font: font, fill: SVGHelper.parseFill(context.styles, context.index), stroke: SVGHelper.parseStroke(context.styles, index: context.index), textAnchor: textAnchor, transform: transform)
text.fillUsesCurrentColor = context.style("fill")?.lowercased() == "currentcolor"
text.strokeUsesCurrentColor = context.style("stroke")?.lowercased() == "currentcolor"
return text
}
return .none
}
Expand Down
6 changes: 6 additions & 0 deletions Source/Parser/SVG/SVGContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class SVGNodeContext: SVGContext {
let element: XMLElement
let useIds: [String]

let elementStyles: [String:String]
let styles: [String:String]
let properties: [String:String]

Expand All @@ -71,6 +72,7 @@ class SVGNodeContext: SVGContext {
self.properties = element.attributes.filter { !SVGConstants.availableStyleAttributes.contains($0.key) }

let styleDict = SVGParser.getStyleAttributes(xml: element, index: root.index)
self.elementStyles = styleDict
self.styles = Self.mergeStyles(element: styleDict, parent: parentStyles)
}

Expand Down Expand Up @@ -109,6 +111,10 @@ class SVGNodeContext: SVGContext {
return styles[name]
}

func hasElementStyle(_ name: String) -> Bool {
elementStyles[name] != nil
}

func property(_ name: String) -> String? {
return properties[name]
}
Expand Down
13 changes: 7 additions & 6 deletions Source/Parser/SVG/SVGParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ public struct SVGParser {
static public func parse(xml: XMLElement?, settings: SVGSettings = .default) -> SVGNode? {
guard let xml = xml else { return nil }

return parse(element: xml, parentContext: SVGRootContext(
let node = parse(element: xml, parentContext: SVGRootContext(
logger: settings.logger,
linker: settings.linker,
screen: SVGScreen.main(ppi: settings.ppi),
index: SVGIndex(element: xml),
defaultFontSize: settings.fontSize))

if let node {
SVGScriptRunner.executeIfNeeded(xmlRoot: xml, nodeRoot: node, logger: settings.logger)
}

return node
}

@available(*, deprecated, message: "Use parse(contentsOf:) function instead")
Expand Down Expand Up @@ -106,11 +112,6 @@ public struct SVGParser {
}
}

// TODO: it's a temporary solution. Need to create a correct style merging mechanics
if styleDict["fill"] == "currentColor", let color = styleDict["color"] {
styleDict["fill"] = color
}

return styleDict
}

Expand Down
75 changes: 75 additions & 0 deletions Source/Parser/SVG/SVGParserPrimitives.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,81 @@ public class SVGHelper: NSObject {
return parseTransform(newAttributeString, transform: finalTransform)
}

static func parseTransformOperations(_ attributes: String, collectedOperations: [CGAffineTransform] = []) -> [CGAffineTransform] {
guard let matcher = SVGParserRegexHelper.getTransformAttributeMatcher() else {
return collectedOperations
}

let attributes = attributes.replacingOccurrences(of: "\n", with: "")
var updatedOperations = collectedOperations
let fullRange = NSRange(location: 0, length: attributes.count)

guard let matchedAttribute = matcher.firstMatch(in: attributes, options: .reportCompletion, range: fullRange) else {
return updatedOperations
}

let attributeName = (attributes as NSString).substring(with: matchedAttribute.range(at: 1))
let values = parseTransformValues((attributes as NSString).substring(with: matchedAttribute.range(at: 2)))
if values.isEmpty {
return updatedOperations
}

var operation = CGAffineTransform.identity
switch attributeName {
case "translate":
if let x = values[0].cgFloatValue {
var y: CGFloat = 0
if values.indices.contains(1) {
y = values[1].cgFloatValue ?? 0
}
operation = operation.translatedBy(x: x, y: y)
}
case "scale":
if let x = values[0].cgFloatValue {
var y = x
if values.indices.contains(1) {
y = values[1].cgFloatValue ?? x
}
operation = operation.scaledBy(x: x, y: y)
}
case "rotate":
if let angle = values[0].cgFloatValue {
if values.count == 1 {
operation = operation.rotated(by: angle.degreesToRadians)
} else if values.count == 3, let x = values[1].cgFloatValue, let y = values[2].cgFloatValue {
operation = operation.translatedBy(x: x, y: y).rotated(by: angle.degreesToRadians).translatedBy(x: -x, y: -y)
}
}
case "skewX":
if let x = values[0].cgFloatValue {
let v = tan((x * .pi) / 180.0)
operation = operation.shear(shx: v, shy: 0)
}
case "skewY":
if let y = values[0].cgFloatValue {
let v = tan((y * .pi) / 180.0)
operation = operation.shear(shx: 0, shy: v)
}
case "matrix":
if values.count != 6 {
return updatedOperations
}
if let a = values[0].cgFloatValue, let b = values[1].cgFloatValue,
let c = values[2].cgFloatValue, let d = values[3].cgFloatValue,
let tx = values[4].cgFloatValue, let ty = values[5].cgFloatValue {
operation = CGAffineTransform(a: a, b: b, c: c, d: d, tx: tx, ty: ty)
}
default:
break
}

updatedOperations.append(operation)
let rangeToRemove = NSRange(location: 0,
length: matchedAttribute.range.location + matchedAttribute.range.length)
let newAttributeString = (attributes as NSString).replacingCharacters(in: rangeToRemove, with: "")
return parseTransformOperations(newAttributeString, collectedOperations: updatedOperations)
}

static func parseTransformValues(_ values: String, collectedValues: [String] = []) -> [String] {
guard let matcher = SVGParserRegexHelper.getTransformMatcher() else {
return collectedValues
Expand Down
Loading