Skip to content

Answer what colour a slide actually is - #31

Merged
welshofer merged 4 commits into
mainfrom
feature/effective-background
Aug 26, 2026
Merged

Answer what colour a slide actually is#31
welshofer merged 4 commits into
mainfrom
feature/effective-background

Conversation

@welshofer

Copy link
Copy Markdown
Owner

Slide.solidBackground answers a narrow question — does this slide set a solid background itself — and its doc comment 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 on a layout, written <a:schemeClr val="tx1">, which only means near-black after the master's clrMap is applied

So all three obvious answers return white for decks that are emphatically not white. Measured on one real deck: 0 of 14 slides carry a p:bg, 13 of 46 layouts do.

What's added

Slide.effectiveBackground walks slide → layout → master in PowerPoint's own order and resolves srgbClr, schemeClr (through the theme, so clrMap is honoured), sysClr, a gradient's first stop, and p:bgRef's colour child.

Three cases it refuses to guess at:

  • a picture fill reports .picture rather than inventing a colour
  • an explicit a:noFill stops the chain rather than climbing past to report a background the audience would never see
  • a declaration whose colour cannot be resolved changes nothing — the chain carries on as though that part said nothing

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, since a title slide is very often the one that breaks the pattern.

The walk lives in BackgroundResolver so this and SVGRenderer cannot drift apart.

Tests

13 new, 698 pass.

🤖 Generated with Claude Code

welshofer and others added 4 commits August 25, 2026 10:27
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 `<text x="0"` for the origin case, which no
longer exists as an attribute and would have passed vacuously — it now checks
translate(0, instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`[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 <noreply@anthropic.com>
`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 `<p:bg><a:solidFill><a:srgbClr val="000000"/>` 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 <noreply@anthropic.com>
`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 `<a:schemeClr val="tx1">`, 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 <noreply@anthropic.com>
@welshofer
welshofer merged commit a35e7b1 into main Aug 26, 2026
6 checks passed
@welshofer
welshofer deleted the feature/effective-background branch August 26, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant