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
334 lines
16 KiB
Swift
334 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|