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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ DerivedData/
# Core dumps (a crashing test run inside a container drops one here)
core
core.*

# Audit scratch: lift-up plans and burn-down reports are session artifacts.
# Their durable findings belong in ROADMAP.md (deferred work) and CHANGELOG.md
# (what shipped), not as 200KB of process notes at the repo root.
/lift-up-plan-*.md
/burn-down-report-*.md
105 changes: 101 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,32 @@ Rostrum is **pre-1.0**: minor versions may change API. Format follows

## [Unreleased]

Nothing yet.

## [0.4.0] — 2026-08-17

The **"Measure & trust"** program (see `ROADMAP.md`): the library's two
biggest asserted qualities become measured ones. Text layout stops guessing —
real font metrics, a computed `normAutofit`, and text that provably fits its
box — and the lossless round-trip stops being unproven against foreign files,
gated by a real-deck corpus and the python-pptx oracle. Alongside it, a pass
of untrusted-input hardening: a malformed or hostile `.pptx` now throws,
clamps, or is recorded, instead of aborting the host process.

### Added

- **`renderSVGReportingProblems(slideAt:pixelWidth:)`** — the same render as
`renderSVG`, with the inheritance diagnostics kept instead of dropped. The
returned `SlideRenderProblems` names a broken slide → layout → master link
(`layoutUnresolved` / `masterUnresolved`, `isEmpty` when the chain is
sound), so a caller can tell a damaged deck apart from one rendered wrong.
A slide with a broken chain still renders; it just comes back without what
it would have inherited.
- **`Shape.markAsPlaceholder(type:idx:)`** — writes the `p:ph` binding onto a
shape the caller positioned itself, so a drawn title is a *title* to
PowerPoint: present in the outline view, the slide navigator, "reuse
slides" and a screen reader. Costs no pixels, since an explicit `a:xfrm`
still wins.
- **Deck extraction** — `Presentation.outline()` projects an opened deck onto
a `Sendable` value model: per slide, the title, subtitle, body paragraphs
with their outline level, table cells, SmartArt labels, speaker notes, an
Expand Down Expand Up @@ -71,6 +95,25 @@ Rostrum is **pre-1.0**: minor versions may change API. Format follows

### Changed

- **`slides.add(layout:)` is now `slides.add(clonedFrom:)`.** The old label
did not say what it did, which left it a near-homophone of the adder that
binds a slide to a layout *without* copying its placeholder shapes. The new
name states plainly that it clones them. The old spelling still compiles,
deprecated with `renamed:`, so existing code keeps building and Xcode
offers the fix-it.
- **Slides built by the one-call builders carry real title placeholders.**
Every deck the builders wrote was headless: they draw the title on their
own grid as a plain text box, and a deck of plain text boxes has no titles
at all as far as PowerPoint is concerned — nothing in the outline view, the
slide navigator, "reuse slides" or a screen reader. Builders now bind the
slide to a layout that declares a title and mark the drawn title with
`p:ph`. Identical pixels; the semantics come back.
- **`ShapeCollection.count` and its subscript stopped walking the whole
tree.** Both routed through `all`, which builds one facade per shape, so
reading `count` — or indexing in a loop — cost a full `p:spTree` walk every
time and made ordinary iteration quadratic. They now count and index the
children directly, building no facade at all for `count` and exactly one
per subscript. No cache, so there is nothing that can go stale.
- **Opening a `.potx` or `.ppsx` no longer converts it.** A template opens as
a template and saves as one, byte-identically; previously it was retyped to
a presentation on open, so a template could not survive a round trip. To
Expand All @@ -94,8 +137,6 @@ Rostrum is **pre-1.0**: minor versions may change API. Format follows
registered render with real word wrap and baseline placement in
`renderSVG`; the rest wrap on a character-width estimate.

### Changed

- **`renderSVG` wraps long text instead of truncating it.** Paragraphs
without registered metrics used to emit one line and drop the remainder
behind an ellipsis, so a preview silently rewrote the deck's own words
Expand Down Expand Up @@ -240,8 +281,6 @@ Rostrum is **pre-1.0**: minor versions may change API. Format follows
coordinates, plus `shape.shapeID` and `shape.explicitFrame`.
`shapes.autoShapes` keeps the old `p:sp`-only view.

### Changed

- **Read budgets are on by default.** `Presentation(data:)`,
`OPCPackage.read` and `ZipReader` now default to `ZipReader.Limits.default`
— 4 GiB of declared uncompressed bytes, far past any real deck — instead of
Expand Down Expand Up @@ -293,6 +332,29 @@ Rostrum is **pre-1.0**: minor versions may change API. Format follows

### Fixed

