Files
lanework/Kanban/UI/Print/PrintTypography.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

164 lines
9.0 KiB
Swift

import AppKit
/// **The type scale of a printed document, derived from one base choice** — "font face, size & style"
/// (the printing card's fourth bullet) answered as the scope ruling settles it: the user picks a face
/// and a body size, and everything else is a multiple of them.
///
/// ### Why one size and not six
///
/// A print dialog that asked separately for the heading size, the byline size and the running-head size
/// would be a typesetting program with a Print button. The ratios below are the same relationships the
/// card window's Preview already uses (`BodyMarkupRenderer` sets every indent, padding and heading step
/// as a multiple of the body point size, so the surface grows with the system text size —
/// 10-accessibility.md ▸ Text); this file is that discipline pointed at paper, where the base comes from
/// `PrintOptions.fontSize` instead of from the system.
///
/// ### Why the face is applied as a *remap* rather than threaded through
///
/// Bodies are rendered by `BodyMarkupRenderer`, which is the app's one Markdown-to-typography pass and
/// hardcodes the system font by design (it draws what the card window draws). Teaching it a font family
/// would put a print-only parameter into the surface that renders every card on screen. So a print sets
/// its face afterwards, by walking the finished string's `.font` runs and rebuilding each one in the
/// chosen family at its own size and with its own traits (`restyled`). One consequence is deliberate:
/// **code stays monospaced**. A fenced block set in Palatino is not what anyone means by choosing
/// Palatino.
@MainActor
enum PrintTypography {
// MARK: - The scale
/// The document's base — body text, comment bodies, and the measure everything else is a multiple
/// of.
static func body(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize, weight: .regular)
}
/// The board's name at the top of a board print. The largest thing on the page, because it is the
/// only thing that names the whole document.
static func boardHeading(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize * 1.7, weight: .bold)
}
/// A lane's name. Below the board and above a card, which is exactly its place in the structure.
static func laneHeading(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize * 1.35, weight: .semibold)
}
/// A card's title.
static func cardTitle(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize * 1.15, weight: .semibold)
}
/// The icon-and-labels line, and a comment's byline — the two secondary lines, at one size so the
/// page has one voice for "this is about the content, not the content".
static func secondary(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize * 0.85, weight: .regular)
}
/// The running head and foot. Smallest on the page: furniture that must be readable and must not
/// compete.
static func runningHead(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize * 0.8, weight: .regular)
}
/// The comments heading — "3 comments" over the thread.
static func commentsHeading(_ options: PrintOptions) -> NSFont {
font(family: options.fontFamily, size: options.fontSize * 0.95, weight: .semibold)
}
// MARK: - Resolving a face
/// A font in `family` at `size`, falling back to the system font of that size and weight.
///
/// **The fallback is the whole leniency story** and it mirrors `ItemSymbol.name(_:fallback:)`
/// exactly: a stored profile is a value that travels between machines and OS releases, and a family
/// that is not installed here must degrade rather than refuse. `NSFont(name:size:)` against a family
/// name resolves the family's regular face on macOS; when it cannot, the system font is the answer.
static func font(family: String?, size: CGFloat, weight: NSFont.Weight) -> NSFont {
let size = max(1, size)
guard let family, !family.isEmpty else {
return NSFont.systemFont(ofSize: size, weight: weight)
}
let descriptor = NSFontDescriptor(fontAttributes: [.family: family])
if let base = NSFont(descriptor: descriptor, size: size) {
return weight == .regular ? base : bolder(base, weight: weight) ?? base
}
return NSFont.systemFont(ofSize: size, weight: weight)
}
/// A heavier cut of `font`, or `nil` when the family has none — a family with only one weight
/// renders a "semibold" heading in its one face, which is what a single-weight face means.
private static func bolder(_ font: NSFont, weight: NSFont.Weight) -> NSFont? {
var traits = font.fontDescriptor.symbolicTraits
traits.insert(.bold)
let descriptor = font.fontDescriptor.withSymbolicTraits(traits)
return NSFont(descriptor: descriptor, size: font.pointSize)
}
/// Whether the running system can set text in `family` — the picker's own filter, and
/// `ItemSymbol.exists`' posture applied to type: the font set is the *machine's*, so a hardcoded
/// list would be wrong on the first machine that had a different one.
static func families() -> [String] {
NSFontManager.shared.availableFontFamilies.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
// MARK: - The remap
/// `attributed` with every non-monospaced `.font` run rebuilt in `family`, keeping each run's own
/// size and traits.
///
/// The traits are carried across rather than recomputed, which is what makes a body's structure
/// survive the change of face: `**bold**` stays bold, `*emphasis*` stays italic, a heading stays
/// whatever weight the renderer gave it, and the size ladder (headings larger, captions smaller) is
/// untouched because each run keeps its own point size.
///
/// **Monospaced runs are skipped on purpose** — see the type's note. `isMonospaced` on
/// `NSFontDescriptor.symbolicTraits` is the test, which catches both the app's explicit
/// `monospacedSystemFont` code style and any face that reports itself fixed-pitch.
///
/// A `nil` family is the identity: the string is returned untouched rather than rebuilt into the
/// system font it is already set in.
static func restyled(_ attributed: NSAttributedString, family: String?) -> NSAttributedString {
guard let family, !family.isEmpty else { return attributed }
let output = NSMutableAttributedString(attributedString: attributed)
output.enumerateAttribute(.font, in: NSRange(location: 0, length: output.length)) { value, range, _ in
guard let font = value as? NSFont else { return }
let traits = font.fontDescriptor.symbolicTraits
guard !traits.contains(.monoSpace) else { return }
var descriptor = NSFontDescriptor(fontAttributes: [.family: family])
descriptor = descriptor.withSymbolicTraits(traits)
guard let replacement = NSFont(descriptor: descriptor, size: font.pointSize) else { return }
output.addAttribute(.font, value: replacement, range: range)
}
return output
}
// MARK: - Ink
/// **Paper is white, so ink is black** — and the app's dynamic colours are not.
///
/// `BodyMarkupRenderer` sets `NSColor.labelColor` and friends, which resolve *at draw time against
/// the drawing appearance*: in a dark-mode app that is near-white, which on paper is nothing at all.
/// The print view therefore draws in a forced light appearance (`PrintDocumentView`), which resolves
/// every one of those dynamic colours the way a printed page needs. This constant is for the text
/// this file's own callers compose — headings, bylines, running heads — where naming the ink
/// explicitly is clearer than relying on the appearance override two files away.
static let ink = NSColor.textColor
/// 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()
}