Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/CeolKitModel/Diagnostic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
14 changes: 13 additions & 1 deletion Sources/CeolKitSVGRenderer/Config/SVGRenderConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions Sources/CeolKitSVGRenderer/Emission/SVGBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<text>`.
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
/// `<text>`.
let fonts: FontProvider?

/// Glyph outline path data by `<defs>` id; an empty string records a glyph that is
/// known to have no outline (a space, say) so it is not decoded again.
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/CeolKitSVGRenderer/Emission/SVGEmitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions Sources/CeolKitSVGRenderer/Font/FontLibrary.swift
Original file line number Diff line number Diff line change
@@ -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..<count {
faces.append(try OpenTypeFont.parse(data, faceIndex: face))
}
} catch {
throw FontLibraryError.unreadableFont(index: index)
}
}
self.faces = faces
}

/// The PostScript names of the faces in the library, in order; a face that records
/// none is left out.
public var postScriptNames: [String] { faces.compactMap(\.postScriptName) }
}

public enum FontLibraryError: Error, Equatable, Sendable {
/// The font at this position in the list given to ``FontLibrary/init(fonts:)`` is not
/// one CeolKit can read: not OpenType or TrueType, or damaged.
case unreadableFont(index: Int)
}
226 changes: 226 additions & 0 deletions Sources/CeolKitSVGRenderer/Font/FontProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import CeolKitModel
import Foundation

/// Where a resolved face came from.
public enum FontOrigin: String, Sendable, Hashable, Codable {
/// A face the host supplied in its ``FontLibrary``.
case registered
/// A face installed on the machine, found because ``SVGRenderConfig/systemFonts`` is on.
case system
/// One of the faces CeolKit bundles: always present, and the last resort.
case bundled
}

/// What a ``FontRequest`` was answered with, and why (issue #190).
///
/// A request is answered by the first source that has the face — the host's
/// ``FontLibrary``, then the system's fonts where they are enabled, then the bundle — trying
/// the family asked for before the families that stand in for it (``FontRequest`` lists
/// them for the PostScript base 14 and the generic families). Where what was drawn is not
/// what was asked for, ``diagnostics(at:)`` says so.
public struct FontResolution: Sendable, Hashable {
public let requested: FontRequest
/// The face used: its PostScript name, family, weight and style.
public let postScriptName: String
public let family: String
public let weight: FontWeight
public let style: FontStyle
public let origin: FontOrigin
/// Faces that matched but were passed over because their licence forbids embedding
/// (`OS/2.fsType`; see ``TextRendering/outlines``), by PostScript name.
public let refusedForEmbedding: [String]

/// Whether the face is the one asked for: the requested PostScript name, or the
/// requested family in the requested weight and style.
public var isExact: Bool {
if let name = requested.postScriptName,
name.caseInsensitiveCompare(postScriptName) == .orderedSame { return true }
return FontRequest.normalised(family) == FontRequest.normalised(requested.family)
&& weight == requested.weight && style == requested.style
}

/// What a document naming this font should be told, reported at `source`: nothing for
/// an exact match; a substitution otherwise — a note where a standard family was
/// answered by one that stands in for it, a warning where the family was not found at
/// all — and a warning for each face refused for its licence.
public func diagnostics(at source: SourceRange) -> [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))
}
}
Loading
Loading