Skip to content

Open more real decks, and make rendered text readable - #30

Merged
welshofer merged 2 commits into
mainfrom
fix/deck-compat-svg-and-untyped-parts
Aug 25, 2026
Merged

Open more real decks, and make rendered text readable#30
welshofer merged 2 commits into
mainfrom
fix/deck-compat-svg-and-untyped-parts

Conversation

@welshofer

Copy link
Copy Markdown
Owner

Two independent compatibility fixes, both found by running Rostrum over a corpus
of 471 real .pptx files rather than over fixtures.

Rendered slide text was invisible in every browser

SVGRenderer emitted font-size in EMU, like every other length. 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, and invisible. Every deck rendered as a coloured rectangle — including
in Lectern's own slide previews and contact sheet.

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 into 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 every 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.

A /[trash]/ part sank the whole deck

[Content_Types].xml is required to cover every part (OPC M1.2), so a package
breaking 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
(2.5%), every one of which opens in PowerPoint and in python-pptx. 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.

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
(a Part with no content type has no legal serialization) and it cannot be
dropped (lossless round-tripping is this library's standing rule), so it goes
in untypedEntries, is re-emitted in sorted order beside the other carried
entries, and lands in readWarnings rather than being 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.

Tests

685 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. Two existing SVG
assertions are updated: one checked for the EMU size string, and one checked
<text x="0" for the origin case — an attribute that no longer exists, so it
would have kept passing while testing nothing.

🤖 Generated with Claude Code

welshofer and others added 2 commits August 25, 2026 11:43
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>
@welshofer
welshofer merged commit 667632c into main Aug 25, 2026
6 checks passed
@welshofer
welshofer deleted the fix/deck-compat-svg-and-untyped-parts branch August 25, 2026 18:51
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