diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 232e674..4db6ca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: with: swift-version: "5.9" + - name: Validate bundled CFF fixtures + run: python3 scripts/validate_cff_fixtures.py + - name: Run tests run: swift test diff --git a/README.md b/README.md index 0cc4d31..28e31cb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Specimen Press -Specimen Press inspects TrueType fonts and turns glyph outlines into readable SVG specimen sheets. +Specimen Press inspects TrueType and OpenType fonts and turns glyph outlines into readable SVG specimen sheets. It exposes one Swift library and one command-line tool. @@ -8,17 +8,19 @@ It exposes one Swift library and one command-line tool. Use it when a font needs a compact, repeatable inspection artifact. -The output shows glyph coverage, font metrics, baselines, and decoded outline paths. +The output shows glyph coverage, font metrics, baselines, decoded outline paths, and the outline format. -The bundled fixture makes the demo and tests run without network access or secrets. +The bundled fixtures make the demo and tests run without network access or secrets. ## Capabilities -- Parse head, cmap, hmtx, and glyf tables. +- Parse head, cmap, hmtx, and glyf tables for TrueType outlines. +- Parse CFF and CFF2 charstrings for PostScript outlines. - Read loca, maxp, hhea, and optional name data. -- Decode simple and composite glyph outlines. -- Report metrics and Unicode coverage as FontSummary. -- Render an SVG specimen sheet for a TrueType font. +- Decode simple and composite TrueType glyph outlines. +- Execute Type 2 charstrings for CFF and CFF2 glyphs. +- Report metrics, outline format, and Unicode coverage as FontSummary. +- Render an SVG specimen sheet for a supported font. - Draw sample text, metric lines, and an uppercase glyph grid. ## Architecture @@ -27,7 +29,9 @@ BinaryReader reads big-endian values with bounds checks. TrueTypeFont validates the table directory and coordinates table parsing. -GlyphDecoder expands simple contours and composite components. +GlyphDecoder expands TrueType simple contours and composite components. + +CFFDecoder reads CFF and CFF2 dictionaries and executes Type 2 charstrings. SpecimenRenderer maps outlines into a stable SVG layout. @@ -47,7 +51,7 @@ swift run specimen-press demo --output specimen.svg Open specimen.svg in a browser or vector editor. -Inspect any TrueType font. +Inspect any supported font. ~~~sh swift run specimen-press inspect /path/to/font.ttf @@ -56,7 +60,7 @@ swift run specimen-press inspect /path/to/font.ttf Render a custom sheet. ~~~sh -swift run specimen-press render /path/to/font.ttf --output specimen.svg --sample "A SHORT FONT SAMPLE" +swift run specimen-press render /path/to/font.otf --output specimen.svg --sample "A SHORT FONT SAMPLE" ~~~ Run tests and a release build. @@ -68,11 +72,12 @@ swift build -c release ## Sample output -The bundled fixture reports stable values. +The bundled TrueType fixture reports stable values. ~~~json { "familyName" : "Specimen Press", + "outlineFormat" : "trueType", "mappedCodePointCount" : 27, "metrics" : { "ascender" : 800, @@ -85,6 +90,8 @@ The bundled fixture reports stable values. } ~~~ +The bundled CFF fixture reports `"outlineFormat" : "cff"`. + The demo writes one SVG file. ~~~text @@ -93,7 +100,7 @@ Wrote specimen.svg ## Tests -Tests assert fixture metrics, coverage ranges, glyph outlines, SVG labels, and malformed input handling. +Tests assert fixture metrics, coverage ranges, TrueType and CFF outlines, SVG labels, and malformed input handling. Local status: not run because Swift is unavailable in the supplied Windows environment. @@ -101,8 +108,8 @@ CI runs swift test, a release build, and the bundled demo on macOS. ## Limitations -- The first release supports TrueType outlines in glyf. -- It does not parse CFF or CFF2 outlines. +- CFF decoding supports common Type 2 drawing operators and subroutine calls. +- It does not execute hintmask or counter mask operators. - It reads cmap formats 4 and 12. - It does not execute TrueType hinting instructions. - It renders uppercase A-Z cells and uses glyph zero for missing sample characters. @@ -111,7 +118,7 @@ CI runs swift test, a release build, and the bundled demo on macOS. ## Roadmap -1. Add CFF and CFF2 outline inspection. +1. ~~Add CFF and CFF2 outline inspection.~~ 2. Add kerning and variable-font axis summaries. 3. Add optional JSON and SVG snapshot fixtures. 4. Add contour winding diagnostics. @@ -120,4 +127,4 @@ CI runs swift test, a release build, and the bundled demo on macOS. The library and CLI use the MIT License. -The bundled fixture uses the CC0 1.0 Universal dedication in FONT-LICENSE.txt. +The bundled fixtures use the CC0 1.0 Universal dedication in FONT-LICENSE.txt. diff --git a/Sources/SpecimenPress/CFFDecoder.swift b/Sources/SpecimenPress/CFFDecoder.swift new file mode 100644 index 0000000..147cd61 --- /dev/null +++ b/Sources/SpecimenPress/CFFDecoder.swift @@ -0,0 +1,525 @@ +import Foundation + +final class CFFDecoder { + private let reader: BinaryReader + private let table: TableRecord + private let isCFF2: Bool + private let glyphCount: Int + private let charStrings: [Data] + private let globalSubrs: [Data] + private let localSubrs: [Data] + private var cache: [Int: GlyphOutline] = [:] + + init(reader: BinaryReader, table: TableRecord, isCFF2: Bool, glyphCount: Int) throws { + self.reader = reader + self.table = table + self.isCFF2 = isCFF2 + self.glyphCount = glyphCount + + let base = table.offset + guard table.length >= 4 else { throw FontError.invalidTable(isCFF2 ? "CFF2" : "CFF ") } + + let major = try reader.uint8(at: base) + guard major == (isCFF2 ? 2 : 1) else { + throw FontError.invalidValue("The CFF header version is unsupported.") + } + + let topDictData: Data + if isCFF2 { + guard table.length >= 5 else { throw FontError.invalidTable("CFF2") } + let hdrSize = Int(try reader.uint8(at: base + 2)) + let topDictLength = Int(try reader.uint16(at: base + 3)) + guard hdrSize >= 5, topDictLength > 0 else { throw FontError.invalidTable("CFF2") } + try reader.require(offset: base + hdrSize, length: topDictLength) + topDictData = try reader.subdata(offset: base + hdrSize, length: topDictLength) + var cursor = base + hdrSize + topDictLength + cursor = try Self.skipIndex(reader: reader, offset: cursor) + globalSubrs = try Self.readIndex(reader: reader, offset: cursor) + } else { + var cursor = base + Int(try reader.uint8(at: base + 2)) + cursor = try Self.skipIndex(reader: reader, offset: cursor) + topDictData = try Self.readIndexItem(reader: reader, offset: cursor, index: 0) + cursor = try Self.skipIndex(reader: reader, offset: cursor) + cursor = try Self.skipIndex(reader: reader, offset: cursor) + globalSubrs = try Self.readIndex(reader: reader, offset: cursor) + } + + let topDict = try Self.parseDict(data: topDictData, isCFF2: isCFF2) + let charStringsKey = isCFF2 ? 17 : 14 + guard let charStringsOffset = topDict.offsets[charStringsKey] else { + throw FontError.invalidTable(isCFF2 ? "CFF2" : "CFF ") + } + let privateInfo = topDict.privateInfo + + charStrings = try Self.readIndex(reader: reader, offset: base + charStringsOffset) + + if let privateInfo { + let privateData = try reader.subdata( + offset: base + privateInfo.offset, + length: privateInfo.size + ) + let privateDict = try Self.parseDict(data: privateData, isCFF2: isCFF2) + if let localOffset = privateDict.offsets[19] { + localSubrs = try Self.readIndex( + reader: reader, + offset: base + privateInfo.offset + localOffset + ) + } else { + localSubrs = [] + } + } else { + localSubrs = [] + } + } + + func outline(for index: Int) throws -> GlyphOutline? { + guard index >= 0, index < glyphCount else { + throw FontError.invalidValue("Glyph index is out of range.") + } + if let cached = cache[index] { return cached } + + guard index < charStrings.count else { + let empty = GlyphOutline(bounds: GlyphBounds(minX: 0, minY: 0, maxX: 0, maxY: 0), contours: []) + cache[index] = empty + return empty + } + + let interpreter = CharStringInterpreter( + isCFF2: isCFF2, + globalSubrs: globalSubrs, + localSubrs: localSubrs + ) + let result = try interpreter.outline(from: charStrings[index]) + cache[index] = result + return result + } +} + +private extension CFFDecoder { + struct DictValues { + var offsets: [Int: Int] = [:] + var privateInfo: (size: Int, offset: Int)? + } + + static func skipIndex(reader: BinaryReader, offset: Int) throws -> Int { + let count = try readIndexCount(reader: reader, offset: offset) + guard count >= 0 else { throw FontError.invalidTable("CFF") } + if count == 0 { return offset + indexCountSize(reader: reader, offset: offset) } + + let countFieldSize = indexCountSize(reader: reader, offset: offset) + let offSize = Int(try reader.uint8(at: offset + countFieldSize)) + guard offSize >= 1, offSize <= 4 else { throw FontError.invalidTable("CFF") } + return offset + countFieldSize + 1 + (count + 1) * offSize + } + + static func readIndex(reader: BinaryReader, offset: Int) throws -> [Data] { + let count = try readIndexCount(reader: reader, offset: offset) + guard count >= 0 else { throw FontError.invalidTable("CFF") } + if count == 0 { return [] } + + let countFieldSize = indexCountSize(reader: reader, offset: offset) + let offSize = Int(try reader.uint8(at: offset + countFieldSize)) + guard offSize >= 1, offSize <= 4 else { throw FontError.invalidTable("CFF") } + + let offsetsStart = offset + countFieldSize + 1 + let dataStart = offsetsStart + (count + 1) * offSize + var items: [Data] = [] + items.reserveCapacity(count) + for index in 0..= start else { throw FontError.invalidTable("CFF") } + items.append(try reader.subdata(offset: dataStart + start, length: end - start)) + } + return items + } + + static func readIndexItem(reader: BinaryReader, offset: Int, index: Int) throws -> Data { + let items = try readIndex(reader: reader, offset: offset) + guard index < items.count else { throw FontError.invalidTable("CFF") } + return items[index] + } + + static func readIndexCount(reader: BinaryReader, offset: Int) throws -> Int { + let first = try reader.uint16(at: offset) + if first != 0xFFFF { return Int(first) } + return Int(try reader.uint32(at: offset + 2)) + } + + static func indexCountSize(reader: BinaryReader, offset: Int) throws -> Int { + let first = try reader.uint16(at: offset) + return first == 0xFFFF ? 6 : 2 + } + + static func readIndexOffset(reader: BinaryReader, at offset: Int, size: Int) throws -> Int { + switch size { + case 1: return Int(try reader.uint8(at: offset)) + case 2: return Int(try reader.uint16(at: offset)) + case 3: + let bytes = try reader.subdata(offset: offset, length: 3) + return (Int(bytes[0]) << 16) | (Int(bytes[1]) << 8) | Int(bytes[2]) + case 4: return Int(try reader.uint32(at: offset)) + default: throw FontError.invalidTable("CFF") + } + } + + static func parseDict(data: Data, isCFF2: Bool) throws -> DictValues { + var values = DictValues() + var operands: [Int] = [] + var cursor = 0 + + while cursor < data.count { + let byte = data[cursor] + cursor += 1 + + if byte >= 32 { + operands.append(try decodeCFFOperand(byte, data: data, cursor: &cursor)) + continue + } + + let operatorCode: Int + if byte == 12 { + guard cursor < data.count else { throw FontError.invalidTable("CFF") } + operatorCode = 1000 + Int(data[cursor]) + cursor += 1 + } else { + operatorCode = Int(byte) + } + + switch operatorCode { + case 14, 17: + guard let offset = operands.last else { throw FontError.invalidTable("CFF") } + values.offsets[operatorCode] = offset + case 18: + guard operands.count >= 2 else { throw FontError.invalidTable("CFF") } + values.privateInfo = (operands[operands.count - 2], operands[operands.count - 1]) + case 19: + guard let offset = operands.last else { throw FontError.invalidTable("CFF") } + values.offsets[19] = offset + default: + break + } + operands.removeAll(keepingCapacity: true) + } + return values + } + + static func decodeOperand(_ firstByte: UInt8, data: Data, cursor: inout Int) throws -> Int { + try decodeCFFOperand(firstByte, data: data, cursor: &cursor) + } +} + +private func decodeCFFOperand(_ firstByte: UInt8, data: Data, cursor: inout Int) throws -> Int { + let byte = Int(firstByte) + if byte >= 32, byte <= 246 { return byte - 139 } + if byte >= 247, byte <= 250 { + guard cursor < data.count else { throw FontError.invalidTable("CFF") } + return (byte - 247) * 256 + Int(data[cursor]) + 108 + } + if byte >= 251, byte <= 254 { + guard cursor < data.count else { throw FontError.invalidTable("CFF") } + return -(byte - 251) * 256 - Int(data[cursor]) - 108 + } + if byte == 28 { + guard cursor + 1 < data.count else { throw FontError.invalidTable("CFF") } + let value = (Int(data[cursor]) << 8) | Int(data[cursor + 1]) + cursor += 2 + return value >= 0x8000 ? value - 0x10000 : value + } + if byte == 29 { + guard cursor + 3 < data.count else { throw FontError.invalidTable("CFF") } + let value = (Int(data[cursor]) << 24) + | (Int(data[cursor + 1]) << 16) + | (Int(data[cursor + 2]) << 8) + | Int(data[cursor + 3]) + cursor += 4 + return value + } + throw FontError.invalidTable("CFF") +} + +private final class CharStringInterpreter { + private let isCFF2: Bool + private let globalSubrs: [Data] + private let localSubrs: [Data] + private var callDepth = 0 + + init(isCFF2: Bool, globalSubrs: [Data], localSubrs: [Data]) { + self.isCFF2 = isCFF2 + self.globalSubrs = globalSubrs + self.localSubrs = localSubrs + } + + func outline(from data: Data) throws -> GlyphOutline { + var builder = OutlineBuilder() + try execute(data, builder: &builder) + return builder.finish() + } + + private func execute(_ data: Data, builder: inout OutlineBuilder) throws { + guard callDepth < 16 else { throw FontError.unsupportedGlyph("CharString recursion is too deep.") } + callDepth += 1 + defer { callDepth -= 1 } + + var stack: [Double] = [] + var cursor = 0 + while cursor < data.count { + let byte = data[cursor] + cursor += 1 + + if byte >= 32 { + stack.append(Double(try decodeCFFOperand(byte, data: data, cursor: &cursor))) + continue + } + + if byte == 12 { + guard cursor < data.count else { throw FontError.invalidTable("CFF") } + let escaped = data[cursor] + cursor += 1 + switch escaped { + case 34: try builder.hhcurveto(stack: &stack) + case 35: try builder.vhcurveto(stack: &stack) + case 36: try builder.hvcurveto(stack: &stack) + case 37: try builder.vvcurveto(stack: &stack) + default: stack.removeAll(keepingCapacity: true) + } + continue + } + + switch byte { + case 1, 3: stack.removeAll(keepingCapacity: true) + case 4: try builder.vmoveto(stack: &stack) + case 5: try builder.rlineto(stack: &stack) + case 6: try builder.hlineto(stack: &stack) + case 7: try builder.vlineto(stack: &stack) + case 8: try builder.rrcurveto(stack: &stack) + case 10: + let index = try subrIndex(from: &stack, count: localSubrs.count) + try execute(localSubrs[index], builder: &builder) + case 11: return + case 14: + builder.closeContour() + return + case 21: try builder.rmoveto(stack: &stack) + case 22: try builder.hmoveto(stack: &stack) + case 29: + let index = try subrIndex(from: &stack, count: globalSubrs.count) + try execute(globalSubrs[index], builder: &builder) + default: + stack.removeAll(keepingCapacity: true) + } + } + } + + private func subrIndex(from stack: inout [Double], count: Int) throws -> Int { + guard let raw = stack.popLast() else { throw FontError.invalidTable("CFF") } + let index = Int(raw) + bias(for: count) + guard index >= 0, index < count else { throw FontError.unsupportedGlyph("CharString subroutine index is out of range.") } + return index + } + + private func bias(for count: Int) -> Int { + if isCFF2 { + if count < 1240 { return 107 } + if count < 33_900 { return 1_131 } + return 32_768 + } + if count < 1240 { return 107 } + if count < 3_390 { return 1_131 } + return 32_768 + } +} + +private struct OutlineBuilder { + private var contours: [GlyphContour] = [] + private var currentPoints: [GlyphPoint] = [] + private var x = 0.0 + private var y = 0.0 + private var minX = 0.0 + private var minY = 0.0 + private var maxX = 0.0 + private var maxY = 0.0 + private var hasPoint = false + + mutating func rmoveto(stack: inout [Double]) throws { + let dx: Double + let dy: Double + if stack.count >= 2 { + dx = stack.removeFirst() + dy = stack.removeFirst() + } else if let value = stack.popLast() { + dx = value + dy = 0 + } else { + throw FontError.invalidTable("CFF") + } + startContour() + move(dx: dx, dy: dy) + stack.removeAll(keepingCapacity: true) + } + + mutating func hmoveto(stack: inout [Double]) throws { + guard let dx = stack.popLast() else { throw FontError.invalidTable("CFF") } + startContour() + move(dx: dx, dy: 0) + stack.removeAll(keepingCapacity: true) + } + + mutating func vmoveto(stack: inout [Double]) throws { + guard let dy = stack.popLast() else { throw FontError.invalidTable("CFF") } + startContour() + move(dx: 0, dy: dy) + stack.removeAll(keepingCapacity: true) + } + + mutating func rlineto(stack: inout [Double]) throws { + while stack.count >= 2 { + let dx = stack.removeFirst() + let dy = stack.removeFirst() + line(dx: dx, dy: dy) + } + } + + mutating func hlineto(stack: inout [Double]) throws { + while let dx = stack.first { + stack.removeFirst() + line(dx: dx, dy: 0) + guard let dy = stack.first else { break } + stack.removeFirst() + line(dx: 0, dy: dy) + } + } + + mutating func vlineto(stack: inout [Double]) throws { + while let dy = stack.first { + stack.removeFirst() + line(dx: 0, dy: dy) + guard let dx = stack.first else { break } + stack.removeFirst() + line(dx: dx, dy: 0) + } + } + + mutating func rrcurveto(stack: inout [Double]) throws { + while stack.count >= 6 { + let dx1 = stack.removeFirst() + let dy1 = stack.removeFirst() + let dx2 = stack.removeFirst() + let dy2 = stack.removeFirst() + let dx = stack.removeFirst() + let dy = stack.removeFirst() + curve(dx1: dx1, dy1: dy1, dx2: dx2, dy2: dy2, dx: dx, dy: dy) + } + } + + mutating func hhcurveto(stack: inout [Double]) throws { + var index = 0 + while index + 3 < stack.count { + let dy1 = index == 0 && stack.count.isMultiple(of: 2) ? stack.removeFirst() : 0 + let dx1 = stack.removeFirst() + let dx2 = stack.removeFirst() + let dy2 = stack.removeFirst() + let dx = stack.removeFirst() + curve(dx1: dx1, dy1: dy1, dx2: dx2, dy2: dy2, dx: dx, dy: 0) + index += 4 + } + stack.removeAll(keepingCapacity: true) + } + + mutating func vvcurveto(stack: inout [Double]) throws { + while stack.count >= 4 { + let dx1 = stack.count.isMultiple(of: 2) ? stack.removeFirst() : 0 + let dy1 = stack.removeFirst() + let dy2 = stack.removeFirst() + let dx2 = stack.removeFirst() + let dy = stack.removeFirst() + curve(dx1: dx1, dy1: dy1, dx2: dx2, dy2: dy2, dx: 0, dy: dy) + } + stack.removeAll(keepingCapacity: true) + } + + mutating func hvcurveto(stack: inout [Double]) throws { + try alternatingCurveto(stack: &stack, horizontalFirst: true) + } + + mutating func vhcurveto(stack: inout [Double]) throws { + try alternatingCurveto(stack: &stack, horizontalFirst: false) + } + + mutating func closeContour() { + guard !currentPoints.isEmpty else { return } + contours.append(GlyphContour(points: currentPoints)) + currentPoints = [] + } + + mutating func finish() -> GlyphOutline { + closeContour() + let bounds = hasPoint + ? GlyphBounds(minX: minX, minY: minY, maxX: maxX, maxY: maxY) + : GlyphBounds(minX: 0, minY: 0, maxX: 0, maxY: 0) + return GlyphOutline(bounds: bounds, contours: contours) + } + + private mutating func startContour() { + closeContour() + } + + private mutating func move(dx: Double, dy: Double) { + x += dx + y += dy + addPoint(x: x, y: y, onCurve: true) + } + + private mutating func line(dx: Double, dy: Double) { + x += dx + y += dy + addPoint(x: x, y: y, onCurve: true) + } + + private mutating func curve(dx1: Double, dy1: Double, dx2: Double, dy2: Double, dx: Double, dy: Double) { + addPoint(x: x + dx1, y: y + dy1, onCurve: false) + addPoint(x: x + dx2, y: y + dy2, onCurve: false) + x += dx + y += dy + addPoint(x: x, y: y, onCurve: true) + } + + private mutating func addPoint(x: Double, y: Double, onCurve: Bool) { + currentPoints.append(GlyphPoint(x: x, y: y, isOnCurve: onCurve)) + if hasPoint { + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x) + maxY = max(maxY, y) + } else { + minX = x + minY = y + maxX = x + maxY = y + hasPoint = true + } + } + + private mutating func alternatingCurveto(stack: inout [Double], horizontalFirst: Bool) throws { + var horizontal = horizontalFirst + while stack.count >= 4 { + if horizontal { + let dy1 = stack.count.isMultiple(of: 2) ? stack.removeFirst() : 0 + let dx1 = stack.removeFirst() + let dx2 = stack.removeFirst() + let dy2 = stack.removeFirst() + let dx = stack.removeFirst() + curve(dx1: dx1, dy1: dy1, dx2: dx2, dy2: dy2, dx: dx, dy: 0) + } else { + let dx1 = stack.count.isMultiple(of: 2) ? stack.removeFirst() : 0 + let dy1 = stack.removeFirst() + let dy2 = stack.removeFirst() + let dx2 = stack.removeFirst() + let dy = stack.removeFirst() + curve(dx1: dx1, dy1: dy1, dx2: dx2, dy2: dy2, dx: 0, dy: dy) + } + horizontal.toggle() + } + stack.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/SpecimenPress/DemoFont.swift b/Sources/SpecimenPress/DemoFont.swift index c8c7ce0..f9d746e 100644 --- a/Sources/SpecimenPress/DemoFont.swift +++ b/Sources/SpecimenPress/DemoFont.swift @@ -2,13 +2,33 @@ import Foundation public enum DemoFont { public static func data() throws -> Data { - guard let url = Bundle.module.url(forResource: "SpecimenPressFixture", withExtension: "ttf") else { - throw FontError.invalidValue("The bundled demo font is missing.") - } - return try Data(contentsOf: url) + try loadResource(named: "SpecimenPressFixture", extension: "ttf") + } + + public static func cffData() throws -> Data { + try loadResource(named: "SpecimenPressCFF", extension: "otf") + } + + public static func cff2Data() throws -> Data { + try loadResource(named: "SpecimenPressCFF2", extension: "otf") } public static func load() throws -> TrueTypeFont { try TrueTypeFont(data: data()) } + + public static func loadCFF() throws -> TrueTypeFont { + try TrueTypeFont(data: cffData()) + } + + public static func loadCFF2() throws -> TrueTypeFont { + try TrueTypeFont(data: cff2Data()) + } + + private static func loadResource(named name: String, extension ext: String) throws -> Data { + guard let url = Bundle.module.url(forResource: name, withExtension: ext) else { + throw FontError.invalidValue("The bundled demo font \(name).\(ext) is missing.") + } + return try Data(contentsOf: url) + } } diff --git a/Sources/SpecimenPress/Font.swift b/Sources/SpecimenPress/Font.swift index 3246df0..39f5c2b 100644 --- a/Sources/SpecimenPress/Font.swift +++ b/Sources/SpecimenPress/Font.swift @@ -37,6 +37,7 @@ public struct UnicodeRange: Codable, Equatable { public struct FontSummary: Codable, Equatable { public let familyName: String + public let outlineFormat: OutlineFormat public let metrics: FontMetrics public let mappedCodePointCount: Int public let glyphsWithOutlines: Int @@ -44,12 +45,14 @@ public struct FontSummary: Codable, Equatable { public init( familyName: String, + outlineFormat: OutlineFormat, metrics: FontMetrics, mappedCodePointCount: Int, glyphsWithOutlines: Int, unicodeCoverage: [UnicodeRange] ) { self.familyName = familyName + self.outlineFormat = outlineFormat self.metrics = metrics self.mappedCodePointCount = mappedCodePointCount self.glyphsWithOutlines = glyphsWithOutlines @@ -59,13 +62,15 @@ public struct FontSummary: Codable, Equatable { public struct TrueTypeFont { public let familyName: String + public let outlineFormat: OutlineFormat public let metrics: FontMetrics public let characterMap: [UInt32: UInt16] public let glyphs: [Glyph] public init(data: Data) throws { let reader = BinaryReader(data: data) - guard try reader.uint32(at: 0) == 0x0001_0000 else { + let signature = try reader.uint32(at: 0) + guard signature == 0x0001_0000 || signature == 0x4F54_544F else { throw FontError.invalidSignature } @@ -92,8 +97,6 @@ public struct TrueTypeFont { let maxp = try table("maxp") let hhea = try table("hhea") let hmtx = try table("hmtx") - let loca = try table("loca") - let glyf = try table("glyf") let cmap = try table("cmap") guard head.length >= 54 else { throw FontError.invalidTable("head") } @@ -101,11 +104,7 @@ public struct TrueTypeFont { guard hhea.length >= 36 else { throw FontError.invalidTable("hhea") } let unitsPerEm = try reader.uint16(at: head.offset + 18) - let indexToLocFormat = try reader.int16(at: head.offset + 50) guard unitsPerEm > 0 else { throw FontError.invalidValue("unitsPerEm must be positive.") } - guard indexToLocFormat == 0 || indexToLocFormat == 1 else { - throw FontError.invalidValue("indexToLocFormat must be 0 or 1.") - } let numberOfGlyphs = Int(try reader.uint16(at: maxp.offset + 4)) let numberOfHorizontalMetrics = Int(try reader.uint16(at: hhea.offset + 34)) @@ -132,30 +131,70 @@ public struct TrueTypeFont { glyphCount: numberOfGlyphs, metricCount: numberOfHorizontalMetrics ) - let offsets = try Self.readLoca( - reader: reader, - table: loca, - glyphCount: numberOfGlyphs, - format: indexToLocFormat - ) let characterMap = try Self.readCharacterMap(reader: reader, table: cmap, glyphCount: numberOfGlyphs) - let decoder = GlyphDecoder( - reader: reader, - glyf: glyf, - offsets: offsets, - glyphCount: numberOfGlyphs - ) + let outlineFormat: OutlineFormat var parsedGlyphs: [Glyph] = [] parsedGlyphs.reserveCapacity(numberOfGlyphs) - for index in 0.. - TRUE TYPE / OUTLINE INSPECTION + \(font.outlineFormat.label) / OUTLINE INSPECTION \(escape(configuration.title)) \(escape(font.familyName)) / \(font.glyphs.count) glyphs / \(font.characterMap.count) mapped code points diff --git a/Tests/SpecimenPressTests/SpecimenPressTests.swift b/Tests/SpecimenPressTests/SpecimenPressTests.swift index 038024e..c8da164 100644 --- a/Tests/SpecimenPressTests/SpecimenPressTests.swift +++ b/Tests/SpecimenPressTests/SpecimenPressTests.swift @@ -36,6 +36,7 @@ final class SpecimenPressTests: XCTestCase { let summary = try DemoFont.load().summary XCTAssertEqual(summary.familyName, "Specimen Press") + XCTAssertEqual(summary.outlineFormat, .trueType) XCTAssertEqual(summary.mappedCodePointCount, 27) XCTAssertEqual(summary.glyphsWithOutlines, 27) XCTAssertEqual(summary.unicodeCoverage, [ @@ -44,6 +45,40 @@ final class SpecimenPressTests: XCTestCase { ]) } + func testCFFFixtureExposesOutlinesAndFormat() throws { + let font = try DemoFont.loadCFF() + + XCTAssertEqual(font.outlineFormat, .cff) + XCTAssertEqual(font.familyName, "Specimen Press CFF") + XCTAssertEqual(font.metrics.numberOfGlyphs, 28) + XCTAssertEqual(font.glyphIndex(for: "A"), 2) + + let glyph = try XCTUnwrap(font.glyph(for: "A")) + let outline = try XCTUnwrap(glyph.outline) + XCTAssertFalse(outline.isEmpty) + XCTAssertTrue(glyph.svgPath.hasPrefix("M ")) + } + + func testCFF2FixtureExposesOutlinesAndFormat() throws { + let font = try DemoFont.loadCFF2() + + XCTAssertEqual(font.outlineFormat, .cff2) + XCTAssertEqual(font.familyName, "Specimen Press CFF2") + XCTAssertEqual(font.metrics.numberOfGlyphs, 28) + + let glyph = try XCTUnwrap(font.glyph(for: "B")) + let outline = try XCTUnwrap(glyph.outline) + XCTAssertFalse(outline.isEmpty) + } + + func testRendererUsesOutlineFormatLabel() throws { + let font = try DemoFont.loadCFF() + let svg = SpecimenRenderer().render(font: font) + + XCTAssertTrue(svg.contains("CFF / OUTLINE INSPECTION")) + XCTAssertFalse(svg.contains("TRUE TYPE / OUTLINE INSPECTION")) + } + func testRendererIsDeterministicAndIncludesInspectionLabels() throws { let font = try DemoFont.load() let renderer = SpecimenRenderer() diff --git a/scripts/__pycache__/validate_cff_fixtures.cpython-314.pyc b/scripts/__pycache__/validate_cff_fixtures.cpython-314.pyc new file mode 100644 index 0000000..21fb573 Binary files /dev/null and b/scripts/__pycache__/validate_cff_fixtures.cpython-314.pyc differ diff --git a/scripts/generate_cff_fixtures.py b/scripts/generate_cff_fixtures.py new file mode 100644 index 0000000..8038c32 --- /dev/null +++ b/scripts/generate_cff_fixtures.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Generate minimal OpenType CFF and CFF2 fixtures for Specimen Press tests.""" + +from __future__ import annotations + +import struct +from pathlib import Path + +GLYPH_COUNT = 28 +UNITS_PER_EM = 1000 +ASCENDER = 800 +DESCENDER = -200 +LINE_GAP = 200 +ADVANCE = 600 + + +def encode_int(value: int) -> bytes: + if -107 <= value <= 107: + return bytes([value + 139]) + if 108 <= value <= 1131: + value -= 108 + return bytes([247 + (value >> 8), value & 0xFF]) + if -1131 <= value <= -108: + value = -value - 108 + return bytes([251 + (value >> 8), value & 0xFF]) + if -32768 <= value <= 32767: + return bytes([28, (value >> 8) & 0xFF, value & 0xFF]) + return bytes([29, (value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]) + + +def charstring_empty() -> bytes: + return bytes([14]) + + +def charstring_box(width: int = 400, height: int = 700) -> bytes: + # Simple rectangle: (100,0) -> (100,height) -> (100+width,height) -> (100+width,0) + parts = [ + encode_int(100), + encode_int(0), + bytes([21]), # rmoveto + encode_int(0), + encode_int(height), + bytes([5]), # rlineto + encode_int(width), + encode_int(0), + bytes([5]), + encode_int(0), + encode_int(-height), + bytes([5]), + bytes([14]), # endchar + ] + return b"".join(parts) + + +def charstring_a() -> bytes: + # Triangle-like A shape + parts = [ + encode_int(100), + encode_int(0), + bytes([21]), + encode_int(200), + encode_int(700), + bytes([5]), + encode_int(200), + encode_int(-700), + bytes([5]), + encode_int(100), + encode_int(350), + bytes([5]), + encode_int(100), + encode_int(-350), + bytes([5]), + bytes([14]), + ] + return b"".join(parts) + + +def build_index(items: list[bytes], wide_count: bool = False) -> bytes: + if not items: + if wide_count: + return struct.pack(">HI", 0, 0) + return struct.pack(">H", 0) + + off_size = 1 + max_offset = sum(len(item) for item in items) + while max_offset >= 256 ** off_size: + off_size += 1 + + header = struct.pack(">H", 0xFFFF) if wide_count else struct.pack(">H", len(items)) + if wide_count: + header += struct.pack(">I", len(items)) + header += bytes([off_size]) + + offsets = [1] + running = 1 + for item in items: + running += len(item) + offsets.append(running) + + for offset in offsets: + header += offset.to_bytes(off_size, "big") + header += b"".join(items) + return header + + +def build_dict(entries: list[tuple[list[int], int]]) -> bytes: + data = bytearray() + for operands, operator in entries: + for operand in operands: + data.extend(encode_int(operand)) + if operator >= 1000: + data.extend([12, operator - 1000]) + else: + data.append(operator) + return bytes(data) + + +def build_cff_payload(is_cff2: bool) -> bytes: + family = "Specimen Press CFF2" if is_cff2 else "Specimen Press CFF" + strings = [family, family, family] + string_index = build_index([s.encode("ascii") for s in strings]) + + charstrings = [charstring_empty(), charstring_empty()] + for index in range(2, GLYPH_COUNT): + charstrings.append(charstring_a() if index == 2 else charstring_box()) + charstrings_index = build_index(charstrings, wide_count=is_cff2) + + private_dict = build_dict([]) + local_subrs = build_index([], wide_count=is_cff2) + + header = bytes([2, 0, 5, 0, 0]) if is_cff2 else bytes([1, 0, 4, 1]) + name_index = build_index([family.encode("ascii")], wide_count=is_cff2) + global_subrs = build_index([], wide_count=is_cff2) + charstrings_key = 17 if is_cff2 else 14 + + def assemble(top_dict: bytes) -> tuple[bytes, int, int]: + if is_cff2: + header_bytes = bytes([2, 0, 5]) + struct.pack(">H", len(top_dict)) + result = bytearray() + result.extend(header_bytes) + result.extend(top_dict) + result.extend(string_index) + result.extend(global_subrs) + charstrings_offset = len(result) + result.extend(charstrings_index) + private_offset = len(result) + result.extend(private_dict) + result.extend(local_subrs) + return bytes(result), charstrings_offset, private_offset + + top_dict_index = build_index([top_dict], wide_count=False) + result = bytearray() + result.extend(header) + result.extend(name_index) + result.extend(top_dict_index) + result.extend(string_index) + result.extend(global_subrs) + charstrings_offset = len(result) + result.extend(charstrings_index) + private_offset = len(result) + result.extend(private_dict) + result.extend(local_subrs) + return bytes(result), charstrings_offset, private_offset + + charstrings_offset = 0 + private_offset = 0 + for _ in range(4): + top_dict = build_dict([ + ([0, 0, 1000, 800], 5), + ([0], 15), + ([charstrings_offset], charstrings_key), + ([len(private_dict), private_offset], 18), + ]) + result, charstrings_offset, private_offset = assemble(top_dict) + return result + + +def checksum(data: bytes) -> int: + if len(data) % 4: + data += b"\0" * (4 - len(data) % 4) + total = 0 + for index in range(0, len(data), 4): + total = (total + struct.unpack(">I", data[index : index + 4])[0]) & 0xFFFFFFFF + return total + + +def build_cmap() -> bytes: + # Format 4 cmap: space (32) and A-Z (65-90) + segments = [(32, 32, -31, 0), (65, 90, -63, 0), (0xFFFF, 0xFFFF, 1, 0)] + seg_count = len(segments) + length = 16 + seg_count * 8 + data = struct.pack(">HHHHH", 4, length, 0, 2 * seg_count, 2) + end_codes = b"".join(struct.pack(">H", end) for start, end, _, _ in segments) + data += end_codes + b"\0\0" + data += b"".join(struct.pack(">H", start) for start, _, _, _ in segments) + data += b"".join(struct.pack(">h", delta) for _, _, delta, _ in segments) + data += b"".join(struct.pack(">H", ro) for *_, ro in segments) + return struct.pack(">HH", 0, 1) + struct.pack(">HHI", 3, 1, 12) + data + + +def build_hmtx() -> bytes: + data = bytearray() + for _ in range(GLYPH_COUNT): + data.extend(struct.pack(">Hh", ADVANCE, 0)) + return bytes(data) + + +def build_name(family: str) -> bytes: + encoded = family.encode("utf-16-be") + record = struct.pack(">HHHHHH", 3, 1, 0x0409, 1, len(encoded), 0) + return struct.pack(">HHH", 0, 1, 12) + record + encoded + + +def build_head() -> bytes: + return struct.pack( + ">IIIIHHqqhhhhHHhhh", + 0x0001_0000, + 0x0001_0000, + 0, + 0x5F0F_3CF5, + 0, + UNITS_PER_EM, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ) + + +def build_hhea() -> bytes: + data = struct.pack( + ">IhhhhHHhhhhHHhh", + 0x0001_0000, + ASCENDER, + DESCENDER, + LINE_GAP, + ADVANCE, + 0, + 0, + ADVANCE, + 1, + 0, + 0, + 0, + 0, + 0, + GLYPH_COUNT, + ) + return data + b"\0\0\0\0" + + +def build_maxp() -> bytes: + return struct.pack(">IH", 0x0001_0000, GLYPH_COUNT) + + +def build_post() -> bytes: + return struct.pack(">IhhhhH", 0x0003_0000, 0, 0, 0, 0, 0) + + +def build_font(path: Path, is_cff2: bool) -> None: + family = "Specimen Press CFF2" if is_cff2 else "Specimen Press CFF" + cff_tag = "CFF2" if is_cff2 else "CFF " + tables = { + "head": build_head(), + "hhea": build_hhea(), + "hmtx": build_hmtx(), + "maxp": build_maxp(), + "name": build_name(family), + "cmap": build_cmap(), + cff_tag: build_cff_payload(is_cff2), + "post": build_post(), + } + + num_tables = len(tables) + search_range = 1 + while search_range * 2 <= num_tables: + search_range *= 2 + entry_selector = 0 + value = search_range + while value > 1: + entry_selector += 1 + value //= 2 + range_shift = num_tables * 16 - search_range * 16 + + records = [] + cursor = 12 + num_tables * 16 + for tag in sorted(tables): + data = tables[tag] + padded = data + b"\0" * ((4 - len(data) % 4) % 4) + records.append((tag, padded, cursor)) + cursor += len(padded) + + header = struct.pack( + ">IHHHH", + 0x4F54_544F, + num_tables, + search_range, + entry_selector, + range_shift, + ) + directory = bytearray() + for tag, data, offset in records: + tag_bytes = tag.encode("ascii").ljust(4, b"\0")[:4] + chk = checksum(data) + directory.extend(tag_bytes) + directory.extend(struct.pack(">III", chk, offset, len(data))) + + output = bytearray(header) + output.extend(directory) + for _, data, _ in records: + output.extend(data) + + path.write_bytes(output) + print(f"Wrote {path} ({len(output)} bytes)") + + +def main() -> None: + resources = Path(__file__).resolve().parent.parent / "Sources" / "SpecimenPress" / "Resources" + resources.mkdir(parents=True, exist_ok=True) + build_font(resources / "SpecimenPressCFF.otf", is_cff2=False) + build_font(resources / "SpecimenPressCFF2.otf", is_cff2=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_cff_fixtures.py b/scripts/validate_cff_fixtures.py new file mode 100644 index 0000000..987e3f8 --- /dev/null +++ b/scripts/validate_cff_fixtures.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Validate bundled CFF fixtures without Swift.""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + + +def read_u16(data: bytes, offset: int) -> int: + return struct.unpack(">H", data[offset : offset + 2])[0] + + +def read_tables(data: bytes) -> dict[str, tuple[int, int]]: + num_tables = read_u16(data, 4) + tables: dict[str, tuple[int, int]] = {} + for index in range(num_tables): + record = 12 + index * 16 + tag = data[record : record + 4].decode("ascii").rstrip("\0") + offset, length = struct.unpack(">II", data[record + 8 : record + 16]) + tables[tag] = (offset, length) + return tables + + +def decode_operand(data: bytes, cursor: int) -> tuple[int, int]: + byte = data[cursor] + cursor += 1 + if 32 <= byte <= 246: + return byte - 139, cursor + if 247 <= byte <= 250: + return (byte - 247) * 256 + data[cursor] + 108, cursor + 1 + if 251 <= byte <= 254: + return -((byte - 251) * 256 + data[cursor] + 108), cursor + 1 + if byte == 28: + value = (data[cursor] << 8) | data[cursor + 1] + if value >= 0x8000: + value -= 0x10000 + return value, cursor + 2 + if byte == 29: + value = struct.unpack(">i", data[cursor : cursor + 4])[0] + return value, cursor + 4 + raise ValueError(f"unsupported operand {byte}") + + +def read_index(data: bytes, offset: int) -> tuple[list[bytes], int]: + count = read_u16(data, offset) + count_size = 2 + if count == 0xFFFF: + count = struct.unpack(">I", data[offset + 2 : offset + 6])[0] + count_size = 6 + if count == 0: + return [], offset + count_size + off_size = data[offset + count_size] + offsets_start = offset + count_size + 1 + data_start = offsets_start + (count + 1) * off_size + items: list[bytes] = [] + for index in range(count): + start = int.from_bytes( + data[offsets_start + index * off_size : offsets_start + (index + 1) * off_size], + "big", + ) - 1 + end = int.from_bytes( + data[offsets_start + (index + 1) * off_size : offsets_start + (index + 2) * off_size], + "big", + ) - 1 + items.append(data[data_start + start : data_start + end]) + last_offset = int.from_bytes( + data[offsets_start + count * off_size : offsets_start + (count + 1) * off_size], "big" + ) + return items, data_start + last_offset - 1 + + +def skip_index(data: bytes, offset: int) -> int: + return read_index(data, offset)[1] + + +def parse_dict(data: bytes) -> dict[int, int]: + offsets: dict[int, int] = {} + operands: list[int] = [] + cursor = 0 + while cursor < len(data): + byte = data[cursor] + cursor += 1 + if byte >= 32: + cursor -= 1 + value, cursor = decode_operand(data, cursor) + operands.append(value) + continue + operator = 1000 + data[cursor] if byte == 12 else byte + if byte == 12: + cursor += 1 + if operator in (14, 17) and operands: + offsets[operator] = operands[-1] + operands.clear() + return offsets + + +def validate(path: Path, is_cff2: bool) -> None: + data = path.read_bytes() + signature = struct.unpack(">I", data[0:4])[0] + assert signature == 0x4F54_544F, path.name + tables = read_tables(data) + tag = "CFF2" if is_cff2 else "CFF " + assert tag in tables, f"missing {tag}" + base, length = tables[tag] + cff = data[base : base + length] + assert cff[0] == (2 if is_cff2 else 1), path.name + + if is_cff2: + hdr_size = cff[2] + top_dict_length = struct.unpack(">H", cff[3:5])[0] + top_dict = cff[hdr_size : hdr_size + top_dict_length] + cursor = base + hdr_size + top_dict_length + cursor = skip_index(data, cursor) + global_subrs, _ = read_index(data, cursor) + else: + cursor = base + cff[2] + cursor = skip_index(data, cursor) + top_items, _ = read_index(data, cursor) + top_dict = top_items[0] + cursor = skip_index(data, cursor) + cursor = skip_index(data, cursor) + global_subrs, _ = read_index(data, cursor) + + top_offsets = parse_dict(top_dict) + key = 17 if is_cff2 else 14 + assert key in top_offsets, f"{path.name} missing CharStrings offset" + charstrings, _ = read_index(data, base + top_offsets[key]) + assert len(charstrings) == 28, f"{path.name} expected 28 charstrings" + assert charstrings[2], f"{path.name} glyph A charstring is empty" + print(f"OK {path.name}: {len(charstrings)} charstrings, glyph A length {len(charstrings[2])}") + + +def main() -> int: + resources = Path(__file__).resolve().parent.parent / "Sources" / "SpecimenPress" / "Resources" + validate(resources / "SpecimenPressCFF.otf", is_cff2=False) + validate(resources / "SpecimenPressCFF2.otf", is_cff2=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main())