Files
lanework/Kanban/UI/Print/PrintSymbol.swift
T
rzen 05bbf78926 Printed symbols become real glyphs — template images resolved before they meet the PDF context
The owner's 2026-08-08 report ("SF symbols don't render well in the PDF output
of File ▸ Print…") photographed solid dark rectangles where the card icons
belong. The cause is not typography and not the renderer's layout: an
`NSImage(systemSymbolName:)` is a *template* image, a shape meant to be tinted
by the AppKit machinery that draws it. A print/PDF context has none of that
machinery, so the tint lands on the image's whole box instead of through its
coverage — a filled rectangle, measured at 1.000 ink coverage through a real
`NSPrintOperation`.

A second failure hid behind the first: a PDF context is a 1× device, so even a
non-template symbol rasterized at 72 ppi on the way onto the page (13 × 12
pixels for an 11 pt icon) and blurred at any zoom.

Both are the same mistake — leaving work for a context that cannot do it — so
`PrintSymbol` does the work first: the symbol is inked in the line's own colour
(resolved against the paper appearance, since a dynamic colour resolves at draw
time and this drawing happens long before the page exists), drawn into a bitmap
at eight times the point box, and handed over as ordinary non-template artwork.
The page now carries a 576 ppi glyph at 0.277 coverage. True vector was
measured and is not available: `NSSymbolImageRep` rasterizes into whatever
context draws it, the symbols are not reachable as font glyphs by name, and
re-wrapping the image in a PDF representation only embeds the same raster one
level down.

While in there, the attachment's baseline stops being a guess. It was
`font.descender * 0.5` — a constant that knew nothing about which symbol it was
placing, so every icon floated by a different amount. It is now the symbol's own
`alignmentRect`, which is Apple's metric for exactly this: the rect's height is
the font's cap height and its origin is the symbol's baseline within its box.

The forced light appearance moves to `PrintTypography.paper` because two places
now depend on it and must not drift: the page view pins it, and the symbol
raster draws under it.

Lane headings were checked and need nothing — `PrintLane` carries no icon, so
card meta lines are the only symbols a printed document has.

Tests drive the real pipeline: `PrintDocumentBuilder` → `PrintDocumentRenderer`
→ a real `NSPrintOperation` to PDF, then measure the ink on a sheet whose only
content is one icon. The coverage assertion fails at 1.000 on the shipped build.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 01:52:26 -04:00

185 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] = [:]
}