import Foundation // MARK: - PrintBlock /// One element of a printed document, in reading order — **the whole vocabulary of what can appear on /// paper**, and the only thing `PrintDocumentBuilder` produces. /// /// ### Why a block list rather than an attributed string /// /// The same argument `BodyMarkup` makes for existing: the decisions and the drawing are different /// jobs, and a decision buried in a pile of font attributes is a decision nobody can test. Every rule /// this feature has — which components appear, in what order, where a page break falls, which end of a /// thread comes first — is settled here, in a value a test can compare against a literal; the renderer /// beneath it (`PrintDocumentRenderer`) is then only ever wrong about *typography*. /// /// `.pageBreak` is the clearest case. It is a **marker in the list**, not a paragraph of whitespace and /// not a hint: `PrintDocumentView` splits the document at these markers and paginates each side /// independently, so "between lanes" really starts a sheet. A test can hold the marker; nobody can hold /// a promise about spacing. /// /// Bodies stay **strings** here rather than parsed `BodyMarkup`. The parse belongs to the render pass /// (which reuses `BodyMarkup.parse` and `BodyMarkupRenderer` wholesale rather than reading Markdown a /// second way), and keeping the string means a test of the *document's structure* compares words rather /// than block trees. What the builder does ask of the Markdown layer is the one question that is /// structural: `BodyMarkup.isEmpty`, which decides whether there is a body to print at all. public enum PrintBlock: Sendable, Equatable { /// A real sheet boundary. Never the first or last block, and never doubled — see /// `PrintDocumentBuilder`. case pageBreak /// The document's title, for a board print: the board's name, once, at the top. A card print has /// none — its own title line is the card's, and the board is named in the running head. case boardHeading(String) /// A lane's name, opening its run of cards. Board prints only. case laneHeading(String) case cardTitle(String) /// The icon-and-labels line. One block for the pair because it is one line on paper /// (`PrintOptions.includesLabels`); emitted only when at least one half has something to say. case cardMeta(icon: String?, labels: [String]) /// The card's Markdown, unparsed — see the type's note. case cardBody(String) /// The thread's own small heading, carrying its count so the reader knows what follows and how /// much of it there is. case commentsHeading(count: Int) case comment(author: String?, created: Date?, body: String) } // MARK: - PrintDocumentBuilder /// **`PrintSource` + `PrintOptions` → `[PrintBlock]`**: every rule about what a printed board or card /// contains, in one pure function. /// /// ### The order within a card is fixed /// /// Title, then the icon-and-labels line, then the body, then the comments. It is not configurable and /// the card that specifies this feature does not ask for it to be: the list is a document's natural /// order (what is this, how is it tagged, what does it say, what was said about it), and an option that /// let a user put the body above the title would be a page-layout program. /// /// ### Empty is empty, all the way up /// /// The build is bottom-up and drops what has nothing in it: /// /// - a **card** that would emit no blocks (an untitled, unlabelled card with an empty body, or every /// component toggled off) contributes nothing — and, crucially, does not consume a page break; /// - a **lane** whose every card dropped out has no heading either; /// - an **empty lane** is omitted entirely (see below). /// /// This is what keeps `.betweenCards` from printing blank sheets for cards that had nothing on them, /// which is the one way a page-break option can be actively harmful. It is also why the checks are here /// rather than in the renderer: "would this print anything" is a question about content, and the /// renderer has no business asking it. /// /// **Empty lanes are omitted** (decision, 2026-08-09 — flagged for review on the card): an empty "Done" /// column is real information on screen, but on paper it is a heading with nothing under it, and under /// `.betweenLanes` it is a heading with nothing under it *on its own sheet*. A print is a document of /// content. The alternative — heading with no break — was declined for making the page-break rule /// conditional on a lane's contents, which is exactly the kind of clever nobody can predict. /// /// ### Page breaks are emitted lazily, before content that follows content /// /// Never leading (a document does not start with a sheet boundary), never trailing, never doubled. The /// mechanism is one flag — has anything been emitted yet — consulted at each lane and each card, which /// is what makes the three modes compose without a case analysis per pair. public enum PrintDocumentBuilder { /// What an untitled card or lane prints as. The word is a **rendering**, exactly as it is /// everywhere else in the app (03-board-ui.md § Card face; `CardWindowHost.subtitle`), which is why /// it lives here and not in `PrintCard.title`. public static let untitled = "Untitled" /// The document. /// /// Options are read through `normalized` so the render's reading is the one that decides — a blank /// custom line does not print an empty footer line, whatever the toggle says (`PrintOptions.normalized`). public static func blocks(from source: PrintSource, options rawOptions: PrintOptions) -> [PrintBlock] { let options = rawOptions.normalized var blocks: [PrintBlock] = [] /// Whether a lane has already been laid down, and therefore whether a sheet boundary is owed /// before the next one — the lazy-break mechanism the type's note describes. `false` until a /// lane has actually been emitted, which is what makes a leading break impossible rather than /// merely unlikely. var hasEmittedLane = false for lane in source.lanes { // Built before anything about the lane is emitted, so a lane whose cards all dropped out // takes its heading and its page break with it. let cardRuns = lane.cards.map { cardBlocks($0, options: options) }.filter { !$0.isEmpty } guard !cardRuns.isEmpty else { continue } if options.pageBreaks != .flow, hasEmittedLane { blocks.append(.pageBreak) } // A card print carries its lane for context (`PrintSource.card`), but the document is the // card: a lane heading over a single card would be a document about the wrong thing. if source.scope == .board { blocks.append(.laneHeading(lane.title ?? untitled)) } hasEmittedLane = true for (offset, run) in cardRuns.enumerated() { if options.pageBreaks == .betweenCards, offset > 0 { blocks.append(.pageBreak) } blocks.append(contentsOf: run) } } // **A document with no content has no heading either** — the "empty is empty, all the way up" rule // taken to the top: with every component switched off, or on a board whose lanes are all empty, the // answer is *nothing*, not a board title over blank paper (which is what `PrintCoordinator` refuses // to spend a sheet on). guard !blocks.isEmpty else { return [] } // The board's name goes on last so it can be conditional on there being something to name — and it // is deliberately outside the page-break bookkeeping above. It is the first lane's running-in title, // not a title page: a break counted from the heading would put the board's name alone on sheet one // of every print with breaks switched on, which nobody asked for and nobody would keep. if source.scope == .board, !source.boardTitle.isEmpty { blocks.insert(.boardHeading(source.boardTitle), at: 0) } return blocks } /// One card's blocks, or `[]` when the options and the card between them have nothing to print. /// /// The four component toggles are read here and nowhere else, so "which components appear" is one /// function rather than a condition spread across a walk. private static func cardBlocks(_ card: PrintCard, options: PrintOptions) -> [PrintBlock] { var blocks: [PrintBlock] = [] if options.includesTitle { blocks.append(.cardTitle(card.title ?? untitled)) } if options.includesLabels { // Nothing to say is nothing printed: a card with no chosen icon and no labels would // otherwise contribute a blank line, and a blank line is the shape a reader reads as a // missing value. let labels = card.labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } if card.icon != nil || !labels.isEmpty { blocks.append(.cardMeta(icon: card.icon, labels: labels)) } } // `BodyMarkup.isEmpty` rather than `body.isEmpty`, which is the same question the card window // asks to decide whether a body is worth previewing (05-card-window.md ▸ Mode grammar): a body // of one newline previews as a blank page, and prints as one too unless something says // otherwise. if options.includesBody, !BodyMarkup.isEmpty(card.body) { blocks.append(.cardBody(card.body)) } if options.includesComments, !card.comments.isEmpty { let ordered = options.commentSort == .newestFirst ? Array(card.comments.reversed()) : card.comments blocks.append(.commentsHeading(count: ordered.count)) for comment in ordered { blocks.append(.comment(author: comment.author, created: comment.created, body: comment.body)) } } // A card whose every block dropped out prints nothing at all — not a title, not a break. See // the type's "Empty is empty" note; the caller relies on this being `[]` and not `[.cardTitle]`. return blocks } }