| 1 | # Slides slice 3 — export — implementation plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **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. |
| 10 | |
| 11 | **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. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - 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. |
| 16 | - **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. |
| 17 | - **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). |
| 18 | - **The paint list is the only input to SVG and to PDF.** Nothing in the SVG or PDF path reads the deck directly. |
| 19 | - **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. |
| 20 | - **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. |
| 21 | - 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). |
| 22 | |
| 23 | --- |
| 24 | |
| 25 | ## File map |
| 26 | |
| 27 | | Path | Responsibility | |
| 28 | |---|---| |
| 29 | | `core/pdf/` (Cargo crate `slides-pdf`, only if the spike passes) | `slides_pdf_render(svgs, fonts) -> pdf bytes` C ABI, zero host imports. | |
| 30 | | `internal/slides/pdfcore/` (only if the spike passes) | wazero host for `slides_pdf.wasm`. | |
| 31 | | `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). | |
| 32 | | `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. | |
| 33 | | `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). | |
| 34 | | `internal/slides/pdfgo/` (fallback only) | Paint → PDF writer: colour text, paths, images (JPEG passthrough, PNG via Flate), per-page. | |
| 35 | | `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`. | |
| 36 | | `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. | |
| 37 | | `internal/slides/static/fonts/*.ttf`, `slides.css` | `@font-face` and the theme font stacks. | |
| 38 | | `internal/slides/templates/export.html` | The export form and status page. | |
| 39 | |
| 40 | --- |
| 41 | |
| 42 | ### Task 1: Spike — `svg2pdf` under wasm32 with embedded fonts (timeboxed) |
| 43 | |
| 44 | - [ ] 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). |
| 45 | - [ ] 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. |
| 46 | |
| 47 | ### Task 2: Fonts everywhere and the layout engine |
| 48 | |
| 49 | - [ ] 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. |
| 50 | - [ ] `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. |
| 51 | |
| 52 | ### Task 3: SVG and thumbnails |
| 53 | |
| 54 | - [ ] `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"`. |
| 55 | - [ ] 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. |
| 56 | |
| 57 | ### Task 4: PDF |
| 58 | |
| 59 | - [ ] 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. |
| 60 | - [ ] 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. |
| 61 | |
| 62 | ### Task 5: The export job, screens, API, gate, docs, deploy |
| 63 | |
| 64 | - [ ] `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. |
| 65 | - [ ] 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). |
| 66 | - [ ] 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. |
| 67 | |