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

334 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
/// **The printed page** — the view `NSPrintOperation` paginates and draws, and the one place a page
/// break becomes a real sheet boundary.
///
/// ### Why a custom view rather than printing an `NSTextView`
///
/// An `NSTextView` paginates itself, which is most of what this file does, and it was the obvious first
/// choice. It cannot keep the one promise the feature is built on: **"between lanes" must really start a
/// new page** (`PrintPageBreaks`). TextKit has no page-break character — a form feed is laid out as
/// whitespace, not as a boundary — so a text view could only ever have been given padding newlines, which
/// land in the right place at one paper size and drift at every other, and which would silently stop
/// working the day someone changed the margins. A break has to be a *pagination* fact, not a spacing one.
///
/// So the document is split into sections at its breaks (`PrintDocumentRenderer.sections`), each section
/// gets its own TextKit stack, and each section's text is flowed into as many page-sized text containers
/// as it needs. A section therefore always begins at the top of a sheet, at every paper size, with any
/// margins — because a container boundary *is* a page boundary here, by construction.
///
/// **TextKit 1 deliberately** (`NSLayoutManager`, `NSTextContainer`), and not by inertia: card bodies are
/// rendered by `BodyMarkupRenderer`, whose GFM tables are `NSTextTable`s — a TextKit 1 construct, which
/// is the same reason the card window's own body surface runs on TextKit 1 (`CardBodySurfaceView`). The
/// multiple-containers-per-layout-manager flow this file relies on is also TextKit 1's; TextKit 2 models
/// it differently and would have to be a separate design, not a search-and-replace.
///
/// ### Header and footer are drawn here, not handed to AppKit
///
/// `NSView` has a `pageHeader`/`pageFooter` pair and `drawPageBorder(withSize:)` to go with them. They
/// are not used: their content is a single attributed string per page with no control over placement,
/// they draw outside the imageable rect the pagination already accounts for, and their behaviour depends
/// on an `NSPrintInfo` dictionary key rather than on anything this app can state. Drawing the two lines
/// inside the page rect — with the text container's height reduced by exactly their heights — makes the
/// running head, the pagination and the folio one arithmetic instead of three that have to agree.
///
/// ### One known limit, stated rather than hidden
///
/// There is no widow/orphan control: a card title can fall as the last line of a page with its body
/// overleaf. `NSParagraphStyle` has no keep-with-next, so the honest fixes are a measure-and-push pass or
/// per-card sections — the second of which is exactly what `.betweenCards` already offers a user who
/// cares. Left as it is, and noted here so the next reader knows it was a decision.
@MainActor
final class PrintDocumentView: NSView {
// MARK: - What it prints
private let session: PrintSession
/// The imageable area of one sheet — paper minus margins, as `NSPrintInfo` reports it. Fixed for the
/// operation: the panel's paper and orientation controls rebuild the operation rather than mutating
/// this.
private let pageSize: CGSize
/// The running head's and foot's heights, computed once from the options' own type scale. Zero when
/// the line has nothing to say, which is what reclaims the paper rather than leaving a blank band
/// (`PrintRunningHead.Line.isEmpty`).
private var headerHeight: CGFloat = 0
private var footerHeight: CGFloat = 0
/// One page: the layout manager that owns its glyphs, and which of its containers this page is.
private struct Page {
let layoutManager: NSLayoutManager
let containerIndex: Int
}
private var pages: [Page] = []
/// The text storages, held only to keep them alive: a layout manager does not retain its storage, and
/// a deallocated storage takes the glyphs with it (a page that draws nothing, intermittently).
private var storages: [NSTextStorage] = []
/// The options the current pagination was computed from — the guard that keeps `knowsPageRange` from
/// re-flowing the whole document on every one of the preview's repeated calls when nothing changed.
private var paginatedOptions: PrintOptions?
/// A ceiling on the page count, so a pathological layout cannot spin forever inside a modal panel.
/// It is deliberately far above any real print: a 500-card board with every comment is a few hundred
/// sheets, and a document that wants more than this has hit a bug, not a use case.
private static let pageLimit = 5000
/// The date the print was configured — stamped once, at construction, so every sheet of one job
/// carries the same date even if the job straddles midnight.
private let printedAt = Date()
init(session: PrintSession, printInfo: NSPrintInfo) {
self.session = session
pageSize = Self.imageableSize(of: printInfo)
super.init(frame: CGRect(origin: .zero, size: pageSize))
// **Forced light appearance, and it is load-bearing.** Bodies come from `BodyMarkupRenderer`,
// which sets `NSColor.labelColor` and its neighbours — dynamic colours resolved against the
// 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.
//
// 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)
required init?(coder: NSCoder) { fatalError("PrintDocumentView is created in code") }
/// Text goes down the page, so the view's y does too — which also makes a page's rect
/// `(pageIndex × height)` rather than a subtraction from the total.
override var isFlipped: Bool { true }
// MARK: - Paper
/// The imageable content size: the paper minus the four margins the print panel is showing.
///
/// `paperSize` and the four margins rather than `imageablePageBounds`, deliberately: the latter is the
/// *printer's* hardware limit, and using it would silently override the margins the user set in the
/// panel — a document that ignored a 1-inch margin because the printer could reach further. The
/// margins are the document's, and the panel owns them.
static func imageableSize(of printInfo: NSPrintInfo) -> CGSize {
let paper = printInfo.paperSize
let width = paper.width - printInfo.leftMargin - printInfo.rightMargin
let height = paper.height - printInfo.topMargin - printInfo.bottomMargin
// A margin set larger than the paper is reachable from a hand-edited print preset; a floor keeps
// the pagination loop from meeting a container it can never fill.
return CGSize(width: max(72, width), height: max(72, height))
}
/// Where the text lives on a page, once the running head and foot have taken theirs.
private var textSize: CGSize {
CGSize(width: pageSize.width, height: max(24, pageSize.height - headerHeight - footerHeight))
}
// MARK: - Pagination
/// **The whole pagination**, run by AppKit before each print and before each preview refresh.
///
/// Re-flowing is guarded on the options rather than done unconditionally: the print panel calls this
/// several times per interaction, and a 500-card board's layout is not free.
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
paginate()
let count = max(1, pages.count)
setFrameSize(CGSize(width: pageSize.width, height: pageSize.height * CGFloat(count)))
range.pointee = NSRange(location: 1, length: count)
return true
}
override func rectForPage(_ page: Int) -> NSRect {
NSRect(
x: 0,
y: CGFloat(page - 1) * pageSize.height,
width: pageSize.width,
height: pageSize.height
)
}
/// How many sheets the document currently needs — the folio's denominator, and the summary line's
/// number. Paginates if it has to, so a caller never has to sequence the two.
func pageCount() -> Int {
paginate()
return max(1, pages.count)
}
private func paginate() {
let options = session.options.normalized
guard paginatedOptions != options else { return }
paginatedOptions = options
measureRunningLines(options: options)
pages = []
storages = []
let sections = PrintDocumentRenderer.sections(
for: session.blocks(),
options: options,
cardFolder: session.cardFolder
)
for section in sections {
let storage = NSTextStorage(attributedString: section)
let manager = NSLayoutManager()
// Font leading, so a line of 18pt heading and a line of 11pt body each take the space their
// own face asks for — the same reason the card window's body surface leaves it on.
manager.usesFontLeading = true
storage.addLayoutManager(manager)
storages.append(storage)
let total = manager.numberOfGlyphs
var laidOut = 0
var containerIndex = 0
// A section with no glyphs still gets no page: `sections(for:...)` never emits an empty one,
// and a defensive page here would print a sheet of running heads over nothing.
while laidOut < total, pages.count < Self.pageLimit {
let container = NSTextContainer(size: textSize)
// The renderer's own indents are the document's; a container inset would add a second,
// invisible one that only printing had.
container.lineFragmentPadding = 0
container.widthTracksTextView = false
container.heightTracksTextView = false
manager.addTextContainer(container)
manager.ensureLayout(for: container)
let glyphs = manager.glyphRange(for: container)
// A container that accepted nothing cannot be filled by another of the same size — an
// image or a table wider or taller than the page. Stopping is the only termination this
// loop can honestly have; the content that did not fit is clipped rather than looping
// forever inside a modal print panel.
guard glyphs.length > 0 else { break }
pages.append(Page(layoutManager: manager, containerIndex: containerIndex))
containerIndex += 1
laidOut = glyphs.location + glyphs.length
}
}
}
/// The two bands' heights, from the running-head font and whether either line has anything in it.
private func measureRunningLines(options: PrintOptions) {
let font = PrintTypography.runningHead(options)
let line = font.ascender - font.descender + font.leading
let gap = options.fontSize * 0.8
headerHeight = PrintRunningHead.header(
options: options,
boardTitle: session.boardTitle,
dateText: dateText
).isEmpty ? 0 : line + gap
// Measured against a representative folio rather than the real one: page 1 of 1 and page 9 of 99
// are the same height, and the count is not known until pagination has run — which is what this
// measurement is an input to.
footerHeight = PrintRunningHead.footer(
options: options,
pageText: PrintRunningHead.pageText(page: 1, of: 1)
).isEmpty ? 0 : line + gap
}
// MARK: - Drawing
override func draw(_ dirtyRect: NSRect) {
let options = session.options.normalized
guard let index = pageIndex(in: dirtyRect), pages.indices.contains(index) else { return }
let page = pages[index]
let pageTop = CGFloat(index) * pageSize.height
let container = page.layoutManager.textContainers[page.containerIndex]
let glyphs = page.layoutManager.glyphRange(for: container)
let origin = CGPoint(x: 0, y: pageTop + headerHeight)
page.layoutManager.drawBackground(forGlyphRange: glyphs, at: origin)
page.layoutManager.drawGlyphs(forGlyphRange: glyphs, at: origin)
drawRunningLines(options: options, pageIndex: index, pageTop: pageTop)
}
/// Which page is being drawn.
///
/// `NSPrintOperation.current?.currentPage` is the authority — it is exactly what the printing
/// machinery is tracking — and the arithmetic is the fallback for the one case where there is no
/// operation: a draw on screen, which only happens if someone ever puts this view in a window.
private func pageIndex(in dirtyRect: NSRect) -> Int? {
if let page = NSPrintOperation.current?.currentPage, page > 0 {
return page - 1
}
guard pageSize.height > 0 else { return nil }
return Int((dirtyRect.minY / pageSize.height).rounded(.down))
}
private func drawRunningLines(options: PrintOptions, pageIndex: Int, pageTop: CGFloat) {
let font = PrintTypography.runningHead(options)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: PrintTypography.secondaryInk
]
if headerHeight > 0 {
draw(
PrintRunningHead.header(options: options, boardTitle: session.boardTitle, dateText: dateText),
attributes: attributes,
in: NSRect(x: 0, y: pageTop, width: pageSize.width, height: headerHeight)
)
}
if footerHeight > 0 {
let line = PrintRunningHead.footer(
options: options,
pageText: PrintRunningHead.pageText(page: pageIndex + 1, of: max(1, pages.count))
)
draw(
line,
attributes: attributes,
in: NSRect(
x: 0,
y: pageTop + pageSize.height - footerHeight,
width: pageSize.width,
height: footerHeight
)
)
}
}
/// One running line, its two ends at the two ends of the measure.
///
/// Each end is drawn separately with its own alignment rather than joined by tabs: a tab stop would
/// have to be recomputed per paper size, and a leading string long enough to reach the trailing one
/// would push it off the page instead of truncating. Two rects cannot collide destructively — the
/// worst case is two texts that meet in the middle, each truncated by its own rect.
private func draw(_ line: PrintRunningHead.Line, attributes: [NSAttributedString.Key: Any], in rect: NSRect) {
let inset = rect
if !line.leading.isEmpty {
var leading = attributes
let style = NSMutableParagraphStyle()
style.alignment = .left
style.lineBreakMode = .byTruncatingTail
leading[.paragraphStyle] = style
NSAttributedString(string: line.leading, attributes: leading)
.draw(with: CGRect(x: inset.minX, y: inset.minY, width: inset.width * 0.6, height: inset.height),
options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
}
if !line.trailing.isEmpty {
var trailing = attributes
let style = NSMutableParagraphStyle()
style.alignment = .right
style.lineBreakMode = .byTruncatingTail
trailing[.paragraphStyle] = style
NSAttributedString(string: line.trailing, attributes: trailing)
.draw(with: CGRect(x: inset.minX + inset.width * 0.6, y: inset.minY, width: inset.width * 0.4, height: inset.height),
options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
}
}
/// The print's date, formatted once — the running head's trailing end.
private var dateText: String {
printedAt.formatted(date: .abbreviated, time: .shortened)
}
}