oficina / slides Public
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 underinternal/slides/, seams, catalog keys,rst-*grammar, WCAG 2.2 AA, actors on writes, trailers,make ciand.amadan/ci.d/10-gatebefore every commit,GOCACHE=/home/paulca/.cache/tmp/claude-1001/go-buildin 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-facefrominternal/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.cssdeclares 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
cqwmaps 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 withdata-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; whenCARLOS_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*.gofor the jobs wiring if present (else the rastrillo skill §6 anddocs/site/jobs.mdin 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.tomlgetrandom backend,panic = "abort",--remap-path-prefix; theno_host_importstest. Build forwasm32-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 withpdftotextif 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/andinternal/slides/fonts/files/;fonts.go(faces by role:Heading,Body,Code; bold/italic;Metrics(face, size)),truetype.gocopied from Docs with origin header;@font-faceinslides.css; the six themes'--slides-font-*values become the Liberation names with system fallbacks; goldens andTestThemeContraststill pass; the browser drive still passes. - [ ]
internal/slides/layout:Layout(d, index); regions per template exactly asslides.cssplaces 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 withfonts.Metrics, greedy line breaking on spaces with aMaxLinesoverflow 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 withfit), 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),-updateflag.
Task 3: SVG and thumbnails
- [ ]
internal/slides/svg:Encode(p Paint, opts); text as<text x y font-family font-size fill>per run withxml:space="preserve"; shapes; images as<image href>(data URI whenopts.InlineImages, else the asset URL); a<title>naming the slide;role="img". - [ ] Thumbnails:
GET /d/{sqid}/slides/{id}.svg(+ share, presentation and API twins),ETagfrom 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 ascore),slides_pdf.wasmcommitted underinternal/slides/pdfcore/testdata/, reproducible build inscripts/build-pdf-wasm.sh, the gate rebuilds andcmps it;Render(ctx, svgs [][]byte) ([]byte, error). Else:internal/slides/pdfgofrom Docs'pdf.goextended with colour (rg/RG), paths (re, Bézier ellipse, lines with caps), images (JPEGDCTDecodepassthrough, 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)ininternal/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:notescheckbox) →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'sjob-statuspartial withdata-polland the<noscript>refresh, and shows a Download link when done;GET /d/{sqid}/export/{job}.pdfserves 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
cmpif it exists;go testcovers a full export of the sample deck asserting page count = slides (and ×2 with notes),%PDFheader, and, whenpdftotextis 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.