- **A processing instruction with no data no longer kills the process on
Linux.** `<?target?>` — the dataless spelling — is a NULL dereference
inside libxml2 by way of swift-corelibs-foundation's `XMLParser`: SIGSEGV,
not a throw, raised before any Rostrum code runs, so no caller could defend
against it. Since Rostrum opens files it did not write, any `.pptx`
carrying one was a **denial of service on untrusted input** — the same
family as the DOCTYPE rejection and the nesting ceiling, and the same
remedy this file already establishes: check the bytes before the parser
sees them, because that parser traps rather than throws. Isolated to
exactly that spelling — `<?t d?>` and `<?t ?>` parse, `<?t?>` crashes,
comments are fine. Every dataless instruction is now given a payload before
parsing and has it turned back into `nil` on the way out, so the node still
knows it was the dataless spelling and still writes itself back as
`<?target?>`; the payload is a token generated per parse rather than a
positional count, so there is no bookkeeping to drift out of step with
whichever instructions libxml2 reports. Comments and CDATA are stepped over
rather than scanned into — a lookalike inside one is content, and rewriting
it would change bytes the round trip promises to keep — and a document
without one, which is very nearly all of them, is not copied at all.
Root-caused in a Linux container rather than inferred from CI.
- `import FoundationNetworking` where Linux needs it: `OpenAIProvider` and
its test were the only two places using `URLSession` without it, which
broke the Linux build once the crash above stopped masking it.
- **An edited part no longer loses its XML comments and processing
instructions.** The promise is that opening and saving never drops XML
Rostrum does not model, and a comment is the plainest case of that XML —
Expand Down Expand Up @@ -389,6 +451,41 @@ Rostrum is **pre-1.0**: minor versions may change API. Format follows

### Lectern (the sample app)

- **The shell is a library.** The app opens on the decks you already have —
Quick Look covers, a grid or a list, search, and drag-and-drop onto the
shell itself — rather than on a compose form that asserted writing a deck
was the only thing the app did. Returning to the library is one movement
instead of three, the grid lays out once at the width it will end up, and
thumbnails survive the trip instead of being rebuilt on every return.
- **One deck inspector, not two.** The two that had grown separately are
reconciled into a single `DeckInspector`/`SlideDigest` pair in LecternCore,
which is why the inspection is testable headlessly on Linux.
- **Slide counts for the whole library are read concurrently, off the main
actor.** They were fetched one deck at a time on the main actor, so a
library of any size stalled the UI on launch. Decks that already have a
count are not re-read.
- **The two image caches are bounded.** Both grew for the life of the
process; they are now LRU with a cap, pinned by tests.
- **Settings only offers providers the app can actually use.** Two were
listed but unwired; OpenAI is now wired for real, and a stored selection
pointing at an unwired provider migrates to the default instead of
silently failing.
- **A file-picker failure is no longer indistinguishable from a cancel** —
the app says what went wrong instead of quietly doing nothing.
- **Rejected drafts are written with file protection on macOS too**, matching
what iOS already did.
- **Deleting a deck asks first.** The redesigned library deleted from a
button that promised a confirmation it never showed.
- The app icon has dark and tinted variants, on an OS that asks for both.
- **The app-hosted tests now run in a gate.** `Lectern/AppTests` compiled but
was executed by nothing; `scripts/verify.sh` runs it, along with a tracked
`scripts/hooks/pre-push` and `scripts/install-hooks.sh`. Implemented
locally rather than as a hosted-macOS CI step, so `ci.yml` is unchanged and
no CI cost was added.
- `Export Everything…` creates the folder it exports into, and test runs no
longer orphan saved API keys.
- Attached documents are fenced in the prompt, and the generation deadline is
a real ceiling rather than a suggestion.
- **Cancel now stops the pipeline, not just the screen.** A cancel during
the QA pass was swallowed by a `try?`, so the run went on to generate paid
images and write a deck the user had stopped; cancellation now propagates,
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ More recipes in the [cookbook](docs/COOKBOOK.md).
Swift Package Manager:

```swift
.package(url: "https://github.com/welshofer/rostrum", from: "0.3.0")
.package(url: "https://github.com/welshofer/rostrum", from: "0.4.0")
```

then add `"Rostrum"` to your target's dependencies.
Expand All @@ -85,7 +85,7 @@ then add `"Rostrum"` to your target's dependencies.
| **Tables** | grid, cell fills & anchors, merge, banded rows |
| **Charts** | bar / line / pie / area / doughnut / scatter / **radar / bubble / combo**, stacked & multi-series, titles, **data labels**, axis control, embedded Edit-Data workbook |
| **Chart editing** | `deck.charts` reads any deck's charts; `replaceData` swaps every cache and the workbook or **refuses without writing a byte**; `addSeries` / `removeSeries` |
| **Rendering** | `renderSVG(slideAt:)` / `exportSVG` — headless slide→SVG previews with real font metrics, master/layout inheritance, no platform text stack |
| **Rendering** | `renderSVG(slideAt:)` / `exportSVG` — headless slide→SVG previews with real font metrics, master/layout inheritance, no platform text stack; `renderSVGReportingProblems` names a broken inheritance chain instead of quietly rendering without it |
| **SmartArt** | Basic Block List creation; **text extraction from any diagram** |
| **Comments** | modern threaded comments, replies, resolve |
| **Notes** | per-slide speaker notes |
Expand Down
50 changes: 47 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ shipped; both are re-opened above (Phase 3 / Phase 4 item 5). P4's "using
the TTF metrics we already parse" was wrong: nothing parsed TTF metrics
until v0.4 M1 below; the SVG renderer's text is still approximate until M2.

