import AppKit /// **An SF Symbol turned into ink** — the one place a symbol name becomes an image a PDF context can /// actually draw, and the answer to "SF Symbols render poorly in printed/PDF output". /// /// ### What went wrong, and why it looked like a bug in the renderer /// /// `NSImage(systemSymbolName:)` answers a **template** image (`isTemplate == true`) backed by a symbol /// representation, and `withSymbolConfiguration` keeps the flag. A template image is not artwork: it is a /// *shape to be tinted*, and the tinting is done by the AppKit machinery that draws it — a button cell, an /// image view, a toolbar item. Hand one to an `NSTextAttachment` and let TextKit draw it straight into a /// print/PDF context, where none of that machinery is present, and the tint is applied to the image's /// whole box instead of through its coverage: **a solid dark rectangle where the glyph should be**. That is /// exactly what the owner's 2026-08-08 report shows, and it reproduces in a bare /// `NSView.dataWithPDF(inside:)` in three lines. It is not a `PrintDocumentRenderer` bug, not a /// `PrintDocumentView` bug, and not a font bug: the image was never drawable in that context. /// /// A second failure hides behind the first. A PDF context is a 1× device, so even a *non*-template symbol /// image rasterizes at 72 ppi on its way into the page — `pdfimages -list` on such a document reports a /// 13 × 12 pixel bitmap for an 11 pt icon. On screen at 100% that passes; on paper, or at any zoom, it is /// the blur the card's first suspected failure mode describes. /// /// ### The fix: resolve the symbol before it meets the page /// /// Both failures are the same mistake — leaving work for a context that cannot do it — so both get the /// same answer. The symbol is drawn **here**, into a bitmap this file owns, at a resolution paper can use, /// with its colour already chosen; what reaches the attachment is ordinary, concrete, non-template artwork /// that any context can put down unaltered. /// /// - **Colour is baked, not deferred.** The configuration carries `paletteColors: [ink]`, so the symbol /// renders monochrome in the line's own ink rather than in SF Symbols' automatic palette (which would /// put a yellow star and a blue document on a black-and-white page). The ink is resolved against /// `PrintTypography.paper` first: a dynamic `NSColor` resolves at *draw* time, and this drawing happens /// long before the page's forced-light appearance is in effect. /// - **Resolution is chosen, not inherited.** The bitmap is `paperScale` times the point box, which puts a /// 576 ppi image on the page — past any desktop printer's addressable resolution, and still clean at 8× /// on screen. True vector would be better still and is not available: `NSSymbolImageRep` rasterizes into /// whatever context draws it, the symbols are not reachable as font glyphs by name /// (`CTFontGetGlyphWithName` answers 0 for every system face), and re-wrapping the image in a PDF /// representation only embeds the same raster one level down. This was measured rather than assumed. /// - **The baseline comes from the symbol.** See `Rendered.baselineOffset`. /// /// ### Why this is cached when `ItemSymbol.exists` deliberately is not /// /// `ItemSymbol` refuses a cache because its work is a lookup AppKit already caches. This work is a /// rasterization — a real draw into a real bitmap — and a board print runs it once per card, hundreds of /// times, for a handful of distinct icons, on every re-pagination the print panel asks for. Sharing one /// `NSImage` between every card that chose the same icon also lets the PDF writer emit the artwork once /// instead of once per card. @MainActor enum PrintSymbol { // MARK: - What a caller gets /// A symbol ready to be attached to a line of text. struct Rendered { /// Concrete, non-template artwork at print resolution, sized in points. let image: NSImage /// The attachment's `bounds.origin.y`: how far the image's box sits **below** the text baseline. /// /// Taken from the symbol's own `alignmentRect`, which is the metric Apple ships for exactly this /// question. Measured across sizes and symbols, the rect's height is the font's cap height and its /// origin is the symbol's own baseline within its box — `textformat` sits 1.0 pt up from the box's /// bottom edge at 11 pt, `lightbulb` 3.0 pt, `tag` 3.5 pt, and all three scale with the point size. /// Placing the box that far below the baseline therefore lands the *symbol's* baseline on the /// *text's*, which is the alignment the symbols were drawn for. /// /// The constant it replaces (`font.descender * 0.5`) knew nothing about the symbol, so every icon /// floated by a different amount — visible as an icon row that never quite sat on its line. let baselineOffset: CGFloat } // MARK: - Making one /// `name` at `pointSize`, inked in `ink` — or `nil` when this system cannot draw that symbol. /// /// `nil` rather than a placeholder is deliberate and is `ItemSymbol`'s promise kept on paper: a name /// the running OS does not have is a line that prints its labels and no icon, never a box. static func rendered(_ name: String, pointSize: CGFloat, ink: NSColor) -> Rendered? { let key = Key(name: name, pointSize: pointSize, ink: ink) if let cached = cache[key] { return cached } guard let source = configured(name, pointSize: pointSize, ink: resolved(ink)) else { return nil } guard let image = rasterized(source) else { return nil } let rendered = Rendered(image: image, baselineOffset: -source.alignmentRect.origin.y) // A wholesale clear rather than an eviction policy: the map is keyed by the icons a document // actually uses, so it is a handful of entries in every real print, and a cap that is only ever // reached by a pathological board is better spent forgetting everything than ranking it. if cache.count >= cacheLimit { cache.removeAll(keepingCapacity: true) } cache[key] = rendered return rendered } /// The symbol at the right size and in the right colour, still at 1× and still an `NSSymbolImageRep`. private static func configured(_ name: String, pointSize: CGFloat, ink: NSColor) -> NSImage? { let configuration = NSImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: .regular) .applying(NSImage.SymbolConfiguration(paletteColors: [ink])) return NSImage(systemSymbolName: name, accessibilityDescription: nil)? .withSymbolConfiguration(configuration) } /// `source` drawn into a bitmap of `paperScale` times its point box. /// /// The draw happens under the paper appearance for the same reason the ink is resolved under it: a /// symbol's own rendering reads the drawing appearance, and a document printed from a dark-mode app /// must not carry dark-mode artwork onto white paper. private static func rasterized(_ source: NSImage) -> NSImage? { let box = source.size guard box.width > 0, box.height > 0 else { return nil } // A ceiling on the pixel grid, so a hand-edited profile with an enormous body size cannot ask for // a bitmap measured in tens of megabytes. let scale = min(paperScale, maximumPixels / max(box.width, box.height)) guard let rep = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: Int((box.width * scale).rounded(.up)), pixelsHigh: Int((box.height * scale).rounded(.up)), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 ) else { return nil } // The rep's *point* size is the symbol's, so the extra pixels read as resolution rather than as a // bigger picture — which is the whole trick. rep.size = box NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) NSGraphicsContext.current?.imageInterpolation = .high PrintTypography.paper.performAsCurrentDrawingAppearance { source.draw(in: CGRect(origin: .zero, size: box), from: .zero, operation: .sourceOver, fraction: 1) } NSGraphicsContext.restoreGraphicsState() let image = NSImage(size: box) image.addRepresentation(rep) // **The flag that started all this**, turned off explicitly rather than left to the new image's // default: what this answers is artwork, and a future reader should see it said so. image.isTemplate = false return image } /// A dynamic colour pinned to the value paper needs, since the bitmap is drawn now and shown later. private static func resolved(_ ink: NSColor) -> NSColor { var answer = ink PrintTypography.paper.performAsCurrentDrawingAppearance { answer = ink.usingColorSpace(.sRGB) ?? ink } return answer } // MARK: - Constants /// 8 × 72 ppi = 576 ppi on the page. See the type's note. private static let paperScale: CGFloat = 8 /// The largest edge, in pixels, any one symbol's bitmap may have. private static let maximumPixels: CGFloat = 2048 private static let cacheLimit = 128 // MARK: - The cache private struct Key: Hashable { let name: String let pointSize: CGFloat /// The colour by description rather than by identity, so two equal `NSColor`s are one key. let ink: String init(name: String, pointSize: CGFloat, ink: NSColor) { self.name = name self.pointSize = pointSize self.ink = "\(ink)" } } private static var cache: [Key: Rendered] = [:] }