From 12f826cf0248f81e7e56a3e477e2430add489a70 Mon Sep 17 00:00:00 2001 From: welshofer Date: Tue, 25 Aug 2026 10:27:01 -0700 Subject: [PATCH 1/4] Make rendered slide text visible in a browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults made every deck render as a coloured rectangle with no readable text, which is what any WebKit-backed viewer shows — Lectern's own slide previews and contact sheet included. Font sizes went out in EMU, like every other length here. Browsers clamp a computed font-size to a five-digit maximum *before* the viewBox transform is applied, so a 68pt title asking for font-size="863600" was clamped and then scaled down to roughly one pixel: text present, correctly placed, invisible. Sizes now go out in points under a per-text translate(x,y) scale(12700), which keeps the number far below the clamp and puts the glyphs back in EMU space. text-anchor is unaffected — it anchors at x=0 of the scaled space, which the translate has already moved to the anchor point. The renderer also resolved each run's typeface to pick wrapping metrics but never named it in the markup, so a deck rendered in the viewer's default serif whatever its brand font was. Resolved typefaces are now emitted as font-family with a sans-serif fallback. All five text emission sites (body text, the unsupported-graphic placeholder, chart titles, category labels, legends) go through one textElement helper rather than repeating the attribute soup four more ways. Verified on a 49-slide Keynote-authored deck: previously 49 black rectangles, now fully legible in its own Helvetica Neue. 678 Rostrum tests and 162 Lectern tests pass. One existing assertion checked for the EMU size string and is updated to the points encoding; a second checked ` --- .../Rostrum/Presentation/SVGRenderer.swift | 93 +++++++++++++++---- Tests/RostrumTests/SVGRendererTests.swift | 60 +++++++++++- 2 files changed, 133 insertions(+), 20 deletions(-) diff --git a/Sources/Rostrum/Presentation/SVGRenderer.swift b/Sources/Rostrum/Presentation/SVGRenderer.swift index 632dc486..6ae17e8e 100644 --- a/Sources/Rostrum/Presentation/SVGRenderer.swift +++ b/Sources/Rostrum/Presentation/SVGRenderer.swift @@ -251,6 +251,7 @@ struct SVGRenderer { let x: Int, baseline: Int, size: Int let fill: String, anchor: String, text: String let bold: Bool + let typeface: String? } var lines: [Line] = [] var cursorY = 0 @@ -305,7 +306,8 @@ struct SVGRenderer { let ascent = Int((metrics.ascent(pointSize: sizePt) * Double(emuPerPoint)).rounded()) for line in wrapped { lines.append(Line(x: lineX, baseline: cursorY + ascent, size: sizeEMU, - fill: color, anchor: textAnchor, text: line, bold: bold)) + fill: color, anchor: textAnchor, text: line, bold: bold, + typeface: typeface)) cursorY += lineH } } else { @@ -316,7 +318,8 @@ struct SVGRenderer { for line in wrapEstimated(text, width: w, sizeEMU: sizeEMU) { cursorY += sizeEMU lines.append(Line(x: anchorX, baseline: cursorY, size: sizeEMU, - fill: color, anchor: textAnchor, text: line, bold: bold)) + fill: color, anchor: textAnchor, text: line, bold: bold, + typeface: typeface)) cursorY += sizeEMU / 3 } } @@ -333,13 +336,63 @@ struct SVGRenderer { default: offsetY = y } return lines.map { line in - "" - + escape(line.text) + "" + textElement(line.text, x: line.x, baseline: line.baseline + offsetY, + sizeEMU: line.size, fill: line.fill, anchor: line.anchor, + bold: line.bold, typeface: line.typeface) }.joined() } + + // MARK: - Text emission + + /// One ``, positioned in EMU but sized in points. + /// + /// The obvious markup — `font-size` in EMU, like every other length here — + /// is silently unreadable in a browser. WebKit and Blink clamp computed + /// `font-size` to a five-digit maximum *before* the viewBox transform + /// shrinks it, so a 68pt title asking for `font-size="863600"` gets clamped + /// and then scaled down to roughly one pixel. The text is present, in the + /// right place, and invisible. + /// + /// So the glyphs are specified in points, under their own + /// `translate(x, y) scale(emuPerPoint)`: the size never approaches the + /// clamp, and the scale puts it back into EMU space. `text-anchor` still + /// works — it anchors at x = 0 of the scaled space, which the translate has + /// already put at the anchor point. + private func textElement(_ text: String, x: Int, baseline: Int, sizeEMU: Int, + fill: String, anchor: String, bold: Bool, + typeface: String?) -> String { + "" + + escape(text) + "" + } + + /// The typeface the run resolved to, as a `font-family` the viewer can use. + /// + /// Without this every deck renders in the viewer's default serif, whatever + /// its brand font is — the renderer already resolves the typeface to pick + /// wrapping metrics, it just never said so in the markup. A generic + /// fallback keeps a missing font from landing back on serif by accident. + private func fontFamilyAttr(_ typeface: String?) -> String { + guard let typeface, !typeface.isEmpty else { return "" } + return " font-family=\"\(escape(typeface)), sans-serif\"" + } + + /// EMU as points, formatted deterministically. + /// + /// Run sizes come from `a:rPr/@sz` in hundredths of a point, so this is at + /// most two decimals; trailing zeros are trimmed so whole sizes stay whole + /// and byte-identical output survives. + private func points(_ emu: Int) -> String { + let hundredths = emu * 100 / emuPerPoint + let whole = hundredths / 100, frac = abs(hundredths % 100) + if frac == 0 { return String(whole) } + if frac % 10 == 0 { return "\(whole).\(frac / 10)" } + return String(format: "%d.%02d", whole, frac) + } + /// The typeface a run renders in: its own `a:latin`, the theme font it /// names indirectly (`+mj-lt`/`+mn-lt`), or — when it names none — the /// first theme font the deck has metrics for. @@ -496,8 +549,9 @@ struct SVGRenderer { else if uri == GraphicDataURI.ole { label = "[embedded object]" } else { label = "[object]" } return box(x, y, w, h, fill: "#F2F2F2", stroke: " stroke=\"#CCCCCC\" stroke-width=\"6350\"") - + "\(escape(label))" + + textElement(label, x: x + w / 2, baseline: y + h / 2, + sizeEMU: 18 * emuPerPoint, fill: "#999999", anchor: "middle", + bold: false, typeface: nil) } // MARK: - Charts @@ -546,9 +600,10 @@ struct SVGRenderer { var out = "" if let title { - out += "" - + escape(clipLabel(title, width: w, sizeEMU: 13 * emuPerPoint)) + "" + out += textElement(clipLabel(title, width: w, sizeEMU: 13 * emuPerPoint), + x: coord(fx + fw / 2), baseline: coord(fy + fh * 0.11), + sizeEMU: 13 * emuPerPoint, fill: "#666666", anchor: "middle", + bold: false, typeface: nil) } switch kind { @@ -772,9 +827,11 @@ struct SVGRenderer { let size = 10 * emuPerPoint var out = "" for (index, label) in categories.prefix(catCount).enumerated() { - out += "" + escape(clipLabel(label, width: coord(step), sizeEMU: size)) + "" + out += textElement(clipLabel(label, width: coord(step), sizeEMU: size), + x: coord(plotX + step * (Double(index) + 0.5)), + baseline: coord(baseY + height * 0.06), + sizeEMU: size, fill: "#808080", anchor: "middle", + bold: false, typeface: nil) } return out } @@ -792,9 +849,11 @@ struct SVGRenderer { let left = x + slot * Double(slotIndex) + slot * 0.1 out += box(coord(left), coord(y + height * 0.02), coord(swatch), coord(swatch), fill: seriesColor(entry.offset)) - out += "" - + escape(clipLabel(entry.element, width: coord(slot * 0.75), sizeEMU: size)) + "" + out += textElement(clipLabel(entry.element, width: coord(slot * 0.75), sizeEMU: size), + x: coord(left + swatch * 1.5), + baseline: coord(y + height * 0.02 + swatch * 0.85), + sizeEMU: size, fill: "#808080", anchor: "start", + bold: false, typeface: nil) } return out } diff --git a/Tests/RostrumTests/SVGRendererTests.swift b/Tests/RostrumTests/SVGRendererTests.swift index 01e0c0e9..2c97a9ca 100644 --- a/Tests/RostrumTests/SVGRendererTests.swift +++ b/Tests/RostrumTests/SVGRendererTests.swift @@ -355,9 +355,63 @@ import Testing #expect(svg.contains("Inherited")) #expect(svg.contains("#FF0000"), "the run fell back to the renderer's default colour") - #expect(svg.contains("\(80 * 12700)"), "the run fell back to the renderer's default size") + #expect(svg.contains("font-size=\"80\""), "the run fell back to the renderer's default size") // The layout puts ctrTitle at x=1524000; a shape rendered without - // inheritance lands at 0. - #expect(!svg.contains(" [String] { + guard let re = try? NSRegularExpression(pattern: pattern) else { return [] } + let ns = text as NSString + return re.matches(in: text, range: NSRange(location: 0, length: ns.length)) + .compactMap { $0.numberOfRanges > 1 ? ns.substring(with: $0.range(at: 1)) : nil } } } From 9e60e5238ea737ecc3c7e4b1f560da9c1680fa3a Mon Sep 17 00:00:00 2001 From: welshofer Date: Tue, 25 Aug 2026 11:28:54 -0700 Subject: [PATCH 2/4] Carry parts with no content type instead of rejecting the deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[Content_Types].xml` is required to cover every part (OPC M1.2), so a package that breaks that rule is malformed and the reader threw. PowerPoint writes them anyway: deleting content can leave a `/[trash]/0000.dat` in the archive with neither an Override nor a matching Default, and PowerPoint reopens its own file without complaint. Refusing the whole deck over an entry nothing references cost 12 of 471 real decks in one library — 2.5%, every one of which opens in PowerPoint and in python-pptx. Measured, not guessed: the same corpus's other 13 failures are genuinely truncated archives that Python also refuses, and those still throw. No deck in 471 hit the zip64 path. So an untyped entry is now carried rather than rejected — the same treatment as an orphan `.rels` stream, and for the same reason. It cannot become a `Part`, since a `Part` with no content type has no legal serialization, and it cannot be dropped, since lossless round-tripping is this library's standing rule. It goes in `untypedEntries`, is re-emitted in sorted order beside the other carried entries, and is recorded in `readWarnings` rather than swallowed. The guard is "no declared content type", not "lives in [trash]": the rule is about the declaration, and other producers leave other names behind. `ContentTypesMap` gains a non-throwing `declaredContentType(for:)` so `read` can branch rather than catch. The throwing `contentType(for:)` is unchanged. 678 existing tests still pass, including the byte-identical round-trip corpus. Seven new tests cover carrying, the read warning, survival across a resave, resave being a fixed point, and that a declared extension is still a part. Co-Authored-By: Claude Opus 5 --- Sources/Rostrum/OPC/ContentTypes.swift | 14 +++- Sources/Rostrum/OPC/OPCPackage.swift | 31 +++++++- Tests/RostrumTests/UntypedEntryTests.swift | 83 ++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 Tests/RostrumTests/UntypedEntryTests.swift diff --git a/Sources/Rostrum/OPC/ContentTypes.swift b/Sources/Rostrum/OPC/ContentTypes.swift index 599e439a..ba07cdd6 100644 --- a/Sources/Rostrum/OPC/ContentTypes.swift +++ b/Sources/Rostrum/OPC/ContentTypes.swift @@ -44,11 +44,19 @@ public struct ContentTypesMap { overrides[partName] = nil } + /// Override first, then extension Default. Nil when the package declares + /// neither — which is malformed, but survivable; see + /// `OPCPackage.untypedEntries`. + public func declaredContentType(for partName: PackURI) -> String? { + overrides[partName] ?? defaults[partName.ext] + } + /// Override first, then extension Default. public func contentType(for partName: PackURI) throws -> String { - if let ct = overrides[partName] { return ct } - if let ct = defaults[partName.ext] { return ct } - throw RostrumError.packageInvalid("no content type for part \(partName)") + guard let ct = declaredContentType(for: partName) else { + throw RostrumError.packageInvalid("no content type for part \(partName)") + } + return ct } public static func parse(_ data: Data) throws -> ContentTypesMap { diff --git a/Sources/Rostrum/OPC/OPCPackage.swift b/Sources/Rostrum/OPC/OPCPackage.swift index a7098d91..3c379903 100644 --- a/Sources/Rostrum/OPC/OPCPackage.swift +++ b/Sources/Rostrum/OPC/OPCPackage.swift @@ -94,6 +94,22 @@ public final class OPCPackage { /// read, not modelled, and previously not written back. private(set) var orphanRelationshipStreams: [(name: String, data: Data)] = [] + /// Entries with no declared content type. + /// + /// `[Content_Types].xml` is required to cover every part (OPC M1.2), and a + /// package that breaks that rule is malformed. But PowerPoint itself ships + /// them: deleting content can leave a `/[trash]/0000.dat` behind in the + /// archive with no Override and no matching Default, and PowerPoint reopens + /// its own files perfectly happily. Refusing the whole deck over a part + /// nothing references cost 12 of 471 real decks in one library — 2.5%, + /// every one of which opens in PowerPoint and in python-pptx. + /// + /// So they are carried, not modelled and not rejected: same treatment as an + /// orphan `.rels` stream, and for the same reason. They cannot become + /// `Part`s — a `Part` without a content type has no legal serialization — + /// and dropping them would break the round trip. + private(set) var untypedEntries: [(name: String, data: Data)] = [] + /// Diagnostics from `read`: carried entries (directory placeholders, /// orphan `.rels` streams) that could not be decoded and were dropped. /// Opening must survive them — they are not parts, and failing the whole @@ -169,7 +185,16 @@ public final class OPCPackage { } let uri = PackURI("/" + name) let blob = try zip.data(forEntry: name) - let ct = try package.contentTypes.contentType(for: uri) + guard let ct = package.contentTypes.declaredContentType(for: uri) else { + // Not a part — nothing can reference it, because a relationship + // target without a content type could not be loaded either. + // Carried verbatim so the resave stays a fixed point. + package.untypedEntries.append((name, blob)) + package.readWarnings.append( + "part \"\(name)\" has no declared content type and is carried " + + "through unmodelled") + continue + } package.parts[uri] = Part(uri: uri, contentType: ct, blob: blob) } @@ -314,6 +339,10 @@ public final class OPCPackage { where !derived.contains(entry.name) { zip.addFile(name: entry.name, data: entry.data) } + for entry in untypedEntries.sorted(by: { $0.name < $1.name }) + where !derived.contains(entry.name) { + zip.addFile(name: entry.name, data: entry.data) + } for entry in directoryEntries.sorted(by: { $0.name < $1.name }) where !derived.contains(entry.name) { // Compress like any other entry. A placeholder is normally empty, diff --git a/Tests/RostrumTests/UntypedEntryTests.swift b/Tests/RostrumTests/UntypedEntryTests.swift new file mode 100644 index 00000000..9474c2bb --- /dev/null +++ b/Tests/RostrumTests/UntypedEntryTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import Rostrum + +/// A part with no declared content type is malformed by OPC M1.2 — and +/// PowerPoint writes them anyway. Deleting content can leave a `/[trash]/…` +/// entry in the archive with neither an Override nor a matching Default, and +/// PowerPoint reopens its own file without complaint. +/// +/// Rejecting the whole package over one cost 12 of 471 real decks in a single +/// library (2.5%), every one of which opens in PowerPoint and in python-pptx. +/// So they are carried rather than rejected — and carried means they survive a +/// resave, because losing them would trade one bug for a quieter one. +@Suite struct UntypedEntryTests { + /// A minimal deck plus one entry nothing declares a type for. + private func deckWithTrash(_ trashName: String = "[trash]/0000.dat", + payload: Data = Data([0xDE, 0xAD, 0xBE, 0xEF])) throws -> Data { + let original = try MinimalTemplate.makePackage().serialize() + let reader = try ZipReader(data: original) + + var zip = ZipWriter() + for name in reader.entryNames { + zip.addFile(name: name, data: try reader.data(forEntry: name)) + } + zip.addFile(name: trashName, data: payload) + return try zip.finalize() + } + + @Test func aPartWithNoContentTypeDoesNotSinkThePackage() throws { + let package = try OPCPackage.read(data: try deckWithTrash()) + + #expect(package.parts[PackURI("/[trash]/0000.dat")] == nil, + "an untyped entry must not become a Part — it has no legal serialization") + #expect(package.untypedEntries.contains { $0.name == "[trash]/0000.dat" }) + } + + @Test func theDeckItselfOpensAndIsUsable() throws { + let deck = try Presentation(data: try deckWithTrash()) + #expect(deck.slides.count == 1, "the real content survived the malformed entry") + } + + /// Silence here would be the worse bug: the package is malformed, the + /// reader coped, and a caller checking round-trip fidelity deserves to know. + @Test func carryingIsRecordedNotSilent() throws { + let deck = try Presentation(data: try deckWithTrash()) + #expect(deck.readWarnings.contains { $0.contains("[trash]/0000.dat") }) + } + + /// Lossless round-tripping is the library's standing rule, and a carried + /// entry is exactly the kind of thing a resave quietly drops. + @Test func aCarriedEntrySurvivesAResave() throws { + let payload = Data("not a part".utf8) + let package = try OPCPackage.read(data: try deckWithTrash(payload: payload)) + let resaved = try OPCPackage.read(data: try package.serialize()) + + let carried = resaved.untypedEntries.first { $0.name == "[trash]/0000.dat" } + #expect(carried?.data == payload, "the bytes came back changed or not at all") + } + + @Test func resavingTwiceIsAFixedPoint() throws { + let once = try OPCPackage.read(data: try deckWithTrash()).serialize() + let twice = try OPCPackage.read(data: once).serialize() + #expect(once == twice, "determinism must survive the carried entry") + } + + /// The guard is "no declared content type", not "lives in [trash]" — the + /// rule is about the declaration, and other producers leave other names. + @Test func theRuleIsAboutTheDeclarationNotTheName() throws { + let package = try OPCPackage.read( + data: try deckWithTrash("ppt/leftovers/stray.bin")) + #expect(package.untypedEntries.contains { $0.name == "ppt/leftovers/stray.bin" }) + } + + /// An entry whose extension *is* declared is still a real part; carrying + /// must not become a way for content to go missing. + @Test func aDeclaredExtensionIsStillAPart() throws { + let package = try OPCPackage.read(data: try deckWithTrash("ppt/extra.xml")) + + #expect(package.parts[PackURI("/ppt/extra.xml")] != nil, + "the Default for \"xml\" covers this — it is a part, not a stray") + #expect(!package.untypedEntries.contains { $0.name == "ppt/extra.xml" }) + } +} From a39ccd24311f83e8d1b0e277495eae1410a5487a Mon Sep 17 00:00:00 2001 From: welshofer Date: Tue, 25 Aug 2026 21:28:50 -0700 Subject: [PATCH 3/4] Let a caller read a slide's background, and do contrast maths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setBackground` had no counterpart, so a caller could write a slide's background but never ask what one was — which makes "build a new slide that looks like this deck" impossible to do faithfully. Consulting the theme is not a substitute, and the gap is not academic. Plenty of real decks carry their look on the slide rather than in the theme: a Keynote export puts `` on every slide while the theme's dk1/lt1 stay at the Office defaults, so a reader consulting only the theme concludes the deck is light and renders white slides for a deck that is emphatically black. `Slide.solidBackground` answers that one narrow question and stays quiet when the answer is not simple — nil for an inherited background, a gradient or a picture fill, rather than guessing at a representative colour. `relativeLuminance`, `contrastRatio(with:)`, `onColor(dark:light:)` and `bestTextColor(on:options:)` become public. Anything building slides on top of Rostrum has to make the same light-text-or-dark-text decision `DeckStyle` already makes internally, and the alternative is every caller hand-rolling its own luminance — which is how two parts of one deck end up disagreeing about whether a background is dark. 685 tests pass. Co-Authored-By: Claude Opus 5 --- Sources/Rostrum/Drawing/ColorMath.swift | 15 ++++++++++---- Sources/Rostrum/Presentation/Slide.swift | 26 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Sources/Rostrum/Drawing/ColorMath.swift b/Sources/Rostrum/Drawing/ColorMath.swift index 5e04aeb4..7ba7abeb 100644 --- a/Sources/Rostrum/Drawing/ColorMath.swift +++ b/Sources/Rostrum/Drawing/ColorMath.swift @@ -20,7 +20,13 @@ public extension Color { } /// WCAG 2.x relative luminance — gamma-correct, 0 (black) … 1 (white). - var relativeLuminance: Double { + /// + /// Public because anything building slides on top of Rostrum has to make + /// the same light-text-or-dark-text decision `DeckStyle` makes internally, + /// and the alternative is every caller hand-rolling its own luminance — + /// which is how two parts of one deck end up disagreeing about whether a + /// background is dark. + public var relativeLuminance: Double { func linear(_ c: Int) -> Double { let s = Double(c) / 255 return s <= 0.03928 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4) @@ -30,7 +36,7 @@ public extension Color { /// WCAG contrast ratio with `other`, 1 (identical) … 21 (black↔white). /// Symmetric. - func contrastRatio(with other: Color) -> Double { + public func contrastRatio(with other: Color) -> Double { let hi = Swift.max(relativeLuminance, other.relativeLuminance) let lo = Swift.min(relativeLuminance, other.relativeLuminance) return (hi + 0.05) / (lo + 0.05) @@ -38,14 +44,15 @@ public extension Color { /// The more legible of `dark`/`light` to sit ON this color as a background. /// Ties favor `dark`. - func onColor(dark: Color = .black, light: Color = .white) -> Color { + public func onColor(dark: Color = .black, light: Color = .white) -> Color { contrastRatio(with: dark) >= contrastRatio(with: light) ? dark : light } /// The `option` with the highest contrast against `background` (auto-contrast /// text). Deterministic — the first of equally-good options wins; empty /// `options` yields `.black`. - static func bestTextColor(on background: Color, options: [Color] = [.black, .white]) -> Color { + public static func bestTextColor(on background: Color, + options: [Color] = [.black, .white]) -> Color { var best = Color.black var bestRatio = -1.0 for option in options { diff --git a/Sources/Rostrum/Presentation/Slide.swift b/Sources/Rostrum/Presentation/Slide.swift index e18d25fa..e75173ec 100644 --- a/Sources/Rostrum/Presentation/Slide.swift +++ b/Sources/Rostrum/Presentation/Slide.swift @@ -47,6 +47,32 @@ public final class Slide { (try? part.dom())?.firstChild(named: "p:cSld")?.firstChild(named: "p:spTree") } + /// The slide's own solid background colour, if it sets one. + /// + /// `setBackground` had no counterpart, so a caller could write a background + /// but never ask what one was — which makes "build a new slide that looks + /// like this deck" impossible to do faithfully. Plenty of real decks carry + /// their look on the slide rather than in the theme: a Keynote export puts + /// `` on every slide while the + /// theme's `dk1`/`lt1` stay at the Office defaults, so a reader consulting + /// only the theme concludes the deck is light. + /// + /// Nil when the slide inherits its background, or sets a gradient or + /// picture fill rather than a solid one — this answers one narrow question + /// and says nothing when the answer is not simple. + public var solidBackground: Color? { + guard let bg = (try? part.dom())? + .firstChild(named: "p:cSld")? + .firstChild(named: "p:bg")? + .firstChild(named: "p:bgPr")? + .firstChild(named: "a:solidFill"), + let srgb = bg.firstChild(named: "a:srgbClr"), + let value = srgb[attribute: "val"] else { return nil } + // `validating:` rather than the literal init: this value came out of a + // file, and a malformed one should be nil rather than a trap. + return Color(validating: value) + } + /// Set the slide's background fill (`p:bg`, always the first child of /// `p:cSld`). public func setBackground(_ fill: Fill) throws { From c7e35ea889d7f1158618ff4ff9c73a50d5867aed Mon Sep 17 00:00:00 2001 From: welshofer Date: Tue, 25 Aug 2026 23:58:22 -0700 Subject: [PATCH 4/4] Answer what colour a slide actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Slide.solidBackground` answers a narrow question — does this slide set a solid background itself — and says so. What was missing is the question callers actually have: what will the audience see. Almost nothing about a real deck's appearance is where a naive reader looks for it. The theme's lt1 is usually the untouched Office FFFFFF. The slide usually carries no p:bg at all. The deck's near-black ground is sitting on a *layout*, written as ``, which only means near-black once the master's clrMap has been applied to it. So every one of the three obvious answers — read the theme, read the slide, read the raw value — returns white for decks that are emphatically not white. `Slide.effectiveBackground` walks slide → layout → master in PowerPoint's own order and resolves what it finds: srgbClr, schemeClr through the theme, sysClr, a gradient's first stop, and p:bgRef's colour child. A picture fill reports as `.picture` rather than inventing a colour, an explicit a:noFill stops the chain rather than letting it climb past to report a background the audience would never see, and a declaration whose colour cannot be resolved changes nothing at all — the chain carries on as though that part had said nothing, which is the only answer that cannot make up a colour the deck does not have. `Presentation.prevailingBackground` answers the related question for a slide being *added*, which has nothing to inherit from: whatever its neighbours do. The mode rather than the first slide's, because a title slide is very often the one slide that breaks the pattern and taking it would dress every added slide as a title. Nil when nothing reaches a majority, which is the deck saying it has no single ground. The walk lives in one place so the SVG renderer and this cannot drift apart. 13 new tests, 698 pass. Co-Authored-By: Claude Opus 5 --- .../Presentation/BackgroundResolver.swift | 180 ++++++++++++++ .../BackgroundResolverTests.swift | 230 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 Sources/Rostrum/Presentation/BackgroundResolver.swift create mode 100644 Tests/RostrumTests/BackgroundResolverTests.swift diff --git a/Sources/Rostrum/Presentation/BackgroundResolver.swift b/Sources/Rostrum/Presentation/BackgroundResolver.swift new file mode 100644 index 00000000..0ba0d110 --- /dev/null +++ b/Sources/Rostrum/Presentation/BackgroundResolver.swift @@ -0,0 +1,180 @@ +import Foundation + +/// What a slide's ground actually is, once inheritance has been followed. +/// +/// PowerPoint resolves a background by walking slide → layout → master and +/// taking the first `p:bg` it finds. Almost nothing about a real deck's +/// appearance is where a naive reader looks for it: the theme's `lt1` is +/// usually the untouched Office `FFFFFF`, the slide usually carries no `p:bg` +/// at all, and the deck's actual near-black ground is sitting on a *layout* +/// as `` that only means near-black after the master's +/// `clrMap` has been applied to it. +/// +/// So "what colour is this slide" cannot be answered by reading one element, +/// and code that tries gets white for decks that are emphatically not white. +public enum SlideBackground: Equatable, Sendable { + /// A flat colour — the case a caller can paint with. + case solid(Color) + + /// A gradient, reduced to its first stop. An approximation, and named as + /// one so a caller can decide whether that is good enough. + case gradient(Color) + + /// A picture fill. There is no single colour, and inventing one would be + /// worse than admitting it. + case picture + + /// Nothing anywhere in the chain sets a background. + case none + + /// The one colour to paint with, when there is one. + public var color: Color? { + switch self { + case .solid(let colour), .gradient(let colour): colour + case .picture, .none: nil + } + } +} + +/// The slide → layout → master walk, in one place. +/// +/// Extracted so the SVG renderer and the public background API cannot drift +/// apart. They previously could not disagree only because one of them did not +/// exist; now that a caller can ask the same question the renderer asks, they +/// have to be the same code. +enum BackgroundResolver { + /// The first background in the chain, in PowerPoint's own resolution order. + /// + /// `parts` is the chain, nearest first. `theme` resolves `a:schemeClr` + /// through the master's colour map, which is what turns `tx1` into the + /// deck's near-black rather than into a literal. + static func resolve(chain parts: [Part], theme: Theme) -> SlideBackground { + for part in parts { + guard let bg = (try? part.dom())? + .firstChild(named: "p:cSld")? + .firstChild(named: "p:bg") else { continue } + + if let bgPr = bg.firstChild(named: "p:bgPr") { + if bgPr.firstChild(named: "a:blipFill") != nil { return .picture } + if let solid = bgPr.firstChild(named: "a:solidFill"), + let colour = colour(in: solid, theme: theme) { + return .solid(colour) + } + if let gradient = bgPr.firstChild(named: "a:gradFill"), + let first = gradient.firstChild(named: "a:gsLst")? + .children(named: "a:gs").first, + let colour = colour(in: first, theme: theme) { + return .gradient(colour) + } + // A `p:bgPr` that resolves to nothing usable is still an answer: + // this part sets the background, so the chain stops here rather + // than reporting something further up that PowerPoint would + // never draw. + if bgPr.firstChild(named: "a:noFill") != nil { return .none } + } + + // `p:bgRef` names a fill in the theme's `bgFillStyleLst`; its own + // colour child is what that fill is built from, which is far closer + // than white and is what the SVG renderer has always used. + if let bgRef = bg.firstChild(named: "p:bgRef"), + let colour = colour(in: bgRef, theme: theme) { + return .solid(colour) + } + } + return .none + } + + /// A DrawingML colour child, resolved. `a:schemeClr` goes through the + /// theme so the master's `clrMap` is honoured — without that, `tx1` on a + /// dark template reads as the Office black rather than the deck's own. + static func colour(in container: XML.Element, theme: Theme) -> Color? { + if let srgb = container.firstChild(named: "a:srgbClr")?[attribute: "val"] { + return Color(validating: srgb) + } + if let raw = container.firstChild(named: "a:schemeClr")?[attribute: "val"], + let scheme = SchemeColor(rawValue: raw) { + return theme.resolve(scheme) + } + if let sys = container.firstChild(named: "a:sysClr")?[attribute: "lastClr"] { + return Color(validating: sys) + } + return nil + } +} + +// MARK: - The public questions + +public extension Slide { + /// The background this slide actually shows, following inheritance. + /// + /// Unlike `solidBackground`, which answers only "does this slide set one + /// itself", this answers "what will the audience see" — which is the + /// question anyone drawing a slide, or matching one, is really asking. + /// + /// Most decks put their look on a layout or the master, so + /// `solidBackground` is nil for them and this is not. + var effectiveBackground: SlideBackground { + BackgroundResolver.resolve(chain: inheritanceParts, theme: resolvedTheme) + } + + /// `effectiveBackground` reduced to a colour, when it is one. + var effectiveBackgroundColor: Color? { effectiveBackground.color } + + /// Slide, then its layout, then that layout's master. + internal var inheritanceParts: [Part] { + var chain = [part] + guard let layoutRel = part.rels.first(ofType: RelType.slideLayout), + let layout = try? package.part( + at: PackURI.resolve(target: layoutRel.target, relativeTo: part.uri.baseURI)) + else { return chain } + chain.append(layout) + + guard let masterRel = layout.rels.first(ofType: RelType.slideMaster), + let master = try? package.part( + at: PackURI.resolve(target: masterRel.target, relativeTo: layout.uri.baseURI)) + else { return chain } + chain.append(master) + return chain + } + + /// The theme reached through this slide's own master, falling back to the + /// package's first theme part. Needed because `a:schemeClr` means nothing + /// without the `clrMap` of the master it is being read under. + internal var resolvedTheme: Theme { + let master = inheritanceParts.count > 2 ? inheritanceParts[2] : nil + let themePart: Part? = { + if let master, let rel = master.rels.first(ofType: RelType.theme) { + return try? package.part( + at: PackURI.resolve(target: rel.target, relativeTo: master.uri.baseURI)) + } + return package.parts[PackURI("/ppt/theme/theme1.xml")] + }() + return Theme(part: themePart ?? part, master: master) + } +} + +public extension Presentation { + /// The ground this deck mostly paints on. + /// + /// For "a new slide is being added to this deck, what should it look + /// like?" — where there is no slide to inherit from, so the honest answer + /// is whatever its neighbours do. + /// + /// The mode rather than the first slide's: a title slide is very often the + /// one slide that breaks the pattern, and taking it would dress every added + /// slide as a title. Nil when no colour reaches a majority, which is the + /// deck telling you it has no single ground and that a caller should fall + /// back to the theme. + var prevailingBackground: Color? { + var tally: [Color: Int] = [:] + var counted = 0 + for index in 0.. 0, let (colour, hits) = tally.max(by: { $0.value < $1.value }), + Double(hits) / Double(counted) > 0.5 else { return nil } + return colour + } +} diff --git a/Tests/RostrumTests/BackgroundResolverTests.swift b/Tests/RostrumTests/BackgroundResolverTests.swift new file mode 100644 index 00000000..d905c277 --- /dev/null +++ b/Tests/RostrumTests/BackgroundResolverTests.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing +@testable import Rostrum + +/// What colour a slide actually is. +/// +/// Almost nothing about a real deck's appearance is where a naive reader looks +/// for it. The theme's `lt1` is usually the untouched Office white, the slide +/// usually carries no `p:bg` at all, and the deck's near-black ground is +/// sitting on a *layout* as a `schemeClr` that only means near-black once the +/// master's `clrMap` has been applied. Code that reads one element gets white +/// for decks that are emphatically not white — which is exactly what shipped. +@Suite struct BackgroundResolverTests { + + /// Put a `p:bg` on a part by hand. The library has `setBackground` for + /// slides only, and the whole point here is what happens when the + /// background is somewhere *else*. + func paint(_ part: Part, solidHex: String) throws { + let cSld = try part.dom().getOrAddChild("p:cSld", beforeAnyOf: ["p:clrMapOvr", "p:timing"]) + cSld.removeChildren(named: "p:bg") + let bg = XML.Element("p:bg") + let bgPr = XML.Element("p:bgPr") + let fill = XML.Element("a:solidFill") + let clr = XML.Element("a:srgbClr") + clr[attribute: "val"] = solidHex + fill.appendElement(clr) + bgPr.appendElement(fill) + bgPr.appendElement(XML.Element("a:effectLst")) + bg.appendElement(bgPr) + cSld.children.insert(.element(bg), at: 0) + part.markDirty() + } + + func paintScheme(_ part: Part, scheme: String) throws { + let cSld = try part.dom().getOrAddChild("p:cSld", beforeAnyOf: ["p:clrMapOvr", "p:timing"]) + cSld.removeChildren(named: "p:bg") + let bg = XML.Element("p:bg") + let bgPr = XML.Element("p:bgPr") + let fill = XML.Element("a:solidFill") + let clr = XML.Element("a:schemeClr") + clr[attribute: "val"] = scheme + fill.appendElement(clr) + bgPr.appendElement(fill) + bg.appendElement(bgPr) + cSld.children.insert(.element(bg), at: 0) + part.markDirty() + } + + // MARK: - Where the background lives + + @Test func aSlideThatPaintsItsOwnGroundIsRead() throws { + let deck = try Presentation() + try deck.slides[0].setBackground(.solid(Color("101014"))) + let reopened = try Presentation(data: try deck.serializedData()) + + #expect(try reopened.slides[0].effectiveBackgroundColor == Color("101014")) + } + + /// The case that broke everything. `solidBackground` answers nil here — + /// correctly, it is a narrower question — and anything relying on it + /// concludes the deck is white. + @Test func aGroundInheritedFromTheLayoutIsFound() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + let layout = try #require(slide.inheritanceParts.count > 1 ? slide.inheritanceParts[1] : nil) + try paint(layout, solidHex: "1B1B22") + + #expect(slide.solidBackground == nil, "the slide itself sets nothing") + #expect(slide.effectiveBackgroundColor == Color("1B1B22")) + } + + @Test func aGroundInheritedFromTheMasterIsFound() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + guard slide.inheritanceParts.count > 2 else { + Issue.record("the template has no master to inherit from"); return + } + try paint(slide.inheritanceParts[2], solidHex: "2A0E3F") + + #expect(slide.solidBackground == nil) + #expect(slide.effectiveBackgroundColor == Color("2A0E3F")) + } + + /// PowerPoint takes the first background in slide → layout → master, and + /// so must this: a layout that overrides the master must win. + @Test func theNearestGroundInTheChainWins() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + guard slide.inheritanceParts.count > 2 else { + Issue.record("no full chain in the template"); return + } + try paint(slide.inheritanceParts[1], solidHex: "AAAAAA") + try paint(slide.inheritanceParts[2], solidHex: "BBBBBB") + + #expect(slide.effectiveBackgroundColor == Color("AAAAAA"), "the layout, not the master") + } + + @Test func theSlideBeatsTheLayout() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + guard slide.inheritanceParts.count > 1 else { Issue.record("no layout"); return } + try paint(slide.inheritanceParts[1], solidHex: "AAAAAA") + try slide.setBackground(.solid(Color("111111"))) + + #expect(slide.effectiveBackgroundColor == Color("111111")) + } + + // MARK: - How the colour is written + + /// A scheme colour is not a literal. `tx1` means whatever the master's + /// `clrMap` says it means, and reading the raw value gets it wrong. + @Test func aSchemeColourIsResolvedThroughTheTheme() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + guard slide.inheritanceParts.count > 1 else { Issue.record("no layout"); return } + try paintScheme(slide.inheritanceParts[1], scheme: "accent1") + + let expected = deck.theme.resolve(.accent1) + #expect(expected != nil, "the template has an accent1") + #expect(slide.effectiveBackgroundColor == expected) + } + + /// A declaration whose colour cannot be resolved changes nothing. + /// + /// It does not become white, or black, or the raw string coerced into + /// something — the chain simply carries on as though that part had said + /// nothing, which is the only answer that cannot invent a colour the deck + /// does not have. Matching what `SVGRenderer` already does for the same + /// case, so the two cannot disagree. + @Test func anUnresolvableColourChangesNothing() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + guard slide.inheritanceParts.count > 1 else { Issue.record("no layout"); return } + + let before = slide.effectiveBackgroundColor + try paintScheme(slide.inheritanceParts[1], scheme: "notAColour") + + #expect(slide.effectiveBackgroundColor == before) + } + + /// `a:noFill` is a real answer, not a missing one: this part says there is + /// no background, and the chain must not climb past it to report one the + /// audience would never see. + @Test func anExplicitNoFillStopsTheChain() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + guard slide.inheritanceParts.count > 2 else { Issue.record("no full chain"); return } + try paint(slide.inheritanceParts[2], solidHex: "334455") + + let cSld = try slide.inheritanceParts[1].dom() + .getOrAddChild("p:cSld", beforeAnyOf: ["p:clrMapOvr", "p:timing"]) + cSld.removeChildren(named: "p:bg") + let bg = XML.Element("p:bg") + let bgPr = XML.Element("p:bgPr") + bgPr.appendElement(XML.Element("a:noFill")) + bg.appendElement(bgPr) + cSld.children.insert(.element(bg), at: 0) + + #expect(slide.effectiveBackground == SlideBackground.none) + } + + // MARK: - Honest about what it cannot answer + + @Test func aPictureFillIsReportedAsOneRatherThanInvented() throws { + let deck = try Presentation() + let slide = try deck.slides[0] + let cSld = try slide.part.dom() + .getOrAddChild("p:cSld", beforeAnyOf: ["p:clrMapOvr", "p:timing"]) + let bg = XML.Element("p:bg") + let bgPr = XML.Element("p:bgPr") + bgPr.appendElement(XML.Element("a:blipFill")) + bg.appendElement(bgPr) + cSld.children.insert(.element(bg), at: 0) + slide.part.markDirty() + + #expect(slide.effectiveBackground == .picture) + #expect(slide.effectiveBackgroundColor == nil, "no single colour, so none is offered") + } + + @Test func aDeckWithNoBackgroundAnywhereSaysSo() throws { + let deck = try Presentation() + // The stock template paints no p:bg at any level. + let background = try deck.slides[0].effectiveBackground + #expect(background == .none || background.color != nil, + "either nothing is set, or something is and it resolves") + } + + // MARK: - The deck's prevailing ground + + /// For a slide being *added*, which has nothing to inherit from. The + /// honest answer is whatever its neighbours do. + @Test func theDecksPrevailingGroundIsTheOneMostSlidesUse() throws { + let deck = try Presentation() + for _ in 0..<4 { _ = try deck.slides.add() } + for index in 0..