## Program: v0.4 — Measure & trust (2026-07-22 →)
## Program: v0.4 — Measure & trust (2026-07-22 → 2026-08-17, shipped as v0.4.0)

The theme: convert the library's two biggest asserted qualities into
*measured* ones. Text layout stops guessing (the #1 pain in Lectern, the
Expand Down Expand Up @@ -279,8 +279,11 @@ Hardening backlog (schedule opportunistically):
bomb amplifies across many entries~~ ✅ 2026-07-27. Resource exhaustion
rather than a trap, so the fix had to be an API decision, not a patch: a
caller-supplied `ZipReader.Limits` threaded through `OPCPackage.read` and
`Presentation.init`, defaulting to `.unlimited` so decks that are merely
large keep opening.
`Presentation.init`. It first defaulted to `.unlimited` so decks that are
merely large kept opening; the default is now `.default` — 4 GiB of
declared uncompressed bytes, far past any real deck — so untrusted input is
bounded out of the box, with `.unlimited` passed explicitly for archives
the caller already trusts.

The budget is enforced **up front, from the central directory**, not
accumulated as entries decode. Since each entry is bounded by its own declared
Expand Down Expand Up @@ -412,6 +415,47 @@ encoding and `OPCPackage.serialize` to honour it for parts that never went
dirty. Found 2026-07-28 by an adversarial review of the corpus gate, which
had been claiming full byte identity in its own doc comment.

## Deferred — real, deliberately not scheduled

Findings from the 2026-08-11 audits that were confirmed against the code and
then *not* acted on, recorded here so they are not rediscovered from scratch.
Each is a judgement about leverage, not a doubt about the finding.

**Rostrum**

- **Effective-frame inheritance matches layout → master by reduced type**
(`Slide.swift`) — a real asymmetry, but no observed deck reaches it and the
fix needs the full placeholder-matching table.
- **`OPCPackage` multi-pass serialisation** — measured in milliseconds against
a whole-deck save; below the noise floor.
- **`RostrumError` carries prose, not structured cases** — a genuine API
ergonomics gap, low leverage while the consumer set is this small.
- **Text measurement ignores kerning, ligatures and shaping**
(`FontMetrics.swift`) — documented behaviour; fixing it means a shaping
engine, which is out of scope for a zero-dependency library.
- **No snapshot or golden-file tests for `SVGRenderer`** —
`SVGRendererTests.swift` asserts structure, so a *visual* regression in the
preview path would pass silently.
- `DeckRenderer`, `KeychainStore` and `SlideRasterizer` have **no tests at
all** — distinct from tests that existed but never ran, which is closed.
- **`Examples/` and `Tools/` have never been audited** — four executable
targets plus `extract-schema.py` sit outside every surveyed set so far.

**Lectern**

- Only one image failure is reported when several fail — the collapse is in a
warning path the user rarely sees.
- No cancel affordance *during* a long generation — needs a cancellation
token threaded through `DeckGenerator` and the provider.
- **The decks already written remain headless.** The title-placeholder fix
applies to newly written decks only; repairing the existing library is a
migration, not a lift-up item.
- iOS keeps live `WKWebView` slide previews while macOS rasterizes —
`takeSnapshot` needs a window, and the iOS path is not currently slow.
- **`DeckRenderer.swift` is 923 lines** — the single place where IR, layout,
furniture, fonts, charts and previews all meet. Not a defect, but previews
and font resolution are both self-contained and the obvious next split.

## Standing quality gates

- `swift test` green on Linux on every push; on macOS in the pull-request
Expand Down
6 changes: 5 additions & 1 deletion Sources/Rostrum/Presentation/SVGRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ struct SVGRenderer {
private let emuPerPoint = 12700

func render(pixelWidth: Int) throws -> (svg: String, problems: SlideRenderProblems) {
let dom = try slidePart.dom()
// Not for the value: this is the one call that surfaces a malformed
// slide part as a thrown error. Everything below reaches the tree
// through `existingSpTree`, which swallows the parse with `try?` and
// would render a silently blank slide instead.
_ = try slidePart.dom()
// p:sldSz comes from the file too, and the aspect-ratio conversion below
// goes through Int(_: Double), which traps when the double is out of
// range — so bound the dimensions before dividing by them.
Expand Down
Loading
Loading