Skip to content

Commit b6cc596

Browse files
committed
Add variable font support for text rendering
Introduces FontVariation type and modifiers for controlling variable font axes like weight, width, slant, italic, and optical size. Integrates with Apus 0.1.1's variable font API.
1 parent febb836 commit b6cc596

File tree

6 files changed

+210
-7
lines changed

6 files changed

+210
-7
lines changed

Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ let package = Package(
1010
dependencies: [
1111
.package(url: "https://github.com/tomasf/manifold-swift.git", .upToNextMinor(from: "0.2.3")),
1212
.package(url: "https://github.com/tomasf/ThreeMF.git", .upToNextMinor(from: "0.2.0")),
13-
.package(url: "https://github.com/tomasf/Apus.git", .upToNextMinor(from: "0.1.0")),
13+
.package(url: "https://github.com/tomasf/Apus.git", .upToNextMinor(from: "0.1.1")),
1414
],
1515
targets: [
1616
.target(
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import Foundation
2+
internal import Apus
3+
4+
/// A variation axis value to apply to a variable font.
5+
///
6+
/// Variable fonts allow continuous variation along design axes such as weight,
7+
/// width, and slant. Use `FontVariation` to specify custom axis values when
8+
/// rendering text.
9+
///
10+
/// ```swift
11+
/// Text("Semibold Condensed")
12+
/// .withFontVariations([.weight(600), .width(85)])
13+
/// ```
14+
///
15+
/// Common axes have convenience factory methods:
16+
/// - ``weight(_:)`` - Font weight (typically 100-900)
17+
/// - ``width(_:)`` - Font width as percentage (typically 50-200)
18+
/// - ``slant(_:)`` - Slant angle in degrees
19+
/// - ``italic(_:)`` - Italic axis (0 = roman, 1 = italic)
20+
/// - ``opticalSize(_:)`` - Optical size in points
21+
///
22+
/// For custom axes, use ``init(tag:value:)``:
23+
/// ```swift
24+
/// FontVariation(tag: "GRAD", value: 50) // Grade axis
25+
/// ```
26+
///
27+
public struct FontVariation: Sendable, Hashable, Codable {
28+
/// The 4-character OpenType axis tag (e.g., "wght", "wdth").
29+
public let tag: String
30+
31+
/// The axis value in design space coordinates.
32+
public let value: Double
33+
34+
/// Creates a variation with a custom axis tag and value.
35+
///
36+
/// - Parameters:
37+
/// - tag: A 4-character OpenType axis tag.
38+
/// - value: The axis value in design space coordinates.
39+
public init(tag: String, value: Double) {
40+
precondition(tag.count == 4, "Axis tag must be exactly 4 characters")
41+
self.tag = tag
42+
self.value = value
43+
}
44+
45+
// MARK: - Common Axes
46+
47+
/// Weight axis (wght). Common range: 100-900.
48+
///
49+
/// Standard weight values:
50+
/// - 100: Thin
51+
/// - 200: Extra Light
52+
/// - 300: Light
53+
/// - 400: Regular
54+
/// - 500: Medium
55+
/// - 600: Semibold
56+
/// - 700: Bold
57+
/// - 800: Extra Bold
58+
/// - 900: Black
59+
///
60+
public static func weight(_ value: Double) -> FontVariation {
61+
FontVariation(tag: Apus.FontVariation.weightTag, value: value)
62+
}
63+
64+
/// Width axis (wdth). Common range: 50-200, where 100 is normal.
65+
///
66+
/// Values below 100 produce condensed text, values above produce expanded text.
67+
///
68+
public static func width(_ value: Double) -> FontVariation {
69+
FontVariation(tag: Apus.FontVariation.widthTag, value: value)
70+
}
71+
72+
/// Slant axis (slnt). Typically in degrees, negative for rightward slant.
73+
///
74+
/// Common range: -12 to 0, where 0 is upright.
75+
///
76+
public static func slant(_ value: Double) -> FontVariation {
77+
FontVariation(tag: Apus.FontVariation.slantTag, value: value)
78+
}
79+
80+
/// Italic axis (ital). Typically 0 (roman) or 1 (italic).
81+
///
82+
public static func italic(_ value: Double) -> FontVariation {
83+
FontVariation(tag: Apus.FontVariation.italicTag, value: value)
84+
}
85+
86+
/// Optical size axis (opsz). Typically matches the point size.
87+
///
88+
/// Fonts with optical size axes adjust their design based on the
89+
/// intended display size.
90+
///
91+
public static func opticalSize(_ value: Double) -> FontVariation {
92+
FontVariation(tag: Apus.FontVariation.opticalSizeTag, value: value)
93+
}
94+
}
95+
96+
internal extension Array where Element == FontVariation {
97+
/// Returns a new array with the given variation replacing any existing variation for the same axis.
98+
func replacingVariation(_ variation: FontVariation) -> [FontVariation] {
99+
var result = self.filter { $0.tag != variation.tag }
100+
result.append(variation)
101+
return result
102+
}
103+
}

Sources/Cadova/Abstract Layer/2D/Text/TextAttributes.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@ internal struct TextAttributes: Sendable, Hashable, Codable {
1313
var fontFace: FontFace?
1414
var fontSize: Double?
1515
var fontFile: URL?
16+
var fontVariations: [FontVariation]?
1617
var horizontalAlignment: HorizontalTextAlignment?
1718
var verticalAlignment: VerticalTextAlignment?
1819
var lineSpacingAdjustment: Double?
1920
var tracking: Double?
2021

21-
init(fontFace: FontFace? = nil, fontSize: Double? = nil, fontFile: URL? = nil, horizontalAlignment: HorizontalTextAlignment? = nil, verticalAlignment: VerticalTextAlignment? = nil, lineSpacingAdjustment: Double? = nil, tracking: Double? = nil) {
22+
init(fontFace: FontFace? = nil, fontSize: Double? = nil, fontFile: URL? = nil, fontVariations: [FontVariation]? = nil, horizontalAlignment: HorizontalTextAlignment? = nil, verticalAlignment: VerticalTextAlignment? = nil, lineSpacingAdjustment: Double? = nil, tracking: Double? = nil) {
2223
self.fontFace = fontFace
2324
self.fontSize = fontSize
2425
self.fontFile = fontFile
26+
self.fontVariations = fontVariations
2527
self.horizontalAlignment = horizontalAlignment
2628
self.verticalAlignment = verticalAlignment
2729
self.lineSpacingAdjustment = lineSpacingAdjustment
@@ -33,6 +35,7 @@ internal struct TextAttributes: Sendable, Hashable, Codable {
3335
fontFace: fontFace ?? .default,
3436
fontSize: fontSize ?? 12,
3537
fontFile: fontFile,
38+
fontVariations: fontVariations ?? [],
3639
horizontalAlignment: horizontalAlignment ?? .left,
3740
verticalAlignment: verticalAlignment ?? .lastBaseline,
3841
lineSpacingAdjustment: lineSpacingAdjustment ?? 0,

Sources/Cadova/Abstract Layer/2D/Text/TextRendering.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@ extension TextAttributes {
1212
preconditionFailure("render(text:) called on unresolved TextAttributes")
1313
}
1414

15+
// Convert Cadova FontVariation to Apus FontVariation
16+
let apusVariations = (fontVariations ?? []).map { variation in
17+
Apus.FontVariation(tag: variation.tag, value: variation.value)
18+
}
19+
1520
// Load font using Apus
1621
let font: Font
1722
if let fontFile {
1823
let fontData = try Data(contentsOf: fontFile)
19-
font = try Font(data: fontData, family: family, style: fontFace?.style)
24+
font = try Font(data: fontData, family: family, style: fontFace?.style, variations: apusVariations)
2025
} else {
2126
do {
22-
font = try Font(family: family, style: fontFace?.style)
27+
font = try Font(family: family, style: fontFace?.style, variations: apusVariations)
2328
} catch Font.FontError.fontNotFound {
2429
throw TextError.fontNotFound(family: family, style: fontFace?.style)
2530
}

Sources/Cadova/Abstract Layer/Environment/Values/Environment+Text.swift

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,28 @@ public extension EnvironmentValues {
145145
get { textAttributes.tracking ?? 0 }
146146
set { textAttributes.tracking = newValue }
147147
}
148+
149+
/// The font variations to apply to variable fonts.
150+
///
151+
/// Font variations control axes like weight, width, and slant for variable fonts.
152+
/// If the font is not a variable font, variations are ignored.
153+
///
154+
/// Use `.withFontVariations([...])` or specific modifiers like `.withFontWeight(_:)`
155+
/// to set variations on geometry.
156+
///
157+
/// ```swift
158+
/// Text("Semibold")
159+
/// .withFontWeight(600)
160+
///
161+
/// Text("Condensed Bold")
162+
/// .withFontVariations([.weight(700), .width(75)])
163+
/// ```
164+
///
165+
/// - SeeAlso: `fontFamily`
166+
var fontVariations: [FontVariation] {
167+
get { textAttributes.fontVariations ?? [] }
168+
set { textAttributes.fontVariations = newValue }
169+
}
148170
}
149171

150172
public extension Geometry {
@@ -235,6 +257,76 @@ public extension Geometry {
235257
$0.tracking = adjustment
236258
}
237259
}
260+
261+
/// Applies font variations to variable fonts for text rendering.
262+
///
263+
/// This replaces any existing font variations. For variable fonts, these
264+
/// variations control design axes like weight, width, slant, etc.
265+
///
266+
/// ```swift
267+
/// Text("Custom Style")
268+
/// .withFontVariations([.weight(600), .width(85), .slant(-6)])
269+
/// ```
270+
///
271+
/// - Parameter variations: The variations to apply.
272+
/// - Returns: A new geometry with the specified font variations.
273+
func withFontVariations(_ variations: [FontVariation]) -> D.Geometry {
274+
withEnvironment {
275+
$0.fontVariations = variations
276+
}
277+
}
278+
279+
/// Sets common font variation axes for variable fonts.
280+
///
281+
/// This modifier updates the specified axes while preserving other existing variations.
282+
/// Only non-nil parameters are applied.
283+
///
284+
/// ```swift
285+
/// Text("Bold Condensed")
286+
/// .withFontVariations(weight: 700, width: 75)
287+
///
288+
/// Text("Oblique")
289+
/// .withFontVariations(slant: -12)
290+
/// ```
291+
///
292+
/// - Parameters:
293+
/// - weight: The weight value (typically 100-900). Common values: 100 Thin, 300 Light,
294+
/// 400 Regular, 500 Medium, 600 Semibold, 700 Bold, 900 Black.
295+
/// - width: The width as a percentage (typically 50-200). 100 is normal,
296+
/// below 100 is condensed, above 100 is expanded.
297+
/// - slant: The slant angle in degrees (typically -12 to 0). Negative values
298+
/// produce a rightward slant.
299+
/// - italic: The italic axis value (typically 0 for roman, 1 for italic).
300+
/// - opticalSize: The optical size in points. Fonts with this axis adjust
301+
/// their design based on the intended display size.
302+
/// - Returns: A new geometry with the specified font variations.
303+
func withFontVariations(
304+
weight: Double? = nil,
305+
width: Double? = nil,
306+
slant: Double? = nil,
307+
italic: Double? = nil,
308+
opticalSize: Double? = nil
309+
) -> D.Geometry {
310+
withEnvironment {
311+
var variations = $0.fontVariations
312+
if let weight {
313+
variations = variations.replacingVariation(.weight(weight))
314+
}
315+
if let width {
316+
variations = variations.replacingVariation(.width(width))
317+
}
318+
if let slant {
319+
variations = variations.replacingVariation(.slant(slant))
320+
}
321+
if let italic {
322+
variations = variations.replacingVariation(.italic(italic))
323+
}
324+
if let opticalSize {
325+
variations = variations.replacingVariation(.opticalSize(opticalSize))
326+
}
327+
$0.fontVariations = variations
328+
}
329+
}
238330
}
239331

240332
/// Horizontal alignment options for text relative to the origin.

0 commit comments

Comments
 (0)