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/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/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/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/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 { 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.. [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 } } } 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" }) + } +}