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
+240
View File
@@ -1199,3 +1199,243 @@ struct PrintSessionTests {
#expect(reads == 1, "and once the user asks, exactly one read serves every relayout")
}
}
// MARK: - Symbols on paper
/// **The printed-symbol regression** (owner report, 2026-08-08: "SF symbols don't render well in the PDF
/// output of File Print", with a screenshot of solid dark rectangles where the card icons belong).
///
/// The typography around it is still legitimately untested see this file's own note but this failure is
/// not typography. It is a drawing fact with two halves, and both are assertable:
///
/// - an `NSImage(systemSymbolName:)` is a **template**, and a print/PDF context tints one across its whole
/// box rather than through its coverage, which is the black rectangle the owner photographed;
/// - a PDF context is a 1× device, so even a non-template symbol image lands as a 13-pixel bitmap and
/// blurs at any zoom.
///
/// So the suite asserts what `PrintSymbol` hands over (concrete artwork, at paper resolution, on the
/// symbol's own baseline) and then prints a page whose only ink is one icon and measures it: a glyph
/// covers a fraction of its own bounding box, a template box covers all of it. The second test would have
/// failed on the shipped build at ~1.0 coverage, which is the whole point of writing it that way.
@Suite("Print ▸ symbols on paper")
@MainActor
struct PrintSymbolTests {
/// A symbol every macOS has had for years, and one whose artwork is plainly not a rectangle.
private static let symbol = "lightbulb"
// MARK: The image handed to the page
@Test("A printed symbol is concrete artwork, not a template to be tinted by machinery that is not there")
func artworkNotTemplate() throws {
let rendered = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: PrintTypography.secondaryInk))
#expect(!rendered.image.isTemplate, "a template image is what drew the black boxes")
let rep = try #require(rendered.image.representations.first as? NSBitmapImageRep)
#expect(rep.size == rendered.image.size, "the extra pixels are resolution, not a bigger picture")
#expect(
CGFloat(rep.pixelsWide) >= rep.size.width * 4,
"a page wants far more than the 72 ppi a PDF context would rasterize at"
)
}
@Test("The artwork is a glyph — most of its box is paper")
func artworkIsNotFilled() throws {
let rendered = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: PrintTypography.secondaryInk))
let rep = try #require(rendered.image.representations.first as? NSBitmapImageRep)
let bytes = try #require(rep.bitmapData)
var inked = 0
for y in 0 ..< rep.pixelsHigh {
for x in 0 ..< rep.pixelsWide {
let alpha = bytes[y * rep.bytesPerRow + x * rep.samplesPerPixel + 3]
if alpha > 12 { inked += 1 }
}
}
let coverage = Double(inked) / Double(rep.pixelsWide * rep.pixelsHigh)
#expect(coverage > 0.02, "a symbol that drew nothing at all is the other way to fail")
#expect(coverage < 0.75, "a filled box is the template bug; a lightbulb is an outline")
}
@Test("An unknown name draws nothing rather than a box — `ItemSymbol`'s promise, kept on paper")
func unknownNameIsOmitted() {
#expect(PrintSymbol.rendered("not.a.symbol.anybody.ships", pointSize: 11, ink: .black) == nil)
}
// MARK: The baseline
@Test("The offset is the symbol's own baseline, per symbol and scaling with the size")
func baselineComesFromTheSymbol() throws {
let ink = PrintTypography.secondaryInk
let bulb = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: ink))
let text = try #require(PrintSymbol.rendered("textformat", pointSize: 11, ink: ink))
let larger = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 22, ink: ink))
// Below the baseline, always the old constant was `font.descender * 0.5`, which knew nothing
// about which symbol it was placing.
#expect(bulb.baselineOffset < 0)
#expect(
bulb.baselineOffset < text.baselineOffset,
"a lightbulb's base sits under the baseline; `textformat` sits on it"
)
#expect(larger.baselineOffset < bulb.baselineOffset, "and the offset is a length, so it scales")
let source = try #require(NSImage(systemSymbolName: Self.symbol, accessibilityDescription: nil)?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 11, weight: .regular)))
#expect(bulb.baselineOffset == -source.alignmentRect.origin.y, "which is Apple's own metric, not a guess")
}
// MARK: The renderer's line
@Test("The icon reaches the page as an attachment carrying that artwork")
func theLineCarriesTheArtwork() throws {
let blocks: [PrintBlock] = [.cardMeta(icon: Self.symbol, labels: ["idea"])]
let section = try #require(PrintDocumentRenderer.sections(for: blocks, options: PrintOptions()).first)
var found: NSImage?
section.enumerateAttribute(.attachment, in: NSRange(location: 0, length: section.length)) { value, _, _ in
if let attachment = value as? NSTextAttachment { found = attachment.image }
}
let image = try #require(found, "the icon is a text attachment on the labels line")
#expect(!image.isTemplate)
#expect(section.string.contains("idea"), "and the labels are still on the line beside it")
}
@Test("A symbol this system cannot draw still prints its labels")
func aMissingSymbolKeepsTheLabels() throws {
let blocks: [PrintBlock] = [.cardMeta(icon: "not.a.symbol.anybody.ships", labels: ["idea"])]
let section = try #require(PrintDocumentRenderer.sections(for: blocks, options: PrintOptions()).first)
#expect(section.string.contains("idea"))
}
// MARK: What actually lands on paper
/// A page whose only ink is one icon: a card print, every component but the labels line switched off,
/// a card with an icon and no labels, and no running head or foot. Whatever is dark on that sheet is
/// the symbol and nothing else, so it can be measured rather than eyeballed.
private func iconOnlyOptions() -> PrintOptions {
var options = PrintOptions()
options.includesTitle = false
options.includesBody = false
options.includesComments = false
options.includesLabels = true
options.headerShowsBoardTitle = false
options.headerShowsPrintDate = false
options.footerShowsPageNumbers = false
options.footerShowsCustomLine = false
return options
}
private func iconOnlyPDF(icon: String) -> Data {
let (store, _, teardown) = makeStore()
defer { teardown() }
let options = iconOnlyOptions()
store.captureLastUsed(options)
let source = PrintSource(
scope: .card,
boardTitle: "Roadmap",
lanes: [PrintLane(title: "One", cards: [PrintCard(title: "A", icon: icon)])]
)
let session = PrintSession(
provider: PrintSourceProvider(complete: source),
profiles: store,
cardFolder: nil,
jobTitle: "A",
boardTitle: "Roadmap"
)
let info = NSPrintInfo()
let view = PrintDocumentView(session: session, printInfo: info)
_ = view.pageCount()
let data = NSMutableData()
let operation = NSPrintOperation.pdfOperation(with: view, inside: view.bounds, to: data, printInfo: info)
operation.showsPrintPanel = false
operation.showsProgressPanel = false
operation.run()
return data as Data
}
/// The document's first page, rasterized onto white at `scale` times its point size what a reader
/// with a magnifying glass would see.
private func firstPage(of pdf: Data, scale: CGFloat) -> NSBitmapImageRep? {
guard let page = NSPDFImageRep(data: pdf) else { return nil }
page.currentPage = 0
let box = page.size
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(box.width * scale), pixelsHigh: Int(box.height * scale),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { return nil }
rep.size = box
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSColor.white.setFill()
CGRect(origin: .zero, size: box).fill()
page.draw(in: CGRect(origin: .zero, size: box))
NSGraphicsContext.restoreGraphicsState()
return rep
}
/// The bounding box of the ink on a page, and how much of that box the ink fills in points, and as a
/// fraction. A glyph fills a fraction of its box; the template bug filled all of it.
private func ink(on rep: NSBitmapImageRep, scale: CGFloat) -> (box: CGRect, coverage: Double)? {
guard let bytes = rep.bitmapData else { return nil }
var minX = rep.pixelsWide, maxX = -1, minY = rep.pixelsHigh, maxY = -1
var inked = 0
for y in 0 ..< rep.pixelsHigh {
let row = y * rep.bytesPerRow
for x in 0 ..< rep.pixelsWide where bytes[row + x * rep.samplesPerPixel] < 220 {
inked += 1
minX = min(minX, x); maxX = max(maxX, x)
minY = min(minY, y); maxY = max(maxY, y)
}
}
guard maxX >= minX, maxY >= minY else { return nil }
let width = maxX - minX + 1
let height = maxY - minY + 1
return (
CGRect(x: CGFloat(minX) / scale, y: CGFloat(minY) / scale,
width: CGFloat(width) / scale, height: CGFloat(height) / scale),
Double(inked) / Double(width * height)
)
}
@Test("A printed page draws the symbol as a glyph, at the size the type scale asked for")
func thePageDrawsAGlyph() throws {
let scale: CGFloat = 4
let pdf = iconOnlyPDF(icon: Self.symbol)
#expect(!pdf.isEmpty, "the operation produced a document")
let page = try #require(firstPage(of: pdf, scale: scale))
let measured = try #require(ink(on: page, scale: scale), "the icon is the only ink on the sheet")
// The line's own size, which is what the icon is built at.
let expected = try #require(PrintSymbol.rendered(
Self.symbol,
pointSize: PrintTypography.secondary(iconOnlyOptions()).pointSize,
ink: PrintTypography.secondaryInk
)).image.size
#expect(
measured.coverage < 0.75,
"coverage \(measured.coverage) — a filled box is the template bug the owner photographed"
)
#expect(measured.coverage > 0.05, "and something was drawn")
// The ink sits *inside* the symbol's box and fills most of it: a lightbulb is narrower than the
// box it is drawn in, but an icon at the wrong size the whole page, or a stray point size
// would miss either bound by a mile.
#expect(
measured.box.width <= expected.width + 1 && measured.box.height <= expected.height + 1,
"ink measured \(measured.box.size) against a symbol of \(expected)"
)
#expect(
measured.box.width >= expected.width * 0.5 && measured.box.height >= expected.height * 0.5,
"ink measured \(measured.box.size) against a symbol of \(expected)"
)
}
}