oficina / slides Public

Slice 3 implementation plan: export

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev ca0648cfc996b02cdde63c45f1efa42e4488dd0e parent 0af8aaf
1 file changed, +66 −0
  • docs/superpowers/plans/2026-09-08-slice-3-export.md +66 −0

Intent

Attributed to this commit: 38.2 g CO₂e (4499453 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-3-export, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/docs/superpowers/plans/2026-09-08-slice-3-export.md b/docs/superpowers/plans/2026-09-08-slice-3-export.md
new file mode 100644
index 0000000..783829f
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-08-slice-3-export.md
@@ -0,0 +1,66 @@
+# Slides slice 3 — export — implementation plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A deck exports to a PDF (one page per slide, optionally with a notes page per slide), every slide has a server-rendered SVG thumbnail, and the browser and the PDF measure text with the same embedded fonts.
+
+**Architecture:** A Go layout engine measures text with the deck's embedded TrueType fonts and places blocks in a 1920 by 1080 frame, producing a paint list. One serialiser turns the paint list into SVG (thumbnails, and the input to PDF). PDF is produced by a Rust `slides_pdf` module (`svg2pdf` + `usvg` with the same fonts) under wazero if Task 1's spike proves it works on `wasm32-unknown-unknown` with zero host imports; otherwise by a Go PDF writer over the paint list, extracted from Docs' `internal/pdf` and `internal/fonts` into this repo (an extraction to a shared module is slice 4). Export runs as a rastrillo job; the result is served from the app when no object store is bound.
+
+**Tech Stack:** Go on rastrillo v0.26.0 (`jobs` package); `golang.org/x/image/font/sfnt` or Docs' `internal/fonts/truetype.go` for metrics; Liberation Sans/Serif/Mono TTFs (SIL OFL, already vendored in Docs at `internal/fonts/files`); Rust `svg2pdf 0.13`, `usvg 0.45` (`text` feature), `fontdb`, `pdf-writer 0.12` if the spike passes.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-slides-design.md` §4.2 (layout engine and SVG), §4.3 (PDF), §1 row 4 (the ruling and its recorded fallback), §9.
+
+## Global Constraints
+
+- Slice 1 and 2 Global Constraints still bind (`docs/superpowers/plans/2026-09-07-slice-1-live.md`, `2026-09-07-slice-2-editor.md`): CGO_ENABLED=0, nested packages under `internal/slides/`, seams, catalog keys, `rst-*` grammar, WCAG 2.2 AA, actors on writes, trailers, `make ci` and `.amadan/ci.d/10-gate` before every commit, `GOCACHE=/home/paulca/.cache/tmp/claude-1001/go-build` in the sandbox, npm cache flag, Chromium needs the sandbox off.
+- **Fonts are one set everywhere.** The six themes' typefaces become Liberation Sans (system-ui stacks today), Liberation Serif (paper's headings) and Liberation Mono (code), served as `@font-face` from `internal/slides/static/fonts/` on every slide surface (viewer, editor stage, presenter, remote, share link) and embedded in the binary for the layout engine and the PDF. `slides.css` declares them; rastrillo's own CSS is untouched. Licence file shipped beside the fonts.
+- **Divergence is confined to line breaking** (spec §4.2): the paint list uses the engine's line breaks; the browser its own; both use the same font metrics and the same sizes (the type scale in `cqw` maps to points at 1920 wide: 1 cqw = 19.2 px).
+- **The paint list is the only input to SVG and to PDF.** Nothing in the SVG or PDF path reads the deck directly.
+- **Export is a job**, never a request that blocks (`jobs.New`, `j.Start`, the status page with `data-poll`, hibernation-safe per the rastrillo skill §6): a deck over 200 slides is refused before the job starts.
+- **No object store today:** the finished PDF is held by the job for one hour (in memory, capped at 50 MiB per deck and 200 MiB per instance) and served by `GET /d/{sqid}/export/{job}.pdf`; when `CARLOS_STORE_*` is bound the same route 302s to a presigned GET after the job uploads the bytes. Both paths in the same handler.
+- Reference: Docs' `internal/pdf/pdf.go` (Builder, Page.Text, font subsetting, ToUnicode), `internal/fonts/` (faces, TrueType parser), `internal/export/pdf.go` (layout over a document) at `/home/paulca/amadan.net/oficina/docs`; Docs' `internal/docs/jobs*.go` for the jobs wiring if present (else the rastrillo skill §6 and `docs/site/jobs.md` in the module).
+
+---
+
+## File map
+
+| Path | Responsibility |
+|---|---|
+| `core/pdf/` (Cargo crate `slides-pdf`, only if the spike passes) | `slides_pdf_render(svgs, fonts) -> pdf bytes` C ABI, zero host imports. |
+| `internal/slides/pdfcore/` (only if the spike passes) | wazero host for `slides_pdf.wasm`. |
+| `internal/slides/fonts/` | Embedded Liberation TTFs, `Face` lookup by theme role (heading/body/code) and weight/style, metrics (`Advance`, `Ascent`, `Descent`, `LineHeight`), subsetting (copied from Docs, header naming the origin). |
+| `internal/slides/layout/` | `Layout(d *deck.Deck, index int) (Paint, error)`: template regions and canvas boxes → `Paint{Frame, Ops []Op}` with `Rect`, `Ellipse`, `Line`, `Image`, `Text{Runs}`; line breaking; list bullets; table grid; code monospace. |
+| `internal/slides/svg/` | `Encode(p Paint) []byte` — SVG 1.1 with `<text>` runs, `xlink:href` images by data URI (thumbnails) or by asset URL (PDF path embeds bytes). |
+| `internal/slides/pdfgo/` (fallback only) | Paint → PDF writer: colour text, paths, images (JPEG passthrough, PNG via Flate), per-page. |
+| `internal/slides/export.go` | `POST /d/{sqid}/export` (form: with notes yes/no), the job, `GET /d/{sqid}/export/{job}` status page, `GET /d/{sqid}/export/{job}.pdf`; API twins `POST /api/v1/decks/{sqid}/export`, `GET .../export/{job}`, `GET .../export/{job}.pdf`. |
+| `internal/slides/thumbs.go` | `GET /d/{sqid}/slides/{id}.svg?w=` (session), API twin `GET /api/v1/decks/{sqid}/slides/{id}.svg`, share and presentation twins; `ETag` by seq. |
+| `internal/slides/static/fonts/*.ttf`, `slides.css` | `@font-face` and the theme font stacks. |
+| `internal/slides/templates/export.html` | The export form and status page. |
+
+---
+
+### Task 1: Spike — `svg2pdf` under wasm32 with embedded fonts (timeboxed)
+
+- [ ] In a scratch crate under `core/pdf/` (kept only if it works): `svg2pdf = "0.13"`, `usvg = { version = "0.45", default-features = false, features = ["text"] }`, `fontdb`, `pdf-writer`; a `#[no_mangle] extern "C" fn slides_pdf_render(...)` taking N SVGs and the three Liberation TTFs from linear memory and returning PDF bytes; the same `.cargo/config.toml` getrandom backend, `panic = "abort"`, `--remap-path-prefix`; the `no_host_imports` test. Build for `wasm32-unknown-unknown`; render one SVG with a `<text>` in Liberation Serif and a `<rect>`; verify the output PDF (size, `%PDF-1.7`, the page count via a tiny Go reader, and the text extractable with `pdftotext` if present).
+- [ ] Outcome recorded in the report and in spec §1 as a new row (13): **"PDF via Rust svg2pdf under wazero: works / does not work; the plan proceeds with X"**. If it fails (unbuildable, host imports, >20 MB module, or text not embedded as glyphs), Task 4 uses `pdfgo`. Timebox: report BLOCKED after four hours of wall clock either way.
+
+### Task 2: Fonts everywhere and the layout engine
+
+- [ ] Copy Docs' Liberation TTFs and licence into `internal/slides/static/fonts/` and `internal/slides/fonts/files/`; `fonts.go` (faces by role: `Heading`, `Body`, `Code`; bold/italic; `Metrics(face, size)`), `truetype.go` copied from Docs with origin header; `@font-face` in `slides.css`; the six themes' `--slides-font-*` values become the Liberation names with system fallbacks; goldens and `TestThemeContrast` still pass; the browser drive still passes.
+- [ ] `internal/slides/layout`: `Layout(d, index)`; regions per template exactly as `slides.css` places them at 1920×1080 (write the table down in one place, `regions.go`, and a test comparing it to the CSS grid areas by name); canvas boxes verbatim; text runs measured with `fonts.Metrics`, greedy line breaking on spaces with a `MaxLines` overflow marker (never clip: overflowing text draws past its box exactly as the CSS now does); lists (bullet/number glyphs), quotes (rule + text), code (monospace, no wrap), tables (equal columns), shapes (rect/ellipse/line/arrow with fill and stroke), images (box with `fit`), dividers, spacers, backgrounds (theme/colour), phone layer ignored (§2.5: print never reads it). Golden paint lists for every template and block type (`layout/testdata/*.json`), `-update` flag.
+
+### Task 3: SVG and thumbnails
+
+- [ ] `internal/slides/svg`: `Encode(p Paint, opts)`; text as `<text x y font-family font-size fill>` per run with `xml:space="preserve"`; shapes; images as `<image href>` (data URI when `opts.InlineImages`, else the asset URL); a `<title>` naming the slide; `role="img"`.
+- [ ] Thumbnails: `GET /d/{sqid}/slides/{id}.svg` (+ share, presentation and API twins), `ETag` from seq, `Cache-Control: private, max-age=0, must-revalidate`; the editor strip and the presenter view switch from scaled HTML to the SVG `<img>` (smaller DOM, faster paint); the browser drive updated. Fixture-based Go tests: every template and block type produces well-formed XML (parse it back), escapes text, and the sample deck's nine slides encode in under 50 ms each.
+
+### Task 4: PDF
+
+- [ ] If the spike passed: `internal/slides/pdfcore` (wazero host, trap containment as `core`), `slides_pdf.wasm` committed under `internal/slides/pdfcore/testdata/`, reproducible build in `scripts/build-pdf-wasm.sh`, the gate rebuilds and `cmp`s it; `Render(ctx, svgs [][]byte) ([]byte, error)`. Else: `internal/slides/pdfgo` from Docs' `pdf.go` extended with colour (`rg`/`RG`), paths (`re`, Bézier ellipse, lines with caps), images (JPEG `DCTDecode` passthrough, PNG decoded to raw RGB + `FlateDecode`, alpha dropped with a note), one page per slide at 1920×1080 pt, ToUnicode maps, subsetting.
+- [ ] Either way: `Export(ctx, d *deck.Deck, withNotes bool) ([]byte, error)` in `internal/slides/export.go`, notes pages as a plain typeset page (Liberation Serif 24 pt, wrapped) following each slide when asked; images fetched from the store (or Dir) with a 20 MiB cap and 2000 px long-edge downscale (`golang.org/x/image/draw`); a deck over 200 slides refused; 60 s deadline per export.
+
+### Task 5: The export job, screens, API, gate, docs, deploy
+
+- [ ] `POST /d/{sqid}/export` (form: `notes` checkbox) → `j.Start(owner=subject, name, location, fn)`; refuses with a flash when the person already has 4 running (`ErrOwnerBusy`); 303 to `/d/{sqid}/export/{job}`; the status page uses rastrillo's `job-status` partial with `data-poll` and the `<noscript>` refresh, and shows a Download link when done; `GET /d/{sqid}/export/{job}.pdf` serves from the job's held bytes (`Content-Disposition: attachment; filename="<title>.pdf"`), or 302 to the store; held bytes expire after one hour (a sweeper); the viewer gets an "Export PDF" button for owner and members; API twins return `{job, status_url, pdf_url}` and the status as JSON.
+- [ ] Gate: the pdf wasm `cmp` if it exists; `go test` covers a full export of the sample deck asserting page count = slides (and ×2 with notes), `%PDF` header, and, when `pdftotext` is on PATH, that the first slide's heading is extractable (skip visibly otherwise).
+- [ ] Docs: AGENTS.md (the paint list rule, fonts everywhere, the export job), README, PRODUCT.md, `docs/api.md` (export endpoints, thumbnails). Spec: §1 row 13 from Task 1; §4.2/§4.3 amended to what shipped. `make release`, `carlos deploy`, verify, push, branch discussion.