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

284 lines
12 KiB
Swift

import AppKit
/// `[PrintBlock]` → the attributed text a page draws: **the drawing half of printing, and only the
/// drawing half**.
///
/// Every decision was already made in `PrintDocumentBuilder` — which components appear, in what order,
/// which end of a thread comes first, where a sheet boundary falls — which is what keeps this file free
/// of policy, exactly as `BodyMarkupRenderer` is kept free of it by `BodyMarkup`. The parallel is not a
/// coincidence: **card bodies are rendered by that very renderer**, through the same
/// `BodyMarkup.parse`, so a printed body is typographically the same document Preview shows. Reading
/// Markdown a second way here would guarantee the two eventually disagreed about a table, a task
/// checkbox or a nested quote.
///
/// ### Sections, not one string
///
/// The output is an **array** of attributed strings, split at `.pageBreak`. That is what makes a page
/// break honest: each section is paginated independently by `PrintDocumentView`, so a section always
/// starts at the top of a sheet. Inserting form feeds or padding newlines into one long string would
/// have been the alternative, and TextKit does not paginate on either — it would have produced a break
/// that looked right at one paper size and drifted at every other.
@MainActor
enum PrintDocumentRenderer {
// MARK: - Sections
/// The document, split into independently paginated sections.
///
/// An empty block list answers `[]` rather than one empty section — a document with nothing in it
/// has no pages, which is the answer `PrintCoordinator` refuses to print rather than spending a
/// sheet on a running head over blank paper.
static func sections(for blocks: [PrintBlock], options rawOptions: PrintOptions, cardFolder: URL? = nil) -> [NSAttributedString] {
let options = rawOptions.normalized
var sections: [NSAttributedString] = []
var current = NSMutableAttributedString()
for block in blocks {
if case .pageBreak = block {
if current.length > 0 { sections.append(current) }
current = NSMutableAttributedString()
continue
}
append(block, to: current, options: options, cardFolder: cardFolder)
}
if current.length > 0 { sections.append(current) }
return sections
}
// MARK: - One block
private static func append(
_ block: PrintBlock,
to output: NSMutableAttributedString,
options: PrintOptions,
cardFolder: URL?
) {
let size = options.fontSize
switch block {
case .pageBreak:
// Consumed by `sections(for:options:cardFolder:)` before it ever reaches here; switched
// exhaustively so a future block cannot be forgotten.
break
case let .boardHeading(title):
append(
title,
font: PrintTypography.boardHeading(options),
color: PrintTypography.ink,
spacingBefore: 0,
spacingAfter: size * 0.9,
to: output
)
case let .laneHeading(title):
// A rule under the lane name, which is the one piece of decoration this document has and
// earns it: in a flowed print the lane heading is the only signal that one column ended and
// another began.
append(
title,
font: PrintTypography.laneHeading(options),
color: PrintTypography.ink,
spacingBefore: size * 1.4,
spacingAfter: size * 0.6,
to: output,
underlined: true
)
case let .cardTitle(title):
append(
title,
font: PrintTypography.cardTitle(options),
color: PrintTypography.ink,
spacingBefore: size * 1.0,
spacingAfter: size * 0.2,
to: output
)
case let .cardMeta(icon, labels):
appendMeta(icon: icon, labels: labels, options: options, to: output)
case let .cardBody(body):
appendBody(body, options: options, cardFolder: cardFolder, to: output)
case let .commentsHeading(count):
append(
commentsHeadingText(count: count),
font: PrintTypography.commentsHeading(options),
color: PrintTypography.secondaryInk,
spacingBefore: size * 0.9,
spacingAfter: size * 0.2,
to: output
)
case let .comment(author, created, body):
append(
byline(author: author, created: created),
font: PrintTypography.secondary(options),
color: PrintTypography.secondaryInk,
spacingBefore: size * 0.5,
spacingAfter: size * 0.1,
to: output,
indent: size * 1.5
)
appendBody(body, options: options, cardFolder: cardFolder, to: output, indent: size * 1.5)
}
}
/// A card's Markdown, through the app's one Markdown pass and then re-faced.
///
/// `cardFolder` is `nil` for a board print, and that is a real limitation rather than an oversight:
/// a body's relative image resolves against *its own card's* folder (`BodyTarget.resolve`), and a
/// board print walks many cards, so passing one folder would resolve some images against the wrong
/// card. An unresolvable relative image renders as the placeholder chip `BodyMarkupRenderer` already
/// draws for a remote one, which is the honest degrade. A card print, which has exactly one folder,
/// passes it and prints its images.
private static func appendBody(
_ body: String,
options: PrintOptions,
cardFolder: URL?,
to output: NSMutableAttributedString,
indent: CGFloat = 0
) {
let markup = BodyMarkup.parse(body)
let rendered = BodyMarkupRenderer.attributedString(
for: markup,
context: BodyMarkupRenderer.Context(pointSize: options.fontSize, cardFolder: cardFolder)
)
let faced = PrintTypography.restyled(rendered, family: options.fontFamily)
guard faced.length > 0 else { return }
guard indent > 0 else {
output.append(faced)
return
}
// A comment body sits under its byline, so it is indented with it. The indent is *added* to
// whatever the body's own paragraph styles already carry (a nested list keeps its nesting),
// which is why this adjusts the existing styles rather than installing one.
let indented = NSMutableAttributedString(attributedString: faced)
indented.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: indented.length)) { value, range, _ in
let style = ((value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
style.firstLineHeadIndent += indent
style.headIndent += indent
indented.addAttribute(.paragraphStyle, value: style, range: range)
}
output.append(indented)
}
/// The icon-and-labels line: the symbol, then the labels joined by a middle dot.
///
/// The icon is a **text attachment** rather than a rendered-to-text name: an SF Symbol has no
/// spelling a reader would recognize, and `NSImage(systemSymbolName:)` is the same resolution
/// `ItemSymbol` performs everywhere else in the app. A symbol that cannot be made into an image at
/// 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.
private static func appendMeta(icon: String?, labels: [String], options: PrintOptions, to output: NSMutableAttributedString) {
let font = PrintTypography.secondary(options)
let style = NSMutableParagraphStyle()
style.paragraphSpacingBefore = 0
style.paragraphSpacing = options.fontSize * 0.45
let line = NSMutableAttributedString()
if let icon,
let symbol = PrintSymbol.rendered(icon, pointSize: font.pointSize, ink: PrintTypography.secondaryInk) {
let attachment = NSTextAttachment()
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: " "))
}
}
if !labels.isEmpty {
line.append(NSAttributedString(string: labels.joined(separator: " · ")))
}
guard line.length > 0 else { return }
line.append(NSAttributedString(string: "\n"))
line.addAttributes(
[.font: font, .foregroundColor: PrintTypography.secondaryInk, .paragraphStyle: style],
range: NSRange(location: 0, length: line.length)
)
output.append(line)
}
// MARK: - Plain lines
private static func append(
_ text: String,
font: NSFont,
color: NSColor,
spacingBefore: CGFloat,
spacingAfter: CGFloat,
to output: NSMutableAttributedString,
underlined: Bool = false,
indent: CGFloat = 0
) {
guard !text.isEmpty else { return }
let style = NSMutableParagraphStyle()
style.paragraphSpacingBefore = spacingBefore
style.paragraphSpacing = spacingAfter
style.firstLineHeadIndent = indent
style.headIndent = indent
if underlined {
// A hairline under the whole measure, drawn by a text block rather than by an underline
// attribute, so it spans the column instead of only the letters — `BodyMarkupRenderer`'s
// thematic-break mechanism, reused.
let rule = NSTextBlock()
rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .maxY)
rule.setBorderColor(.separatorColor)
rule.setWidth(font.pointSize * 0.2, type: .absoluteValueType, for: .padding, edge: .maxY)
style.textBlocks = [rule]
}
output.append(NSAttributedString(string: text + "\n", attributes: [
.font: font,
.foregroundColor: color,
.paragraphStyle: style
]))
}
// MARK: - The words the document says about itself
/// "3 comments" / "1 comment" — the thread's heading.
static func commentsHeadingText(count: Int) -> String {
count == 1 ? "1 comment" : "\(count) comments"
}
/// A comment's byline. Both halves are optional and each is a lenient field, so all four
/// combinations have to read as a sentence:
///
/// - both → "Ada Lovelace — 9 Aug 2026 at 14:30"
/// - author only → "Ada Lovelace" (a comment whose `created` was unreadable — the thread already
/// sorts those last rather than refusing them)
/// - date only → the date ("**Missing renders unattributed**" — `Comment.author`)
/// - neither → "Comment", so the body still has a line announcing it and never runs into the one
/// above it
static func byline(author: String?, created: Date?) -> String {
let name = author?.trimmingCharacters(in: .whitespacesAndNewlines)
let stamp = created.map { $0.formatted(date: .abbreviated, time: .shortened) }
switch (name?.isEmpty == false ? name : nil, stamp) {
case let (author?, stamp?): return "\(author)\(stamp)"
case let (author?, nil): return author
case let (nil, stamp?): return stamp
case (nil, nil): return "Comment"
}
}
}