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
16 changes: 11 additions & 5 deletions CommandLine/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,17 @@ Available keys for --format swift:
Available keys for --format sfsymbol:
--insets alignment of regular variant: top,left,bottom,right | auto
--size size category to generate: small, medium large. (default is small)
--ultralight svg file of ultralight variant
--ultralight-insets alignment of ultralight variant: top,left,bottom,right | auto
--black svg file of black variant
--black-insets alignment of black variant: top,left,bottom,right | auto
--legacy use the original, less precise alignment logic from earlier swiftdraw versions.
--ultralight svg file of ultralight variant
--ultralight-insets alignment of ultralight variant: top,left,bottom,right | auto
--ultralight-stroke-width auto-generate ultralight variant by scaling regular stroke-width: 0.5 | 50%
--black svg file of black variant
--black-insets alignment of black variant: top,left,bottom,right | auto
--black-stroke-width auto-generate black variant by scaling regular stroke-width: 2.0 | 200%
--legacy use the original, less precise alignment logic from earlier swiftdraw versions.

Notes:
An explicit --ultralight or --black file always wins over the matching stroke-width flag.
Stroke-width scaling has no effect on shapes that lack a stroke (a warning is printed).


""")
Expand Down
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,13 @@ Available keys for --format swift:
Available keys for --format sfsymbol:
--insets alignment of regular variant: top,left,bottom,right | auto
--size size category to generate: small, medium large. (default is small)
--ultralight svg file of ultralight variant
--ultralight-insets alignment of ultralight variant: top,left,bottom,right | auto
--black svg file of black variant
--black-insets alignment of black variant: top,left,bottom,right | auto
--legacy use the original, less precise alignment logic from earlier swiftdraw versions.
--ultralight svg file of ultralight variant
--ultralight-insets alignment of ultralight variant: top,left,bottom,right | auto
--ultralight-stroke-width auto-generate ultralight variant by scaling regular stroke-width: 0.5 | 50%
--black svg file of black variant
--black-insets alignment of black variant: top,left,bottom,right | auto
--black-stroke-width auto-generate black variant by scaling regular stroke-width: 2.0 | 200%
--legacy use the original, less precise alignment logic from earlier swiftdraw versions.
```

```bash
Expand Down Expand Up @@ -179,6 +181,21 @@ Alignment: --insets 40,30,40,30

Variants can also be aligned using `--ultralightInsets` and `--blackInsets`.

#### Auto-generated weight variants

When matching ultralight or black SVGs are not available, SwiftDraw can synthesize them from the regular SVG by scaling every `stroke-width` value before strokes are expanded. Both decimal and percent forms are accepted:

```bash
$ swiftdraw key.svg --format sfsymbol \
--ultralight-stroke-width 50% \
--black-stroke-width 2.0
```

Notes:
- An explicit `--ultralight` or `--black` SVG always wins over the matching `--*-stroke-width` flag. A warning is printed if both are supplied.
- The scaler walks element attributes, inline styles, and `<style>` rules. If the regular SVG has no `stroke-width` values to scale, a warning is printed and the regular paths are reused unchanged.
- Only `stroke-width` is scaled today. Future work could add scaling for related attributes (for example, dash arrays).

### Swift Code Generation

Swift source code can also be generated from an SVG using the tool:
Expand Down
4 changes: 3 additions & 1 deletion SwiftDraw/Sources/CommandLine/CommandLine+Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ public extension CommandLine {
insetsUltralight: config.insetsUltralight ?? config.insets,
insetsBlack: config.insetsBlack ?? config.insets,
precision: config.precision ?? 3,
isLegacyInsets: config.isLegacyInsetsEnabled
isLegacyInsets: config.isLegacyInsetsEnabled,
ultralightStrokeScale: config.ultralightStrokeScale,
blackStrokeScale: config.blackStrokeScale
)
let svg = try renderer.render(
regular: config.input,
Expand Down
6 changes: 6 additions & 0 deletions SwiftDraw/Sources/CommandLine/CommandLine.Arguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ extension CommandLine {
case api
case ultralight
case ultralightInsets
case ultralightStrokeWidth
case black
case blackInsets
case blackStrokeWidth
case hideUnsupportedFilters
case legacy

Expand All @@ -65,8 +67,12 @@ extension CommandLine {
switch text {
case "ultralight-insets":
return .ultralightInsets
case "ultralight-stroke-width":
return .ultralightStrokeWidth
case "black-insets":
return .blackInsets
case "black-stroke-width":
return .blackStrokeWidth
case "hide-unsupported-filters":
return .hideUnsupportedFilters
default:
Expand Down
19 changes: 18 additions & 1 deletion SwiftDraw/Sources/CommandLine/CommandLine.Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ extension CommandLine {
public var precision: Int?
public var symbolSize: SFSymbolRenderer.SizeCategory?
public var isLegacyInsetsEnabled: Bool
public var ultralightStrokeScale: SFSymbolRenderer.StrokeWidthScale?
public var blackStrokeScale: SFSymbolRenderer.StrokeWidthScale?
}

public enum Format: String {
Expand Down Expand Up @@ -116,8 +118,10 @@ extension CommandLine {
let api = try parseAPI(from: modifiers[.api])
let ultralight = try parseFileURL(file: modifiers[.ultralight], within: baseDirectory)
let ultralightInsets = try parseInsets(from: modifiers[.ultralightInsets])
let ultralightStroke = try parseStrokeScale(from: modifiers[.ultralightStrokeWidth])
let black = try parseFileURL(file: modifiers[.black], within: baseDirectory)
let blackInsets = try parseInsets(from: modifiers[.blackInsets])
let blackStroke = try parseStrokeScale(from: modifiers[.blackStrokeWidth])
let output = try parseFileURL(file: modifiers[.output], within: baseDirectory)
let symbolSize = try parseSymbolSize(from: modifiers[.size], format: format)

Expand All @@ -138,10 +142,23 @@ extension CommandLine {
options: options,
precision: precision,
symbolSize: symbolSize,
isLegacyInsetsEnabled: modifiers.keys.contains(.legacy)
isLegacyInsetsEnabled: modifiers.keys.contains(.legacy),
ultralightStrokeScale: ultralightStroke,
blackStrokeScale: blackStroke
)
}

static func parseStrokeScale(from value: String??) throws -> SFSymbolRenderer.StrokeWidthScale? {
guard let value = value,
let value = value else {
return nil
}
guard let scale = SFSymbolRenderer.StrokeWidthScale(rawValue: value) else {
throw Error.invalid
}
return scale
}

static func parseFileURL(file: String, within directory: URL) throws -> URL {
guard #available(macOS 10.11, *) else {
throw Error.invalid
Expand Down
129 changes: 129 additions & 0 deletions SwiftDraw/Sources/Renderer/Renderer.SFSymbol+StrokeScale.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// Renderer.SFSymbol+StrokeScale.swift
// SwiftDraw
//
// Created by Simon Whitty on 22/4/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

public extension SFSymbolRenderer {

/// Multiplier applied to every stroke-width in a source SVG when generating
/// an autoscaled weight variant. Accepts decimal (`0.5`) and percent (`50%`) syntax
struct StrokeWidthScale: Equatable, Sendable {
public let multiplier: Double

public init(multiplier: Double) {
self.multiplier = multiplier
}

public init?(rawValue: String) {
let trimmed = rawValue.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasSuffix("%") {
let body = trimmed.dropLast()
guard let value = Double(body), value > 0 else { return nil }
self.multiplier = value / 100.0
} else {
guard let value = Double(trimmed), value > 0 else { return nil }
self.multiplier = value
}
}
}
}

enum StrokeWidthScaler {

/// Walks the SVG DOM and multiplies every stroke-width it finds (element attributes,
/// inline styles, and stylesheet rules) by `scale`. Returns the number of values mutated
@discardableResult
static func scale(_ svg: DOM.SVG, by scale: SFSymbolRenderer.StrokeWidthScale) -> Int {
var count = 0
scaleAttributes(of: svg, by: scale, count: &count)
scaleChildren(of: svg, by: scale, count: &count)
scaleDefs(&svg.defs, by: scale, count: &count)
scaleStyles(&svg.styles, by: scale, count: &count)
return count
}

private static func scaleChildren(of container: any ContainerElement, by scale: SFSymbolRenderer.StrokeWidthScale, count: inout Int) {
for child in container.childElements {
scaleAttributes(of: child, by: scale, count: &count)
if let nested = child as? any ContainerElement {
scaleChildren(of: nested, by: scale, count: &count)
}
}
}

private static func scaleAttributes(of element: DOM.GraphicsElement, by scale: SFSymbolRenderer.StrokeWidthScale, count: inout Int) {
if let value = element.attributes.strokeWidth {
element.attributes.strokeWidth = multiply(value, by: scale)
count += 1
}
if let value = element.style.strokeWidth {
element.style.strokeWidth = multiply(value, by: scale)
count += 1
}
}

private static func scaleDefs(_ defs: inout DOM.SVG.Defs, by scale: SFSymbolRenderer.StrokeWidthScale, count: inout Int) {
for clipPath in defs.clipPaths {
scaleChildren(of: clipPath, by: scale, count: &count)
}
for mask in defs.masks {
scaleAttributes(of: mask, by: scale, count: &count)
scaleChildren(of: mask, by: scale, count: &count)
}
for pattern in defs.patterns {
scaleChildren(of: pattern, by: scale, count: &count)
}
for element in defs.elements.values {
scaleAttributes(of: element, by: scale, count: &count)
if let container = element as? any ContainerElement {
scaleChildren(of: container, by: scale, count: &count)
}
}
}

private static func scaleStyles(_ styles: inout [DOM.StyleSheet], by scale: SFSymbolRenderer.StrokeWidthScale, count: inout Int) {
for sheetIndex in styles.indices {
for selector in styles[sheetIndex].attributes.keys {
if let value = styles[sheetIndex].attributes[selector]?.strokeWidth {
styles[sheetIndex].attributes[selector]?.strokeWidth = multiply(value, by: scale)
count += 1
}
}
}
}

private static func multiply(_ value: DOM.Float, by scale: SFSymbolRenderer.StrokeWidthScale) -> DOM.Float {
DOM.Float(Double(value) * scale.multiplier)
}
}

42 changes: 37 additions & 5 deletions SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public struct SFSymbolRenderer {
private let insetsBlack: CommandLine.Insets
private let formatter: CoordinateFormatter
private let isLegacyInsets: Bool
private let ultralightStrokeScale: StrokeWidthScale?
private let blackStrokeScale: StrokeWidthScale?

public enum SizeCategory {
case small
Expand All @@ -55,7 +57,9 @@ public struct SFSymbolRenderer {
insetsUltralight: CommandLine.Insets,
insetsBlack: CommandLine.Insets,
precision: Int,
isLegacyInsets: Bool
isLegacyInsets: Bool,
ultralightStrokeScale: StrokeWidthScale? = nil,
blackStrokeScale: StrokeWidthScale? = nil
) {
self.size = size
self.options = options
Expand All @@ -67,13 +71,41 @@ public struct SFSymbolRenderer {
precision: .capped(max: precision)
)
self.isLegacyInsets = isLegacyInsets
self.ultralightStrokeScale = ultralightStrokeScale
self.blackStrokeScale = blackStrokeScale
}

public func render(regular: URL, ultralight: URL?, black: URL?) throws -> String {
let regular = try DOM.SVG.parse(fileURL: regular)
let ultralight = try ultralight.map { try DOM.SVG.parse(fileURL: $0) }
let black = try black.map { try DOM.SVG.parse(fileURL: $0) }
return try render(default: regular, ultralight: ultralight, black: black)
let regularDOM = try DOM.SVG.parse(fileURL: regular)
let ultralightDOM = try makeVariant(
regular: regular,
explicit: ultralight,
scale: ultralightStrokeScale,
variant: .ultralight
)
let blackDOM = try makeVariant(
regular: regular,
explicit: black,
scale: blackStrokeScale,
variant: .black
)
return try render(default: regularDOM, ultralight: ultralightDOM, black: blackDOM)
}

private func makeVariant(regular: URL, explicit: URL?, scale: StrokeWidthScale?, variant: Variant) throws -> DOM.SVG? {
if let explicit {
if scale != nil {
print("Warning:", "explicit --\(variant.rawValue) overrides --\(variant.rawValue)-stroke-width.", to: &.standardError)
}
return try DOM.SVG.parse(fileURL: explicit)
}
guard let scale else { return nil }
let dom = try DOM.SVG.parse(fileURL: regular)
let count = StrokeWidthScaler.scale(dom, by: scale)
if count == 0 {
print("Warning:", "--\(variant.rawValue)-stroke-width has no effect: source SVG has no stroke-width values.", to: &.standardError)
}
return dom
}

func render(default image: DOM.SVG, ultralight: DOM.SVG?, black: DOM.SVG?) throws -> String {
Expand Down
33 changes: 33 additions & 0 deletions SwiftDraw/Tests/CommandLine/CommandLine.ConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,39 @@ final class CommandLineConfigurationTests: XCTestCase {
XCTAssertFalse(CommandLine.Insets(bottom: 1).isEmpty)
XCTAssertFalse(CommandLine.Insets(right: 1).isEmpty)
}

func testParseConfigurationUltralightStrokeWidth() throws {
let config = try parseConfiguration(
"swiftdraw", "file.svg", "--format", "sfsymbol",
"--ultralight-stroke-width", "50%"
)
XCTAssertEqual(config.ultralightStrokeScale?.multiplier, 0.5)
XCTAssertNil(config.blackStrokeScale)
}

func testParseConfigurationBlackStrokeWidth() throws {
let config = try parseConfiguration(
"swiftdraw", "file.svg", "--format", "sfsymbol",
"--black-stroke-width", "2"
)
XCTAssertEqual(config.blackStrokeScale?.multiplier, 2.0)
XCTAssertNil(config.ultralightStrokeScale)
}

func testParseConfigurationStrokeWidthInvalid() {
XCTAssertThrowsError(
try parseConfiguration(
"swiftdraw", "file.svg", "--format", "sfsymbol",
"--ultralight-stroke-width", "abc"
)
)
XCTAssertThrowsError(
try parseConfiguration(
"swiftdraw", "file.svg", "--format", "sfsymbol",
"--black-stroke-width", "0"
)
)
}
}

private func parseConfiguration(_ args: String...) throws -> CommandLine.Configuration {
Expand Down
Loading
Loading