oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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 cmps 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.