import AppKit /// **The type scale of a printed document, derived from one base choice** — "font face, size & style" /// (the printing card's fourth bullet) answered as the scope ruling settles it: the user picks a face /// and a body size, and everything else is a multiple of them. /// /// ### Why one size and not six /// /// A print dialog that asked separately for the heading size, the byline size and the running-head size /// would be a typesetting program with a Print button. The ratios below are the same relationships the /// card window's Preview already uses (`BodyMarkupRenderer` sets every indent, padding and heading step /// as a multiple of the body point size, so the surface grows with the system text size — /// 10-accessibility.md ▸ Text); this file is that discipline pointed at paper, where the base comes from /// `PrintOptions.fontSize` instead of from the system. /// /// ### Why the face is applied as a *remap* rather than threaded through /// /// Bodies are rendered by `BodyMarkupRenderer`, which is the app's one Markdown-to-typography pass and /// hardcodes the system font by design (it draws what the card window draws). Teaching it a font family /// would put a print-only parameter into the surface that renders every card on screen. So a print sets /// its face afterwards, by walking the finished string's `.font` runs and rebuilding each one in the /// chosen family at its own size and with its own traits (`restyled`). One consequence is deliberate: /// **code stays monospaced**. A fenced block set in Palatino is not what anyone means by choosing /// Palatino. @MainActor enum PrintTypography { // MARK: - The scale /// The document's base — body text, comment bodies, and the measure everything else is a multiple /// of. static func body(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize, weight: .regular) } /// The board's name at the top of a board print. The largest thing on the page, because it is the /// only thing that names the whole document. static func boardHeading(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize * 1.7, weight: .bold) } /// A lane's name. Below the board and above a card, which is exactly its place in the structure. static func laneHeading(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize * 1.35, weight: .semibold) } /// A card's title. static func cardTitle(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize * 1.15, weight: .semibold) } /// The icon-and-labels line, and a comment's byline — the two secondary lines, at one size so the /// page has one voice for "this is about the content, not the content". static func secondary(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize * 0.85, weight: .regular) } /// The running head and foot. Smallest on the page: furniture that must be readable and must not /// compete. static func runningHead(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize * 0.8, weight: .regular) } /// The comments heading — "3 comments" over the thread. static func commentsHeading(_ options: PrintOptions) -> NSFont { font(family: options.fontFamily, size: options.fontSize * 0.95, weight: .semibold) } // MARK: - Resolving a face /// A font in `family` at `size`, falling back to the system font of that size and weight. /// /// **The fallback is the whole leniency story** and it mirrors `ItemSymbol.name(_:fallback:)` /// exactly: a stored profile is a value that travels between machines and OS releases, and a family /// that is not installed here must degrade rather than refuse. `NSFont(name:size:)` against a family /// name resolves the family's regular face on macOS; when it cannot, the system font is the answer. static func font(family: String?, size: CGFloat, weight: NSFont.Weight) -> NSFont { let size = max(1, size) guard let family, !family.isEmpty else { return NSFont.systemFont(ofSize: size, weight: weight) } let descriptor = NSFontDescriptor(fontAttributes: [.family: family]) if let base = NSFont(descriptor: descriptor, size: size) { return weight == .regular ? base : bolder(base, weight: weight) ?? base } return NSFont.systemFont(ofSize: size, weight: weight) } /// A heavier cut of `font`, or `nil` when the family has none — a family with only one weight /// renders a "semibold" heading in its one face, which is what a single-weight face means. private static func bolder(_ font: NSFont, weight: NSFont.Weight) -> NSFont? { var traits = font.fontDescriptor.symbolicTraits traits.insert(.bold) let descriptor = font.fontDescriptor.withSymbolicTraits(traits) return NSFont(descriptor: descriptor, size: font.pointSize) } /// Whether the running system can set text in `family` — the picker's own filter, and /// `ItemSymbol.exists`' posture applied to type: the font set is the *machine's*, so a hardcoded /// list would be wrong on the first machine that had a different one. static func families() -> [String] { NSFontManager.shared.availableFontFamilies.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } // MARK: - The remap /// `attributed` with every non-monospaced `.font` run rebuilt in `family`, keeping each run's own /// size and traits. /// /// The traits are carried across rather than recomputed, which is what makes a body's structure /// survive the change of face: `**bold**` stays bold, `*emphasis*` stays italic, a heading stays /// whatever weight the renderer gave it, and the size ladder (headings larger, captions smaller) is /// untouched because each run keeps its own point size. /// /// **Monospaced runs are skipped on purpose** — see the type's note. `isMonospaced` on /// `NSFontDescriptor.symbolicTraits` is the test, which catches both the app's explicit /// `monospacedSystemFont` code style and any face that reports itself fixed-pitch. /// /// A `nil` family is the identity: the string is returned untouched rather than rebuilt into the /// system font it is already set in. static func restyled(_ attributed: NSAttributedString, family: String?) -> NSAttributedString { guard let family, !family.isEmpty else { return attributed } let output = NSMutableAttributedString(attributedString: attributed) output.enumerateAttribute(.font, in: NSRange(location: 0, length: output.length)) { value, range, _ in guard let font = value as? NSFont else { return } let traits = font.fontDescriptor.symbolicTraits guard !traits.contains(.monoSpace) else { return } var descriptor = NSFontDescriptor(fontAttributes: [.family: family]) descriptor = descriptor.withSymbolicTraits(traits) guard let replacement = NSFont(descriptor: descriptor, size: font.pointSize) else { return } output.addAttribute(.font, value: replacement, range: range) } return output } // MARK: - Ink /// **Paper is white, so ink is black** — and the app's dynamic colours are not. /// /// `BodyMarkupRenderer` sets `NSColor.labelColor` and friends, which resolve *at draw time against /// the drawing appearance*: in a dark-mode app that is near-white, which on paper is nothing at all. /// The print view therefore draws in a forced light appearance (`PrintDocumentView`), which resolves /// every one of those dynamic colours the way a printed page needs. This constant is for the text /// this file's own callers compose — headings, bylines, running heads — where naming the ink /// explicitly is clearer than relying on the appearance override two files away. static let ink = NSColor.textColor /// Secondary ink, for bylines and running heads. Dynamic like `ink`, resolved by the same forced /// appearance. static let secondaryInk = NSColor.secondaryLabelColor }