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())