diff --git a/DOM/Sources/DOM.FontFace.swift b/DOM/Sources/DOM.FontFace.swift new file mode 100644 index 0000000..b8339ea --- /dev/null +++ b/DOM/Sources/DOM.FontFace.swift @@ -0,0 +1,75 @@ +// +// DOM.FontFace.swift +// SwiftDraw +// +// Created by Simon Whitty on 14/2/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 Foundation + +package extension DOM { + + enum FontFamily: Hashable { + case keyword(Keyword) + case name(String) + + package enum Keyword: String { + case serif + case sansSerif = "sans-serif" + case monospace + case cursive + case fantasy + } + } + + struct FontFace: Hashable { + package var family: String + package var src: Source + + package enum Source: Hashable { + case url(url: DOM.URL, format: String?) + case local(String) + } + + package init(family: String, src: Source) { + self.family = family + self.src = src + } + } +} + +package extension DOM.FontFamily { + + var name: String { + switch self { + case .name(let s): + return s + case .keyword(let k): + return k.rawValue + } + } +} diff --git a/DOM/Sources/DOM.PresentationAttributes.swift b/DOM/Sources/DOM.PresentationAttributes.swift index 16a08b3..f6d4b36 100644 --- a/DOM/Sources/DOM.PresentationAttributes.swift +++ b/DOM/Sources/DOM.PresentationAttributes.swift @@ -52,7 +52,7 @@ package extension DOM { package var fillOpacity: DOM.Float? package var fillRule: DOM.FillRule? - package var fontFamily: String? + package var fontFamily: [DOM.FontFamily]? package var fontSize: Float? package var textAnchor: TextAnchor? diff --git a/DOM/Sources/DOM.SVG.swift b/DOM/Sources/DOM.SVG.swift index 9fd3877..917ea2f 100644 --- a/DOM/Sources/DOM.SVG.swift +++ b/DOM/Sources/DOM.SVG.swift @@ -99,5 +99,6 @@ package extension DOM { } package var attributes: [Selector: PresentationAttributes] = [:] + package var fonts: [DOM.FontFace] = [] } } diff --git a/DOM/Sources/Parser.XML.Attributes.swift b/DOM/Sources/Parser.XML.Attributes.swift index f50f968..6834d63 100644 --- a/DOM/Sources/Parser.XML.Attributes.swift +++ b/DOM/Sources/Parser.XML.Attributes.swift @@ -130,24 +130,18 @@ extension XMLParser { func parseUrl(_ value: String) throws -> DOM.URL { guard let url = URL(maybeData: value) else { throw XMLParser.Error.invalid } return url - } + func parseUrlSelector(_ value: String) throws -> DOM.URL { var scanner = XMLParser.Scanner(text: value) - - try scanner.scanString("url(") - let urlText = try scanner.scanString(upTo: ")") - _ = try? scanner.scanString(")") - - let url = urlText.trimmingCharacters(in: .whitespaces) - + let url = try scanner.scanStringFunction("url") guard !url.isEmpty, scanner.isEOF else { throw XMLParser.Error.invalid } return try parseUrl(url) } - + func parsePoints(_ value: String) throws -> [DOM.Point] { var points = [DOM.Point]() var scanner = XMLParser.Scanner(text: value) @@ -167,7 +161,21 @@ extension XMLParser { return points } - + + func parseFontFamily(_ key: String) throws -> [DOM.FontFamily] { + var scanner = XMLParser.Scanner(text: key) + return try scanner + .scanStrings(delimitedBy: ",") + .map { + let value = $0.unquoted + if let keyword = DOM.FontFamily.Keyword(rawValue: value) { + return .keyword(keyword) + } else { + return .name(value) + } + } + } + func parseRaw(_ value: String) throws -> T where T.RawValue == String { guard let obj = T(rawValue: value.trimmingCharacters(in: .whitespaces)) else { throw XMLParser.Error.invalid diff --git a/DOM/Sources/Parser.XML.Element.swift b/DOM/Sources/Parser.XML.Element.swift index 8cdc40e..e8dce81 100644 --- a/DOM/Sources/Parser.XML.Element.swift +++ b/DOM/Sources/Parser.XML.Element.swift @@ -250,7 +250,8 @@ extension XMLParser { el.fillOpacity = try att.parsePercentage("fill-opacity") el.fillRule = try att.parseRaw("fill-rule") - el.fontFamily = (try att.parseString("font-family"))?.trimmingCharacters(in: .whitespacesAndNewlines) + + el.fontFamily = try att.parseFontFamily("font-family") el.fontSize = try att.parseFloat("font-size") el.textAnchor = try att.parseRaw("text-anchor") @@ -278,6 +279,13 @@ extension XMLParser { var `class`: String? } + func parseFontFace(_ att: any AttributeParser) throws -> DOM.FontFace { + try DOM.FontFace( + family: att.parseString("font-family").unquoted, + src: att.parseFontSource("src") + ) + } + package static func logParsingError(for error: any Swift.Error, filename: String?, parsing element: XML.Element? = nil) { let elementName = element.map { "<\($0.name)>" } ?? "" let filename = filename ?? "" diff --git a/DOM/Sources/Parser.XML.Scanner.swift b/DOM/Sources/Parser.XML.Scanner.swift index 0bebd02..f27eb4d 100644 --- a/DOM/Sources/Parser.XML.Scanner.swift +++ b/DOM/Sources/Parser.XML.Scanner.swift @@ -112,17 +112,117 @@ package extension XMLParser { currentIndex = scanner.currentIndex return match } - - package mutating func scanString(upTo characters: Foundation.CharacterSet) throws -> String { + + package mutating func scanString(upTo characters: Foundation.CharacterSet, preservingStrings: Bool = false) throws -> String { let location = currentIndex - guard let match = scanner.scanUpToCharacters(from: characters) else { + + guard preservingStrings else { + guard let match = scanner.scanUpToCharacters(from: characters) else { + scanner.currentIndex = location + throw Error.invalid + } + currentIndex = scanner.currentIndex + return match + } + + var result = "" + let terminatorsAndQuotes = characters.union(CharacterSet(charactersIn: "\"'(")) + let savedSkip = scanner.charactersToBeSkipped + scanner.charactersToBeSkipped = nil + defer { scanner.charactersToBeSkipped = savedSkip } + + // skip leading whitespace once before scanning + _ = scanner.scanCharacters(from: .whitespacesAndNewlines) + + while !scanner.isAtEnd { + if let text = scanner.scanUpToCharacters(from: terminatorsAndQuotes) { + result += text + } + + guard !scanner.isAtEnd else { break } + + let ch = scanner.string[scanner.currentIndex] + + if ch == "\"" || ch == "'" { + let quote = String(ch) + scanner.currentIndex = scanner.string.index(after: scanner.currentIndex) + let body = scanner.scanUpToString(quote) ?? "" + result += quote + body + if !scanner.isAtEnd { + result += quote + scanner.currentIndex = scanner.string.index(after: scanner.currentIndex) + } + } else if ch == "(" { + scanner.currentIndex = scanner.string.index(after: scanner.currentIndex) + let body = scanner.scanUpToString(")") ?? "" + result += "(" + body + if !scanner.isAtEnd { + result += ")" + scanner.currentIndex = scanner.string.index(after: scanner.currentIndex) + } + } else { + // Hit an actual terminator + break + } + } + + guard !result.isEmpty else { scanner.currentIndex = location throw Error.invalid } + currentIndex = scanner.currentIndex - return match + return result } - + + package mutating func scanFunction(_ name: String, preservingStrings: Bool = false) throws -> [String] { + scanner.currentIndex = currentIndex + try scanString(name) + try scanString("(") + var args = [String]() + let delimiters = CharacterSet(charactersIn: ",)") + while !doScanString(")") { + let arg = try scanString(upTo: delimiters, preservingStrings: preservingStrings) + .trimmingCharacters(in: .whitespaces) + args.append(arg) + _ = doScanString(",") + } + currentIndex = scanner.currentIndex + return args + } + + package mutating func scanStrings(delimitedBy delimiter: String = ",") throws -> [String] { + scanner.currentIndex = currentIndex + let delimiters = CharacterSet(charactersIn: delimiter) + var strings = [String]() + while !isEOF { + let value = try scanString(upTo: delimiters, preservingStrings: true) + .trimmingCharacters(in: .whitespaces) + strings.append(value) + _ = doScanString(delimiter) + } + currentIndex = scanner.currentIndex + return strings + } + + package mutating func scanStringFunction(_ name: String) throws -> String { + scanner.currentIndex = currentIndex + try scanString(name) + try scanString("(") + let arg = try scanString(upTo: ")") + _ = try scanString(")") + currentIndex = scanner.currentIndex + return arg.unquoted + } + + package mutating func scanURLFunction(_ name: String) throws -> DOM.URL { + let body = try scanStringFunction(name) + guard let url = DOM.URL(string: body) else { + throw Error.invalid + } + return url + } + package mutating func scanCharacter(matchingAny characters: Foundation.CharacterSet) throws -> Character { let location = currentIndex guard let scalar = scanner.scan(first: characters) else { diff --git a/DOM/Sources/Parser.XML.StyleSheet.swift b/DOM/Sources/Parser.XML.StyleSheet.swift index 1a2b58e..91d7fa5 100644 --- a/DOM/Sources/Parser.XML.StyleSheet.swift +++ b/DOM/Sources/Parser.XML.StyleSheet.swift @@ -59,25 +59,49 @@ extension XMLParser { } func parseStyleSheetElement(_ text: String?) throws -> DOM.StyleSheet { - let entries = try Self.parseEntries(text) + let selectorEntries = try Self.parseSelectorEntries(text) + let fontEntries = try Self.parseFontFaceEntries(text) var sheet = DOM.StyleSheet() - sheet.attributes = try entries.mapValues(parsePresentationAttributes) + sheet.attributes = try selectorEntries.mapValues(parsePresentationAttributes) + sheet.fonts = try fontEntries.map(parseFontFace) return sheet } - static func parseEntries(_ text: String?) throws -> [DOM.StyleSheet.Selector: [String: String]] { + static func parseSelectorEntries(_ text: String?) throws -> [DOM.StyleSheet.Selector: [String: String]] { guard let text = text else { return [:] } var scanner = XMLParser.Scanner(text: removeCSSComments(from: text)) var entries = [DOM.StyleSheet.Selector: [String: String]]() - while let (selectors, attributes) = try scanner.scanNextSelectorDecl() { - for selector in selectors { - var copy = entries[selector] ?? [:] - for (key, value) in attributes { - copy[key] = value + while let (decl, attributes) = try scanner.scanNextBlockDecl() { + switch decl { + case .selector(let selectors): + for selector in selectors { + var copy = entries[selector] ?? [:] + for (key, value) in attributes { + copy[key] = value + } + entries[selector] = copy } - entries[selector] = copy + case .atRule: + () + } + } + + return entries + } + + static func parseFontFaceEntries(_ text: String?) throws -> [[String: String]] { + guard let text = text else { return [] } + var scanner = XMLParser.Scanner(text: removeCSSComments(from: text)) + var entries = [[String: String]]() + + while let (decl, attributes) = try scanner.scanNextBlockDecl() { + switch decl { + case .atRule("font-face"): + entries.append(attributes) + default: + () } } @@ -93,10 +117,18 @@ extension XMLParser { extension XMLParser.Scanner { - mutating func scanNextSelectorDecl() throws -> ([DOM.StyleSheet.Selector], [String: String])? { + enum BlockDeclaration { + case selector([DOM.StyleSheet.Selector]) + case atRule(String) + } + + mutating func scanNextBlockDecl() throws -> (BlockDeclaration, [String: String])? { + if let attributes = try scanNextFontFace() { + return (.atRule("font-face"), attributes) + } let selectorTypes = try scanSelectorTypes() guard !selectorTypes.isEmpty else { return nil } - return (selectorTypes, try scanAtttributes()) + return (.selector(selectorTypes), try scanAtttributes()) } private mutating func scanNextClass() throws -> String? { @@ -109,6 +141,13 @@ extension XMLParser.Scanner { return try scanSelectorName() } + mutating func scanNextFontFace() throws -> [String: String]? { + guard doScanString("@font-face") else { + return nil + } + return try scanAtttributes() + } + private mutating func scanNextElement() throws -> String? { do { return try scanSelectorName() @@ -147,14 +186,15 @@ extension XMLParser.Scanner { } } - private mutating func scanAtttributes() throws -> [String: String] { + mutating func scanAtttributes() throws -> [String: String] { _ = doScanString("{") var attributes = [String: String]() var last: String? repeat { last = try scanNextAttributeKey() if let last = last { - attributes[last] = try scanNextAttributeValue() + let val = try scanNextAttributeValue() + attributes[last] = val } } while last != nil return attributes @@ -175,12 +215,21 @@ extension XMLParser.Scanner { } mutating func scanNextAttributeValue() throws -> String { - let value = try scanString(upTo: .init(charactersIn: ";\n}")) + let value = try scanString(upTo: .init(charactersIn: ";\n}"), preservingStrings: true) _ = doScanString(";") return value.trimmingCharacters(in: .whitespacesAndNewlines) } } +extension String { + var unquoted: String { + if (hasPrefix("'") && hasSuffix("'")) || + (hasPrefix("\"") && hasSuffix("\"")) { + return String(dropFirst().dropLast()) + } + return self + } +} //Allow Dictionary to become an attribute parser extension Dictionary: AttributeParser where Key == String, Value == String { package var parser: any AttributeValueParser { return XMLParser.ValueParser() } diff --git a/DOM/Sources/Parser.XML.Text.swift b/DOM/Sources/Parser.XML.Text.swift index 87e7c6c..66b65d6 100644 --- a/DOM/Sources/Parser.XML.Text.swift +++ b/DOM/Sources/Parser.XML.Text.swift @@ -37,6 +37,11 @@ extension XMLParser { guard let text = element.innerText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + if let tspan = element.children.first(where: { $0.name == "tspan" }), + let text = tspan.innerText?.trimmingCharacters(in: .whitespacesAndNewlines) { + let tspanAtt = try parseAttributes(tspan) + return try parseText(tspanAtt, value: text) + } return nil } diff --git a/DOM/Sources/Parser.XML.swift b/DOM/Sources/Parser.XML.swift index 220e0cd..029271c 100644 --- a/DOM/Sources/Parser.XML.swift +++ b/DOM/Sources/Parser.XML.swift @@ -30,6 +30,7 @@ // package typealias DOMXMLParser = XMLParser +import struct Foundation.URL package struct XMLParser { package enum Error: Swift.Error { @@ -70,6 +71,7 @@ package protocol AttributeValueParser { func parseUrl(_ value: String) throws -> DOM.URL func parseUrlSelector(_ value: String) throws -> DOM.URL func parsePoints(_ value: String) throws -> [DOM.Point] + func parseFontFamily(_ value: String) throws -> [DOM.FontFamily] func parseRaw(_ value: String) throws -> T where T.RawValue == String } @@ -134,6 +136,17 @@ package extension AttributeParser { return try parse(key) { return try parser.parsePoints($0) } } + func parseFontSource(_ key: String) throws -> DOM.FontFace.Source { + return try parse(key) { value in + var scanner = XMLParser.Scanner(text: value) + let urlText = try scanner.scanStringFunction("url") + let url = try parser.parseUrl(urlText) + _ = try? scanner.scanString(";") + let format = try? scanner.scanStringFunction("format") + return .url(url: url, format: format) + } + } + func parseRaw(_ key: String) throws -> T where T.RawValue == String { return try parse(key) { return try parser.parseRaw($0) } } @@ -202,6 +215,10 @@ package extension AttributeParser { return try parse(key) { return try parser.parsePoints($0) } } + func parseFontFamily(_ key: String) throws -> [DOM.FontFamily]? { + return try parse(key) { return try parser.parseFontFamily($0) } + } + func parseRaw(_ key: String) throws -> T? where T.RawValue == String { return try parse(key) { return try parser.parseRaw($0) } } diff --git a/DOM/Tests/Parser.AttributesTests.swift b/DOM/Tests/Parser.AttributesTests.swift index 65c73a2..1593918 100644 --- a/DOM/Tests/Parser.AttributesTests.swift +++ b/DOM/Tests/Parser.AttributesTests.swift @@ -179,9 +179,10 @@ struct AttributeParserTests { @Test func parseURLSelector() throws { - let att = ["clip": "url(#shape)", "mask": "aa"] + let att = ["clip": "url('#shape')", "mask": "aa", "font": "url(data:;base64,f00d)"] #expect(try att.parseUrlSelector("clip") == URL(string: "#shape")) #expect(try att.parseUrlSelector("missing") == nil) + #expect(try att.parseUrlSelector("font") == URL(string: "data:;base64,f00d")) #expect(throws: (any Error).self) { try att.parseUrlSelector("mask") } diff --git a/DOM/Tests/Parser.XML.StyleSheetTests.swift b/DOM/Tests/Parser.XML.StyleSheetTests.swift index 7c78189..9116669 100644 --- a/DOM/Tests/Parser.XML.StyleSheetTests.swift +++ b/DOM/Tests/Parser.XML.StyleSheetTests.swift @@ -56,7 +56,7 @@ struct ParserXMLStyleSheetTests { @Test func parsesSelectors() throws { - let entries = try XMLParser.parseEntries( + let entries = try XMLParser.parseSelectorEntries( """ .s { stroke: darkgray; @@ -104,23 +104,59 @@ struct ParserXMLStyleSheetTests { fill: blue; } + @font-face { + font-family: 'Silkscreen'; + src: url('data:font/truetype;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA=='); + } + rect { fill: pink; } + + @font-face { + font-family: Claudette; + src: url('data:font/woff2;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==') format('woff2'); + } /* comment */ """ - ).attributes + ) + + let styles = sheet.attributes + #expect(styles[.class("s")]?.stroke == .color(.keyword(.darkgray))) + #expect(styles[.class("s")]?.strokeWidth == 5) + #expect(styles[.class("s")]?.fillOpacity == 0.3) + #expect(styles[.class("b")]?.fill == .color(.keyword(.blue))) + #expect(styles[.element("rect")]?.fill == .color(.keyword(.pink))) + + let fonts = sheet.fonts + + #expect( + fonts == [ + DOM.FontFace( + family: "Silkscreen", + src: .url(url: DOM.URL(maybeData: "data:font/truetype;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==")!, format: nil) + ), + DOM.FontFace( + family: "Claudette", + src: .url(url: DOM.URL(maybeData: "data:font/woff2;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==")!, format: "woff2") + ) + ] + ) + } + + @Test + func parsesFontFaceFile() throws { + let dom = try DOM.SVG.parse(fileNamed: "fontface-ttf.svg", in: .test) - #expect(sheet[.class("s")]?.stroke == .color(.keyword(.darkgray))) - #expect(sheet[.class("s")]?.strokeWidth == 5) - #expect(sheet[.class("s")]?.fillOpacity == 0.3) - #expect(sheet[.class("b")]?.fill == .color(.keyword(.blue))) - #expect(sheet[.element("rect")]?.fill == .color(.keyword(.pink))) + let fonts = Set(dom.styles.flatMap { $0.fonts.map(\.family) }) + #expect( + fonts == ["Silkscreen"] + ) } @Test func mergesSelectors() throws { - let entries = try XMLParser.parseEntries( + let entries = try XMLParser.parseSelectorEntries( """ .a { fill: red; @@ -139,7 +175,7 @@ struct ParserXMLStyleSheetTests { @Test func mutlipleSelectors() throws { - let entries = try XMLParser.parseEntries( + let entries = try XMLParser.parseSelectorEntries( """ .a, .b { fill: red; @@ -154,4 +190,32 @@ struct ParserXMLStyleSheetTests { ] ) } + + @Test + func parsesFontFaceEntries() throws { + let entries = try XMLParser.parseFontFaceEntries( + """ + @font-face { + font-family: 'Silkscreen'; + src: url('data:font/truetype;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==') format('truetype'); + } + @font-face { + font-family: 'Claudette'; + src: url('data:font/woff2;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==') format('woff2'); + } + """ + ) + #expect( + entries == [ + [ + "font-family": "'Silkscreen'", + "src": "url('data:font/truetype;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==') format('truetype')" + ], + [ + "font-family": "'Claudette'", + "src": "url('data:font/woff2;base64,ZXZlcnkgZ3JhaW4gb2Ygc2FuZA==') format('woff2')" + ] + ] + ) + } } diff --git a/DOM/Tests/Parser.XML.TextTests.swift b/DOM/Tests/Parser.XML.TextTests.swift index 2dc2aa2..93c9731 100644 --- a/DOM/Tests/Parser.XML.TextTests.swift +++ b/DOM/Tests/Parser.XML.TextTests.swift @@ -42,7 +42,7 @@ struct ParserXMLTextTests { el.innerText = "Simon" el.attributes["x"] = "1" el.attributes["y"] = "2" - el.attributes["font-family"] = "Futura" + el.attributes["font-family"] = "'Futura', sans-serif" el.attributes["font-size"] = "12.5" el.attributes["text-anchor"] = "end" @@ -52,11 +52,31 @@ struct ParserXMLTextTests { #expect(text.x == 1) #expect(text.y == 2) #expect(text.value == "Simon") - #expect(text.attributes.fontFamily == "Futura") + #expect(text.attributes.fontFamily == [.name("Futura"), .keyword(.sansSerif)]) #expect(text.attributes.fontSize == 12.5) #expect(text.attributes.textAnchor == .end) } + @Test + func textTSpanNodeParses() throws { + let tspan = XML.Element(name: "tspan", attributes: [:]) + tspan.innerText = "Simon" + tspan.attributes["x"] = "1" + tspan.attributes["y"] = "2" + tspan.attributes["font-family"] = "'Futura', sans-serif" + tspan.attributes["font-size"] = "12.5" + tspan.attributes["text-anchor"] = "end" + let el = XML.Element(name: "text", attributes: [:]) + el.children.append(tspan) + + let parsed = try XMLParser().parseGraphicsElement(el) as? DOM.Text + let text = try #require(parsed) + + #expect(text.x == 1) + #expect(text.y == 2) + #expect(text.value == "Simon") + } + @Test func emptyTextNodeReturnsNil() throws { let el = XML.Element(name: "text", attributes: [:]) diff --git a/DOM/Tests/Test.bundle/fontface-ttf.svg b/DOM/Tests/Test.bundle/fontface-ttf.svg new file mode 100644 index 0000000..662f365 --- /dev/null +++ b/DOM/Tests/Test.bundle/fontface-ttf.svg @@ -0,0 +1,11 @@ + + + + + Every leaf that trembles + diff --git a/DOM/Tests/XML.Parser.ScannerTests.swift b/DOM/Tests/XML.Parser.ScannerTests.swift index c25b20c..c291ded 100644 --- a/DOM/Tests/XML.Parser.ScannerTests.swift +++ b/DOM/Tests/XML.Parser.ScannerTests.swift @@ -216,6 +216,62 @@ struct ScannerTests { _ = try? scanner.scanString(",") #expect(try scanner.scanCoordinate() == 5 * 16) } + + @Test + func scanUpToPreservesStrings() throws { + var scanner = XMLParser.Scanner(text: #"foo: bar; baz: "bing;bob" zab;"#) + + #expect( + try scanner.scanString(upTo: ";", preservingStrings: true) == "foo: bar" + ) + _ = try? scanner.scanString(";") + #expect( + try scanner.scanString(upTo: ";", preservingStrings: true) == #"baz: "bing;bob" zab"# + ) + } + + @Test + func scanFunction() throws { + var scanner = XMLParser.Scanner(text: #"url('fish') foo( 1 , 'chips')"#) + + #expect( + try scanner.scanFunction("url") == ["'fish'"] + ) + + #expect(throws: (any Error).self) { + try scanner.scanFunction("url") + } + + #expect( + try scanner.scanFunction("foo") == ["1", "'chips'"] + ) + } + + @Test + func scanURLFunction() throws { + var scanner = XMLParser.Scanner(text: #"url('fish')"#) + #expect( + try scanner.scanURLFunction("url") == URL(string: "fish") + ) + } + + @Test + func scanAttributes() throws { + var scanner = XMLParser.Scanner(text: #""" + { + foo: bar; + src: url(foo;bar); + bar: baz; + } + """#) + #expect( + try scanner.scanAtttributes() == [ + "foo": "bar", + "src": "url(foo;bar)", + "bar": "baz" + ] + ) + } } private func scanUInt8(_ text: String) -> UInt8? { diff --git a/Examples/Sources/GalleryView.swift b/Examples/Sources/GalleryView.swift index 9bc5071..54723de 100644 --- a/Examples/Sources/GalleryView.swift +++ b/Examples/Sources/GalleryView.swift @@ -37,6 +37,7 @@ struct GalleryView: View { var images = [ "thats-no-moon.svg", "heliocentric.svg", + "every-grain.svg", "avocado.svg", "angry.svg", "ogre.svg", @@ -53,6 +54,7 @@ struct GalleryView: View { "yawning.svg", "alert.svg", "effigy.svg", + "d10.svg", "stylesheet-multiple.svg" ] diff --git a/Samples.bundle/d10.svg b/Samples.bundle/d10.svg new file mode 100644 index 0000000..fa3c36e --- /dev/null +++ b/Samples.bundle/d10.svg @@ -0,0 +1,72 @@ + + + + + + D1 + + DO + + diff --git a/Samples.bundle/every-grain.svg b/Samples.bundle/every-grain.svg new file mode 100644 index 0000000..35b113d --- /dev/null +++ b/Samples.bundle/every-grain.svg @@ -0,0 +1,17 @@ + + + + In the fury of moment + I can see the master's hand + In every leaf that trembles + In every grain of sand + diff --git a/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift b/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift index 2c1e49f..3dbe534 100644 --- a/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift +++ b/SwiftDraw/Sources/Formatter/XML.Formatter.SVG.swift @@ -133,7 +133,7 @@ extension XML.Formatter { attributes["fill"] = graphic.fill.map(encodeFill) attributes["fill-rule"] = graphic.fillRule?.rawValue - attributes["font-family"] = graphic.fontFamily + attributes["font-family"] = encodeFontFamily(graphic.fontFamily) attributes["font-size"] = formatter.format(graphic.fontSize) attributes["text-anchor"] = graphic.textAnchor?.rawValue @@ -284,6 +284,21 @@ extension XML.Formatter { } } + func encodeFontFamily(_ font: [DOM.FontFamily]?) -> String? { + guard let font else { return nil } + return font + .map(encodeFont) + .joined(separator: ", ") + } + + func encodeFont(_ font: DOM.FontFamily) -> String { + if font.name.contains(" ") { + return "'\(font.name)'" + } else { + return font.name + } + } + func encodeURL(_ url: URL) -> String { "url(\(url.absoluteString))" } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift index 0ff7d24..aedbb68 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Layer.swift @@ -58,13 +58,17 @@ extension LayerTree.Builder { return .layer(l) } - static func makeTextContents(from text: DOM.Text, with state: State) -> LayerTree.Layer.Contents { + func makeTextContents(from text: DOM.Text, with state: State) -> LayerTree.Layer.Contents { var point = Point(text.x ?? 0, text.y ?? 0) var att = makeTextAttributes(with: state) - att.fontName = text.attributes.fontFamily ?? att.fontName + + if let fontFamily = text.attributes.fontFamily { + att.font = fontFamily.flatMap(makeFonts) + } att.size = text.attributes.fontSize ?? att.size att.anchor = text.attributes.textAnchor ?? att.anchor - point.x += makeXOffset(for: text.value, with: att) + let offset = Self.makeXOffset(for: text.value, with: att) + point.x += offset return .text(text.value, point, att) } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Text.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Text.swift index 95fac60..f92b6c7 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Text.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.Text.swift @@ -41,17 +41,16 @@ extension LayerTree.Builder { #if canImport(CoreText) static func makeXOffset(for text: String, with attributes: LayerTree.TextAttributes) -> LayerTree.Float { - let font = CTFontCreateWithName(attributes.fontName as CFString, - CGFloat(attributes.size), - nil) - guard let bounds = text.toPath(font: font)?.boundingBoxOfPath else { return 0 } + let font = CGProvider.createCTFont(for: attributes.font, size: attributes.size) + let line = text.toLine(font: font) + let width = CTLineGetTypographicBounds(line, nil, nil, nil) switch attributes.anchor { case .start: - return LayerTree.Float(bounds.minX) + return 0 case .middle: - return LayerTree.Float(-bounds.midX) + return LayerTree.Float(-width / 2) case .end: - return LayerTree.Float(-bounds.maxX) + return LayerTree.Float(-width) } } #else @@ -62,3 +61,23 @@ extension LayerTree.Builder { } + +extension DOM.FontFamily { + + var fontName: String { + switch self { + case .name(let s): + return s + case .keyword(.serif): + return "Times New Roman" + case .keyword(.sansSerif): + return "Helvetica" + case .keyword(.monospace): + return "Courier" + case .keyword(.fantasy): + return "Papyrus" + case .keyword(.cursive): + return "Apple Chancery" + } + } +} diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift index ad8c852..810d9fe 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift @@ -152,7 +152,7 @@ extension LayerTree { if let shape = Builder.makeShape(from: element) { return makeShapeContents(from: shape, with: state) } else if let text = element as? DOM.Text { - return Builder.makeTextContents(from: text, with: state) + return makeTextContents(from: text, with: state) } else if let image = element as? DOM.Image { return try? Builder.makeImageContents(from: image) } else if let use = element as? DOM.Use { @@ -278,18 +278,77 @@ extension LayerTree.Builder { return gradient } - static func makeTextAttributes(with state: State) -> LayerTree.TextAttributes { + func makeTextAttributes(with state: State) -> LayerTree.TextAttributes { let fill = LayerTree.Color .create(from: state.fill.makeColor(), current: state.color) .withAlpha(state.fillOpacity).maybeNone() + return LayerTree.TextAttributes( color: fill, - fontName: state.fontFamily, + font: state.fontFamily.flatMap(makeFonts), size: state.fontSize, anchor: state.textAnchor ) } + func makeFonts(with font: DOM.FontFamily) -> [LayerTree.TextAttributes.Font] { + switch font { + case .name(let name): + return makeFonts(faceName: name) + case .keyword(.serif): + return [.name("Times New Roman")] + case .keyword(.sansSerif): + return [.name("Helvetica")] + case .keyword(.monospace): + return [.name("Courier")] + case .keyword(.fantasy): + return [.name("Papyrus")] + case .keyword(.cursive): + return [.name("Apple Chancery")] + } + } + + func makeFonts(faceName: String) -> [LayerTree.TextAttributes.Font] { + let fonts = svg.fontSources(for: faceName) + .compactMap { try? makeFont(for: $0) } + + guard !fonts.isEmpty else { + return [.name(faceName)] + } + return fonts + } + + func makeFont(for source: DOM.FontFace.Source) throws -> LayerTree.TextAttributes.Font { + switch source { + case .local(let name): + return .name(name) + case let .url(url: url, format: format): + if let (mime, data) = url.decodedData { + if mime == "font/truetype" || format == "truetype" { + return .truetype(data) + } else if mime == "font/woff" || format == "woff" { + #if canImport(Compression) + let decoded = try WOFF(data: data) + return .truetype(decoded.fontData) + #else + throw LayerTree.Error.invalid("unsupported font: \(mime)") + #endif + } else if mime == "font/woff2" || format == "woff2" { + #if canImport(Compression) + let decoded = try WOFF2(data: data) + return .truetype(decoded.fontData) + #else + throw LayerTree.Error.invalid("unsupported font: \(mime)") + #endif + } else { + throw LayerTree.Error.invalid("unsupported font: \(mime)") + } + } else { + throw LayerTree.Error.invalid("unsupported format: \(format ?? "unknown")") + } + } + } + func makePattern(for element: DOM.Pattern) -> LayerTree.Pattern { let frame = LayerTree.Rect(x: 0, y: 0, width: element.width, height: element.height) let pattern = LayerTree.Pattern(frame: frame) @@ -387,7 +446,7 @@ extension LayerTree.Builder { var filter: DOM.URL? - var fontFamily: String + var fontFamily: [DOM.FontFamily] var fontSize: DOM.Float var textAnchor: DOM.TextAnchor @@ -410,7 +469,7 @@ extension LayerTree.Builder { fillRule = .nonzero textAnchor = .start - fontFamily = "Helvetica" + fontFamily = [.name("Helvetica")] fontSize = 12.0 } } @@ -516,3 +575,16 @@ private extension DOM.Fill { } } } + +private extension DOM.SVG { + + func fontSources(for family: String) -> [DOM.FontFace.Source] { + var sources = [DOM.FontFace.Source]() + for style in styles { + for font in style.fonts where font.family == family { + sources.append(font.src) + } + } + return sources + } +} diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift index 216e650..a85b393 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift @@ -27,6 +27,7 @@ // import SwiftDrawDOM +import struct Foundation.Data extension LayerTree { final class Layer: Hashable { @@ -150,9 +151,14 @@ extension LayerTree { struct TextAttributes: Hashable { var color: Color - var fontName: String + var font: [Font] var size: Float var anchor: DOM.TextAnchor + + enum Font: Hashable { + case truetype(Data) + case name(String) + } } } diff --git a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift index 05b993d..b98041d 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift @@ -217,15 +217,42 @@ struct CGProvider: RendererTypeProvider { } func createPath(from text: String, at origin: LayerTree.Point, with attributes: LayerTree.TextAttributes) -> Types.Path? { - let font = CTFontCreateWithName(attributes.fontName as CFString, - createFloat(from: attributes.size), - nil) + let font = CGProvider.createCTFont(for: attributes.font, size: attributes.size) guard let path = text.toPath(font: font) else { return nil } - var transform = CGAffineTransform(translationX: createFloat(from: origin.x), y: createFloat(from: origin.y)) return path.copy(using: &transform) } + private static func createCTFont(for font: LayerTree.TextAttributes.Font, size: CGFloat) -> CTFont? { + switch font { + case .truetype(let data): + guard let provider = CGDataProvider(data: data as CFData), + let cgFont = CGFont(provider) else { return nil } + return CTFontCreateWithGraphicsFont(cgFont, size, nil, nil) + case .name(let name): + let ctFont = CTFontCreateWithName(name as CFString, size, nil) + let postScriptName = CTFontCopyPostScriptName(ctFont) as String + if postScriptName.caseInsensitiveCompare(name) == .orderedSame + || CTFontCopyFamilyName(ctFont) as String == name { + return ctFont + } + return nil + } + } + + static func createCTFont(for fonts: [LayerTree.TextAttributes.Font], size: Float) -> CTFont { + let cgSize = CGFloat(size) + let fallback = CTFontCreateWithName("Times New Roman" as CFString, cgSize, nil) + let ctFonts = fonts.compactMap { createCTFont(for: $0, size: cgSize) } + guard let primary = ctFonts.first else { return fallback } + guard ctFonts.count > 1 else { return primary } + let descriptors = ctFonts.dropFirst().map { CTFontCopyFontDescriptor($0) } + let cascade = CTFontDescriptorCreateWithAttributes( + [kCTFontCascadeListAttribute: descriptors] as CFDictionary + ) + return CTFontCreateCopyWithAttributes(primary, cgSize, nil, cascade) + } + func createPattern(from pattern: LayerTree.Pattern, contents: [RendererCommand]) -> CGTransformingPattern { let bounds = createRect(from: pattern.frame) return CGTransformingPattern(bounds: bounds, contents: contents) diff --git a/SwiftDraw/Sources/Utilities/CGPath+Segment.swift b/SwiftDraw/Sources/Utilities/CGPath+Segment.swift index 3da65ee..420f220 100644 --- a/SwiftDraw/Sources/Utilities/CGPath+Segment.swift +++ b/SwiftDraw/Sources/Utilities/CGPath+Segment.swift @@ -39,47 +39,47 @@ import UIKit #endif extension CGPath { - func doApply(action: @escaping (CGPathElement)->()) { - var action = action - withUnsafeMutablePointer(to: &action) { action in - apply(info: action) { - let action = $0!.bindMemory(to: ((CGPathElement)->()).self, capacity: 1).pointee - action($1.pointee) - } + func doApply(action: @escaping (CGPathElement)->()) { + var action = action + withUnsafeMutablePointer(to: &action) { action in + apply(info: action) { + let action = $0!.bindMemory(to: ((CGPathElement)->()).self, capacity: 1).pointee + action($1.pointee) + } + } } - } } extension CGPath { - enum Segment: Equatable { - case move(CGPoint) - case line(CGPoint) - case quad(CGPoint, CGPoint) - case cubic(CGPoint, CGPoint, CGPoint) - case close - } - - func segments() -> [Segment] { - var segments = [Segment]() - self.doApply { - let p = $0 - switch (p.type) { - case .moveToPoint: - segments.append(Segment.move(p.points[0])) - case .addLineToPoint: - segments.append(Segment.line(p.points[0])) - case .addQuadCurveToPoint: - segments.append(Segment.quad(p.points[0], p.points[1])) - case .addCurveToPoint: - segments.append(Segment.cubic(p.points[0], p.points[1], p.points[2])) - case .closeSubpath: - segments.append(Segment.close) - @unknown default: - () - } + enum Segment: Equatable { + case move(CGPoint) + case line(CGPoint) + case quad(CGPoint, CGPoint) + case cubic(CGPoint, CGPoint, CGPoint) + case close + } + + func segments() -> [Segment] { + var segments = [Segment]() + self.doApply { + let p = $0 + switch (p.type) { + case .moveToPoint: + segments.append(Segment.move(p.points[0])) + case .addLineToPoint: + segments.append(Segment.line(p.points[0])) + case .addQuadCurveToPoint: + segments.append(Segment.quad(p.points[0], p.points[1])) + case .addCurveToPoint: + segments.append(Segment.cubic(p.points[0], p.points[1], p.points[2])) + case .closeSubpath: + segments.append(Segment.close) + @unknown default: + () + } + } + return segments } - return segments - } } extension CGPath { @@ -116,56 +116,60 @@ private extension LayerTree.Point { extension String { - func toPath(font: CTFont) -> CGPath? { - let attributes = [kCTFontAttributeName: font] - let attString = CFAttributedStringCreate(nil, self as CFString, attributes as CFDictionary)! - let line = CTLineCreateWithAttributedString(attString) - let glyphRuns = CTLineGetGlyphRuns(line) - - var ascent = CGFloat(0) - var descent = CGFloat(0) - var leading = CGFloat(0) - CTLineGetTypographicBounds(line, &ascent, &descent, &leading) - let baseline = ascent - - - let path = CGMutablePath() - - for idx in 0.. CTLine { + let attributes = [kCTFontAttributeName: font] + let attString = CFAttributedStringCreate(nil, self as CFString, attributes as CFDictionary)! + return CTLineCreateWithAttributedString(attString) } - return path - } + func toPath(font: CTFont) -> CGPath? { + let line = toLine(font: font) + let glyphRuns = CTLineGetGlyphRuns(line) + + var ascent = CGFloat(0) + var descent = CGFloat(0) + var leading = CGFloat(0) + CTLineGetTypographicBounds(line, &ascent, &descent, &leading) + let baseline = ascent + + + let path = CGMutablePath() + + for idx in 0.. CTFont { - guard let value = attributes[kCTFontAttributeName] else { - return fallback + return path } - // CoreFoundation type bridging: ensure it's a CTFont before use. - if CFGetTypeID(value as CFTypeRef) == CTFontGetTypeID() { - return unsafeDowncast(value as AnyObject, to: CTFont.self) + + // Use the font CoreText resolved for this run, else fall back to the requested font. + private func resolveRunFont(attributes: NSDictionary, fallback: CTFont) -> CTFont { + guard let value = attributes[kCTFontAttributeName] else { + return fallback + } + // CoreFoundation type bridging: ensure it's a CTFont before use. + if CFGetTypeID(value as CFTypeRef) == CTFontGetTypeID() { + return unsafeDowncast(value as AnyObject, to: CTFont.self) + } + return fallback } - return fallback - } } #endif diff --git a/SwiftDraw/Sources/WOFF/Data+Brotli.swift b/SwiftDraw/Sources/WOFF/Data+Brotli.swift new file mode 100644 index 0000000..eb3f182 --- /dev/null +++ b/SwiftDraw/Sources/WOFF/Data+Brotli.swift @@ -0,0 +1,130 @@ +// +// Data+Brotli.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// + +#if canImport(Compression) +import Foundation +import Compression + +extension Data { + + /// Decompresses Brotli-compressed data. + /// - Parameter decompressedSize: The size of the decompressed data. + /// - Returns: The decompressed data. + /// - Throws: `BrotliError.decompressionFailed` if decompression fails. + func decompressBrotli(decompressedSize: Int) throws(BrotliError) -> Data { +#if compiler(>=6.2) + if #available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { + return try _decompressBrotliSpan(decompressedSize: decompressedSize) + } else { + return try _decompressBrotliLegacy(decompressedSize: decompressedSize) + } +#else + return try _decompressBrotliLegacy(decompressedSize: decompressedSize) +#endif + } + +#if compiler(>=6.2) + @available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + private func _decompressBrotliSpan(decompressedSize: Int) throws(BrotliError) -> Data { + let srcSpan = self.bytes + var decompressedData = Data(count: decompressedSize) + var dstSpan = decompressedData.mutableBytes + var actualSize = 0 + + srcSpan.withUnsafeBytes { srcBuffer in + dstSpan.withUnsafeMutableBytes { dstBuffer in + guard let srcPtr = srcBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self), + let dstPtr = dstBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return + } + actualSize = compression_decode_buffer( + dstPtr, + decompressedSize, + srcPtr, + count, + nil, + COMPRESSION_BROTLI + ) + } + } + + guard actualSize > 0 else { + throw .decompressionFailed + } + + decompressedData.count = actualSize + return decompressedData + } +#endif + + private func _decompressBrotliLegacy(decompressedSize: Int) throws(BrotliError) -> Data { + var decompressedData = Data(count: decompressedSize) + var actualSize = 0 + var thrownError: BrotliError? + + self.withUnsafeBytes { srcBuffer in + guard let srcPtr = srcBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + thrownError = .decompressionFailed + return + } + + actualSize = decompressedData.withUnsafeMutableBytes { dstBuffer -> Int in + guard let dstPtr = dstBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return 0 + } + return compression_decode_buffer( + dstPtr, + decompressedSize, + srcPtr, + count, + nil, + COMPRESSION_BROTLI + ) + } + } + + if let error = thrownError { + throw error + } + + guard actualSize > 0 else { + throw .decompressionFailed + } + + decompressedData.count = actualSize + return decompressedData + } +} + +enum BrotliError: Error { + case decompressionFailed +} +#endif diff --git a/SwiftDraw/Sources/WOFF/FontDataProvider.swift b/SwiftDraw/Sources/WOFF/FontDataProvider.swift new file mode 100644 index 0000000..f8aee46 --- /dev/null +++ b/SwiftDraw/Sources/WOFF/FontDataProvider.swift @@ -0,0 +1,46 @@ +// +// FontDataProvider.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// + +#if canImport(CoreGraphics) +import Foundation +import CoreGraphics + +/// A protocol for types that can provide font data and create CGFonts +protocol FontDataProvider { + init(data: Data) throws + init(contentsOf url: URL) throws + func makeCGFont() throws -> CGFont +} + +extension TTF: FontDataProvider {} +extension WOFF: FontDataProvider {} +extension WOFF2: FontDataProvider {} +#endif diff --git a/SwiftDraw/Sources/WOFF/GlyfTransform.swift b/SwiftDraw/Sources/WOFF/GlyfTransform.swift new file mode 100644 index 0000000..f252a2d --- /dev/null +++ b/SwiftDraw/Sources/WOFF/GlyfTransform.swift @@ -0,0 +1,696 @@ +// +// GlyfTransform.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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 Foundation + +// MARK: - GlyfTransform + +/// Handles WOFF2 glyf/loca table transform reversal +/// Reference: https://www.w3.org/TR/WOFF2/ Section 5 +struct GlyfTransform { + + /// Header from transformed glyf table (36 bytes) + struct Header { + let reserved: UInt16 + let optionFlags: UInt16 + let numGlyphs: UInt16 + let indexFormat: UInt16 // 0 = short loca (UInt16), 1 = long loca (UInt32) + let nContourStreamSize: UInt32 + let nPointsStreamSize: UInt32 + let flagStreamSize: UInt32 + let glyphStreamSize: UInt32 + let compositeStreamSize: UInt32 + let bboxStreamSize: UInt32 + let instructionStreamSize: UInt32 + + /// Whether the overlap simple bitmap is present + var hasOverlapBitmap: Bool { + (optionFlags & 0x0001) != 0 + } + } + + /// Result of glyf/loca reconstruction + struct ReconstructedTables { + let glyf: Data + let loca: Data + } + + /// Parses the 36-byte transformed glyf header + static func parseHeader(from data: Data) throws -> Header { + guard data.count >= 36 else { + throw GlyfTransformError.invalidHeader + } + + var offset = 0 + let reserved = data.readUInt16BE(at: &offset) + + guard reserved == 0 else { + throw GlyfTransformError.invalidHeader + } + + let optionFlags = data.readUInt16BE(at: &offset) + let numGlyphs = data.readUInt16BE(at: &offset) + let indexFormat = data.readUInt16BE(at: &offset) + let nContourStreamSize = data.readUInt32BE(at: &offset) + let nPointsStreamSize = data.readUInt32BE(at: &offset) + let flagStreamSize = data.readUInt32BE(at: &offset) + let glyphStreamSize = data.readUInt32BE(at: &offset) + let compositeStreamSize = data.readUInt32BE(at: &offset) + let bboxStreamSize = data.readUInt32BE(at: &offset) + let instructionStreamSize = data.readUInt32BE(at: &offset) + + return Header( + reserved: reserved, + optionFlags: optionFlags, + numGlyphs: numGlyphs, + indexFormat: indexFormat, + nContourStreamSize: nContourStreamSize, + nPointsStreamSize: nPointsStreamSize, + flagStreamSize: flagStreamSize, + glyphStreamSize: glyphStreamSize, + compositeStreamSize: compositeStreamSize, + bboxStreamSize: bboxStreamSize, + instructionStreamSize: instructionStreamSize + ) + } + + /// Calculates stream offsets from header + static func streamOffsets(from header: Header) -> StreamOffsets { + let headerSize = 36 + var offset = headerSize + + let nContour = offset + offset += Int(header.nContourStreamSize) + + let nPoints = offset + offset += Int(header.nPointsStreamSize) + + let flags = offset + offset += Int(header.flagStreamSize) + + let glyph = offset + offset += Int(header.glyphStreamSize) + + let composite = offset + offset += Int(header.compositeStreamSize) + + let bbox = offset + offset += Int(header.bboxStreamSize) + + let instruction = offset + + return StreamOffsets( + nContour: nContour, + nPoints: nPoints, + flags: flags, + glyph: glyph, + composite: composite, + bbox: bbox, + instruction: instruction + ) + } + + /// Stream offsets within the transformed glyf data + struct StreamOffsets { + let nContour: Int + let nPoints: Int + let flags: Int + let glyph: Int + let composite: Int + let bbox: Int + let instruction: Int + } + + /// Reconstructs standard glyf and loca tables from transformed data + static func reconstruct(from transformedGlyf: Data) throws -> ReconstructedTables { + let header = try parseHeader(from: transformedGlyf) + let offsets = streamOffsets(from: header) + + // Create stream readers + var nContourReader = StreamReader(data: transformedGlyf, offset: offsets.nContour) + var nPointsReader = StreamReader(data: transformedGlyf, offset: offsets.nPoints) + var flagReader = StreamReader(data: transformedGlyf, offset: offsets.flags) + var glyphReader = StreamReader(data: transformedGlyf, offset: offsets.glyph) + var compositeReader = StreamReader(data: transformedGlyf, offset: offsets.composite) + var bboxReader = StreamReader(data: transformedGlyf, offset: offsets.bbox) + var instructionReader = StreamReader(data: transformedGlyf, offset: offsets.instruction) + + // Parse bbox bitmap - must be 4-byte aligned per WOFF2 spec + let bboxBitmapSize = ((Int(header.numGlyphs) + 31) >> 5) << 2 + let bboxBitmap = transformedGlyf.subdata(in: offsets.bbox..<(offsets.bbox + bboxBitmapSize)) + bboxReader.offset += bboxBitmapSize + + // Build glyf table + var glyfData = Data() + var locaOffsets: [UInt32] = [] + + for glyphIndex in 0.. 0 { + // Simple glyph + let glyphData = try reconstructSimpleGlyph( + nContours: Int(nContour), + glyphIndex: glyphIndex, + bboxBitmap: bboxBitmap, + nPointsReader: &nPointsReader, + flagReader: &flagReader, + glyphReader: &glyphReader, + bboxReader: &bboxReader, + instructionReader: &instructionReader + ) + glyfData.append(glyphData) + } else { + // Composite glyph (nContour == -1) + let glyphData = try reconstructCompositeGlyph( + glyphIndex: glyphIndex, + bboxBitmap: bboxBitmap, + compositeReader: &compositeReader, + glyphReader: &glyphReader, + bboxReader: &bboxReader, + instructionReader: &instructionReader + ) + glyfData.append(glyphData) + } + + // Pad to 4-byte boundary (per Google's reference implementation) + while glyfData.count % 4 != 0 { + glyfData.append(0) + } + } + + // Final loca entry + locaOffsets.append(UInt32(glyfData.count)) + + // Build loca table + let locaData = buildLocaTable(offsets: locaOffsets, indexFormat: header.indexFormat) + + return ReconstructedTables(glyf: glyfData, loca: locaData) + } + + // MARK: - Simple Glyph Reconstruction + + private static func reconstructSimpleGlyph( + nContours: Int, + glyphIndex: Int, + bboxBitmap: Data, + nPointsReader: inout StreamReader, + flagReader: inout StreamReader, + glyphReader: inout StreamReader, + bboxReader: inout StreamReader, + instructionReader: inout StreamReader + ) throws -> Data { + // Read points per contour and build endPtsOfContours + var endPtsOfContours: [UInt16] = [] + var totalPoints = 0 + + for _ in 0.. Data { + var glyphData = Data() + + // numberOfContours = -1 for composite + glyphData.appendInt16BE(-1) + + // Read bbox (always explicit for composites) + let xMin = bboxReader.readInt16BE() + let yMin = bboxReader.readInt16BE() + let xMax = bboxReader.readInt16BE() + let yMax = bboxReader.readInt16BE() + + glyphData.appendInt16BE(xMin) + glyphData.appendInt16BE(yMin) + glyphData.appendInt16BE(xMax) + glyphData.appendInt16BE(yMax) + + // Read components + var hasInstructions = false + var moreComponents = true + + while moreComponents { + let flags = compositeReader.readUInt16BE() + let glyphIdx = compositeReader.readUInt16BE() + + glyphData.appendUInt16BE(flags) + glyphData.appendUInt16BE(glyphIdx) + + // Read arguments + if (flags & CompositeFlags.arg1And2AreWords) != 0 { + glyphData.appendInt16BE(compositeReader.readInt16BE()) + glyphData.appendInt16BE(compositeReader.readInt16BE()) + } else { + glyphData.append(compositeReader.readUInt8()) + glyphData.append(compositeReader.readUInt8()) + } + + // Read transform + if (flags & CompositeFlags.weHaveAScale) != 0 { + glyphData.appendInt16BE(compositeReader.readInt16BE()) + } else if (flags & CompositeFlags.weHaveAnXAndYScale) != 0 { + glyphData.appendInt16BE(compositeReader.readInt16BE()) + glyphData.appendInt16BE(compositeReader.readInt16BE()) + } else if (flags & CompositeFlags.weHaveATwoByTwo) != 0 { + glyphData.appendInt16BE(compositeReader.readInt16BE()) + glyphData.appendInt16BE(compositeReader.readInt16BE()) + glyphData.appendInt16BE(compositeReader.readInt16BE()) + glyphData.appendInt16BE(compositeReader.readInt16BE()) + } + + if (flags & CompositeFlags.weHaveInstructions) != 0 { + hasInstructions = true + } + + moreComponents = (flags & CompositeFlags.moreComponents) != 0 + } + + // Read instructions if present + // Note: instruction length is read from glyphReader, not compositeReader! + if hasInstructions { + let instructionLength = try read255UInt16(from: &glyphReader) + let instructions = instructionReader.readBytes(Int(instructionLength)) + glyphData.appendUInt16BE(instructionLength) + glyphData.append(contentsOf: instructions) + } + + return glyphData + } + + // MARK: - Triplet Decoding + + /// Decodes a coordinate triplet from the flag and glyph streams + /// Returns (dx, dy, onCurve) + private static func decodeTriplet( + flagReader: inout StreamReader, + glyphReader: inout StreamReader + ) throws -> (dx: Int, dy: Int, onCurve: Bool) { + let flag = flagReader.readUInt8() + let onCurve = (flag & 0x80) == 0 // Bit 7 clear = on curve + let flagValue = Int(flag & 0x7F) + + // Triplet encoding - based on Google's TripletDecode + let (dx, dy) = try decodeTripletCoordinates(flag: flagValue, glyphReader: &glyphReader) + + return (dx, dy, onCurve) + } + + /// Applies sign based on flag bit - matches Google's WithSign function + private static func withSign(_ flag: Int, _ baseval: Int) -> Int { + return (flag & 1) != 0 ? baseval : -baseval + } + + /// Decodes coordinate values based on flag index (0-127) + /// This matches Google's TripletDecode implementation exactly + private static func decodeTripletCoordinates( + flag: Int, + glyphReader: inout StreamReader + ) throws -> (dx: Int, dy: Int) { + let dx: Int + let dy: Int + + if flag < 10 { + // dx = 0, dy computed with sign + let byte = Int(glyphReader.readUInt8()) + dx = 0 + dy = withSign(flag, ((flag & 14) << 7) + byte) + } else if flag < 20 { + // dx computed with sign, dy = 0 + let byte = Int(glyphReader.readUInt8()) + dx = withSign(flag, (((flag - 10) & 14) << 7) + byte) + dy = 0 + } else if flag < 84 { + // Both dx, dy from packed nibbles (1 byte) + let b0 = flag - 20 + let b1 = Int(glyphReader.readUInt8()) + dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4)) + dy = withSign(flag >> 1, 1 + ((b0 & 0x0c) << 2) + (b1 & 0x0f)) + } else if flag < 120 { + // dx, dy each from separate bytes (2 bytes) + let b0 = flag - 84 + let byte0 = Int(glyphReader.readUInt8()) + let byte1 = Int(glyphReader.readUInt8()) + dx = withSign(flag, 1 + ((b0 / 12) << 8) + byte0) + dy = withSign(flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + byte1) + } else if flag < 124 { + // 12-bit coordinates (3 bytes) + let byte0 = Int(glyphReader.readUInt8()) + let b2 = Int(glyphReader.readUInt8()) + let byte2 = Int(glyphReader.readUInt8()) + dx = withSign(flag, (byte0 << 4) + (b2 >> 4)) + dy = withSign(flag >> 1, ((b2 & 0x0f) << 8) + byte2) + } else { + // 16-bit coordinates (4 bytes) + let byte0 = Int(glyphReader.readUInt8()) + let byte1 = Int(glyphReader.readUInt8()) + let byte2 = Int(glyphReader.readUInt8()) + let byte3 = Int(glyphReader.readUInt8()) + dx = withSign(flag, (byte0 << 8) + byte1) + dy = withSign(flag >> 1, (byte2 << 8) + byte3) + } + + return (dx, dy) + } + + // MARK: - 255UInt16 Decoding + + /// Reads a 255UInt16 variable-length integer + static func read255UInt16(from reader: inout StreamReader) throws -> UInt16 { + let byte0 = reader.readUInt8() + + if byte0 < 253 { + return UInt16(byte0) + } else if byte0 == 253 { + let b1 = UInt16(reader.readUInt8()) + let b2 = UInt16(reader.readUInt8()) + return (b1 << 8) | b2 + } else if byte0 == 254 { + let b1 = UInt16(reader.readUInt8()) + return 506 + b1 + } else { // byte0 == 255 + let b1 = UInt16(reader.readUInt8()) + return 253 + b1 + } + } + + // MARK: - Helper Functions + + private static func hasBboxBit(bitmap: Data, glyphIndex: Int) -> Bool { + let byteIndex = glyphIndex / 8 + let bitIndex = 7 - (glyphIndex % 8) + guard byteIndex < bitmap.count else { return false } + return (bitmap[byteIndex] & (1 << bitIndex)) != 0 + } + + private static func computeBbox(from points: [(x: Int16, y: Int16, onCurve: Bool)]) -> (xMin: Int16, yMin: Int16, xMax: Int16, yMax: Int16) { + guard !points.isEmpty else { + return (0, 0, 0, 0) + } + + var xMin = points[0].x + var yMin = points[0].y + var xMax = points[0].x + var yMax = points[0].y + + for point in points { + xMin = min(xMin, point.x) + yMin = min(yMin, point.y) + xMax = max(xMax, point.x) + yMax = max(yMax, point.y) + } + + return (xMin, yMin, xMax, yMax) + } + + private static func buildSimpleGlyph( + nContours: Int, + bbox: (xMin: Int16, yMin: Int16, xMax: Int16, yMax: Int16), + endPtsOfContours: [UInt16], + instructions: [UInt8], + points: [(x: Int16, y: Int16, onCurve: Bool)] + ) -> Data { + var data = Data() + + // Header + data.appendInt16BE(Int16(nContours)) + data.appendInt16BE(bbox.xMin) + data.appendInt16BE(bbox.yMin) + data.appendInt16BE(bbox.xMax) + data.appendInt16BE(bbox.yMax) + + // End points of contours + for endPt in endPtsOfContours { + data.appendUInt16BE(endPt) + } + + // Instructions + data.appendUInt16BE(UInt16(instructions.count)) + data.append(contentsOf: instructions) + + // Encode flags and coordinates + let (flags, xCoords, yCoords) = encodeCoordinates(points: points) + data.append(contentsOf: flags) + data.append(contentsOf: xCoords) + data.append(contentsOf: yCoords) + + return data + } + + private static func encodeCoordinates(points: [(x: Int16, y: Int16, onCurve: Bool)]) -> (flags: [UInt8], xCoords: Data, yCoords: Data) { + var flags: [UInt8] = [] + var xCoords = Data() + var yCoords = Data() + + var prevX: Int = 0 + var prevY: Int = 0 + + for point in points { + let dx = Int(point.x) - prevX + let dy = Int(point.y) - prevY + + var flag: UInt8 = point.onCurve ? 0x01 : 0x00 + + // Encode X + if dx == 0 { + flag |= 0x10 // X is same + } else if dx > -256 && dx < 256 { + flag |= 0x02 // X is short + if dx > 0 { + flag |= 0x10 // X is positive + xCoords.append(UInt8(dx)) + } else { + xCoords.append(UInt8(-dx)) + } + } else { + xCoords.appendInt16BE(Int16(clamping: dx)) + } + + // Encode Y + if dy == 0 { + flag |= 0x20 // Y is same + } else if dy > -256 && dy < 256 { + flag |= 0x04 // Y is short + if dy > 0 { + flag |= 0x20 // Y is positive + yCoords.append(UInt8(dy)) + } else { + yCoords.append(UInt8(-dy)) + } + } else { + yCoords.appendInt16BE(Int16(clamping: dy)) + } + + flags.append(flag) + prevX = Int(point.x) + prevY = Int(point.y) + } + + return (flags, xCoords, yCoords) + } + + private static func buildLocaTable(offsets: [UInt32], indexFormat: UInt16) -> Data { + var data = Data() + + if indexFormat == 0 { + // Short format: UInt16 offsets divided by 2 + for offset in offsets { + data.appendUInt16BE(UInt16(offset / 2)) + } + } else { + // Long format: UInt32 offsets + for offset in offsets { + data.appendUInt32BE(offset) + } + } + + return data + } +} + +// MARK: - Composite Glyph Flags + +private struct CompositeFlags { + static let arg1And2AreWords: UInt16 = 0x0001 + static let argsAreXYValues: UInt16 = 0x0002 + static let roundXYToGrid: UInt16 = 0x0004 + static let weHaveAScale: UInt16 = 0x0008 + static let moreComponents: UInt16 = 0x0020 + static let weHaveAnXAndYScale: UInt16 = 0x0040 + static let weHaveATwoByTwo: UInt16 = 0x0080 + static let weHaveInstructions: UInt16 = 0x0100 + static let useMyMetrics: UInt16 = 0x0200 + static let overlapCompound: UInt16 = 0x0400 +} + +// MARK: - Stream Reader + +struct StreamReader { + let data: Data + var offset: Int + + mutating func readUInt8() -> UInt8 { + let value = data[offset] + offset += 1 + return value + } + + mutating func readInt16BE() -> Int16 { + let value = Int16(bitPattern: UInt16(data[offset]) << 8 | UInt16(data[offset + 1])) + offset += 2 + return value + } + + mutating func readUInt16BE() -> UInt16 { + let value = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + offset += 2 + return value + } + + mutating func readUInt32BE() -> UInt32 { + let value = UInt32(data[offset]) << 24 | + UInt32(data[offset + 1]) << 16 | + UInt32(data[offset + 2]) << 8 | + UInt32(data[offset + 3]) + offset += 4 + return value + } + + mutating func readBytes(_ count: Int) -> [UInt8] { + guard count > 0 else { return [] } + let endOffset = min(offset + count, data.count) + let bytes = Array(data[offset.. UInt16 { + let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) + offset += 2 + return value + } + + func readUInt32BE(at offset: inout Int) -> UInt32 { + let value = UInt32(self[offset]) << 24 | + UInt32(self[offset + 1]) << 16 | + UInt32(self[offset + 2]) << 8 | + UInt32(self[offset + 3]) + offset += 4 + return value + } + + mutating func appendInt16BE(_ value: Int16) { + let unsigned = UInt16(bitPattern: value) + append(UInt8((unsigned >> 8) & 0xFF)) + append(UInt8(unsigned & 0xFF)) + } + + mutating func appendUInt16BE(_ value: UInt16) { + append(UInt8((value >> 8) & 0xFF)) + append(UInt8(value & 0xFF)) + } + + mutating func appendUInt32BE(_ value: UInt32) { + append(UInt8((value >> 24) & 0xFF)) + append(UInt8((value >> 16) & 0xFF)) + append(UInt8((value >> 8) & 0xFF)) + append(UInt8(value & 0xFF)) + } +} diff --git a/SwiftDraw/Sources/WOFF/TTF.swift b/SwiftDraw/Sources/WOFF/TTF.swift new file mode 100644 index 0000000..b87a620 --- /dev/null +++ b/SwiftDraw/Sources/WOFF/TTF.swift @@ -0,0 +1,295 @@ +// +// TTF.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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 Foundation + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +// MARK: - TTF + +/// A parsed TTF (TrueType Font) file +struct TTF { + + /// The TTF file header + let header: Header + + /// The font tables contained in the file + let tables: [Table] + + /// The raw font data + let fontData: Data + + /// The PostScript name of the font, if present in the name table + var postScriptName: String? { + parsePostScriptName() + } + + /// Creates a TTF by reading and parsing a file + /// - Parameter url: The URL of the TTF file + /// - Throws: Error if reading or parsing fails + init(contentsOf url: URL) throws { + try self.init(data: Data(contentsOf: url)) + } + + /// Creates a TTF by parsing file data + /// - Parameter data: The TTF file data + /// - Throws: TTFError if parsing fails + init(data: Data) throws { + let parsedHeader = try Self.parseHeader(from: data) + let tableDirectory = try Self.parseTableDirectory(from: data, numTables: parsedHeader.numTables) + + self.header = Header( + sfntVersion: parsedHeader.sfntVersion, + numTables: parsedHeader.numTables, + searchRange: parsedHeader.searchRange, + entrySelector: parsedHeader.entrySelector, + rangeShift: parsedHeader.rangeShift + ) + + self.tables = tableDirectory.map { entry in + Table( + tag: entry.tag, + checksum: entry.checksum, + offset: entry.offset, + length: entry.length + ) + } + + self.fontData = data + } + + // MARK: - Header Parsing + + private static func parseHeader(from data: Data) throws -> TTFHeader { + guard data.count >= 12 else { + throw TTFError.invalidHeader + } + + var offset = 0 + + let sfntVersion = data.readUInt32(at: &offset) + + // Check for valid sfnt version + // 0x00010000 = TrueType outlines + // 0x4F54544F = "OTTO" = OpenType with CFF + guard sfntVersion == 0x00010000 || sfntVersion == 0x4F54544F else { + throw TTFError.invalidSignature + } + + let numTables = data.readUInt16(at: &offset) + let searchRange = data.readUInt16(at: &offset) + let entrySelector = data.readUInt16(at: &offset) + let rangeShift = data.readUInt16(at: &offset) + + return TTFHeader( + sfntVersion: sfntVersion, + numTables: numTables, + searchRange: searchRange, + entrySelector: entrySelector, + rangeShift: rangeShift + ) + } + + // MARK: - Table Directory Parsing + + private static func parseTableDirectory(from data: Data, numTables: UInt16) throws -> [TableDirectoryEntry] { + var offset = 12 // After header + var entries: [TableDirectoryEntry] = [] + + for _ in 0.. String? { + // Find the name table + guard let nameTable = tables.first(where: { $0.tag == "name" }) else { + return nil + } + + let nameTableOffset = Int(nameTable.offset) + var offset = nameTableOffset + + // Read the name table header + guard offset + 6 <= fontData.count else { return nil } + + _ = fontData.readUInt16(at: &offset) // format + let count = fontData.readUInt16(at: &offset) + let storageOffset = fontData.readUInt16(at: &offset) + + // Look for PostScript name (nameID = 6) + for _ in 0.. CGFont { + guard let provider = CGDataProvider(data: fontData as CFData), + let font = CGFont(provider) else { + throw TTFError.invalidFontData + } + return font + } +} +#endif + +// MARK: - Data Extensions + +private extension Data { + func readUInt16(at offset: inout Int) -> UInt16 { + let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) + offset += 2 + return value + } + + func readUInt32(at offset: inout Int) -> UInt32 { + let value = UInt32(self[offset]) << 24 | + UInt32(self[offset + 1]) << 16 | + UInt32(self[offset + 2]) << 8 | + UInt32(self[offset + 3]) + offset += 4 + return value + } +} diff --git a/SwiftDraw/Sources/WOFF/WOFF.swift b/SwiftDraw/Sources/WOFF/WOFF.swift new file mode 100644 index 0000000..8ffb72c --- /dev/null +++ b/SwiftDraw/Sources/WOFF/WOFF.swift @@ -0,0 +1,502 @@ +// +// WOFF.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// + +#if canImport(Compression) +import Foundation +import Compression + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +// MARK: - WOFF + +/// A parsed WOFF (Web Open Font Format 1.0) file +struct WOFF { + + /// The WOFF file header + let header: Header + + /// The font tables contained in the file + let tables: [Table] + + /// The decompressed font data + let fontData: Data + + /// The PostScript name of the font, if present in the name table + var postScriptName: String? { + parsePostScriptName() + } + + /// Creates a WOFF by reading and parsing a file + /// - Parameter url: The URL of the WOFF file + /// - Throws: Error if reading or parsing fails + init(contentsOf url: URL) throws { + try self.init(data: Data(contentsOf: url)) + } + + /// Creates a WOFF by parsing and decompressing file data + /// - Parameter data: The WOFF file data + /// - Throws: WOFFError if parsing or decompression fails + init(data: Data) throws { + let parsedHeader = try Self.parseHeader(from: data) + let tableDirectory = try Self.parseTableDirectory(from: data, numTables: parsedHeader.numTables) + let decompressedData = try Self.decompressTables(from: data, tables: tableDirectory, flavor: parsedHeader.flavor) + + self.header = Header( + flavor: parsedHeader.flavor, + numTables: parsedHeader.numTables, + totalSfntSize: parsedHeader.totalSfntSize, + majorVersion: parsedHeader.majorVersion, + minorVersion: parsedHeader.minorVersion + ) + + self.tables = tableDirectory.map { entry in + Table( + tag: entry.tag, + offset: entry.offset, + compLength: entry.compLength, + origLength: entry.origLength, + origChecksum: entry.origChecksum + ) + } + + self.fontData = decompressedData + } + + // MARK: - Header Parsing + + private static func parseHeader(from data: Data) throws -> WOFFHeader { + guard data.count >= 44 else { + throw WOFFError.invalidHeader + } + + var offset = 0 + + // Read signature (should be 0x774F4646 = "wOFF") + let signature = data.readUInt32(at: &offset) + guard signature == 0x774F4646 else { + throw WOFFError.invalidSignature + } + + let flavor = data.readUInt32(at: &offset) + let length = data.readUInt32(at: &offset) + let numTables = data.readUInt16(at: &offset) + let reserved = data.readUInt16(at: &offset) + let totalSfntSize = data.readUInt32(at: &offset) + let majorVersion = data.readUInt16(at: &offset) + let minorVersion = data.readUInt16(at: &offset) + let metaOffset = data.readUInt32(at: &offset) + let metaLength = data.readUInt32(at: &offset) + let metaOrigLength = data.readUInt32(at: &offset) + let privOffset = data.readUInt32(at: &offset) + let privLength = data.readUInt32(at: &offset) + + return WOFFHeader( + signature: signature, + flavor: flavor, + length: length, + numTables: numTables, + reserved: reserved, + totalSfntSize: totalSfntSize, + majorVersion: majorVersion, + minorVersion: minorVersion, + metaOffset: metaOffset, + metaLength: metaLength, + metaOrigLength: metaOrigLength, + privOffset: privOffset, + privLength: privLength + ) + } + + // MARK: - Table Directory Parsing + + private static func parseTableDirectory(from data: Data, numTables: UInt16) throws -> [TableDirectoryEntry] { + var offset = 44 // After header + var entries: [TableDirectoryEntry] = [] + + for _ in 0.. Data { + // First, decompress all tables and collect their data + var tableDataMap: [(entry: TableDirectoryEntry, data: Data)] = [] + + for table in tables { + let tableOffset = Int(table.offset) + let compLength = Int(table.compLength) + let origLength = Int(table.origLength) + + guard tableOffset + compLength <= data.count else { + throw WOFFError.invalidCompressedData + } + + let compressedTableData = data.subdata(in: tableOffset..<(tableOffset + compLength)) + + let tableData: Data + if compLength == origLength { + // Table is not compressed + tableData = compressedTableData + } else { + // Table is zlib compressed + tableData = try decompressZlib(compressedTableData, decompressedSize: origLength) + } + + tableDataMap.append((table, tableData)) + } + + // Build a proper sfnt file + return buildSfntFile(flavor: flavor, tables: tableDataMap) + } + + private static func buildSfntFile(flavor: UInt32, tables: [(entry: TableDirectoryEntry, data: Data)]) -> Data { + let numTables = UInt16(tables.count) + + // Calculate searchRange, entrySelector, rangeShift + var searchRange: UInt16 = 1 + var entrySelector: UInt16 = 0 + while searchRange * 2 <= numTables { + searchRange *= 2 + entrySelector += 1 + } + searchRange *= 16 + let rangeShift = numTables * 16 - searchRange + + var sfntData = Data() + + // Write sfnt header (12 bytes) + sfntData.appendUInt32(flavor) + sfntData.appendUInt16(numTables) + sfntData.appendUInt16(searchRange) + sfntData.appendUInt16(entrySelector) + sfntData.appendUInt16(rangeShift) + + // Calculate where table data starts (after header + table directory) + let headerSize = 12 + let tableDirectorySize = Int(numTables) * 16 + var currentOffset = UInt32(headerSize + tableDirectorySize) + + // Build table directory entries and collect offsets + var tableOffsets: [(tag: String, checksum: UInt32, offset: UInt32, length: UInt32)] = [] + + for (entry, tableData) in tables { + let checksum = entry.origChecksum + let length = UInt32(tableData.count) + tableOffsets.append((entry.tag, checksum, currentOffset, length)) + + // Advance offset, padding to 4-byte boundary + let paddedLength = (length + 3) & ~3 + currentOffset += paddedLength + } + + // Write table directory (sorted by tag for binary search) + let sortedTables = tableOffsets.sorted { $0.tag < $1.tag } + for table in sortedTables { + // Write 4-byte tag + if let tagData = table.tag.data(using: .ascii), tagData.count == 4 { + sfntData.append(tagData) + } else { + sfntData.append(Data([0x3F, 0x3F, 0x3F, 0x3F])) // "????" + } + sfntData.appendUInt32(table.checksum) + sfntData.appendUInt32(table.offset) + sfntData.appendUInt32(table.length) + } + + // Write table data in original order + for (_, tableData) in tables { + sfntData.append(tableData) + + // Pad to 4-byte boundary + let padding = (4 - (tableData.count % 4)) % 4 + if padding > 0 { + sfntData.append(Data(count: padding)) + } + } + + return sfntData + } + + private static func decompressZlib(_ data: Data, decompressedSize: Int) throws -> Data { + // WOFF uses zlib-wrapped deflate. The Compression framework's COMPRESSION_ZLIB + // expects raw deflate, so we need to strip the 2-byte zlib header and 4-byte + // Adler-32 checksum trailer. + guard data.count > 6 else { + throw WOFFError.decompressionFailed + } + + // Skip 2-byte zlib header (CMF + FLG) and 4-byte Adler-32 trailer + let rawDeflate = data.dropFirst(2).dropLast(4) + + var decompressedData = Data(count: decompressedSize) + var actualSize = 0 + + rawDeflate.withUnsafeBytes { srcBuffer in + decompressedData.withUnsafeMutableBytes { dstBuffer in + guard let srcPtr = srcBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self), + let dstPtr = dstBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return + } + actualSize = compression_decode_buffer( + dstPtr, decompressedSize, + srcPtr, rawDeflate.count, + nil, + COMPRESSION_ZLIB + ) + } + } + + guard actualSize > 0 else { + throw WOFFError.decompressionFailed + } + + decompressedData.count = actualSize + return decompressedData + } + + // MARK: - Name Table Parsing + + private func parsePostScriptName() -> String? { + // fontData is now a proper sfnt file, so we need to read the table directory + // to find the name table offset + guard fontData.count >= 12 else { return nil } + + var offset = 4 // Skip sfnt version + let numTables = fontData.readUInt16(at: &offset) + offset = 12 // Skip rest of header (searchRange, entrySelector, rangeShift) + + // Find the name table in the table directory + var nameTableOffset: Int? + for _ in 0.. CGFont { + guard let provider = CGDataProvider(data: fontData as CFData), + let font = CGFont(provider) else { + throw WOFFError.invalidFontData + } + return font + } +} +#endif + +// MARK: - Data Extensions + +private extension Data { + func readUInt16(at offset: inout Int) -> UInt16 { + let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) + offset += 2 + return value + } + + func readUInt32(at offset: inout Int) -> UInt32 { + let value = UInt32(self[offset]) << 24 | + UInt32(self[offset + 1]) << 16 | + UInt32(self[offset + 2]) << 8 | + UInt32(self[offset + 3]) + offset += 4 + return value + } + + mutating func appendUInt16(_ value: UInt16) { + append(UInt8((value >> 8) & 0xFF)) + append(UInt8(value & 0xFF)) + } + + mutating func appendUInt32(_ value: UInt32) { + append(UInt8((value >> 24) & 0xFF)) + append(UInt8((value >> 16) & 0xFF)) + append(UInt8((value >> 8) & 0xFF)) + append(UInt8(value & 0xFF)) + } +} +#endif diff --git a/SwiftDraw/Sources/WOFF/WOFF2.swift b/SwiftDraw/Sources/WOFF/WOFF2.swift new file mode 100644 index 0000000..5a845ad --- /dev/null +++ b/SwiftDraw/Sources/WOFF/WOFF2.swift @@ -0,0 +1,874 @@ +// +// WOFF2.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// +#if canImport(Compression) +import Foundation + +// MARK: - WOFF2 + +/// A parsed WOFF2 (Web Open Font Format 2.0) file +struct WOFF2 { + + /// The WOFF2 file header + let header: Header + + /// The font tables contained in the file + let tables: [Table] + + /// The decompressed font data + let fontData: Data + + /// The PostScript name of the font, if present in the name table + var postScriptName: String? { + parsePostScriptName() + } + + /// Creates a WOFF2 by reading and parsing a file + /// - Parameter url: The URL of the WOFF2 file + /// - Throws: Error if reading or parsing fails + init(contentsOf url: URL) throws { + try self.init(data: Data(contentsOf: url)) + } + + /// Creates a WOFF2 by parsing and decompressing file data + /// - Parameter data: The WOFF2 file data + /// - Throws: WOFF2Error if parsing or decompression fails + init(data: Data) throws { + let parsedHeader = try Self.parseHeader(from: data) + let tableDirectory = try Self.parseTableDirectory(from: data, header: parsedHeader) + let decompressedData = try Self.decompressBrotli(data: data, header: parsedHeader) + + self.header = Header( + flavor: parsedHeader.flavor, + numTables: parsedHeader.numTables, + totalSfntSize: parsedHeader.totalSfntSize, + majorVersion: parsedHeader.majorVersion, + minorVersion: parsedHeader.minorVersion + ) + + self.tables = tableDirectory.map { entry in + Table( + tag: entry.tag.stringValue, + origLength: entry.origLength, + transformLength: entry.transformLength + ) + } + + // Build sfnt file from decompressed data, applying transforms + self.fontData = try Self.buildSfntFile( + flavor: parsedHeader.flavor, + tables: self.tables, + tableDirectory: tableDirectory, + decompressedData: decompressedData + ) + } + + // MARK: - Header Parsing + + private static func parseHeader(from data: Data) throws -> WOFF2Header { + guard data.count >= 48 else { + throw WOFF2Error.invalidHeader + } + + var offset = 0 + + // Read signature (should be 0x774F4632 = "wOF2") + let signature = data.readUInt32(at: &offset) + guard signature == 0x774F4632 else { + throw WOFF2Error.invalidSignature + } + + let flavor = data.readUInt32(at: &offset) + let length = data.readUInt32(at: &offset) + let numTables = data.readUInt16(at: &offset) + let reserved = data.readUInt16(at: &offset) + let totalSfntSize = data.readUInt32(at: &offset) + let totalCompressedSize = data.readUInt32(at: &offset) + let majorVersion = data.readUInt16(at: &offset) + let minorVersion = data.readUInt16(at: &offset) + let metaOffset = data.readUInt32(at: &offset) + let metaLength = data.readUInt32(at: &offset) + let metaOrigLength = data.readUInt32(at: &offset) + let privOffset = data.readUInt32(at: &offset) + let privLength = data.readUInt32(at: &offset) + + return WOFF2Header( + signature: signature, + flavor: flavor, + length: length, + numTables: numTables, + reserved: reserved, + totalSfntSize: totalSfntSize, + totalCompressedSize: totalCompressedSize, + majorVersion: majorVersion, + minorVersion: minorVersion, + metaOffset: metaOffset, + metaLength: metaLength, + metaOrigLength: metaOrigLength, + privOffset: privOffset, + privLength: privLength + ) + } + + // MARK: - Table Directory Parsing + + private static func parseTableDirectory(from data: Data, header: WOFF2Header) throws -> [TableDirectoryEntry] { + var offset = 48 // After header + var entries: [TableDirectoryEntry] = [] + + for _ in 0..> 6) & 0x03 + + if tagBits == 10 || tagBits == 11 { // glyf or loca + if transformVersion == 0 || transformVersion == 1 { + transformLength = try data.readUIntBase128(at: &offset) + } + } else { + if transformVersion != 0 { + transformLength = try data.readUIntBase128(at: &offset) + } + } + + entries.append(TableDirectoryEntry( + tag: tag, + flags: flags, + origLength: origLength, + transformLength: transformLength + )) + } + + return entries + } + + // MARK: - Brotli Decompression + + private static func decompressBrotli(data: Data, header: WOFF2Header) throws -> Data { + // Find where compressed data starts + var compressedDataOffset = 48 // After header + + // Skip table directory to find compressed data + var tempOffset = compressedDataOffset + for _ in 0..> 6) & 0x03 + + if tagBits == 10 || tagBits == 11 { // glyf or loca + if transformVersion == 0 || transformVersion == 1 { + _ = try data.readUIntBase128(at: &tempOffset) + } + } else { + if transformVersion != 0 { + _ = try data.readUIntBase128(at: &tempOffset) + } + } + } + + compressedDataOffset = tempOffset + let compressedLength = Int(header.totalCompressedSize) + + guard compressedDataOffset + compressedLength <= data.count else { + throw WOFF2Error.invalidCompressedData + } + + let compressedData = data.subdata(in: compressedDataOffset..<(compressedDataOffset + compressedLength)) + + do { + return try compressedData.decompressBrotli(decompressedSize: Int(header.totalSfntSize)) + } catch { + throw WOFF2Error.decompressionFailed + } + } + + // MARK: - Build sfnt File + + private static func buildSfntFile( + flavor: UInt32, + tables: [Table], + tableDirectory: [TableDirectoryEntry], + decompressedData: Data + ) throws -> Data { + let numTables = UInt16(tables.count) + + // Calculate searchRange, entrySelector, rangeShift + var searchRange: UInt16 = 1 + var entrySelector: UInt16 = 0 + while searchRange * 2 <= numTables { + searchRange *= 2 + entrySelector += 1 + } + searchRange *= 16 + let rangeShift = numTables * 16 - searchRange + + // Calculate where table data starts (after header + table directory) + let headerSize = 12 + let tableDirectorySize = Int(numTables) * 16 + var currentOffset = UInt32(headerSize + tableDirectorySize) + + // Extract table data from decompressed stream, applying transforms + var decompressedOffset = 0 + var tableDataList: [(tag: String, checksum: UInt32, offset: UInt32, length: UInt32, data: Data)] = [] + + // Find glyf and loca indices for transform handling + var glyfIndex: Int? + var locaIndex: Int? + for (index, entry) in tableDirectory.enumerated() { + if entry.tag == .glyf { glyfIndex = index } + if entry.tag == .loca { locaIndex = index } + } + + // Check if glyf is transformed (transformVersion == 0 for glyf means transformed) + var glyfTransformed = false + if let gi = glyfIndex { + let flags = tableDirectory[gi].flags + let transformVersion = (flags >> 6) & 0x03 + // For glyf, transformVersion 0 means transformed, 3 means not transformed + glyfTransformed = (transformVersion == 0) + } + + var reconstructedGlyf: Data? + var reconstructedLoca: Data? + + // If glyf is transformed, reconstruct it + if glyfTransformed, let gi = glyfIndex { + // Find glyf offset in decompressed data + var glyfOffset = 0 + for i in 0.. 0 { + sfntData.append(Data(count: padding)) + } + } + + return sfntData + } + + private static func calculateChecksum(_ data: Data) -> UInt32 { + var sum: UInt32 = 0 + var i = 0 + while i + 4 <= data.count { + let value = UInt32(data[i]) << 24 | + UInt32(data[i + 1]) << 16 | + UInt32(data[i + 2]) << 8 | + UInt32(data[i + 3]) + sum = sum &+ value + i += 4 + } + // Handle remaining bytes + if i < data.count { + var value: UInt32 = 0 + var shift = 24 + while i < data.count { + value |= UInt32(data[i]) << shift + shift -= 8 + i += 1 + } + sum = sum &+ value + } + return sum + } + + // MARK: - Name Table Parsing + + private func parsePostScriptName() -> String? { + // fontData is now a proper sfnt file, so we need to read the table directory + // to find the name table offset + guard fontData.count >= 12 else { return nil } + + var offset = 4 // Skip sfnt version + let numTables = fontData.readUInt16(at: &offset) + offset = 12 // Skip rest of header (searchRange, entrySelector, rangeShift) + + // Find the name table in the table directory + var nameTableOffset: Int? + for _ in 0..> 24) & 0xFF)) + chars.append(UInt8((value >> 16) & 0xFF)) + chars.append(UInt8((value >> 8) & 0xFF)) + chars.append(UInt8(value & 0xFF)) + return String(bytes: chars, encoding: .ascii) ?? "????" + } + } +} + +// MARK: - Errors + +enum WOFF2Error: Error { + case invalidHeader + case invalidSignature + case invalidTableDirectory + case invalidFormat + case invalidCompressedData + case decompressionFailed + #if canImport(CoreGraphics) + case invalidFontData + #endif +} + +// MARK: - CGFont + +#if canImport(CoreGraphics) +import CoreGraphics + +extension WOFF2 { + /// Creates a CGFont from the reconstructed font data + /// - Returns: A CGFont instance + /// - Throws: WOFF2Error.invalidFontData if the font cannot be created + func makeCGFont() throws -> CGFont { + guard let provider = CGDataProvider(data: fontData as CFData), + let font = CGFont(provider) else { + throw WOFF2Error.invalidFontData + } + return font + } +} +#endif + +// MARK: - Data Extensions + +private extension Data { + mutating func readUInt8(at offset: inout Int) -> UInt8 { + let value = self[offset] + offset += 1 + return value + } + + func readUInt16(at offset: inout Int) -> UInt16 { + let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) + offset += 2 + return value + } + + func readUInt32(at offset: inout Int) -> UInt32 { + let value = UInt32(self[offset]) << 24 | + UInt32(self[offset + 1]) << 16 | + UInt32(self[offset + 2]) << 8 | + UInt32(self[offset + 3]) + offset += 4 + return value + } + + func readUIntBase128(at offset: inout Int) throws -> UInt32 { + var result: UInt32 = 0 + + for i in 0..<5 { + guard offset < self.count else { + throw WOFF2Error.invalidFormat + } + + let byte = self[offset] + offset += 1 + + // If last iteration and high bit is set, overflow + if i == 4 && (byte & 0x80) != 0 { + throw WOFF2Error.invalidFormat + } + + result = (result << 7) | UInt32(byte & 0x7F) + + if (byte & 0x80) == 0 { + break + } + } + + return result + } + + mutating func appendUInt16(_ value: UInt16) { + append(UInt8((value >> 8) & 0xFF)) + append(UInt8(value & 0xFF)) + } + + mutating func appendUInt32(_ value: UInt32) { + append(UInt8((value >> 24) & 0xFF)) + append(UInt8((value >> 16) & 0xFF)) + append(UInt8((value >> 8) & 0xFF)) + append(UInt8(value & 0xFF)) + } +} + +#endif diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.Builder.LayerTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.Builder.LayerTests.swift index 274d2b7..b9fb779 100644 --- a/SwiftDraw/Tests/LayerTree/LayerTree.Builder.LayerTests.swift +++ b/SwiftDraw/Tests/LayerTree/LayerTree.Builder.LayerTests.swift @@ -37,8 +37,9 @@ final class LayerTreeBuilderLayerTests: XCTestCase { func testMakeTextContentsFromDOM() { let text = DOM.Text(value: "Hello") - let contents = LayerTree.Builder.makeTextContents(from: text, with: .init()) - + let builder = LayerTree.Builder(svg: DOM.SVG(width: 10, height: 10)) + let contents = builder.makeTextContents(from: text, with: .init()) + guard case .text(let t, _, _) = contents else { XCTFail(); return } XCTAssertEqual(t, "Hello") } diff --git a/SwiftDraw/Tests/LayerTree/LayerTree.LayerTests.swift b/SwiftDraw/Tests/LayerTree/LayerTree.LayerTests.swift index ef365e5..fd8ca67 100644 --- a/SwiftDraw/Tests/LayerTree/LayerTree.LayerTests.swift +++ b/SwiftDraw/Tests/LayerTree/LayerTree.LayerTests.swift @@ -101,7 +101,7 @@ extension LayerTree.TextAttributes { static var normal: Self { LayerTree.TextAttributes( color: .black, - fontName: "Helvetica", + font: [.name("Times New Roman")], size: 12.0, anchor: .start ) diff --git a/SwiftDraw/Tests/Test.bundle/Barrio-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/Barrio-Regular.woff2 new file mode 100644 index 0000000..b99c12a Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Barrio-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/EBGaramond-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/EBGaramond-Regular.woff2 new file mode 100644 index 0000000..1bb2eaf Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/EBGaramond-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/Inter-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/Inter-Regular.woff2 new file mode 100644 index 0000000..412ff55 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Inter-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/Lato-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/Lato-Regular.woff2 new file mode 100644 index 0000000..ff60934 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Lato-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/OFL.txt b/SwiftDraw/Tests/Test.bundle/OFL.txt new file mode 100644 index 0000000..99b3fd5 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/OFL.txt @@ -0,0 +1,106 @@ +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +===================================================== + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +================================================================================ +FONTS INCLUDED IN THIS BUNDLE +================================================================================ + +The following fonts are included under the SIL Open Font License: + +- Inter (Rasmus Andersson) +- Roboto (Google) +- Roboto Medium Latin (Google) +- Lato (Łukasz Dziedzic) +- EB Garamond (Georg Duffner) +- Barrio (Omnibus-Type) +- Playwrite US Trad Guides (TypeTogether) +- Source Code Pro (Adobe) +- Silkscreen (Jason Kottke) +- VT323 (Peter Hull) + +Fonts were obtained from their respective source repositories and are +used in this test bundle for testing font parsing and rendering. diff --git a/SwiftDraw/Tests/Test.bundle/PlaywriteUSTradGuides-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/PlaywriteUSTradGuides-Regular.woff2 new file mode 100644 index 0000000..e9246eb Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/PlaywriteUSTradGuides-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/README.md b/SwiftDraw/Tests/Test.bundle/README.md new file mode 100644 index 0000000..f74096d --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/README.md @@ -0,0 +1,19 @@ +# Test Fonts + +Fonts used for testing WOFF/WOFF2 parsing and rendering. All fonts are licensed under the [SIL Open Font License](OFL.txt). + +| Filename | Font | Author | Description | +|----------|------|--------|-------------| +| Inter-Regular.woff2 | [Inter](https://rsms.me/inter/) | Rasmus Andersson | Modern sans-serif designed for screens, similar to San Francisco | +| Roboto-Regular.woff2 | [Roboto](https://fonts.google.com/specimen/Roboto) | Christian Robertson | Google's signature font family featuring friendly and open curves | +| Roboto-Regular.woff | [Roboto](https://fonts.google.com/specimen/Roboto) | Christian Robertson | WOFF version for testing | +| Roboto-Regular.ttf | [Roboto](https://fonts.google.com/specimen/Roboto) | Christian Robertson | TTF version for testing | +| Roboto-Medium-Latin.woff2 | [Roboto](https://fonts.google.com/specimen/Roboto) | Christian Robertson | Medium weight, Latin subset | +| Lato-Regular.woff2 | [Lato](https://fonts.google.com/specimen/Lato) | Łukasz Dziedzic | Sans-serif with classical proportions and semi-rounded details | +| EBGaramond-Regular.woff2 | [EB Garamond](https://github.com/georgd/EB-Garamond) | Georg Duffner | Revival of Claude Garamond's classic 16th century typeface | +| Barrio-Regular.woff2 | [Barrio](https://github.com/Omnibus-Type/Barrio) | Omnibus-Type | Playful display font with a hand-drawn, urban aesthetic | +| PlaywriteUSTradGuides-Regular.woff2 | [Playwrite US Trad Guides](https://fonts.google.com/specimen/Playwrite+US+Trad+Guides) | TypeTogether | Handwriting font with guide lines for learning cursive | +| SourceCodePro-Regular.woff2 | [Source Code Pro](https://fonts.google.com/specimen/Source+Code+Pro) | Paul D. Hunt / Adobe | Monospaced font designed for coding environments | +| SourceCodePro-Regular.otf | [Source Code Pro](https://fonts.google.com/specimen/Source+Code+Pro) | Paul D. Hunt / Adobe | OTF version for testing | +| Silkscreen-Regular.woff2 | [Silkscreen](https://kottke.org/plus/type/silkscreen/) | Jason Kottke | Bitmap-style pixel font inspired by classic computer interfaces | +| VT323-Regular.woff2 | [VT323](https://github.com/phoikoi/VT323) | Peter Hull | Monospace font inspired by 1980s terminal screens | diff --git a/SwiftDraw/Tests/Test.bundle/Roboto-Medium-Latin.woff2 b/SwiftDraw/Tests/Test.bundle/Roboto-Medium-Latin.woff2 new file mode 100644 index 0000000..29342a8 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Roboto-Medium-Latin.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/Roboto-Regular.ttf b/SwiftDraw/Tests/Test.bundle/Roboto-Regular.ttf new file mode 100644 index 0000000..bcaeba1 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Roboto-Regular.ttf differ diff --git a/SwiftDraw/Tests/Test.bundle/Roboto-Regular.woff b/SwiftDraw/Tests/Test.bundle/Roboto-Regular.woff new file mode 100644 index 0000000..b523706 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Roboto-Regular.woff differ diff --git a/SwiftDraw/Tests/Test.bundle/Roboto-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/Roboto-Regular.woff2 new file mode 100644 index 0000000..1d1539e Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Roboto-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/Silkscreen-Regular.ttf b/SwiftDraw/Tests/Test.bundle/Silkscreen-Regular.ttf new file mode 100755 index 0000000..b110719 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Silkscreen-Regular.ttf differ diff --git a/SwiftDraw/Tests/Test.bundle/Silkscreen-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/Silkscreen-Regular.woff2 new file mode 100644 index 0000000..0122317 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/Silkscreen-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/SourceCodePro-Regular.otf b/SwiftDraw/Tests/Test.bundle/SourceCodePro-Regular.otf new file mode 100644 index 0000000..16c7b0b Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/SourceCodePro-Regular.otf differ diff --git a/SwiftDraw/Tests/Test.bundle/SourceCodePro-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/SourceCodePro-Regular.woff2 new file mode 100644 index 0000000..a1622c3 Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/SourceCodePro-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/VT323-Regular.woff2 b/SwiftDraw/Tests/Test.bundle/VT323-Regular.woff2 new file mode 100644 index 0000000..b2b2c5b Binary files /dev/null and b/SwiftDraw/Tests/Test.bundle/VT323-Regular.woff2 differ diff --git a/SwiftDraw/Tests/Test.bundle/fontface-ttf.svg b/SwiftDraw/Tests/Test.bundle/fontface-ttf.svg new file mode 100644 index 0000000..662f365 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/fontface-ttf.svg @@ -0,0 +1,11 @@ + + + + + Every leaf that trembles + diff --git a/SwiftDraw/Tests/WOFF/Data+BrotliTests.swift b/SwiftDraw/Tests/WOFF/Data+BrotliTests.swift new file mode 100644 index 0000000..9984969 --- /dev/null +++ b/SwiftDraw/Tests/WOFF/Data+BrotliTests.swift @@ -0,0 +1,58 @@ +// +// Data+BrotliTests.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// + +#if canImport(Compression) +import Foundation +import Testing +@testable import SwiftDraw + +struct DataBrotliTests { + + @Test + func brotli_decompresses_string() throws { + let base64 = "CwaASGVsbG8sIFdvcmxkIQM=" + let compressed = Data(base64Encoded: base64)! + let decompressed = try compressed.decompressBrotli(decompressedSize: 13) + #expect( + String(data: decompressed, encoding: .utf8) == "Hello, World!" + ) + } + + @Test + func brotli_throws_on_invalid_data() { + let invalidData = Data([0x00, 0x01, 0x02, 0x03]) + + #expect(throws: BrotliError.self) { + try invalidData.decompressBrotli(decompressedSize: 100) + } + } +} +#endif diff --git a/SwiftDraw/Tests/WOFF/TTFTests.swift b/SwiftDraw/Tests/WOFF/TTFTests.swift new file mode 100644 index 0000000..8e998a5 --- /dev/null +++ b/SwiftDraw/Tests/WOFF/TTFTests.swift @@ -0,0 +1,108 @@ +// +// TTFTests.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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 Foundation +import Testing +#if canImport(CoreGraphics) +import CoreGraphics +#endif +@testable import SwiftDraw + +struct TTFTests { + + @Test + func parses_TTF_from_Roboto() throws { + let ttf = try TTF(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.ttf")) + + #expect(ttf.header.numTables > 0) + #expect(!ttf.tables.isEmpty) + #expect(ttf.fontData.count > 0) + } + + @Test + func extracts_postscript_name_from_Roboto_TTF() throws { + let ttf = try TTF(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.ttf")) + + #expect(ttf.postScriptName == "RobotoRegular") + } + + @Test + func parses_OTF_from_SourceCodePro() throws { + let otf = try TTF(contentsOf: Bundle.test.url(forResource: "SourceCodePro-Regular.otf")) + + #expect(otf.header.sfntVersion == 0x4F54544F) // "OTTO" + #expect(otf.header.numTables > 0) + #expect(!otf.tables.isEmpty) + } + + @Test + func extracts_postscript_name_from_SourceCodePro_OTF() throws { + let otf = try TTF(contentsOf: Bundle.test.url(forResource: "SourceCodePro-Regular.otf")) + + #expect(otf.postScriptName == "SourceCodePro-Regular") + } + + @Test + func throws_on_invalid_data() { + let invalidData = Data([0x00, 0x01, 0x02, 0x03]) + + #expect(throws: TTFError.self) { + try TTF(data: invalidData) + } + } + + @Test + func throws_on_empty_data() { + let emptyData = Data() + + #expect(throws: TTFError.self) { + try TTF(data: emptyData) + } + } + +#if canImport(CoreGraphics) + @Test + func makes_CGFont_from_Roboto_TTF() throws { + let ttf = try TTF(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.ttf")) + let cgFont = try ttf.makeCGFont() + + #expect(cgFont.postScriptName == "RobotoRegular" as CFString) + } + + @Test + func makes_CGFont_from_SourceCodePro_OTF() throws { + let ttf = try TTF(contentsOf: Bundle.test.url(forResource: "SourceCodePro-Regular.otf")) + let cgFont = try ttf.makeCGFont() + + #expect(cgFont.postScriptName == "SourceCodePro-Regular" as CFString) + } +#endif +} diff --git a/SwiftDraw/Tests/WOFF/WOFF2Tests.swift b/SwiftDraw/Tests/WOFF/WOFF2Tests.swift new file mode 100644 index 0000000..a9d3378 --- /dev/null +++ b/SwiftDraw/Tests/WOFF/WOFF2Tests.swift @@ -0,0 +1,221 @@ +// +// WOFF2Tests.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// + +#if canImport(Compression) +import Foundation +import Testing +import CoreGraphics +import CoreText +import ImageIO +@testable import SwiftDraw + +struct WOFF2Tests { + + @Test + func bundle_loads_urls() { + let url = Bundle.test.url(forResource: "Roboto-Regular.woff2", withExtension: nil) + #expect(url != nil) + } + + @Test + func bundle_does_not_load_missing_urls() { + let url = Bundle.test.url(forResource: "Missing.woff2", withExtension: nil) + #expect(url == nil) + } + + @Test + func parses_WOFF2_from_Roboto() throws { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.woff2")) + + #expect(woff2.header.numTables > 0) + #expect(!woff2.tables.isEmpty) + #expect(woff2.fontData.count > 0) + } + + @Test + func extracts_postscript_name_from_Roboto() throws { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.woff2")) + + #expect(woff2.postScriptName == "RobotoRegular") + } + + @Test + func throws_on_invalid_data() { + let invalidData = Data([0x00, 0x01, 0x02, 0x03]) + + #expect(throws: WOFF2Error.self) { + try WOFF2(data: invalidData) + } + } + + @Test + func throws_on_empty_data() { + let emptyData = Data() + + #expect(throws: WOFF2Error.self) { + try WOFF2(data: emptyData) + } + } + + @Test + func makes_CGFont_from_Roboto_WOFF2() throws { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.woff2")) + let cgFont = try woff2.makeCGFont() + + #expect(cgFont.postScriptName == "RobotoRegular" as CFString) + } + + @Test + func makes_CGFont_from_Lato_WOFF2() throws { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: "Lato-Regular.woff2")) + let cgFont = try woff2.makeCGFont() + + #expect(cgFont.postScriptName == "Lato-Regular" as CFString) + } + + @Test + func makes_CGFont_from_SourceCodePro_WOFF2() throws { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: "SourceCodePro-Regular.woff2")) + let cgFont = try woff2.makeCGFont() + + #expect(cgFont.postScriptName == "SourceCodeProExtraLight-Regular" as CFString) + } + + @Test + func makes_CGFont_from_Roboto_Medium_Latin_WOFF2() throws { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: "Roboto-Medium-Latin.woff2")) + let cgFont = try woff2.makeCGFont() + + #expect(cgFont.postScriptName != nil) + } + + @Test + func generates_font_preview_PDF() throws { + let woff2Fonts: [(filename: String, displayName: String)] = [ + ("Barrio-Regular.woff2", "Barrio"), + ("EBGaramond-Regular.woff2", "EB Garamond"), + ("Inter-Regular.woff2", "Inter"), + + ("PlaywriteUSTradGuides-Regular.woff2", "Playwrite US Trad"), + ("Roboto-Regular.woff2", "Roboto"), + ("Silkscreen-Regular.woff2", "Silkscreen"), + ("SourceCodePro-Regular.woff2", "Source Code Pro"), + ("VT323-Regular.woff2", "VT323") + ] + + let sampleText = "The times they are a-changin'" + let outputDir = FileManager.default.temporaryDirectory.appendingPathComponent("FontPreviews") + try? FileManager.default.removeItem(at: outputDir) + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + // Build font entries with measured dimensions + var fontEntries: [(displayName: String, ctFont: CTFont, textWidth: CGFloat, rowHeight: CGFloat)] = [] + for (filename, displayName) in woff2Fonts { + let woff2 = try WOFF2(contentsOf: Bundle.test.url(forResource: filename)) + let cgFont = try woff2.makeCGFont() + let ctFont = CTFontCreateWithGraphicsFont(cgFont, 48, nil, nil) + let textWidth = measureTextWidth(text: sampleText, font: ctFont) + let ascent = CTFontGetAscent(ctFont) + let descent = CTFontGetDescent(ctFont) + let rowHeight = max(ascent + descent + 30, 80) + fontEntries.append((displayName, ctFont, textWidth, rowHeight)) + } + + // Calculate page size based on widest text and total height + let maxTextWidth = fontEntries.map(\.textWidth).max() ?? 500 + let margin: CGFloat = 40 + let topPadding: CGFloat = 30 + let totalRowHeight = fontEntries.map(\.rowHeight).reduce(0, +) + let pageWidth = ceil(maxTextWidth) + margin * 2 + let pageHeight = totalRowHeight + margin + topPadding + + let outputURL = outputDir.appendingPathComponent("FontPreviews.pdf") + let pdfData = NSMutableData() + + var mediaBox = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) + guard let consumer = CGDataConsumer(data: pdfData as CFMutableData), + let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { + return + } + + pdfContext.beginPDFPage(nil) + + // White background + pdfContext.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1)) + pdfContext.fill(mediaBox) + + let grayColor = CGColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1) + let blackColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1) + let labelFont = CTFontCreateWithName("Helvetica" as CFString, 12, nil) + + var yOffset = pageHeight - topPadding + for entry in fontEntries { + let descent = CTFontGetDescent(entry.ctFont) + let baseline = yOffset - entry.rowHeight + descent + 20 + + // Draw sample text + let sampleAttributes: [CFString: Any] = [ + kCTFontAttributeName: entry.ctFont, + kCTForegroundColorAttributeName: blackColor + ] + let sampleString = CFAttributedStringCreate(nil, sampleText as CFString, sampleAttributes as CFDictionary)! + let sampleLine = CTLineCreateWithAttributedString(sampleString) + pdfContext.textPosition = CGPoint(x: margin, y: baseline) + CTLineDraw(sampleLine, pdfContext) + + // Draw font name label (below sample text) + let labelAttributes: [CFString: Any] = [ + kCTFontAttributeName: labelFont, + kCTForegroundColorAttributeName: grayColor + ] + let labelString = CFAttributedStringCreate(nil, entry.displayName as CFString, labelAttributes as CFDictionary)! + let labelLine = CTLineCreateWithAttributedString(labelString) + pdfContext.textPosition = CGPoint(x: margin, y: baseline - descent - 16) + CTLineDraw(labelLine, pdfContext) + + yOffset -= entry.rowHeight + } + + pdfContext.endPDFPage() + pdfContext.closePDF() + + try pdfData.write(to: outputURL, options: .atomic) + print("Font preview PDF written to: \(outputURL.path)") + } + + private func measureTextWidth(text: String, font: CTFont) -> CGFloat { + let attributes: [CFString: Any] = [kCTFontAttributeName: font] + let attrString = CFAttributedStringCreate(nil, text as CFString, attributes as CFDictionary)! + let line = CTLineCreateWithAttributedString(attrString) + return CTLineGetTypographicBounds(line, nil, nil, nil) + } +} +#endif diff --git a/SwiftDraw/Tests/WOFF/WOFFTests.swift b/SwiftDraw/Tests/WOFF/WOFFTests.swift new file mode 100644 index 0000000..d17228d --- /dev/null +++ b/SwiftDraw/Tests/WOFF/WOFFTests.swift @@ -0,0 +1,82 @@ +// +// WOFFTests.swift +// swift-woff2 +// +// Created by Simon Whitty on 7/2/26. +// Copyright 2026 Simon Whitty +// +// Distributed under the permissive zlib license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-woff2 +// +// 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. +// + +#if canImport(Compression) +import Foundation +import Testing +import CoreGraphics +@testable import SwiftDraw + +struct WOFFTests { + + @Test + func parses_WOFF_from_Roboto() throws { + let woff = try WOFF(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.woff")) + + #expect(woff.header.numTables > 0) + #expect(!woff.tables.isEmpty) + #expect(woff.fontData.count > 0) + } + + @Test + func extracts_postscript_name_from_Roboto_WOFF() throws { + let woff = try WOFF(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.woff")) + + #expect(woff.postScriptName == "Roboto-Regular") + } + + @Test + func throws_on_invalid_data() { + let invalidData = Data([0x00, 0x01, 0x02, 0x03]) + + #expect(throws: WOFFError.self) { + try WOFF(data: invalidData) + } + } + + @Test + func throws_on_empty_data() { + let emptyData = Data() + + #expect(throws: WOFFError.self) { + try WOFF(data: emptyData) + } + } + + @Test + func makes_CGFont_from_Roboto_WOFF() throws { + let woff = try WOFF(contentsOf: Bundle.test.url(forResource: "Roboto-Regular.woff")) + let cgFont = try woff.makeCGFont() + + #expect(cgFont.postScriptName == "Roboto-Regular" as CFString) + } +} +#endif