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(
|
||||
|
||||
@@ -90,7 +90,11 @@ final class PrintDocumentView: NSView {
|
||||
// drawing appearance at draw time. In a dark-mode app that resolves to near-white, which on paper
|
||||
// is a blank sheet. Pinning the view's appearance resolves every one of them the way paper needs,
|
||||
// without the renderer having to know it is being printed.
|
||||
appearance = NSAppearance(named: .aqua)
|
||||
//
|
||||
// The appearance is `PrintTypography`'s rather than a local `NSAppearance(named: .aqua)` because
|
||||
// `PrintSymbol` needs the same one — it inks an icon into a bitmap before this view exists, so it
|
||||
// cannot inherit this line and the two must not be able to drift apart.
|
||||
appearance = PrintTypography.paper
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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] = [:]
|
||||
}
|
||||
@@ -150,4 +150,14 @@ enum PrintTypography {
|
||||
/// Secondary ink, for bylines and running heads. Dynamic like `ink`, resolved by the same forced
|
||||
/// appearance.
|
||||
static let secondaryInk = NSColor.secondaryLabelColor
|
||||
|
||||
/// **The appearance a printed page is drawn in** — the light one, always, because the dynamic colours
|
||||
/// above have to resolve the way paper needs rather than the way the app's window happens to look.
|
||||
///
|
||||
/// It lives here, next to the two inks it resolves, because two places now depend on it and they have
|
||||
/// to agree: `PrintDocumentView` pins it for the whole page, and `PrintSymbol` draws under it when it
|
||||
/// rasterizes an icon — a draw that happens *before* the page exists, so it cannot inherit the view's.
|
||||
/// A second `NSAppearance(named: .aqua)` written out in the other file would be a duplicate that only
|
||||
/// looked like a constant.
|
||||
static let paper = NSAppearance(named: .aqua) ?? NSAppearance.currentDrawing()
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user