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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user