Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Sources/Rostrum/Drawing/ColorMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -30,22 +36,23 @@ 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)
}

/// 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 {
Expand Down
14 changes: 11 additions & 3 deletions Sources/Rostrum/OPC/ContentTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion Sources/Rostrum/OPC/OPCPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down
180 changes: 180 additions & 0 deletions Sources/Rostrum/Presentation/BackgroundResolver.swift
Original file line number Diff line number Diff line change
@@ -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 `<a:schemeClr val="tx1">` 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..<slides.count {
guard let slide = try? slides[index] else { continue }
counted += 1
if let colour = slide.effectiveBackgroundColor { tally[colour, default: 0] += 1 }
}
guard counted > 0, let (colour, hits) = tally.max(by: { $0.value < $1.value }),
Double(hits) / Double(counted) > 0.5 else { return nil }
return colour
}
}
Loading
Loading