diff --git a/Kanban/UI/Print/PrintDocumentRenderer.swift b/Kanban/UI/Print/PrintDocumentRenderer.swift index 3a2c6d2..a7e30fb 100644 --- a/Kanban/UI/Print/PrintDocumentRenderer.swift +++ b/Kanban/UI/Print/PrintDocumentRenderer.swift @@ -173,6 +173,12 @@ enum PrintDocumentRenderer { /// 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. @@ -183,10 +189,16 @@ enum PrintDocumentRenderer { style.paragraphSpacing = options.fontSize * 0.45 let line = NSMutableAttributedString() - if let icon, let image = symbolImage(icon, size: font.pointSize) { + if let icon, + let symbol = PrintSymbol.rendered(icon, pointSize: font.pointSize, ink: PrintTypography.secondaryInk) { let attachment = NSTextAttachment() - attachment.image = image - attachment.bounds = CGRect(x: 0, y: font.descender * 0.5, width: image.size.width, height: image.size.height) + 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: " ")) @@ -205,12 +217,6 @@ enum PrintDocumentRenderer { output.append(line) } - /// One SF Symbol at text size, or `nil` when this system cannot draw it. - private static func symbolImage(_ name: String, size: CGFloat) -> NSImage? { - guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil } - return image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: size, weight: .regular)) - } - // MARK: - Plain lines private static func append( diff --git a/Kanban/UI/Print/PrintDocumentView.swift b/Kanban/UI/Print/PrintDocumentView.swift index ce34d14..74efd7c 100644 --- a/Kanban/UI/Print/PrintDocumentView.swift +++ b/Kanban/UI/Print/PrintDocumentView.swift @@ -90,7 +90,11 @@ final class PrintDocumentView: NSView { // 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) + // + // 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) diff --git a/Kanban/UI/Print/PrintSymbol.swift b/Kanban/UI/Print/PrintSymbol.swift new file mode 100644 index 0000000..292b7fc --- /dev/null +++ b/Kanban/UI/Print/PrintSymbol.swift @@ -0,0 +1,184 @@ +import AppKit + +/// **An SF Symbol turned into ink** — the one place a symbol name becomes an image a PDF context can +/// actually draw, and the answer to "SF Symbols render poorly in printed/PDF output". +/// +/// ### What went wrong, and why it looked like a bug in the renderer +/// +/// `NSImage(systemSymbolName:)` answers a **template** image (`isTemplate == true`) backed by a symbol +/// representation, and `withSymbolConfiguration` keeps the flag. A template image is not artwork: it is a +/// *shape to be tinted*, and the tinting is done by the AppKit machinery that draws it — a button cell, an +/// image view, a toolbar item. Hand one to an `NSTextAttachment` and let TextKit draw it straight into a +/// print/PDF context, where none of that machinery is present, and the tint is applied to the image's +/// whole box instead of through its coverage: **a solid dark rectangle where the glyph should be**. That is +/// exactly what the owner's 2026-08-08 report shows, and it reproduces in a bare +/// `NSView.dataWithPDF(inside:)` in three lines. It is not a `PrintDocumentRenderer` bug, not a +/// `PrintDocumentView` bug, and not a font bug: the image was never drawable in that context. +/// +/// A second failure hides behind the first. A PDF context is a 1× device, so even a *non*-template symbol +/// image rasterizes at 72 ppi on its way into the page — `pdfimages -list` on such a document reports a +/// 13 × 12 pixel bitmap for an 11 pt icon. On screen at 100% that passes; on paper, or at any zoom, it is +/// the blur the card's first suspected failure mode describes. +/// +/// ### The fix: resolve the symbol before it meets the page +/// +/// Both failures are the same mistake — leaving work for a context that cannot do it — so both get the +/// same answer. The symbol is drawn **here**, into a bitmap this file owns, at a resolution paper can use, +/// with its colour already chosen; what reaches the attachment is ordinary, concrete, non-template artwork +/// that any context can put down unaltered. +/// +/// - **Colour is baked, not deferred.** The configuration carries `paletteColors: [ink]`, so the symbol +/// renders monochrome in the line's own ink rather than in SF Symbols' automatic palette (which would +/// put a yellow star and a blue document on a black-and-white page). The ink is resolved against +/// `PrintTypography.paper` first: a dynamic `NSColor` resolves at *draw* time, and this drawing happens +/// long before the page's forced-light appearance is in effect. +/// - **Resolution is chosen, not inherited.** The bitmap is `paperScale` times the point box, which puts a +/// 576 ppi image on the page — past any desktop printer's addressable resolution, and still clean at 8× +/// on screen. True vector would be better still and is not available: `NSSymbolImageRep` rasterizes into +/// whatever context draws it, the symbols are not reachable as font glyphs by name +/// (`CTFontGetGlyphWithName` answers 0 for every system face), and re-wrapping the image in a PDF +/// representation only embeds the same raster one level down. This was measured rather than assumed. +/// - **The baseline comes from the symbol.** See `Rendered.baselineOffset`. +/// +/// ### Why this is cached when `ItemSymbol.exists` deliberately is not +/// +/// `ItemSymbol` refuses a cache because its work is a lookup AppKit already caches. This work is a +/// rasterization — a real draw into a real bitmap — and a board print runs it once per card, hundreds of +/// times, for a handful of distinct icons, on every re-pagination the print panel asks for. Sharing one +/// `NSImage` between every card that chose the same icon also lets the PDF writer emit the artwork once +/// instead of once per card. +@MainActor +enum PrintSymbol { + + // MARK: - What a caller gets + + /// A symbol ready to be attached to a line of text. + struct Rendered { + + /// Concrete, non-template artwork at print resolution, sized in points. + let image: NSImage + + /// The attachment's `bounds.origin.y`: how far the image's box sits **below** the text baseline. + /// + /// Taken from the symbol's own `alignmentRect`, which is the metric Apple ships for exactly this + /// question. Measured across sizes and symbols, the rect's height is the font's cap height and its + /// origin is the symbol's own baseline within its box — `textformat` sits 1.0 pt up from the box's + /// bottom edge at 11 pt, `lightbulb` 3.0 pt, `tag` 3.5 pt, and all three scale with the point size. + /// Placing the box that far below the baseline therefore lands the *symbol's* baseline on the + /// *text's*, which is the alignment the symbols were drawn for. + /// + /// The constant it replaces (`font.descender * 0.5`) knew nothing about the symbol, so every icon + /// floated by a different amount — visible as an icon row that never quite sat on its line. + let baselineOffset: CGFloat + } + + // MARK: - Making one + + /// `name` at `pointSize`, inked in `ink` — or `nil` when this system cannot draw that symbol. + /// + /// `nil` rather than a placeholder is deliberate and is `ItemSymbol`'s promise kept on paper: a name + /// the running OS does not have is a line that prints its labels and no icon, never a box. + static func rendered(_ name: String, pointSize: CGFloat, ink: NSColor) -> Rendered? { + let key = Key(name: name, pointSize: pointSize, ink: ink) + if let cached = cache[key] { return cached } + + guard let source = configured(name, pointSize: pointSize, ink: resolved(ink)) else { return nil } + guard let image = rasterized(source) else { return nil } + + let rendered = Rendered(image: image, baselineOffset: -source.alignmentRect.origin.y) + // A wholesale clear rather than an eviction policy: the map is keyed by the icons a document + // actually uses, so it is a handful of entries in every real print, and a cap that is only ever + // reached by a pathological board is better spent forgetting everything than ranking it. + if cache.count >= cacheLimit { cache.removeAll(keepingCapacity: true) } + cache[key] = rendered + return rendered + } + + /// The symbol at the right size and in the right colour, still at 1× and still an `NSSymbolImageRep`. + private static func configured(_ name: String, pointSize: CGFloat, ink: NSColor) -> NSImage? { + let configuration = NSImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: .regular) + .applying(NSImage.SymbolConfiguration(paletteColors: [ink])) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + } + + /// `source` drawn into a bitmap of `paperScale` times its point box. + /// + /// The draw happens under the paper appearance for the same reason the ink is resolved under it: a + /// symbol's own rendering reads the drawing appearance, and a document printed from a dark-mode app + /// must not carry dark-mode artwork onto white paper. + private static func rasterized(_ source: NSImage) -> NSImage? { + let box = source.size + guard box.width > 0, box.height > 0 else { return nil } + + // A ceiling on the pixel grid, so a hand-edited profile with an enormous body size cannot ask for + // a bitmap measured in tens of megabytes. + let scale = min(paperScale, maximumPixels / max(box.width, box.height)) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int((box.width * scale).rounded(.up)), + pixelsHigh: Int((box.height * scale).rounded(.up)), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { return nil } + // The rep's *point* size is the symbol's, so the extra pixels read as resolution rather than as a + // bigger picture — which is the whole trick. + rep.size = box + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSGraphicsContext.current?.imageInterpolation = .high + PrintTypography.paper.performAsCurrentDrawingAppearance { + source.draw(in: CGRect(origin: .zero, size: box), from: .zero, operation: .sourceOver, fraction: 1) + } + NSGraphicsContext.restoreGraphicsState() + + let image = NSImage(size: box) + image.addRepresentation(rep) + // **The flag that started all this**, turned off explicitly rather than left to the new image's + // default: what this answers is artwork, and a future reader should see it said so. + image.isTemplate = false + return image + } + + /// A dynamic colour pinned to the value paper needs, since the bitmap is drawn now and shown later. + private static func resolved(_ ink: NSColor) -> NSColor { + var answer = ink + PrintTypography.paper.performAsCurrentDrawingAppearance { + answer = ink.usingColorSpace(.sRGB) ?? ink + } + return answer + } + + // MARK: - Constants + + /// 8 × 72 ppi = 576 ppi on the page. See the type's note. + private static let paperScale: CGFloat = 8 + + /// The largest edge, in pixels, any one symbol's bitmap may have. + private static let maximumPixels: CGFloat = 2048 + + private static let cacheLimit = 128 + + // MARK: - The cache + + private struct Key: Hashable { + let name: String + let pointSize: CGFloat + /// The colour by description rather than by identity, so two equal `NSColor`s are one key. + let ink: String + + init(name: String, pointSize: CGFloat, ink: NSColor) { + self.name = name + self.pointSize = pointSize + self.ink = "\(ink)" + } + } + + private static var cache: [Key: Rendered] = [:] +} diff --git a/Kanban/UI/Print/PrintTypography.swift b/Kanban/UI/Print/PrintTypography.swift index 4597d68..4a999d8 100644 --- a/Kanban/UI/Print/PrintTypography.swift +++ b/Kanban/UI/Print/PrintTypography.swift @@ -150,4 +150,14 @@ enum PrintTypography { /// Secondary ink, for bylines and running heads. Dynamic like `ink`, resolved by the same forced /// appearance. static let secondaryInk = NSColor.secondaryLabelColor + + /// **The appearance a printed page is drawn in** — the light one, always, because the dynamic colours + /// above have to resolve the way paper needs rather than the way the app's window happens to look. + /// + /// It lives here, next to the two inks it resolves, because two places now depend on it and they have + /// to agree: `PrintDocumentView` pins it for the whole page, and `PrintSymbol` draws under it when it + /// rasterizes an icon — a draw that happens *before* the page exists, so it cannot inherit the view's. + /// A second `NSAppearance(named: .aqua)` written out in the other file would be a duplicate that only + /// looked like a constant. + static let paper = NSAppearance(named: .aqua) ?? NSAppearance.currentDrawing() } diff --git a/KanbanTests/PrintTests.swift b/KanbanTests/PrintTests.swift index 008e3b8..38e3be8 100644 --- a/KanbanTests/PrintTests.swift +++ b/KanbanTests/PrintTests.swift @@ -1199,3 +1199,243 @@ struct PrintSessionTests { #expect(reads == 1, "and once the user asks, exactly one read serves every relayout") } } + +// MARK: - Symbols on paper + +/// **The printed-symbol regression** (owner report, 2026-08-08: "SF symbols don't render well in the PDF +/// output of File ▸ Print…", with a screenshot of solid dark rectangles where the card icons belong). +/// +/// The typography around it is still legitimately untested — see this file's own note — but this failure is +/// not typography. It is a drawing fact with two halves, and both are assertable: +/// +/// - an `NSImage(systemSymbolName:)` is a **template**, and a print/PDF context tints one across its whole +/// box rather than through its coverage, which is the black rectangle the owner photographed; +/// - a PDF context is a 1× device, so even a non-template symbol image lands as a 13-pixel bitmap and +/// blurs at any zoom. +/// +/// So the suite asserts what `PrintSymbol` hands over (concrete artwork, at paper resolution, on the +/// symbol's own baseline) and then prints a page whose only ink is one icon and measures it: a glyph +/// covers a fraction of its own bounding box, a template box covers all of it. The second test would have +/// failed on the shipped build at ~1.0 coverage, which is the whole point of writing it that way. +@Suite("Print ▸ symbols on paper") +@MainActor +struct PrintSymbolTests { + + /// A symbol every macOS has had for years, and one whose artwork is plainly not a rectangle. + private static let symbol = "lightbulb" + + // MARK: The image handed to the page + + @Test("A printed symbol is concrete artwork, not a template to be tinted by machinery that is not there") + func artworkNotTemplate() throws { + let rendered = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: PrintTypography.secondaryInk)) + + #expect(!rendered.image.isTemplate, "a template image is what drew the black boxes") + let rep = try #require(rendered.image.representations.first as? NSBitmapImageRep) + #expect(rep.size == rendered.image.size, "the extra pixels are resolution, not a bigger picture") + #expect( + CGFloat(rep.pixelsWide) >= rep.size.width * 4, + "a page wants far more than the 72 ppi a PDF context would rasterize at" + ) + } + + @Test("The artwork is a glyph — most of its box is paper") + func artworkIsNotFilled() throws { + let rendered = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: PrintTypography.secondaryInk)) + let rep = try #require(rendered.image.representations.first as? NSBitmapImageRep) + let bytes = try #require(rep.bitmapData) + + var inked = 0 + for y in 0 ..< rep.pixelsHigh { + for x in 0 ..< rep.pixelsWide { + let alpha = bytes[y * rep.bytesPerRow + x * rep.samplesPerPixel + 3] + if alpha > 12 { inked += 1 } + } + } + let coverage = Double(inked) / Double(rep.pixelsWide * rep.pixelsHigh) + #expect(coverage > 0.02, "a symbol that drew nothing at all is the other way to fail") + #expect(coverage < 0.75, "a filled box is the template bug; a lightbulb is an outline") + } + + @Test("An unknown name draws nothing rather than a box — `ItemSymbol`'s promise, kept on paper") + func unknownNameIsOmitted() { + #expect(PrintSymbol.rendered("not.a.symbol.anybody.ships", pointSize: 11, ink: .black) == nil) + } + + // MARK: The baseline + + @Test("The offset is the symbol's own baseline, per symbol and scaling with the size") + func baselineComesFromTheSymbol() throws { + let ink = PrintTypography.secondaryInk + let bulb = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: ink)) + let text = try #require(PrintSymbol.rendered("textformat", pointSize: 11, ink: ink)) + let larger = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 22, ink: ink)) + + // Below the baseline, always — the old constant was `font.descender * 0.5`, which knew nothing + // about which symbol it was placing. + #expect(bulb.baselineOffset < 0) + #expect( + bulb.baselineOffset < text.baselineOffset, + "a lightbulb's base sits under the baseline; `textformat` sits on it" + ) + #expect(larger.baselineOffset < bulb.baselineOffset, "and the offset is a length, so it scales") + + let source = try #require(NSImage(systemSymbolName: Self.symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 11, weight: .regular))) + #expect(bulb.baselineOffset == -source.alignmentRect.origin.y, "which is Apple's own metric, not a guess") + } + + // MARK: The renderer's line + + @Test("The icon reaches the page as an attachment carrying that artwork") + func theLineCarriesTheArtwork() throws { + let blocks: [PrintBlock] = [.cardMeta(icon: Self.symbol, labels: ["idea"])] + let section = try #require(PrintDocumentRenderer.sections(for: blocks, options: PrintOptions()).first) + + var found: NSImage? + section.enumerateAttribute(.attachment, in: NSRange(location: 0, length: section.length)) { value, _, _ in + if let attachment = value as? NSTextAttachment { found = attachment.image } + } + let image = try #require(found, "the icon is a text attachment on the labels line") + #expect(!image.isTemplate) + #expect(section.string.contains("idea"), "and the labels are still on the line beside it") + } + + @Test("A symbol this system cannot draw still prints its labels") + func aMissingSymbolKeepsTheLabels() throws { + let blocks: [PrintBlock] = [.cardMeta(icon: "not.a.symbol.anybody.ships", labels: ["idea"])] + let section = try #require(PrintDocumentRenderer.sections(for: blocks, options: PrintOptions()).first) + #expect(section.string.contains("idea")) + } + + // MARK: What actually lands on paper + + /// A page whose only ink is one icon: a card print, every component but the labels line switched off, + /// a card with an icon and no labels, and no running head or foot. Whatever is dark on that sheet is + /// the symbol and nothing else, so it can be measured rather than eyeballed. + private func iconOnlyOptions() -> PrintOptions { + var options = PrintOptions() + options.includesTitle = false + options.includesBody = false + options.includesComments = false + options.includesLabels = true + options.headerShowsBoardTitle = false + options.headerShowsPrintDate = false + options.footerShowsPageNumbers = false + options.footerShowsCustomLine = false + return options + } + + private func iconOnlyPDF(icon: String) -> Data { + let (store, _, teardown) = makeStore() + defer { teardown() } + let options = iconOnlyOptions() + store.captureLastUsed(options) + + let source = PrintSource( + scope: .card, + boardTitle: "Roadmap", + lanes: [PrintLane(title: "One", cards: [PrintCard(title: "A", icon: icon)])] + ) + let session = PrintSession( + provider: PrintSourceProvider(complete: source), + profiles: store, + cardFolder: nil, + jobTitle: "A", + boardTitle: "Roadmap" + ) + + let info = NSPrintInfo() + let view = PrintDocumentView(session: session, printInfo: info) + _ = view.pageCount() + + let data = NSMutableData() + let operation = NSPrintOperation.pdfOperation(with: view, inside: view.bounds, to: data, printInfo: info) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + operation.run() + return data as Data + } + + /// The document's first page, rasterized onto white at `scale` times its point size — what a reader + /// with a magnifying glass would see. + private func firstPage(of pdf: Data, scale: CGFloat) -> NSBitmapImageRep? { + guard let page = NSPDFImageRep(data: pdf) else { return nil } + page.currentPage = 0 + let box = page.size + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(box.width * scale), pixelsHigh: Int(box.height * scale), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + rep.size = box + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSColor.white.setFill() + CGRect(origin: .zero, size: box).fill() + page.draw(in: CGRect(origin: .zero, size: box)) + NSGraphicsContext.restoreGraphicsState() + return rep + } + + /// The bounding box of the ink on a page, and how much of that box the ink fills — in points, and as a + /// fraction. A glyph fills a fraction of its box; the template bug filled all of it. + private func ink(on rep: NSBitmapImageRep, scale: CGFloat) -> (box: CGRect, coverage: Double)? { + guard let bytes = rep.bitmapData else { return nil } + var minX = rep.pixelsWide, maxX = -1, minY = rep.pixelsHigh, maxY = -1 + var inked = 0 + for y in 0 ..< rep.pixelsHigh { + let row = y * rep.bytesPerRow + for x in 0 ..< rep.pixelsWide where bytes[row + x * rep.samplesPerPixel] < 220 { + inked += 1 + minX = min(minX, x); maxX = max(maxX, x) + minY = min(minY, y); maxY = max(maxY, y) + } + } + guard maxX >= minX, maxY >= minY else { return nil } + let width = maxX - minX + 1 + let height = maxY - minY + 1 + return ( + CGRect(x: CGFloat(minX) / scale, y: CGFloat(minY) / scale, + width: CGFloat(width) / scale, height: CGFloat(height) / scale), + Double(inked) / Double(width * height) + ) + } + + @Test("A printed page draws the symbol as a glyph, at the size the type scale asked for") + func thePageDrawsAGlyph() throws { + let scale: CGFloat = 4 + let pdf = iconOnlyPDF(icon: Self.symbol) + #expect(!pdf.isEmpty, "the operation produced a document") + + let page = try #require(firstPage(of: pdf, scale: scale)) + let measured = try #require(ink(on: page, scale: scale), "the icon is the only ink on the sheet") + + // The line's own size, which is what the icon is built at. + let expected = try #require(PrintSymbol.rendered( + Self.symbol, + pointSize: PrintTypography.secondary(iconOnlyOptions()).pointSize, + ink: PrintTypography.secondaryInk + )).image.size + + #expect( + measured.coverage < 0.75, + "coverage \(measured.coverage) — a filled box is the template bug the owner photographed" + ) + #expect(measured.coverage > 0.05, "and something was drawn") + + // The ink sits *inside* the symbol's box and fills most of it: a lightbulb is narrower than the + // box it is drawn in, but an icon at the wrong size — the whole page, or a stray point size — + // would miss either bound by a mile. + #expect( + measured.box.width <= expected.width + 1 && measured.box.height <= expected.height + 1, + "ink measured \(measured.box.size) against a symbol of \(expected)" + ) + #expect( + measured.box.width >= expected.width * 0.5 && measured.box.height >= expected.height * 0.5, + "ink measured \(measured.box.size) against a symbol of \(expected)" + ) + } +}