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
This commit is contained in:
2026-08-09 01:52:26 -04:00
parent d16f10b058
commit 05bbf78926
5 changed files with 454 additions and 10 deletions
+15 -9
View File
@@ -173,6 +173,12 @@ enum PrintDocumentRenderer {
/// print time contributes nothing the line still prints its labels, which is the same
/// omit-rather-than-box degrade `ItemSymbol` promises.
///
/// The image itself comes from `PrintSymbol` rather than from `NSImage(systemSymbolName:)` directly,
/// and that indirection is the whole of the printed-symbols fix: a symbol image is a *template*, which
/// a print context tints across its whole box instead of through its coverage a black rectangle
/// where the icon should be. `PrintSymbol` hands back concrete artwork, already inked and already at
/// paper resolution, plus the offset that sits it on the baseline; see its note.
///
/// Labels are joined with " · " rather than drawn as chips. A chip is a screen affordance (a
/// coloured, rounded, hit-testable thing); on paper it is ink around a word, and 01's reserved
/// `labels` key carries no colour to draw it in anyway.
@@ -183,10 +189,16 @@ enum PrintDocumentRenderer {
style.paragraphSpacing = options.fontSize * 0.45
let line = NSMutableAttributedString()
if let icon, let image = symbolImage(icon, size: font.pointSize) {
if let icon,
let symbol = PrintSymbol.rendered(icon, pointSize: font.pointSize, ink: PrintTypography.secondaryInk) {
let attachment = NSTextAttachment()
attachment.image = image
attachment.bounds = CGRect(x: 0, y: font.descender * 0.5, width: image.size.width, height: image.size.height)
attachment.image = symbol.image
attachment.bounds = CGRect(
x: 0,
y: symbol.baselineOffset,
width: symbol.image.size.width,
height: symbol.image.size.height
)
line.append(NSAttributedString(attachment: attachment))
if !labels.isEmpty {
line.append(NSAttributedString(string: " "))
@@ -205,12 +217,6 @@ enum PrintDocumentRenderer {
output.append(line)
}
/// One SF Symbol at text size, or `nil` when this system cannot draw it.
private static func symbolImage(_ name: String, size: CGFloat) -> NSImage? {
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil }
return image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: size, weight: .regular))
}
// MARK: - Plain lines
private static func append(