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. appearance = NSAppearance(named: .aqua) } @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) } }