From 24e861df0af063bb09e84186a56043dacac80c7e Mon Sep 17 00:00:00 2001 From: Stephen Beitzel Date: Fri, 2 Oct 2026 20:20:25 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20#190=20=E2=80=94=20font=20lookup:=20regis?= =?UTF-8?q?tered,=20system=20and=20bundled=20faces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer could draw text only in the faces it bundles, and OutlineFontSet.resolve answered nil for any other name. - FontRequest (public): family, weight and style, or parsed from a PostScript name (Times-BoldItalic, Helvetica-Oblique). The base 14 and the generic families (serif, sans-serif, monospace) list stand-ins: Times/Times New Roman/Liberation Serif/Nimbus/DejaVu and so on. - FontLibrary (public, immutable, Sendable): faces the host supplies as font Data, CFF or TrueType, single faces or collections, parsed once. Set on SVGRenderConfig.fontLibrary. Per-config rather than process-wide, so a config always gives the same output. - SVGRenderConfig.systemFonts (default false): installed fonts on Apple platforms. CoreText only locates the file and face, which our own parser (#189) then reads, so every face is outlined and fsType-checked by the same code. With a mandatory attribute set, CoreText's everything-is-Helvetica fallback cannot answer a missing family. - FontProvider resolves a request through registered, then system, then bundled faces. For each family (asked-for first, then stand-ins), an exact face from any source beats a near one. Faces whose fsType forbids embedding are skipped when outlining. Results are cached per provider. The emitter's own Bravura and Libertinus Serif requests go straight to the bundle, so existing output is unchanged. - FontResolution (public) records the face used, its origin and any refusals. diagnostics(at:) produces .fontSubstituted (info for a stand-in, warning otherwise) and .fontNotEmbeddable, for #186 to report against the directive that named the font. - FaceKey becomes an open-ended key. Bundled faces keep their ids; other faces get "f-". - TextOutliner.outline(_:font:fontSize:fill:library:systemFonts:) draws a named face, and OutlinedText.resolution says which face was used. - OpenTypeFont reads family (name 16/1), usWeightClass and italic, with head.macStyle as the fallback for faces without OS/2 (macOS Courier.ttc). Co-Authored-By: Claude Opus 5.5 --- Sources/CeolKitModel/Diagnostic.swift | 6 + .../Config/SVGRenderConfig.swift | 14 +- .../Emission/SVGBuilder.swift | 8 +- .../Emission/SVGEmitter.swift | 4 +- .../CeolKitSVGRenderer/Font/FontLibrary.swift | 52 ++++ .../Font/FontProvider.swift | 226 ++++++++++++++++++ .../CeolKitSVGRenderer/Font/FontRequest.swift | 103 ++++++++ .../Font/OpenTypeFont.swift | 46 +++- .../Font/OutlineFontSet.swift | 26 +- .../CeolKitSVGRenderer/Font/SystemFonts.swift | 84 +++++++ .../Font/TextOutliner.swift | 49 +++- .../FontProviderTests.swift | 202 ++++++++++++++++ .../StaffBraceTests.swift | 2 +- .../TextOutlinerTests.swift | 2 +- 14 files changed, 799 insertions(+), 25 deletions(-) create mode 100644 Sources/CeolKitSVGRenderer/Font/FontLibrary.swift create mode 100644 Sources/CeolKitSVGRenderer/Font/FontProvider.swift create mode 100644 Sources/CeolKitSVGRenderer/Font/FontRequest.swift create mode 100644 Sources/CeolKitSVGRenderer/Font/SystemFonts.swift create mode 100644 Tests/CeolKitSVGRendererTests/FontProviderTests.swift diff --git a/Sources/CeolKitModel/Diagnostic.swift b/Sources/CeolKitModel/Diagnostic.swift index 8cd19dd..9ae2347 100644 --- a/Sources/CeolKitModel/Diagnostic.swift +++ b/Sources/CeolKitModel/Diagnostic.swift @@ -127,4 +127,10 @@ public enum DiagnosticCode: String, Codable, Sendable { case circularInclude case includeIgnoredInline case usingDefaultFileResolver + // Fonts (issue #190) + /// The font a document named was not available, and another face was used in its place. + case fontSubstituted + /// A face matched the font a document named, but its licence (`OS/2.fsType`) forbids + /// copying its outlines into a document, so another face was used. + case fontNotEmbeddable } diff --git a/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift b/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift index b89c36f..ec9a1e1 100644 --- a/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift +++ b/Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift @@ -49,6 +49,14 @@ public struct SVGRenderConfig: Sendable { /// short rather than smeared across the page. Systems the source broke are not capped. /// Generous by design — see ``Justifier/maxStretch``. public var maxSystemStretch: Double + /// Faces the host supplies, tried before any other when text names a font (issue #190). + /// `nil` — the default — leaves the system's fonts, if enabled, and the bundled faces. + public var fontLibrary: FontLibrary? + /// Whether a font named by the document may be looked up among the fonts installed on + /// the machine (Apple platforms only). Off by default, because it makes the output + /// depend on the machine it is rendered on; the SVG that results is still portable in + /// ``TextRendering/outlines`` mode, which carries the glyphs with it. + public var systemFonts: Bool public init( pageSize: PageSize = .letter, @@ -64,7 +72,9 @@ public struct SVGRenderConfig: Sendable { graceNoteSpacing: Double = 1.05, textRendering: TextRendering = .outlines, lineOverflowTolerance: Double = 0.02, - maxSystemStretch: Double = 3.0 + maxSystemStretch: Double = 3.0, + fontLibrary: FontLibrary? = nil, + systemFonts: Bool = false ) { self.pageSize = pageSize self.margins = margins @@ -80,6 +90,8 @@ public struct SVGRenderConfig: Sendable { self.textRendering = textRendering self.lineOverflowTolerance = lineOverflowTolerance self.maxSystemStretch = maxSystemStretch + self.fontLibrary = fontLibrary + self.systemFonts = systemFonts } /// Returns a copy with `staffSize` and the vertical gaps derived from it multiplied diff --git a/Sources/CeolKitSVGRenderer/Emission/SVGBuilder.swift b/Sources/CeolKitSVGRenderer/Emission/SVGBuilder.swift index 81afdc1..350a60d 100644 --- a/Sources/CeolKitSVGRenderer/Emission/SVGBuilder.swift +++ b/Sources/CeolKitSVGRenderer/Emission/SVGBuilder.swift @@ -22,8 +22,10 @@ struct SVGBuilder: Sendable { private(set) var elements: [String] = [] let textRendering: TextRendering - /// The parsed faces outlines are read from. `nil` leaves every run on ``. - let fonts: OutlineFontSet? + /// Where the faces outlines are read from — the bundle, and the host's and system's + /// fonts where the configuration allows them (issue #190). `nil` leaves every run on + /// ``. + let fonts: FontProvider? /// Glyph outline path data by `` id; an empty string records a glyph that is /// known to have no outline (a space, say) so it is not decoded again. @@ -35,7 +37,7 @@ struct SVGBuilder: Sendable { /// name still get unique `id`s — see ``tagGroup(name:x:y:fontFamily:fontSize:textAnchor:_:)``. private var tagCounts: [String: Int] = [:] - init(textRendering: TextRendering = .fontFace, fonts: OutlineFontSet? = nil) { + init(textRendering: TextRendering = .fontFace, fonts: FontProvider? = nil) { self.textRendering = textRendering self.fonts = fonts } diff --git a/Sources/CeolKitSVGRenderer/Emission/SVGEmitter.swift b/Sources/CeolKitSVGRenderer/Emission/SVGEmitter.swift index 55ec924..09cebaa 100644 --- a/Sources/CeolKitSVGRenderer/Emission/SVGEmitter.swift +++ b/Sources/CeolKitSVGRenderer/Emission/SVGEmitter.swift @@ -122,7 +122,7 @@ struct SVGEmitter: Sendable { libertinusSerif: try LibertinusSerifMetrics.loadBase64(), libertinusSerifItalic: try LibertinusSerifMetrics.loadItalicBase64()) : nil - let fonts = config.textRendering.emitsOutlines ? try OutlineFontSet.shared() : nil + let fonts = config.textRendering.emitsOutlines ? try FontProvider(config: config) : nil // Threaded across every page/system so ties and slurs that span a system or page // break (#27) are resolved with dangling arcs instead of being silently dropped. var pendingTies: [TieAnchor] = [] @@ -145,7 +145,7 @@ struct SVGEmitter: Sendable { // MARK: - Page private func emitPage(_ page: ResolvedPage, pageNumber: Int, layout: ResolvedLayout, - embeddedFaces: EmbeddedFaces?, fonts: OutlineFontSet?, + embeddedFaces: EmbeddedFaces?, fonts: FontProvider?, pendingTies: inout [TieAnchor], pendingSlurs: inout [SlurAnchor]) -> String { var builder = SVGBuilder(textRendering: config.textRendering, fonts: fonts) emitScrollSyncMetadata(for: page, pageNumber: pageNumber, builder: &builder) diff --git a/Sources/CeolKitSVGRenderer/Font/FontLibrary.swift b/Sources/CeolKitSVGRenderer/Font/FontLibrary.swift new file mode 100644 index 0000000..d2ae94f --- /dev/null +++ b/Sources/CeolKitSVGRenderer/Font/FontLibrary.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Faces a host app supplies for the renderer to draw text in (issue #190). +/// +/// The host knows which fonts it ships and is licensed to use; this is how it hands them +/// over. Give the library the bytes of each font file — OpenType/CFF or TrueType, a single +/// face or a collection (`.ttc`/`.otc`) — and set it on ``SVGRenderConfig/fontLibrary``. +/// Every face in it is then found by PostScript name or by family, weight and style before +/// any system font or bundled face is tried. +/// +/// The faces are parsed once, here, so one library serves any number of renders; it is +/// immutable and safe to share across threads. Registration is per configuration rather +/// than process-wide, so two renders with different libraries do not see each other's faces +/// and the same configuration always produces the same output. +/// +/// ```swift +/// let library = try FontLibrary(fonts: [courierData, helveticaCollectionData]) +/// var config = SVGRenderConfig() +/// config.fontLibrary = library +/// ``` +public final class FontLibrary: Sendable { + /// Every face the library holds, in the order the fonts were given and, within a + /// collection, in face order. + let faces: [OpenTypeFont] + + /// - Throws: ``FontLibraryError/unreadableFont(index:)`` for a font file — or any face + /// of a collection — that cannot be parsed, naming its position in `fonts`. + public init(fonts: [Data]) throws { + var faces: [OpenTypeFont] = [] + for (index, data) in fonts.enumerated() { + do { + let count = try OpenTypeFont.postScriptNames(in: data).count + for face in 0.. [Diagnostic] { + var out = refusedForEmbedding.map { name in + Diagnostic(severity: .warning, code: .fontNotEmbeddable, + message: "\(name) may not be embedded in a document (its licence " + + "restricts embedding); using \(postScriptName)", + source: source) + } + guard !isExact else { return out } + let standIn = requested.aliases.contains { + FontRequest.normalised($0) == FontRequest.normalised(family) + } + out.append(Diagnostic( + severity: standIn ? .info : .warning, code: .fontSubstituted, + message: "\(requested) not found; using \(postScriptName)", source: source)) + return out + } +} + +/// Finds the face to draw a run of text in (issue #190). +/// +/// Sits between the emitter and the faces: the host's ``FontLibrary``, the system's fonts +/// where ``SVGRenderConfig/systemFonts`` allows them, and the bundled faces, in that order. +/// Each request is resolved once per provider and remembered, so a render that sets a +/// thousand chord symbols in one face looks it up once. +/// +/// The bundled families the emitter itself names — Bravura, Libertinus Serif — resolve +/// straight to the bundle, exactly as they did before there was anywhere else to look: a +/// document that names nothing else is written byte-for-byte as it was. +final class FontProvider: @unchecked Sendable { + /// A face ready to draw: the key its glyphs are stored under, the face, and the account + /// of how it was chosen. + struct Resolved: Sendable { + let key: OutlineFontSet.FaceKey + let font: OpenTypeFont + let resolution: FontResolution + } + + private let library: FontLibrary? + private let systemFonts: Bool + /// Whether faces are copied into the document as outlines, so that a face whose licence + /// forbids embedding has to be passed over. + private let outlinesEmbed: Bool + private let bundled: OutlineFontSet + + // `@unchecked Sendable`: the cache is the only mutable state, and every access to it + // holds `lock`. + private let lock = NSLock() + private var cache: [FontRequest: Resolved] = [:] + + init(library: FontLibrary?, systemFonts: Bool, outlinesEmbed: Bool) throws { + self.library = library + self.systemFonts = systemFonts + self.outlinesEmbed = outlinesEmbed + self.bundled = try OutlineFontSet.shared() + } + + convenience init(config: SVGRenderConfig) throws { + try self.init(library: config.fontLibrary, systemFonts: config.systemFonts, + outlinesEmbed: config.textRendering.emitsOutlines) + } + + /// Every request answered so far, in no particular order. + var resolutions: [FontResolution] { + lock.lock(); defer { lock.unlock() } + return cache.values.map(\.resolution) + } + + /// The face the emitter's `font-family` / `font-style` pair names. The bundled + /// families go straight to the bundle; anything else is a ``FontRequest``. + func resolve(family: String, italic: Bool) -> (key: OutlineFontSet.FaceKey, font: OpenTypeFont)? { + if let face = bundled.resolve(family: family, italic: italic) { return face } + let resolved = resolve(FontRequest(family: family, style: italic ? .italic : .upright)) + return (resolved.key, resolved.font) + } + + func resolve(_ request: FontRequest) -> Resolved { + lock.lock() + if let hit = cache[request] { lock.unlock(); return hit } + lock.unlock() + + let resolved = lookUp(request) + lock.lock(); defer { lock.unlock() } + cache[request] = resolved + return resolved + } + + // MARK: - Lookup + + private func lookUp(_ request: FontRequest) -> Resolved { + var refused: [String] = [] + // The family asked for, then whatever stands in for it. + var families = [request.family] + for alias in request.aliases + where !families.contains(where: { FontRequest.normalised($0) == FontRequest.normalised(alias) }) { + families.append(alias) + } + + for (index, family) in families.enumerated() { + let wanted = index == 0 ? request : request.inFamily(family) + let sources = candidateSources(for: wanted) + // The exact face from any source beats a near miss from the first. + for exact in [true, false] { + for (origin, faces) in sources { + guard let face = best(in: faces, for: wanted, exact: exact, + refused: &refused) else { continue } + return resolved(face, origin: origin, request: request, refused: refused) + } + } + } + return bundledFallback(request, refused: refused) + } + + private func candidateSources(for request: FontRequest) -> [(FontOrigin, [OpenTypeFont])] { + var sources: [(FontOrigin, [OpenTypeFont])] = [] + if let library { sources.append((.registered, library.faces)) } + if systemFonts { + let system = SystemFonts.faces(postScriptName: request.postScriptName) + + SystemFonts.faces(family: request.family) + if !system.isEmpty { sources.append((.system, system)) } + } + return sources + } + + /// The face of `faces` that answers `request` best: its PostScript name, or its family + /// in the nearest weight and style. With `exact`, only a face matching the PostScript + /// name, or the family, weight and style all three, will do. + private func best(in faces: [OpenTypeFont], for request: FontRequest, exact: Bool, + refused: inout [String]) -> OpenTypeFont? { + let wantedFamily = FontRequest.normalised(request.family) + var candidates: [(face: OpenTypeFont, score: Int)] = [] + for face in faces { + let name = face.postScriptName ?? "" + let byName = request.postScriptName + .map { name.caseInsensitiveCompare($0) == .orderedSame } ?? false + let byFamily = face.familyName.map { FontRequest.normalised($0) == wantedFamily } ?? false + guard byName || byFamily else { continue } + let weightMatches = (face.weightClass >= 600) == (request.weight == .bold) + let styleMatches = face.isItalic == (request.style == .italic) + let score = byName ? 4 : (styleMatches ? 2 : 0) + (weightMatches ? 1 : 0) + guard !exact || score >= 3 else { continue } + if outlinesEmbed && !face.embedding.allowsOutlineEmbedding { + if !refused.contains(name) { refused.append(name) } + continue + } + candidates.append((face, score)) + } + return candidates.max { $0.score < $1.score }?.face + } + + private func resolved(_ face: OpenTypeFont, origin: FontOrigin, request: FontRequest, + refused: [String]) -> Resolved { + let name = face.postScriptName ?? face.familyName ?? request.family + return Resolved( + key: OutlineFontSet.FaceKey(postScriptName: name), + font: face, + resolution: FontResolution( + requested: request, postScriptName: name, + family: face.familyName ?? request.family, + weight: face.weightClass >= 600 ? .bold : .regular, + style: face.isItalic ? .italic : .upright, + origin: origin, refusedForEmbedding: refused)) + } + + /// The bundle's answer, which always exists: Bravura for Bravura, Libertinus Serif in + /// the requested style for everything else. The bundle has no bold. + private func bundledFallback(_ request: FontRequest, refused: [String]) -> Resolved { + let face: CeolKitFonts.Face = + FontRequest.normalised(request.family) == "bravura" ? .bravura + : request.style == .italic ? .libertinusSerifItalic : .libertinusSerifRegular + let key = OutlineFontSet.FaceKey(face) + // `shared()` succeeded in `init`, so every bundled face is there. + let font = bundled.resolve(family: face.familyName, italic: face.isItalic)!.font + return Resolved( + key: key, font: font, + resolution: FontResolution( + requested: request, postScriptName: font.postScriptName ?? face.rawValue, + family: face.familyName, weight: .regular, + style: face.isItalic ? .italic : .upright, + origin: .bundled, refusedForEmbedding: refused)) + } +} diff --git a/Sources/CeolKitSVGRenderer/Font/FontRequest.swift b/Sources/CeolKitSVGRenderer/Font/FontRequest.swift new file mode 100644 index 0000000..fa8e612 --- /dev/null +++ b/Sources/CeolKitSVGRenderer/Font/FontRequest.swift @@ -0,0 +1,103 @@ +/// How heavy a face is asked to be. +/// +/// Two weights, because the font names ABC uses (§11.4.2 asks for PostScript names) only +/// ever say "Bold" or nothing. A face matches by `OS/2.usWeightClass`: 600 and above is bold. +public enum FontWeight: String, Sendable, Hashable, Codable, CaseIterable { + case regular + case bold +} + +/// Whether a face is asked to slant. Italic and oblique are one request: a font family has +/// one or the other, and `Helvetica-Oblique` is answered by whichever slanted face it has. +public enum FontStyle: String, Sendable, Hashable, Codable, CaseIterable { + case upright + case italic +} + +/// A face to draw text in, as a document or a host names it (issue #190). +/// +/// Usually made from a PostScript name, which is what ABC's font directives carry +/// (`%%wordsfont Courier-Bold 16`): ``init(postScriptName:)`` splits it into a family, a +/// weight and a style. The name itself is kept, because a face whose PostScript name is +/// exactly that is the best match there is. +public struct FontRequest: Sendable, Hashable, Codable, CustomStringConvertible { + /// The family asked for: "Times", "Courier New", or a generic family — `serif`, + /// `sans-serif`, `monospace`. + public let family: String + public let weight: FontWeight + public let style: FontStyle + /// The PostScript name the request was made from, where it was made from one. + public let postScriptName: String? + + public init(family: String, weight: FontWeight = .regular, style: FontStyle = .upright) { + self.family = family + self.weight = weight + self.style = style + self.postScriptName = nil + } + + /// Reads a PostScript font name: `Times-Roman`, `Times-BoldItalic`, `Helvetica-Oblique`, + /// `Courier-Bold`. The part after the last hyphen gives the weight and style where it is + /// one of the PostScript suffixes (`Roman`, `Regular`, `Bold`, `Italic`, `Oblique`, and + /// their combinations); any other name is taken as a family whole. + public init(postScriptName name: String) { + self.postScriptName = name + guard let hyphen = name.lastIndex(of: "-"), hyphen != name.startIndex, + let (weight, style) = Self.suffix(name[name.index(after: hyphen)...]) else { + (family, weight, style) = (name, .regular, .upright) + return + } + family = String(name[.. (FontWeight, FontStyle)? { + switch suffix.lowercased() { + case "roman", "regular", "book", "normal": return (.regular, .upright) + case "bold": return (.bold, .upright) + case "italic", "oblique": return (.regular, .italic) + case "bolditalic", "boldoblique": return (.bold, .italic) + default: return nil + } + } + + public var description: String { + postScriptName ?? family + (weight == .bold ? " Bold" : "") + + (style == .italic ? " Italic" : "") + } + + /// The same request in another family, keeping its weight and style — how an alias is + /// tried in place of the family asked for. + func inFamily(_ family: String) -> FontRequest { + FontRequest(family: family, weight: weight, style: style) + } + + // MARK: - Aliases + + /// Families that stand in for the one asked for when it is not found, best first. + /// + /// The PostScript base 14 — Times, Helvetica, Courier — are the fonts §11.4.2 implies, and + /// abcm2ps makes `serif` Times and `sans-serif` Helvetica. Each is followed by the faces + /// that ship in its place: the metric-compatible ones macOS and Windows carry, then the + /// Liberation, Nimbus (URW base 35) and DejaVu families most Linux systems have. + var aliases: [String] { + switch Self.normalised(family) { + case "times", "timesroman", "serif": + return ["Times", "Times New Roman", "Liberation Serif", "Nimbus Roman", + "DejaVu Serif"] + case "helvetica", "arial", "sansserif": + return ["Helvetica", "Arial", "Liberation Sans", "Nimbus Sans", "DejaVu Sans"] + case "courier", "couriernew", "monospace": + return ["Courier", "Courier New", "Liberation Mono", "Nimbus Mono PS", + "DejaVu Sans Mono"] + default: + return [] + } + } + + /// A family name with case, spaces and hyphens taken out, so that "Times New Roman", + /// "TimesNewRoman" and "times-new-roman" compare equal. + static func normalised(_ family: String) -> String { + String(family.lowercased().filter { $0.isLetter || $0.isNumber }) + } +} diff --git a/Sources/CeolKitSVGRenderer/Font/OpenTypeFont.swift b/Sources/CeolKitSVGRenderer/Font/OpenTypeFont.swift index 6643fa8..5eb96ab 100644 --- a/Sources/CeolKitSVGRenderer/Font/OpenTypeFont.swift +++ b/Sources/CeolKitSVGRenderer/Font/OpenTypeFont.swift @@ -113,6 +113,15 @@ struct OpenTypeFont: Sendable { /// The face's PostScript name (`name` ID 6), where it records one. let postScriptName: String? + /// The face's family: its typographic family (`name` ID 16) where it records one, its + /// legacy family (ID 1) otherwise — "Courier New", not "Courier New Bold". + let familyName: String? + /// `OS/2.usWeightClass`: 400 regular, 700 bold. Where the face has no `OS/2`, 700 or + /// 400 by `head.macStyle`'s bold bit. + let weightClass: Int + /// Whether the face is italic or oblique: `OS/2.fsSelection` bit 0, or `head.macStyle` + /// bit 1 where there is no `OS/2`. + let isItalic: Bool /// What the face's licence lets a document do with it, from `OS/2.fsType`. let embedding: EmbeddingPermissions @@ -152,7 +161,7 @@ struct OpenTypeFont: Sendable { let bytes = FontBytes(data) for directory in try faceDirectories(bytes) { let tables = try readTableDirectory(bytes, at: directory) - if try readPostScriptName(bytes, tables: tables) == postScriptName { + if try readName(6, bytes, tables: tables) == postScriptName { return try parse(bytes, directory: directory) } } @@ -164,7 +173,7 @@ struct OpenTypeFont: Sendable { static func postScriptNames(in data: Data) throws -> [String?] { let bytes = FontBytes(data) return try faceDirectories(bytes).map { - try readPostScriptName(bytes, tables: try readTableDirectory(bytes, at: $0)) + try readName(6, bytes, tables: try readTableDirectory(bytes, at: $0)) } } @@ -209,11 +218,20 @@ struct OpenTypeFont: Sendable { throw OpenTypeError.missingTable("CFF ") } + let os2 = tables["OS/2"] + let macStyle = try bytes.u16(head + 44, "head") return OpenTypeFont( unitsPerEm: Double(unitsPerEm), ascender: Double(ascender), descender: Double(descender), - postScriptName: try readPostScriptName(bytes, tables: tables), + postScriptName: try readName(6, bytes, tables: tables), + familyName: try readName(16, bytes, tables: tables) + ?? readName(1, bytes, tables: tables), + // `head.macStyle` bit 0 for a face with no `OS/2` — macOS's own Courier.ttc. + weightClass: try os2.map { try bytes.u16($0 + 4, "OS/2") } + ?? (macStyle & 0x0001 != 0 ? 700 : 400), + isItalic: try os2.map { try bytes.u16($0 + 62, "OS/2") & 0x0001 != 0 } + ?? (macStyle & 0x0002 != 0), embedding: EmbeddingPermissions( fsType: try tables["OS/2"].map { try bytes.u16($0 + 8, "OS/2") } ?? 0), cmap: cmap, @@ -253,19 +271,22 @@ struct OpenTypeFont: Sendable { // MARK: Names - /// `name` ID 6, the PostScript name: Unicode (platform 0) or Windows (3) records in - /// UTF-16BE, Macintosh (1) in Roman, which for the ASCII a PostScript name is limited to - /// is ASCII. `nil` where the face has no `name` table or no such record. - private static func readPostScriptName(_ bytes: FontBytes, - tables: [String: Int]) throws -> String? { + /// A `name` record: Unicode (platform 0) or Windows (3) records in UTF-16BE, preferring + /// US English among Windows ones; Macintosh (1) Roman records read as ASCII, which is all + /// a PostScript name may hold and what nearly every family name is. `nil` where the face + /// has no `name` table or no record for `nameID`. + private static func readName(_ nameID: Int, _ bytes: FontBytes, + tables: [String: Int]) throws -> String? { guard let name = tables["name"] else { return nil } let count = try bytes.u16(name + 2, "name") let storage = name + (try bytes.u16(name + 4, "name")) var fallback: String? + var unicode: String? for i in 0..= 0, start + length <= bytes.count else { @@ -277,14 +298,17 @@ struct OpenTypeFont: Sendable { let units = stride(from: raw.startIndex, to: raw.endIndex - 1, by: 2).map { UInt16(raw[$0]) << 8 | UInt16(raw[$0 + 1]) } - return String(decoding: units, as: UTF16.self) + let decoded = String(decoding: units, as: UTF16.self) + // Windows US English (0x0409) is the record every tool reads first. + if platform == 3 && language == 0x0409 { return decoded } + unicode = unicode ?? decoded case 1: fallback = fallback ?? String(decoding: raw, as: UTF8.self) default: continue } } - return fallback + return unicode ?? fallback } /// Horizontal advances per glyph, in font units. diff --git a/Sources/CeolKitSVGRenderer/Font/OutlineFontSet.swift b/Sources/CeolKitSVGRenderer/Font/OutlineFontSet.swift index df9470a..0cb3423 100644 --- a/Sources/CeolKitSVGRenderer/Font/OutlineFontSet.swift +++ b/Sources/CeolKitSVGRenderer/Font/OutlineFontSet.swift @@ -10,10 +10,18 @@ import Foundation struct OutlineFontSet: Sendable { /// Identifies a face in the emitted document, so every glyph gets a stable `` id. - enum FaceKey: String, Sendable, CaseIterable { - case bravura - case libertinusSerif - case libertinusSerifItalic + /// + /// Open-ended since faces can come from a host's ``FontLibrary`` or the system as well + /// as the bundle (issue #190). The bundled faces keep the ids they have always had, so + /// a document that draws only them is written exactly as before. + struct FaceKey: RawRepresentable, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { self.rawValue = rawValue } + + static let bravura = FaceKey(rawValue: "bravura") + static let libertinusSerif = FaceKey(rawValue: "libertinusSerif") + static let libertinusSerifItalic = FaceKey(rawValue: "libertinusSerifItalic") init(_ face: CeolKitFonts.Face) { switch face { @@ -22,6 +30,16 @@ struct OutlineFontSet: Sendable { case .libertinusSerifItalic: self = .libertinusSerifItalic } } + + /// The key for a face found outside the bundle: its PostScript name, reduced to the + /// characters an XML id may hold and prefixed so it can never collide with a + /// bundled face's. + init(postScriptName: String) { + rawValue = "f-" + String(postScriptName.unicodeScalars.map { + $0.properties.isAlphabetic || ("0"..."9").contains($0) || $0 == "-" + ? Character($0) : "_" + }) + } } private let fonts: [FaceKey: OpenTypeFont] diff --git a/Sources/CeolKitSVGRenderer/Font/SystemFonts.swift b/Sources/CeolKitSVGRenderer/Font/SystemFonts.swift new file mode 100644 index 0000000..c7a116e --- /dev/null +++ b/Sources/CeolKitSVGRenderer/Font/SystemFonts.swift @@ -0,0 +1,84 @@ +import Foundation +#if canImport(CoreText) +import CoreText +#endif + +/// The fonts installed on the machine, found through CoreText and read with CeolKit's own +/// parser (issue #190). +/// +/// CoreText is asked only *where* a face is — the file and the face's PostScript name — and +/// the file is then read by ``OpenTypeFont`` like any other. Every face, from whatever +/// source, is therefore outlined, measured and checked for embedding permission by the same +/// code, and a system face draws exactly as the same file registered by a host would. A +/// face in a format the parser does not read is passed over. +/// +/// Only Apple platforms have a lookup. Elsewhere every query answers nothing, and a host +/// supplies its faces through ``FontLibrary`` instead; system lookup there would need +/// fontconfig, deferred until someone asks for it. +enum SystemFonts { + + /// Every installed face of `family`, in all its weights and styles. + static func faces(family: String) -> [OpenTypeFont] { + cached("family:" + FontRequest.normalised(family)) { + #if canImport(CoreText) + return locate([kCTFontFamilyNameAttribute: family], + mandatory: kCTFontFamilyNameAttribute) + #else + return [] + #endif + } + } + + /// The installed face whose PostScript name is `postScriptName`, if there is one. + static func faces(postScriptName: String?) -> [OpenTypeFont] { + guard let postScriptName else { return [] } + return cached("name:" + postScriptName) { + #if canImport(CoreText) + return locate([kCTFontNameAttribute: postScriptName], mandatory: kCTFontNameAttribute) + .filter { $0.postScriptName == postScriptName } + #else + return [] + #endif + } + } + + // MARK: - Cache + + // Process-wide: what is installed does not change during a render, and parsing a system + // face is far dearer than looking it up. + private nonisolated(unsafe) static var cache: [String: [OpenTypeFont]] = [:] + private static let lock = NSLock() + + private static func cached(_ key: String, _ find: () -> [OpenTypeFont]) -> [OpenTypeFont] { + lock.lock() + if let hit = cache[key] { lock.unlock(); return hit } + lock.unlock() + let found = find() + lock.lock(); defer { lock.unlock() } + cache[key] = found + return found + } + + // MARK: - CoreText + + #if canImport(CoreText) + /// The faces CoreText matches to `attributes`, holding `mandatory` to an exact match — + /// otherwise CoreText answers every query with *some* face, and a request for a family + /// that is not installed would come back as Helvetica. + private static func locate(_ attributes: [CFString: Any], mandatory: CFString) -> [OpenTypeFont] { + let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary) + let required: Set = [mandatory as String] + guard let matches = CTFontDescriptorCreateMatchingFontDescriptors( + descriptor, required as CFSet) as? [CTFontDescriptor] else { return [] } + var files: [URL: Data] = [:] + return matches.compactMap { match in + guard let url = CTFontDescriptorCopyAttribute(match, kCTFontURLAttribute) as? URL, + let name = CTFontDescriptorCopyAttribute(match, kCTFontNameAttribute) as? String + else { return nil } + guard let data = files[url] ?? (try? Data(contentsOf: url)) else { return nil } + files[url] = data + return try? OpenTypeFont.parse(data, postScriptName: name) + } + } + #endif +} diff --git a/Sources/CeolKitSVGRenderer/Font/TextOutliner.swift b/Sources/CeolKitSVGRenderer/Font/TextOutliner.swift index 56ba386..58ce022 100644 --- a/Sources/CeolKitSVGRenderer/Font/TextOutliner.swift +++ b/Sources/CeolKitSVGRenderer/Font/TextOutliner.swift @@ -20,6 +20,9 @@ public struct OutlinedText: Sendable, Equatable { /// The face's typographic descender at this size, as a positive distance below the /// baseline — the same sign convention as ``LibertinusSerifMetrics/descenderRatio``. public let descent: Double + /// Which face the text was drawn in and why, where it was asked for by + /// ``FontRequest``; `nil` for a bundled ``CeolKitFonts/Face`` asked for directly. + public let resolution: FontResolution? } /// Outlines text in a bundled face without going through the parser or a `Score` (#146). @@ -48,7 +51,48 @@ public enum TextOutliner { fontSize: Double, fill: String = "black" ) throws -> OutlinedText { - let font = try OutlineFontSet.font(for: face) + outline(text, in: try OutlineFontSet.font(for: face), fontSize: fontSize, fill: fill, + resolution: nil) + } + + /// Outlines `text` in the face `font` names, found as the engraver finds it: in + /// `library`, then — with `systemFonts` — among the fonts installed on the machine, then + /// in the bundle, which always has an answer (issue #190). + /// + /// ``OutlinedText/resolution`` says which face was used; its + /// ``FontResolution/diagnostics(at:)`` say what a document that named this font should + /// hear about the choice. A face whose licence forbids embedding is passed over, since + /// outlining copies its glyphs into whatever document the result lands in. + /// + /// - Throws: ``CeolKitFontsError`` if the bundled faces cannot be read. + public static func outline( + _ text: String, + font request: FontRequest, + fontSize: Double, + fill: String = "black", + library: FontLibrary? = nil, + systemFonts: Bool = false + ) throws -> OutlinedText { + let provider = try fontProvider(library: library, systemFonts: systemFonts) + let resolved = provider.resolve(request) + return outline(text, in: resolved.font, fontSize: fontSize, fill: fill, + resolution: resolved.resolution) + } + + private static func fontProvider(library: FontLibrary?, + systemFonts: Bool) throws -> FontProvider { + do { + return try FontProvider(library: library, systemFonts: systemFonts, + outlinesEmbed: true) + } catch { + // Report it as the bundled-face API does: the bundle is what failed. + _ = try OutlineFontSet.font(for: .libertinusSerifRegular) + throw error + } + } + + private static func outline(_ text: String, in font: OpenTypeFont, fontSize: Double, + fill: String, resolution: FontResolution?) -> OutlinedText { let scale = fontSize / font.unitsPerEm let run = OutlineRun(text, font: font) @@ -69,7 +113,8 @@ public enum TextOutliner { svg: svg, advanceWidth: font.width(of: text, fontSize: fontSize), ascent: font.ascender * scale, - descent: -font.descender * scale) + descent: -font.descender * scale, + resolution: resolution) } /// How wide `text` is drawn in `face` at `fontSize` — the ``OutlinedText/advanceWidth`` diff --git a/Tests/CeolKitSVGRendererTests/FontProviderTests.swift b/Tests/CeolKitSVGRendererTests/FontProviderTests.swift new file mode 100644 index 0000000..ddfacd3 --- /dev/null +++ b/Tests/CeolKitSVGRendererTests/FontProviderTests.swift @@ -0,0 +1,202 @@ +// +// FontProviderTests.swift +// CeolKitSVGRendererTests +// +// Issue #190: text can be drawn in faces the host registers and, where enabled, faces +// installed on the system, with the bundled faces as the fallback that always answers. +// + +import CeolKitModel +import Foundation +import Testing +@testable import CeolKitSVGRenderer + +@Suite("Font lookup: registered, system and bundled faces (#190)") +struct FontProviderTests { + + private static func fixture(_ name: String, _ ext: String) throws -> Data { + let url = try #require(Bundle.module.url(forResource: name, withExtension: ext)) + return try Data(contentsOf: url) + } + + private static func library(_ files: [(String, String)]) throws -> FontLibrary { + try FontLibrary(fonts: files.map { try fixture($0.0, $0.1) }) + } + + private let here = SourceRange(file: nil, byteOffset: 0, length: 0, line: 1, column: 1) + + // MARK: - Requests + + @Test("PostScript names split into family, weight and style", + arguments: [ + ("Times-Roman", "Times", FontWeight.regular, FontStyle.upright), + ("Times-BoldItalic", "Times", .bold, .italic), + ("Helvetica-Oblique", "Helvetica", .regular, .italic), + ("Courier-Bold", "Courier", .bold, .upright), + ("Helvetica", "Helvetica", .regular, .upright), + ("CeolKitTest-Restricted", "CeolKitTest-Restricted", .regular, .upright), + ]) + func postScriptNames(_ name: String, _ family: String, _ weight: FontWeight, + _ style: FontStyle) { + let request = FontRequest(postScriptName: name) + #expect(request.family == family && request.weight == weight && request.style == style) + #expect(request.postScriptName == name) + } + + @Test("The base 14 and the generic families have stand-ins; other families do not") + func aliases() { + #expect(FontRequest(family: "serif").aliases.first == "Times") + #expect(FontRequest(postScriptName: "Helvetica-Bold").aliases.contains("Arial")) + #expect(FontRequest(family: "monospace").aliases.contains("Liberation Mono")) + #expect(FontRequest(family: "Gill Sans").aliases.isEmpty) + } + + // MARK: - Registered faces + + @Test("A registered face is drawn when named, by PostScript name or by family") + func registeredFaceIsDrawn() throws { + let library = try Self.library([("CeolKitTest-Short", "ttf")]) + #expect(library.postScriptNames == ["CeolKitTest-Regular"]) + for request in [FontRequest(postScriptName: "CeolKitTest-Regular"), + FontRequest(family: "CeolKitTest")] { + let text = try TextOutliner.outline("A", font: request, fontSize: 1000, + library: library) + let resolution = try #require(text.resolution) + #expect(resolution.origin == .registered) + #expect(resolution.postScriptName == "CeolKitTest-Regular") + #expect(resolution.isExact) + #expect(resolution.diagnostics(at: here).isEmpty) + // The fixture's "A", not Libertinus's. + #expect(text.svg.contains("M0 0L300 700L600 0Z")) + #expect(text.advanceWidth == 700) + } + } + + @Test("A registered face in another style answers, and says it is a substitute") + func nearestStyleInFamily() throws { + let library = try Self.library([("CeolKitTest-Short", "ttf")]) + let text = try TextOutliner.outline( + "A", font: FontRequest(family: "CeolKitTest", weight: .bold), fontSize: 12, + library: library) + let resolution = try #require(text.resolution) + #expect(resolution.origin == .registered) + #expect(!resolution.isExact) + #expect(resolution.diagnostics(at: here).map(\.code) == [.fontSubstituted]) + } + + @Test("A face whose licence forbids embedding is passed over, with a diagnostic") + func restrictedFaceIsRefused() throws { + let library = try Self.library([("CeolKitTest", "ttc")]) + let text = try TextOutliner.outline( + "A", font: FontRequest(postScriptName: "CeolKitTest-Restricted"), fontSize: 12, + library: library) + let resolution = try #require(text.resolution) + #expect(resolution.refusedForEmbedding == ["CeolKitTest-Restricted"]) + #expect(resolution.postScriptName != "CeolKitTest-Restricted") + let codes = resolution.diagnostics(at: here).map(\.code) + #expect(codes.contains(.fontNotEmbeddable)) + #expect(codes.contains(.fontSubstituted)) + } + + @Test("A font file the parser cannot read is refused by position") + func unreadableLibraryFont() throws { + #expect(throws: FontLibraryError.unreadableFont(index: 1)) { + try FontLibrary(fonts: [try Self.fixture("CeolKitTest-Short", "ttf"), + Data("not a font".utf8)]) + } + } + + // MARK: - The bundled fallback + + @Test("With nothing registered and system lookup off, the bundle answers every request") + func bundledFallback() throws { + let text = try TextOutliner.outline( + "A", font: FontRequest(postScriptName: "Courier-BoldOblique"), fontSize: 12) + let resolution = try #require(text.resolution) + #expect(resolution.origin == .bundled) + #expect(resolution.family == "Libertinus Serif" && resolution.style == .italic) + let diagnostics = resolution.diagnostics(at: here) + #expect(diagnostics.map(\.code) == [.fontSubstituted]) + #expect(diagnostics.first?.message.contains("Courier-BoldOblique") == true) + } + + @Test("The emitter's own families resolve to the bundle with the keys they always had") + func bundledKeysUnchanged() throws { + let provider = try FontProvider(library: try Self.library([("CeolKitTest", "ttc")]), + systemFonts: true, outlinesEmbed: true) + #expect(provider.resolve(family: "Bravura", italic: false)?.key == .bravura) + #expect(provider.resolve(family: "Libertinus Serif", italic: true)?.key + == .libertinusSerifItalic) + } + + @Test("A registered face drawn by the builder gets its own glyph definitions") + func builderDrawsRegisteredFace() throws { + let provider = try FontProvider(library: try Self.library([("CeolKitTest-Short", "ttf")]), + systemFonts: false, outlinesEmbed: true) + var builder = SVGBuilder(textRendering: .outlines, fonts: provider) + builder.text("AA", x: 10, y: 20, fontFamily: "CeolKitTest", fontSize: 12) + let svg = builder.buildDocument(width: 100, height: 100, embeddedFaces: nil) + #expect(svg.contains(#"id="f-CeolKitTest-Regular-g2""#)) + #expect(svg.components(separatedBy: "#f-CeolKitTest-Regular-g2").count - 1 == 4) + #expect(provider.resolutions.map(\.origin) == [.registered]) + } + + @Test("Glyph keys for outside faces are valid ids and never a bundled face's") + func faceKeys() { + #expect(OutlineFontSet.FaceKey(postScriptName: "Times New Roman:Bold").rawValue + == "f-Times_New_Roman_Bold") + #expect(OutlineFontSet.FaceKey(postScriptName: "bravura") != .bravura) + } + + // MARK: - System faces + + #if canImport(CoreText) + private static let hasCourier = FileManager.default.fileExists( + atPath: "/System/Library/Fonts/Courier.ttc") + + @Test("With system lookup on, Courier-Bold resolves through CoreText and is outlined", + .enabled(if: hasCourier)) + func systemCourierBold() throws { + let text = try TextOutliner.outline( + "Ag", font: FontRequest(postScriptName: "Courier-Bold"), fontSize: 12, + systemFonts: true) + let resolution = try #require(text.resolution) + #expect(resolution.origin == .system) + #expect(resolution.postScriptName == "Courier-Bold") + #expect(resolution.isExact && resolution.weight == .bold) + #expect(!text.svg.isEmpty) + // Courier is monospaced: both glyphs advance the same, 0.6 em. + #expect(abs(text.advanceWidth - 2 * 0.6 * 12) < 0.01) + } + + @Test("A generic family is answered by its stand-in, as a note rather than a warning", + .enabled(if: FileManager.default.fileExists(atPath: "/System/Library/Fonts/Times.ttc"))) + func genericSerif() throws { + let text = try TextOutliner.outline( + "A", font: FontRequest(family: "serif", style: .italic), fontSize: 12, + systemFonts: true) + let resolution = try #require(text.resolution) + #expect(resolution.origin == .system) + #expect(resolution.postScriptName == "Times-Italic") + let diagnostics = resolution.diagnostics(at: here) + #expect(diagnostics.map(\.code) == [.fontSubstituted]) + #expect(diagnostics.first?.severity == .info) + } + + @Test("System lookup is off by default", .enabled(if: hasCourier)) + func systemLookupOffByDefault() throws { + #expect(SVGRenderConfig().systemFonts == false) + let text = try TextOutliner.outline( + "A", font: FontRequest(postScriptName: "Courier-Bold"), fontSize: 12) + #expect(text.resolution?.origin == .bundled) + } + + @Test("A family that is not installed is not answered by CoreText's fallback") + func missingSystemFamily() throws { + let text = try TextOutliner.outline( + "A", font: FontRequest(family: "No Such Family Anywhere"), fontSize: 12, + systemFonts: true) + #expect(text.resolution?.origin == .bundled) + } + #endif +} diff --git a/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift b/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift index 933df06..72587c1 100644 --- a/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift +++ b/Tests/CeolKitSVGRendererTests/StaffBraceTests.swift @@ -235,7 +235,7 @@ struct StaffBraceTests { @Suite("Anisotropic glyph scaling") struct StretchedTextTests { - private let fonts = try! OutlineFontSet.shared() + private let fonts = try! FontProvider(library: nil, systemFonts: false, outlinesEmbed: true) /// A glyph Bravura actually draws, so the outline route has an outline to emit. private let glyph = String(SMuFLGlyph.brace.character) diff --git a/Tests/CeolKitSVGRendererTests/TextOutlinerTests.swift b/Tests/CeolKitSVGRendererTests/TextOutlinerTests.swift index 7443b78..0706792 100644 --- a/Tests/CeolKitSVGRendererTests/TextOutlinerTests.swift +++ b/Tests/CeolKitSVGRendererTests/TextOutlinerTests.swift @@ -84,7 +84,7 @@ struct TextOutlinerTests { @Test(arguments: [CeolKitFonts.Face.libertinusSerifRegular, .libertinusSerifItalic]) func glyphsSitWhereTheEngraverPutsThem(face: CeolKitFonts.Face) throws { let text = "Tripping Up the Stairs" - var builder = SVGBuilder(textRendering: .outlines, fonts: try OutlineFontSet.shared()) + var builder = SVGBuilder(textRendering: .outlines, fonts: try FontProvider(config: SVGRenderConfig())) builder.text(text, x: 0, y: 0, fontFamily: face.familyName, fontSize: 21, fontStyle: face.isItalic ? "italic" : nil) let engraved = transforms(in: builder.elements.joined())