Files
lanework/Kanban/UI/Print/PrintDocumentRenderer.swift
T
rzen 7651e40318 Print boards and cards with configurable components and named print profiles
⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.

The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.

A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.

Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.

DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:42:11 -04:00

278 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.
///
/// 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 image = symbolImage(icon, size: font.pointSize) {
let attachment = NSTextAttachment()
attachment.image = image
attachment.bounds = CGRect(x: 0, y: font.descender * 0.5, width: image.size.width, height: 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)
}
/// 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(
_ 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"
}
}
}