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. /// /// The image itself comes from `PrintSymbol` rather than from `NSImage(systemSymbolName:)` directly, /// and that indirection is the whole of the printed-symbols fix: a symbol image is a *template*, which /// a print context tints across its whole box instead of through its coverage — a black rectangle /// where the icon should be. `PrintSymbol` hands back concrete artwork, already inked and already at /// paper resolution, plus the offset that sits it on the baseline; see its note. /// /// 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 symbol = PrintSymbol.rendered(icon, pointSize: font.pointSize, ink: PrintTypography.secondaryInk) { let attachment = NSTextAttachment() attachment.image = symbol.image attachment.bounds = CGRect( x: 0, y: symbol.baselineOffset, width: symbol.image.size.width, height: symbol.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) } // 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" } } }