oficina / slides Public

feat: PDF export through the slides_pdf module

core/pdf is production: the ABI hardened so a (0, 0) host pair is an
empty slice rather than a slice over a null pointer, the borrow rule
written down, the module rebuilt (reproducible, 3.5 MiB, 0 imports).
internal/slides/pdfcore hosts it under wazero with a fresh instance per
Render, a 60 s job deadline, traps and deadlines as errors and clean
refusals as RejectedError. internal/slides/export.go is the pipeline:
Layout -> svg.Encode with every image inlined (20 MiB cap, box-filter
downscale to 2000 px, dropped with a warning when it cannot be inlined)
-> Render, with notes pages typeset from the notes text when asked, and
a deck over 200 slides refused. The gate rebuilds and cmps the pdf wasm
as it does the CRDT core's.
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 0ca08b05f08a7b8b59d0143626cbb0395612ab60 parent effa68d
10 files changed, +1755 −193
  • .amadan/ci.d/10-gate +28 −0
  • AGENTS.md +15 −0
  • core/pdf/src/abi.rs +27 −3
  • core/pdf/tests/render.rs +29 −0
  • internal/slides/export.go +476 −0
  • internal/slides/export_test.go +579 −0
  • internal/slides/pdfcore/pdfcore.go +318 −0
  • internal/slides/pdfcore/pdfcore_test.go +283 −0
  • internal/slides/pdfcore/spike_test.go +0 −190
  • internal/slides/pdfcore/testdata/slides_pdf.wasm binary

Intent

Human

<task-notification> <task-id>aca8fca9bf783d5d5</task-id> <tool-use-id>toolu_01Wa1zkS4g4Fp4Skc2g7Yddz</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aca8fca9bf783d5d5.output</output-file> <status>completed</status> <summary>Agent "S3 Task 2: fonts and layout engine" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>**Status:** done. Working tree clean; nothing of the other agent's touched. **Commit:** `effa68d feat: embedded Liberation fonts on every surface; the layout engine` **Tests:** `make ci` green (vet, gofmt, all Go tests, migration check). Browser drive **ran and passed** — it failed on `socket()` under the sandbox as predicted, so I re-ran it with the sandbox off. New goldens: `render/testdata` (font stacks only, one line each) and `layout/testdata/*.golden.json` (nine slides). **Concerns (full list in the report):** - `slides.css` has a real bug: `[slides-slide] { gap: var(--slides-gap) }` is a `cqw` on the query container itself, so region spacing and two-column widths move with the browser window. The engine uses the fullscreen-1920 value; fixing it needs the grid moved off the section. - Driving the numbers through Chromium found two errors I'd otherwise have shipped: `1cqw` is 16.8 not 19.2 on padded templates (content box, not border box — six templates were 12.5% oversized), and flow images/shapes are flex items that shrink. Both are now pinned by a browser test. - Fonts are duplicated (4.2 MB × 2) per the brief; `@font-face` uses the real family name, where Docs deliberately uses a private one. Both are one-line changes if you want them different. - I followed slides.css over three of the brief's parentheticals (divider thickness, quote italic/no rule, line heights) and the brief over slides.css on one (a shape's label is drawn). **Report:** `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-08-slice-3-export/task-2-report.md`</result> <usage><subagent_tokens>389681</subagent_tokens><tool_uses>165</tool_uses><duration_ms>2600311</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>a253a3f05d37b5136</task-id> <tool-use-id>toolu_019hVpGkzakTnTfQfBKYG8U9</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a253a3f05d37b5136.output</output-file> <status>completed</status> <summary>Agent "Review slice 3 Task 2" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>I now have sufficient evidence to write the full review. ### Spec Compliance Task 2's scope matches the brief exactly: only `internal/slides/fonts/`, `internal/slides/layout/`, `internal/slides/static/{fonts,slides.css}`, and a minimal, behavior-preserving seam extraction in `internal/slides/render/{render.go,themes.go}` are touched. No files owned by the concurrent SVG/thumbs/pdfcore/export tasks were touched. - **Fonts everywhere**: twelve Liberation TTFs embedded in both `internal/slides/fonts/files/` and `internal/slides/static/fonts/`, byte-identical (`TestServedFontsMatchTheEmbeddedOnes`), OFL licence served at both locations. `fonts.go` API matches the brief: `Role{Heading,Body,Code}`, `Face{Family,Bold,Italic}`, `Get`, `Data`, `Metrics`, plus a documented, justified deviation on `Advance`'s signature (`(width, missing []rune)` instead of a single value) to satisfy §11's degrade-loudly rule — reasonable and disclosed. - **`truetype.go`**: diffed byte-for-byte against `/home/paulca/amadan.net/oficina/docs/internal/fonts/truetype.go` — identical except the documented header and the one intentional change (`Data(f)` instead of a `Data` field, `f.File()` method vs field), exactly as claimed. - **`layout.Layout(d, index) (Paint, []Warning, error)`**, `Paint{W,H,Background,Ops}`, `Rect/Ellipse/Line/Image/Text{Runs}` — signatures verified directly against the brief, no drift. - **`regions.go` + `regions_test.go`**: the structural test genuinely regex-parses `grid-template-areas` (and scoped `grid-area` rules) out of `slides.css` and cross-checks region-name sets against the table, both directions. Not a rubber-stamp. - **1 cqw mapping**: verified in `sizes.go`/`regions_test.go` and pinned by a Chromium browser test. `scaleFor` correctly maps `padded` (16.8px) to the six templates that keep the section's 6.25% padding and `full` (19.2px) to `image-full` and every canvas slide (both drop padding in `slides.css` at lines 324 and 582-585) — checked against the CSS directly, mapping is correct per-template, not global. - **Greedy line breaking, never clip**: confirmed in `text.go`; overflow surfaces as `Warning{Kind: WarnOverflow}` rather than a `MaxLines` field literally — a reasonable, disclosed reinterpretation of the brief's wording that achieves the same "never clip, report" contract (`TestALongWordOverflowsRatherThanBeingCut`, `TestABlockTallerThanItsRegionIsReported`). - **Blocks**: table is equal-width columns with header bold (`colW := s.w/cols`, `head.face.Bold = true`), columns block clamps `count` to 2 or 3 and lays out equal-width children left to right, phone layer is never read (`TestThePhoneLayerIsNeverRead`), canvas z-order confirmed stable-sorted (`TestCanvasPaintsInZOrder`). - **Goldens**: 9 golden files for 9 fixture slides covering all 7 flow templates (including `blank`, which the real `sample.go` doesn't even use) + both canvas modes + every block type in §2.4, `-update` flag present, `TestNoOpIsNaNOrNegativelySized` guards every golden against NaN/Inf/negative sizes. - **`@font-face` hashes**: URLs carry a 16-hex SHA-256-derived content hash; spot-checked one against the actual file — matches. `TestSlidesCSSFontURLsCarryTheirContentHash` and `TestTheAppServesTheDeclaredFontFiles` (asserts `immutable`) both exist and are meaningful. - **`TestThemeContrast`**: verified it tests only hex colours, unaffected by the font change, still meaningful. Render goldens diff-checked line by line — the only changes are the font-stack strings, everything else (colors, structure) is byte-identical. - **Browser test**: compiles under `-tags browser` (`go vet` clean), uses the shared `harness` package which skips visibly without Chromium (`t.Fatal`/`t.Skip` gated by `RASTRILLO_BROWSER_OPTIONAL`) and is otherwise bounded by Go's test timeout — no retry/poll loops, one fixed 2s `chromedp.Sleep` for font load (see Minor issue below). - **`go test ./internal/slides/layout/ ./internal/slides/fonts/ ./internal/slides/render/... ./internal/slidestest/...`** all pass with `-count=1` (39.8s for slidestest, non-browser-tagged). ### Strengths - Exceptional traceability: nearly every non-obvious number in `sizes.go`/`regions.go` cites the exact `slides.css` rule it derives from, and the browser test that caught the two real bugs (cqw-on-content-box, flex-shrink) is kept as a permanent regression pin rather than being thrown away after the bugs were fixed. - The `slides.css` `gap`-on-container bug is diagnosed correctly (verified: `[slides-slide] { gap: var(--slides-gap) }` is written on the container itself, an element can't be its own query container), the engine's choice to use the fullscreen value is right for an export/projector engine, and it's disclosed rather than silently patched around. - `render.go`'s `SlideVars`/`ThemeID` extraction is clean and provably behavior-preserving (golden diffs show zero output change besides font strings). - The `Advance` missing-glyph handling is properly tested against non-BMP/CJK input and confirmed non-panicking. ### Issues **Minor** - `internal/slidestest/browser_layout_test.go` uses a fixed `chromedp.Sleep(2_000_000_000)` (2s) to wait for the stylesheet and fonts to apply, rather than polling `document.fonts.ready`. It's bounded and documented, not a hang risk, but it's the one named-risk item ("no sleeps") not fully met — a flaky-CI risk if font load ever takes longer than 2s on a loaded box. - Divergences from the brief's literal wording on divider thickness (8.4px vs. an implied 2px) and quote styling (italic, no left rule) are well-justified by reading the actual `slides.css` (verified: divider `block-size: var(--slides-rule)`, quote `blockquote { font-style: italic }` with no border-left declared anywhere) and consistent with the plan's "stylesheet is the truth" doctrine — flagging only because it's a deviation from the brief's parenthetical, not because it's wrong. No Critical or Important issues found. ### Assessment: Approved Every named-risk check (a–j) verified directly against source, not just the report's prose: the TrueType parser is copied verbatim, the regions test really parses the CSS grid, the 16.8/19.2 cqw split is applied correctly per template, goldens are guarded against NaN/negative values, `TestThemeContrast` remains valid and render goldens changed only in font strings, the browser test is bounded and skips cleanly, font URLs carry verified-correct content hashes with a licence-serving test, table/columns layout is equal-width with bold headers, and canvas z-order is stable-sorted. Focused test runs pass. The two disclosed divergences (slides.css's `gap`-on-container bug, and following slides.css over the brief's parenthetical on divider/quote) are reasoned, documented, and consistent with the plan's own "the paint list is the only input" / stylesheet-is-truth constraints — not compliance failures.</result> <usage><subagent_tokens>125978</subagent_tokens><tool_uses>70</tool_uses><duration_ms>281633</duration_ms></usage> </task-notification>

Attributed to this commit: 21.3 g CO₂e (2730870 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/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index f1da706..39a9e28 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -106,6 +106,34 @@ echo "=== rust: test"
# build first.
SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/Cargo.toml
+# The PDF module (core/pdf, hosted by internal/slides/pdfcore) is a
+# second crate with the same shape and the same four steps, in the same
+# order and for the same reasons as the CRDT core's above: fmt, clippy
+# (the exact invocation scripts/build-pdf-wasm.sh runs, so nothing
+# looser sails through), build the artifact BEFORE its own
+# no_host_imports test reads it, and the byte comparison against the
+# committed copy go:embed actually ships. A stale slides_pdf.wasm is
+# the same trap as a stale slides_core.wasm: the Go tests pass against
+# whatever is committed, and only this cmp knows the difference.
+echo "=== rust: fmt (pdf)"
+cargo fmt --manifest-path core/pdf/Cargo.toml --check
+
+echo "=== rust: clippy (pdf)"
+cargo clippy --manifest-path core/pdf/Cargo.toml --all-targets -- -D warnings
+
+echo "=== wasm: the pdf artifact the host embeds"
+./scripts/build-pdf-wasm.sh
+
+echo "=== the embedded pdf wasm must match a fresh build"
+if ! cmp -s core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm internal/slides/pdfcore/testdata/slides_pdf.wasm; then
+ echo "gate: internal/slides/pdfcore/testdata/slides_pdf.wasm does not match a fresh build — rebuild and commit the wasm:" >&2
+ echo " ./scripts/build-pdf-wasm.sh && cp core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm internal/slides/pdfcore/testdata/slides_pdf.wasm" >&2
+ exit 1
+fi
+
+echo "=== rust: test (pdf)"
+SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/pdf/Cargo.toml
+
echo "=== fixtures: node dependencies"
# The versions are printed rather than pinned. What Node is only matters
# as a floor — 20+, for `node --test` and the ESM loading this tree
diff --git a/AGENTS.md b/AGENTS.md
index ccdd67b..3e17881 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -105,6 +105,19 @@ stylesheets and the committed editor bundle. `core/` at the repo root is
the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path,
unused here.
+`core/pdf/` is a second Rust crate beside the CRDT core: `slides_pdf.wasm`
+wraps `svg2pdf` and takes one SVG per page plus the Liberation TTFs, and
+writes one PDF with the text as subset, embedded glyphs. `internal/slides/pdfcore`
+hosts it under wazero with the same containment as `core` but a simpler
+shape — a fresh module instance per `Render`, discarded afterwards, under
+a sixty-second job deadline rather than the CRDT host's one-second call
+deadline. `internal/slides/export.go` is the pipeline over it
+(`layout.Layout` → `svg.Encode` with every image inlined → `Render`),
+including the notes pages, which are paint lists typeset here with the
+fonts package. `scripts/build-pdf-wasm.sh` builds the module; the
+artifact is committed at `internal/slides/pdfcore/testdata/slides_pdf.wasm`
+and the gate rebuilds and `cmp`s it exactly as it does the CRDT core's.
+
`fixtures/` at the repo root is the browser half and the contract that
keeps it honest: the editor's ES modules, their `node --test` tests,
`build-bundles.mjs` (esbuild, output committed), and `generate.mjs` with
@@ -309,6 +322,8 @@ rather than serving an editor that disagrees with the server.
embed a stale `internal/slides/core/testdata/slides_core.wasm` and the
Go tests will pass against it. The gate rebuilds and `cmp`s; when it
fails, rebuild and commit the artifact rather than arguing with it.
+ The same holds for `internal/slides/pdfcore/testdata/slides_pdf.wasm`
+ and `scripts/build-pdf-wasm.sh`.
- **`Doc::new()` and `Doc::with_client_id()` are banned in the Rust core**
(`clippy.toml`) — both draw entropy this wasm build has no safe source
for. Use `Options::with_guid_and_client_id`. A doc comment alone was got
diff --git a/core/pdf/src/abi.rs b/core/pdf/src/abi.rs
index bcc9b3a..8db67c4 100644
--- a/core/pdf/src/abi.rs
+++ b/core/pdf/src/abi.rs
@@ -36,6 +36,12 @@ pub const ERR_BUFFER_TOO_SMALL: i32 = -4;
/// `in_bounds`.
pub const ERR_INVALID_LENGTH: i32 = -5;
+/// Files the outcome of a render. This is the ONLY place `OUT` and `ERR`
+/// are borrowed mutably, and it runs after `render` has returned: no
+/// `RefCell` guard is ever held across a call into usvg or svg2pdf, so a
+/// trap inside them (which does not unwind under `panic = "abort"`)
+/// cannot leave a guard behind. The host discards the instance after a
+/// trap anyway, but the rule is the same one `core/src/abi.rs` keeps.
fn set_result(r: Result<Vec<u8>, RenderError>) -> i32 {
match r {
Ok(pdf) => {
@@ -89,6 +95,22 @@ fn in_bounds(ptr: u32, len: u32) -> bool {
}
}
+/// Builds the slice for a host `ptr`/`len` pair that `in_bounds` has
+/// already accepted. A zero length never touches the pointer: the host
+/// sends `(0, 0)` for "no frames" and `slice::from_raw_parts` over a null
+/// pointer is undefined even for zero bytes.
+///
+/// # Safety
+///
+/// `in_bounds(ptr, len)` must be true, and the range must not be written
+/// by anyone for the lifetime of the returned slice.
+unsafe fn host_slice<'a>(ptr: u32, len: u32) -> &'a [u8] {
+ if len == 0 {
+ return &[];
+ }
+ std::slice::from_raw_parts(ptr as *const u8, len as usize)
+}
+
/// Allocates `len` bytes for the host to write into; 0 on failure, which
/// the host must check.
#[no_mangle]
@@ -129,8 +151,8 @@ pub unsafe extern "C" fn slides_pdf_render(
if !in_bounds(svgs_ptr, svgs_len) || !in_bounds(fonts_ptr, fonts_len) {
return ERR_INVALID_LENGTH;
}
- let svgs = std::slice::from_raw_parts(svgs_ptr as *const u8, svgs_len as usize);
- let fonts = std::slice::from_raw_parts(fonts_ptr as *const u8, fonts_len as usize);
+ let svgs = host_slice(svgs_ptr, svgs_len);
+ let fonts = host_slice(fonts_ptr, fonts_len);
render_frames(svgs, fonts)
}
@@ -149,7 +171,9 @@ unsafe fn read_into(bytes: &[u8], out_ptr: u32, out_len: u32) -> i32 {
if !in_bounds(out_ptr, out_len) {
return ERR_INVALID_LENGTH;
}
- std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr as *mut u8, bytes.len());
+ if !bytes.is_empty() {
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr as *mut u8, bytes.len());
+ }
len_code(bytes)
}
diff --git a/core/pdf/tests/render.rs b/core/pdf/tests/render.rs
index 2c3ff88..3b6f9ce 100644
--- a/core/pdf/tests/render.rs
+++ b/core/pdf/tests/render.rs
@@ -147,6 +147,35 @@ fn abi_frames_round_trip() {
);
}
+#[test]
+fn a_null_zero_length_pair_is_no_frames_not_a_dereference() {
+ // The host sends (0, 0) for "no frames". Natively there is no linear
+ // memory to bound-check against, so this exercises exactly the path
+ // that must not build a slice over a null pointer: no pages is a
+ // refusal with a message, and the fonts side reads as empty.
+ // SAFETY: (0, 0) is the documented "nothing" pair; nothing is read.
+ let rc = unsafe { slides_pdf::abi::slides_pdf_render(0, 0, 0, 0) };
+ assert_eq!(rc, slides_pdf::abi::ERR_RENDER);
+ assert!(slides_pdf::abi::slides_pdf_err_len() > 0);
+ assert_eq!(slides_pdf::abi::slides_pdf_out_len(), 0);
+ // Reading an empty result into an empty buffer is 0, not a copy.
+ // SAFETY: a zero-length destination is never written.
+ assert_eq!(unsafe { slides_pdf::abi::slides_pdf_out_read(0, 0) }, 0);
+}
+
+#[test]
+fn text_with_no_fonts_at_all_still_renders_the_shapes() {
+ // A face the host did not send: usvg drops the text with a log
+ // warning, never substitutes and never errors. The shapes are still
+ // a page.
+ let pdf = slides_pdf::render(&[SVG.as_bytes()], &[]).expect("render");
+ assert_eq!(
+ count(&pdf, b"/Type /Page") - count(&pdf, b"/Type /Pages"),
+ 1
+ );
+ assert!(find(&pdf, b"/FontFile2").is_none());
+}
+
#[test]
fn raster_image_is_embedded() {
// A 2x2 opaque red PNG, so the `image` feature is exercised end to end.
diff --git a/internal/slides/export.go b/internal/slides/export.go
new file mode 100644
index 0000000..18ec436
--- /dev/null
+++ b/internal/slides/export.go
@@ -0,0 +1,476 @@
+// export.go turns a deck into a PDF (spec §4.3): one 1920 by 1080 pt
+// page per slide, and when asked, the speaker notes as a page after
+// each slide.
+//
+// The pipeline is the one the plan's global constraints lay down and
+// nothing here shortcuts it: layout.Layout draws each slide into a
+// paint list, svg.Encode writes that paint list as an SVG, and the Rust
+// module under internal/slides/pdfcore turns the SVGs into a PDF with
+// the text as glyphs from the same Liberation faces the layout engine
+// measured with. The PDF path never reads a deck; a notes page is a
+// paint list too, built here from the notes text and typeset with the
+// same fonts package, so it goes through the same two steps.
+//
+// # Self-contained
+//
+// A PDF is a file somebody emails. Every image is inlined into its
+// page's SVG before the module sees it, and an image that cannot be
+// inlined — the bytes are not there, or are not a picture this build
+// can decode, or are bigger than the cap — is left out with a Warning
+// that says so, never replaced by a URL the reader's machine would have
+// to fetch. Images are downscaled to maxImageEdge on the long edge
+// first (§4.3), because a 6000 px photograph on a 1920 pt page is bytes
+// nobody sees.
+//
+// The routes, the job and the status page are in the export handlers;
+// this file is the function they call.
+package slides
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "image/draw"
+ "image/jpeg"
+ "image/png"
+ "strings"
+ "sync"
+
+ // The decoders Assets can hand back. Registering them is what makes
+ // image.Decode recognise the bytes; a format nobody registered is
+ // "not a picture this build can decode" and is dropped with a
+ // warning rather than sent to the module, which would fail the
+ // whole render on it (core/pdf/src/lib.rs).
+ _ "image/gif"
+
+ "amadan.net/oficina/slides/internal/slides/deck"
+ "amadan.net/oficina/slides/internal/slides/fonts"
+ "amadan.net/oficina/slides/internal/slides/layout"
+ "amadan.net/oficina/slides/internal/slides/pdfcore"
+ "amadan.net/oficina/slides/internal/slides/svg"
+)
+
+// MaxExportSlides is the most slides one export will take (§4.3). A
+// deck past it is refused before any work is done, with
+// ErrTooManySlides.
+const MaxExportSlides = 200
+
+// ErrTooManySlides is Export's refusal of a deck over MaxExportSlides.
+var ErrTooManySlides = errors.New("slides: the deck has more slides than an export will take")
+
+// maxExportAsset bounds one image's stored bytes: 20 MiB, the plan's
+// cap. Bigger is dropped with a warning, not decoded.
+const maxExportAsset = 20 << 20
+
+// maxImagePixels bounds what one image is allowed to decode to. The
+// byte cap above says nothing about pixels — a 20 MiB PNG can be a
+// hundred thousand pixels a side — and decoding is where the memory
+// goes. Fifty megapixels is an 8000 by 6000 photograph; anything larger
+// is dropped with a warning.
+const maxImagePixels = 50_000_000
+
+// maxImageEdge is the long edge an inlined image is scaled down to
+// (§4.3). Slightly over the page's own 1920, so a full-bleed picture is
+// not upscaled by the reader.
+const maxImageEdge = 2000
+
+// ExportOptions is what Export needs beyond the deck.
+type ExportOptions struct {
+ // WithNotes puts a page of speaker notes after every slide (a slide
+ // with long notes gets more than one). Every slide gets one, notes
+ // or not, so the pages pair up slide, notes, slide, notes all the
+ // way through and a printed handout is predictable.
+ WithNotes bool
+
+ // Assets returns an image's stored bytes and MIME type by the
+ // deck's asset id (layout.Image.Asset). It is the caller's because
+ // where the bytes live — the object store, a directory — is the
+ // app's business and not the PDF's. ok=false is "not available",
+ // which is a warning and an omitted image. nil means no image can
+ // be fetched at all: every image is omitted, each with a warning.
+ Assets func(asset string) (data []byte, mime string, ok bool)
+}
+
+// pdfRuntime is the one compiled PDF module the process shares:
+// compiling is the expensive step and a Runtime is safe for concurrent
+// use, with every Render on its own instance. Built on first use so a
+// process that never exports never pays for it.
+var pdfRuntime = sync.OnceValues(func() (*pdfcore.Runtime, error) {
+ return pdfcore.New(context.Background())
+})
+
+// Export renders d as a PDF. The warnings are everything worth telling
+// the person who asked — a block that overflowed, an image that was
+// left out, a character no face can draw — and never stop the export;
+// the error is what does: a deck over MaxExportSlides, a renderer that
+// refused or trapped, a deadline.
+func Export(ctx context.Context, d *deck.Deck, o ExportOptions) ([]byte, []layout.Warning, error) {
+ if d == nil {
+ return nil, nil, errors.New("slides: export: no deck")
+ }
+ if len(d.Slides) > MaxExportSlides {
+ return nil, nil, fmt.Errorf("%w: %d slides, the most is %d", ErrTooManySlides, len(d.Slides), MaxExportSlides)
+ }
+ if len(d.Slides) == 0 {
+ return nil, nil, errors.New("slides: export: the deck has no slides")
+ }
+
+ x := &exporter{deck: d, opts: o}
+ var pages [][]byte
+ for i := range d.Slides {
+ if err := ctx.Err(); err != nil {
+ return nil, x.warnings, err
+ }
+ p, warnings, err := layout.Layout(d, i)
+ if err != nil {
+ return nil, x.warnings, fmt.Errorf("slides: export: slide %d: %w", i+1, err)
+ }
+ x.warnings = append(x.warnings, warnings...)
+ title := slideTitle(d.Slides[i])
+ if title == "" {
+ title = d.Meta.Title
+ }
+ pages = append(pages, x.encode(p, title))
+ if o.WithNotes {
+ for _, np := range x.notesPages(d.Slides[i], title) {
+ pages = append(pages, x.encode(np, title))
+ }
+ }
+ }
+
+ rt, err := pdfRuntime()
+ if err != nil {
+ return nil, x.warnings, fmt.Errorf("slides: export: %w", err)
+ }
+ var faces [][]byte
+ for _, f := range fonts.All() {
+ faces = append(faces, fonts.Data(f))
+ }
+ pdf, err := rt.Render(ctx, pages, faces)
+ if err != nil {
+ return nil, x.warnings, fmt.Errorf("slides: export: %w", err)
+ }
+ return pdf, x.warnings, nil
+}
+
+// exporter is one Export call: the deck, the options, and what has
+// been noticed so far.
+type exporter struct {
+ deck *deck.Deck
+ opts ExportOptions
+ warnings []layout.Warning
+
+ // inlined caches an asset's inlined form for the length of one
+ // export, so a picture on ten slides is fetched and scaled once.
+ inlined map[string]inlinedImage
+}
+
+type inlinedImage struct {
+ data []byte
+ mime string
+ ok bool
+}
+
+func (x *exporter) warn(kind, block, detail string) {
+ x.warnings = append(x.warnings, layout.Warning{Kind: kind, Block: block, Detail: detail})
+}
+
+// encode writes one paint list as a self-contained SVG page.
+func (x *exporter) encode(p layout.Paint, title string) []byte {
+ return svg.Encode(p, svg.Options{
+ Title: title,
+ InlineImages: x.inline,
+ // No AssetURL: an image that is not inlined is not drawn. A URL
+ // in a PDF is a picture the reader's machine has to fetch, and
+ // the module would refuse it anyway.
+ })
+}
+
+// slideTitle is the slide's first heading, as plain text, or "".
+func slideTitle(s deck.Slide) string {
+ for _, b := range s.Blocks {
+ if b.Type == "heading" {
+ return strings.TrimSpace(b.Text.Plain())
+ }
+ }
+ return ""
+}
+
+// ── Images ───────────────────────────────────────────────────────────
+
+// inline is the svg.Options.InlineImages hook: the asset's bytes,
+// decoded, scaled down to maxImageEdge if they are bigger, and handed
+// back as a picture the PDF module accepts. ok=false is "leave it out",
+// and every path to it has already recorded why.
+func (x *exporter) inline(asset string) ([]byte, string, bool) {
+ if x.inlined == nil {
+ x.inlined = map[string]inlinedImage{}
+ }
+ if c, done := x.inlined[asset]; done {
+ return c.data, c.mime, c.ok
+ }
+ data, mime, ok := x.fetchImage(asset)
+ x.inlined[asset] = inlinedImage{data, mime, ok}
+ return data, mime, ok
+}
+
+func (x *exporter) fetchImage(asset string) ([]byte, string, bool) {
+ if x.opts.Assets == nil {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q: no way to read assets was given; left out of the PDF", asset))
+ return nil, "", false
+ }
+ data, mime, ok := x.opts.Assets(asset)
+ if !ok {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be read; left out of the PDF", asset))
+ return nil, "", false
+ }
+ if len(data) > maxExportAsset {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is %d bytes, over the %d byte cap; left out of the PDF", asset, len(data), maxExportAsset))
+ return nil, "", false
+ }
+ if mime == "image/svg+xml" {
+ // A vector picture is already what the page is made of: the
+ // encoder nests it into the slide (it was sanitised on
+ // registration, svg.go). The one it will not nest is a picture
+ // carrying a stylesheet, which would restyle the slide around
+ // it; the encoder falls back to the asset's URL, and this
+ // export gives it none, so say why the picture is missing
+ // rather than letting it vanish.
+ if bytes.Contains(bytes.ToLower(data), []byte("<style")) {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF", asset))
+ return nil, "", false
+ }
+ return data, mime, true
+ }
+
+ cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q (%s) is not a picture this build can decode; left out of the PDF", asset, mime))
+ return nil, "", false
+ }
+ if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is %d by %d pixels, more than the PDF will take; left out", asset, cfg.Width, cfg.Height))
+ return nil, "", false
+ }
+ if max(cfg.Width, cfg.Height) <= maxImageEdge && (format == "png" || format == "jpeg" || format == "gif") {
+ // Small enough and in a format the module reads: the stored
+ // bytes go in untouched, which for a JPEG means DCTDecode
+ // passthrough rather than a second lossy generation.
+ return data, "image/" + format, true
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q (%s) could not be decoded: %v; left out of the PDF", asset, mime, err))
+ return nil, "", false
+ }
+ scaled := downscale(img, maxImageEdge)
+ var buf bytes.Buffer
+ if format == "jpeg" {
+ // A photograph stays a photograph: JPEG in, JPEG out, at a
+ // quality the eye does not separate from the original on a
+ // page.
+ if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 88}); err != nil {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be re-encoded: %v; left out of the PDF", asset, err))
+ return nil, "", false
+ }
+ return buf.Bytes(), "image/jpeg", true
+ }
+ if err := png.Encode(&buf, scaled); err != nil {
+ x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be re-encoded: %v; left out of the PDF", asset, err))
+ return nil, "", false
+ }
+ return buf.Bytes(), "image/png", true
+}
+
+// downscale returns img no larger than edge on its long side, or img
+// itself if it already is.
+//
+// A box filter, written here because golang.org/x/image/draw is not
+// the standard library and the stdlib's image/draw only copies. Each
+// target pixel is the mean of the source rectangle it covers, in
+// premultiplied RGBA so a transparent edge does not bleed its colour
+// into its neighbours. For a reduction — the only direction this is
+// used in — a box filter is the right filter: it is what "average the
+// pixels you are merging" means, and it does not ring.
+func downscale(img image.Image, edge int) image.Image {
+ b := img.Bounds()
+ sw, sh := b.Dx(), b.Dy()
+ if sw <= edge && sh <= edge {
+ return img
+ }
+ tw, th := edge, edge
+ if sw >= sh {
+ th = max(1, sh*edge/sw)
+ } else {
+ tw = max(1, sw*edge/sh)
+ }
+
+ src := image.NewRGBA(image.Rect(0, 0, sw, sh))
+ draw.Draw(src, src.Bounds(), img, b.Min, draw.Src)
+ dst := image.NewRGBA(image.Rect(0, 0, tw, th))
+ for ty := 0; ty < th; ty++ {
+ y0, y1 := ty*sh/th, (ty+1)*sh/th
+ if y1 <= y0 {
+ y1 = y0 + 1
+ }
+ for tx := 0; tx < tw; tx++ {
+ x0, x1 := tx*sw/tw, (tx+1)*sw/tw
+ if x1 <= x0 {
+ x1 = x0 + 1
+ }
+ var r, g, bl, a uint64
+ for y := y0; y < y1; y++ {
+ row := src.Pix[y*src.Stride+x0*4 : y*src.Stride+x1*4]
+ for i := 0; i < len(row); i += 4 {
+ r += uint64(row[i])
+ g += uint64(row[i+1])
+ bl += uint64(row[i+2])
+ a += uint64(row[i+3])
+ }
+ }
+ n := uint64((y1 - y0) * (x1 - x0))
+ o := ty*dst.Stride + tx*4
+ dst.Pix[o] = uint8((r + n/2) / n)
+ dst.Pix[o+1] = uint8((g + n/2) / n)
+ dst.Pix[o+2] = uint8((bl + n/2) / n)
+ dst.Pix[o+3] = uint8((a + n/2) / n)
+ }
+ }
+ return dst
+}
+
+// ── Notes pages ──────────────────────────────────────────────────────
+
+// The notes page's geometry, in the layout engine's own units (1920 by
+// 1080, the padded scale from layout/sizes.go: 1 cqw = 16.8 px inside a
+// section with the stylesheet's 6.25% padding). The sizes are the
+// stylesheet's t-3 and body-m, so a notes page reads as the deck's own
+// type rather than as a different document stapled to it.
+const (
+ notesPad = 0.0625 * layout.FrameW // --slides-pad
+ notesCqw = (layout.FrameW - 2*notesPad) / 100
+ notesTitle = 4.0 * notesCqw // --slides-t-3
+ notesBody = 2.8 * notesCqw // --slides-body-m
+ notesGap = 2.6 * notesCqw // --slides-gap, title to text
+ notesLead = 1.3 // the section's line-height
+ notesLeadT = 1.1 // a heading's line-height
+ notesParaGap = 0.6 // em between paragraphs, as slides.css
+ notesBg = layout.Fill("#ffffff")
+ notesFg = layout.Fill("#111111")
+)
+
+// notesPages typesets s's speaker notes: the slide's title at t-3, then
+// the notes at body-m in Liberation Sans, greedily wrapped and broken
+// across as many 1920 by 1080 pages as they need. A slide with no notes
+// still gets its page (see ExportOptions.WithNotes).
+//
+// Plain text: marks are dropped, as §2.6's notes are read aloud, not
+// looked at. Nothing is ever clipped — a word wider than the page runs
+// past the margin, the same rule the layout engine keeps.
+func (x *exporter) notesPages(s deck.Slide, title string) []layout.Paint {
+ titleFace := fonts.Face{Family: fonts.Sans, Bold: true}
+ bodyFace := fonts.Face{Family: fonts.Sans}
+ colW := layout.FrameW - 2*notesPad
+ bottom := layout.FrameH - notesPad
+
+ var pages []layout.Paint
+ cur := layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: notesBg}
+ top := notesPad
+
+ // place puts one wrapped block on the page, breaking to a new page
+ // wherever the next line would cross the bottom margin.
+ place := func(lines []string, face fonts.Face, size, lead float64) {
+ asc, desc, _ := fonts.Metrics(face, size)
+ lineH := lead * size
+ for _, ln := range lines {
+ if top+lineH > bottom && len(cur.Ops) > 0 {
+ pages = append(pages, cur)
+ cur = layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: notesBg}
+ top = notesPad
+ }
+ baseline := top + (lineH-(asc+desc))/2 + asc
+ if ln != "" {
+ cur.Ops = append(cur.Ops, layout.Text{X: notesPad, Y: baseline, Runs: []layout.Run{
+ {S: ln, Face: face, Size: size, Fill: notesFg},
+ }})
+ }
+ top += lineH
+ }
+ }
+
+ if title != "" {
+ place(x.wrapNotes(s.ID, title, titleFace, notesTitle, colW), titleFace, notesTitle, notesLeadT)
+ top += notesGap
+ }
+ for i, para := range s.Notes {
+ if i > 0 {
+ top += notesParaGap * notesBody
+ }
+ text := strings.TrimSpace(plainParagraph(para))
+ if text == "" {
+ // An empty paragraph is an empty line box, as <p></p> is.
+ top += notesLead * notesBody
+ continue
+ }
+ place(x.wrapNotes(s.ID, text, bodyFace, notesBody, colW), bodyFace, notesBody, notesLead)
+ }
+ return append(pages, cur)
+}
+
+// plainParagraph is one paragraph's runs concatenated, marks dropped.
+func plainParagraph(para []deck.Run) string {
+ var b strings.Builder
+ for _, r := range para {
+ b.WriteString(r.T)
+ }
+ return b.String()
+}
+
+// wrapNotes breaks text into lines no wider than w, greedily, measured
+// with the same fonts package the layout engine measures with. A rune
+// no face has a glyph for is reported once per slide.
+func (x *exporter) wrapNotes(block, text string, face fonts.Face, size, w float64) []string {
+ space, _ := fonts.Advance(face, size, " ")
+ var lines []string
+ var cur strings.Builder
+ var curW float64
+ var missing []rune
+ for _, word := range strings.Fields(text) {
+ ww, miss := fonts.Advance(face, size, word)
+ missing = append(missing, miss...)
+ switch {
+ case cur.Len() == 0:
+ cur.WriteString(word)
+ curW = ww
+ case curW+space+ww > w:
+ lines = append(lines, cur.String())
+ cur.Reset()
+ cur.WriteString(word)
+ curW = ww
+ default:
+ cur.WriteByte(' ')
+ cur.WriteString(word)
+ curW += space + ww
+ }
+ }
+ if cur.Len() > 0 {
+ lines = append(lines, cur.String())
+ }
+ if len(missing) > 0 {
+ seen := map[rune]bool{}
+ var fresh []string
+ for _, r := range missing {
+ if !seen[r] {
+ seen[r] = true
+ fresh = append(fresh, fmt.Sprintf("%q", r))
+ }
+ }
+ x.warn(layout.WarnGlyph, block, fmt.Sprintf(
+ "the notes use %s, which no embedded face has a glyph for; drawn at the width of a box",
+ strings.Join(fresh, ", ")))
+ }
+ return lines
+}
diff --git a/internal/slides/export_test.go b/internal/slides/export_test.go
new file mode 100644
index 0000000..0d8c49e
--- /dev/null
+++ b/internal/slides/export_test.go
@@ -0,0 +1,579 @@
+package slides
+
+import (
+ "bytes"
+ "compress/zlib"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/slides/internal/slides/core"
+ "amadan.net/oficina/slides/internal/slides/deck"
+ "amadan.net/oficina/slides/internal/slides/layout"
+)
+
+// sampleDeckForExport is the seeded sample deck (sample.go) as a view,
+// driven through the core so the export sees exactly what a real deck
+// is rather than a hand-written approximation of one.
+func sampleDeckForExport(t *testing.T) *deck.Deck {
+ t.Helper()
+ ctx := context.Background()
+ rt, err := core.New(ctx)
+ if err != nil {
+ t.Fatalf("core.New: %v", err)
+ }
+ t.Cleanup(func() { rt.Close(ctx) })
+ h, err := rt.NewDoc(ctx, "export-test", 1)
+ if err != nil {
+ t.Fatalf("NewDoc: %v", err)
+ }
+ t.Cleanup(func() { h.Close(ctx) })
+ ops, err := sampleOps()
+ if err != nil {
+ t.Fatal(err)
+ }
+ encoded, err := opsJSON(ops)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.Ops(ctx, encoded); err != nil {
+ t.Fatalf("Ops: %v", err)
+ }
+ view, err := h.JSON(ctx)
+ if err != nil {
+ t.Fatalf("JSON: %v", err)
+ }
+ d, err := deck.Parse(view)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return d
+}
+
+func pdfPages(pdf []byte) int {
+ return bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages"))
+}
+
+func TestExportSampleDeck(t *testing.T) {
+ d := sampleDeckForExport(t)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+
+ pdf, warnings, err := Export(ctx, d, ExportOptions{})
+ if err != nil {
+ t.Fatalf("Export: %v", err)
+ }
+ if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
+ t.Fatalf("not a PDF: %q", pdf[:min(16, len(pdf))])
+ }
+ if got, want := pdfPages(pdf), len(d.Slides); got != want {
+ t.Errorf("pages = %d, want one per slide = %d", got, want)
+ }
+ for _, w := range warnings {
+ t.Logf("warning: %s %s: %s", w.Kind, w.Block, w.Detail)
+ }
+ for _, w := range warnings {
+ if w.Kind == layout.WarnAsset {
+ t.Errorf("the sample deck has no images, yet: %s", w.Detail)
+ }
+ }
+
+ // Every slide's heading is in the PDF as glyph runs: the content
+ // streams' Tj strings, mapped back through each font's ToUnicode
+ // CMap. Spaces are dropped on both sides because svg2pdf positions
+ // each glyph on its own, so a word gap is a coordinate rather than
+ // a character.
+ text := strings.ReplaceAll(pdfText(t, pdf), " ", "")
+ var headings []string
+ for _, s := range d.Slides {
+ if h := slideTitle(s); h != "" {
+ headings = append(headings, h)
+ if !strings.Contains(text, strings.ReplaceAll(h, " ", "")) {
+ t.Errorf("heading %q is not in the PDF's text", h)
+ }
+ }
+ }
+ if len(headings) < 6 {
+ t.Fatalf("only %d headings in the sample deck; the check above proved little", len(headings))
+ }
+
+ // A real extractor, when one is around.
+ if path, err := exec.LookPath("pdftotext"); err == nil {
+ file := filepath.Join(t.TempDir(), "sample.pdf")
+ if err := os.WriteFile(file, pdf, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ out, err := exec.Command(path, file, "-").Output()
+ if err != nil {
+ t.Fatalf("pdftotext: %v", err)
+ }
+ if !strings.Contains(string(out), headings[0]) {
+ t.Errorf("pdftotext did not find %q; got:\n%s", headings[0], out)
+ }
+ } else {
+ t.Logf("pdftotext is not on PATH: the external extractor assertion was skipped (the ToUnicode check above ran)")
+ }
+
+ // With notes: at least one notes page after every slide.
+ withNotes, _, err := Export(ctx, d, ExportOptions{WithNotes: true})
+ if err != nil {
+ t.Fatalf("Export with notes: %v", err)
+ }
+ if got, want := pdfPages(withNotes), 2*len(d.Slides); got < want {
+ t.Errorf("pages with notes = %d, want at least %d", got, want)
+ }
+ notesText := strings.ReplaceAll(pdfText(t, withNotes), " ", "")
+ for _, s := range d.Slides {
+ plain := strings.ReplaceAll(strings.Fields(s.Notes.Plain())[0], " ", "")
+ if !strings.Contains(notesText, plain) {
+ t.Errorf("notes word %q of slide %s is not in the PDF's text", plain, s.ID)
+ }
+ }
+ if os.Getenv("SLIDES_WRITE_PDF") != "" {
+ os.WriteFile(os.Getenv("SLIDES_WRITE_PDF"), withNotes, 0o644)
+ }
+}
+
+func TestLongNotesBreakAcrossPages(t *testing.T) {
+ sentence := "Speaker notes that run on for a long time have to break across pages rather than run off the bottom of the one they started on. "
+ d := &deck.Deck{
+ Meta: deck.Meta{Title: "Notes", Theme: defaultTheme},
+ Slides: []deck.Slide{{
+ ID: "slide00000001", Layout: "flow", Template: "title",
+ Notes: deck.Text{{{T: strings.Repeat(sentence, 40)}}, {{T: "A second paragraph."}}},
+ Blocks: []deck.Block{{ID: "b00000000001", Type: "heading", Region: "heading", Props: map[string]any{"level": float64(1)}, Text: deck.Text{{{T: "One slide"}}}}},
+ }},
+ }
+ pdf, warnings, err := Export(context.Background(), d, ExportOptions{WithNotes: true})
+ if err != nil {
+ t.Fatalf("Export: %v", err)
+ }
+ if got := pdfPages(pdf); got < 3 {
+ t.Errorf("pages = %d, want the slide plus at least two notes pages", got)
+ }
+ for _, w := range warnings {
+ t.Errorf("unexpected warning: %+v", w)
+ }
+}
+
+func TestExportRefusesOver200Slides(t *testing.T) {
+ build := func(n int) *deck.Deck {
+ d := &deck.Deck{Meta: deck.Meta{Title: "Big", Theme: defaultTheme}}
+ for i := 0; i < n; i++ {
+ d.Slides = append(d.Slides, deck.Slide{
+ ID: fmt.Sprintf("slide%08d", i), Layout: "flow", Template: "section",
+ Blocks: []deck.Block{{ID: fmt.Sprintf("b%011d", i), Type: "heading", Region: "heading",
+ Props: map[string]any{"level": float64(1)}, Text: deck.Text{{{T: fmt.Sprintf("Slide %d", i+1)}}}}},
+ })
+ }
+ return d
+ }
+ _, _, err := Export(context.Background(), build(MaxExportSlides+1), ExportOptions{})
+ if !errors.Is(err, ErrTooManySlides) {
+ t.Fatalf("201 slides: err = %v, want ErrTooManySlides", err)
+ }
+
+ start := time.Now()
+ pdf, _, err := Export(context.Background(), build(MaxExportSlides), ExportOptions{})
+ if err != nil {
+ t.Fatalf("200 slides: %v", err)
+ }
+ if got := pdfPages(pdf); got != MaxExportSlides {
+ t.Errorf("200 slides: pages = %d", got)
+ }
+ t.Logf("200 slides: %s, %d bytes", time.Since(start).Round(time.Millisecond), len(pdf))
+}
+
+// pngBytes is a w by h PNG filled with c.
+func pngBytes(t *testing.T, w, h int, c color.Color) []byte {
+ t.Helper()
+ img := image.NewNRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ img.Set(x, y, c)
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ t.Fatal(err)
+ }
+ return buf.Bytes()
+}
+
+func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) {
+ assets := map[string]struct {
+ data []byte
+ mime string
+ }{
+ "small": {pngBytes(t, 300, 200, color.NRGBA{200, 30, 30, 255}), "image/png"},
+ "big": {pngBytes(t, 3000, 1500, color.NRGBA{30, 30, 200, 255}), "image/png"},
+ "junk": {[]byte("RIFF\x00\x00\x00\x00WEBPnot really"), "image/webp"},
+ "huge": {make([]byte, maxExportAsset+1), "image/png"},
+ }
+ ids := []string{"small", "big", "junk", "huge", "missing"}
+ d := &deck.Deck{
+ Meta: deck.Meta{Title: "Pictures", Theme: defaultTheme},
+ Assets: map[string]deck.Asset{},
+ }
+ for i, id := range ids {
+ d.Assets[id] = deck.Asset{Ref: "ref-" + id, Name: id + ".png", Width: 300, Height: 200, Alt: id}
+ d.Slides = append(d.Slides, deck.Slide{
+ ID: fmt.Sprintf("slide%08d", i), Layout: "flow", Template: "image-full",
+ Blocks: []deck.Block{{ID: fmt.Sprintf("b%011d", i), Type: "image", Region: "media",
+ Props: map[string]any{"asset": id}}},
+ })
+ }
+ var asked []string
+ opts := ExportOptions{Assets: func(asset string) ([]byte, string, bool) {
+ asked = append(asked, asset)
+ a, ok := assets[asset]
+ return a.data, a.mime, ok
+ }}
+
+ pdf, warnings, err := Export(context.Background(), d, opts)
+ if err != nil {
+ t.Fatalf("Export: %v", err)
+ }
+ if got := pdfPages(pdf); got != len(ids) {
+ t.Errorf("pages = %d, want %d", got, len(ids))
+ }
+
+ // Two pictures made it in: the small one as it was stored, the big
+ // one scaled to 2000 on its long edge.
+ if got := bytes.Count(pdf, []byte("/Subtype /Image")); got != 2 {
+ t.Errorf("image XObjects = %d, want 2 (small and big)", got)
+ }
+ for _, want := range []string{"/Width 300", "/Height 200", "/Width 2000", "/Height 1000"} {
+ if !bytes.Contains(pdf, []byte(want)) {
+ t.Errorf("PDF lacks %q", want)
+ }
+ }
+ if bytes.Contains(pdf, []byte("/Width 3000")) {
+ t.Error("the big image went in unscaled")
+ }
+
+ // Three did not, each with a warning saying which and why, and
+ // nothing in the file points anywhere outside it.
+ dropped := map[string]bool{}
+ for _, w := range warnings {
+ if w.Kind != layout.WarnAsset {
+ t.Errorf("unexpected warning: %+v", w)
+ continue
+ }
+ for _, id := range []string{"junk", "huge", "missing"} {
+ if strings.Contains(w.Detail, fmt.Sprintf("%q", id)) {
+ dropped[id] = true
+ }
+ }
+ t.Logf("warning: %s", w.Detail)
+ }
+ for _, id := range []string{"junk", "huge", "missing"} {
+ if !dropped[id] {
+ t.Errorf("no warning names the dropped image %q", id)
+ }
+ }
+ if len(dropped) != 3 || len(warnings) != 3 {
+ t.Errorf("warnings = %d, want exactly 3", len(warnings))
+ }
+ if bytes.Contains(pdf, []byte("/URI")) || bytes.Contains(pdf, []byte("http")) {
+ t.Error("the PDF refers to something outside itself")
+ }
+ sort.Strings(asked)
+ if got := strings.Join(asked, ","); got != "big,huge,junk,missing,small" {
+ t.Errorf("assets asked for: %s", got)
+ }
+
+ // No way to read assets at all: every picture is left out, loudly.
+ _, warnings, err = Export(context.Background(), d, ExportOptions{})
+ if err != nil {
+ t.Fatalf("Export without Assets: %v", err)
+ }
+ if len(warnings) != len(ids) {
+ t.Errorf("without Assets: %d warnings, want one per image (%d)", len(warnings), len(ids))
+ }
+}
+
+func TestDownscaleIsABoxFilterThatKeepsTheAspect(t *testing.T) {
+ // 4000 by 2000 comes down to 2000 by 1000.
+ wide := image.NewNRGBA(image.Rect(0, 0, 4000, 2000))
+ if b := downscale(wide, maxImageEdge).Bounds(); b.Dx() != 2000 || b.Dy() != 1000 {
+ t.Errorf("wide: %v", b)
+ }
+ // 1000 by 3000 comes down to 666 by 2000, not 2000 by 6000.
+ tall := image.NewNRGBA(image.Rect(0, 0, 1000, 3000))
+ if b := downscale(tall, maxImageEdge).Bounds(); b.Dx() != 666 || b.Dy() != 2000 {
+ t.Errorf("tall: %v", b)
+ }
+ // Small enough is untouched, the same value back.
+ small := image.NewNRGBA(image.Rect(0, 0, 10, 10))
+ if downscale(small, maxImageEdge) != small {
+ t.Error("a small image was copied")
+ }
+ // A 2 by 2 checkerboard averaged to one pixel is mid grey: the
+ // filter averages, it does not pick a corner.
+ board := image.NewNRGBA(image.Rect(0, 0, 2, 2))
+ board.Set(0, 0, color.White)
+ board.Set(1, 1, color.White)
+ board.Set(1, 0, color.Black)
+ board.Set(0, 1, color.Black)
+ r, g, b, _ := downscale(board, 1).At(0, 0).RGBA()
+ for _, v := range []uint32{r, g, b} {
+ if v>>8 < 126 || v>>8 > 129 {
+ t.Errorf("checkerboard averaged to %d, want about 128", v>>8)
+ }
+ }
+}
+
+// ── A small text extractor ───────────────────────────────────────────
+//
+// pdfText reads the words back out of a PDF the module wrote: every
+// FlateDecode stream inflated, every Type0 font's ToUnicode CMap
+// parsed, and every form XObject's content stream walked for Tf and Tj,
+// mapping each two-byte CID through the current font's CMap. It is
+// enough to prove the text is there as glyphs with a Unicode mapping,
+// which is what "select and copy from the PDF" needs; it is not an
+// extractor anyone should use for anything else.
+
+var objRE = regexp.MustCompile(`(?m)^(\d+) 0 obj\n`)
+
+func pdfText(t *testing.T, pdf []byte) string {
+ t.Helper()
+ plain := inflateStreams(t, pdf)
+
+ objects := map[int][]byte{}
+ locs := objRE.FindAllSubmatchIndex(plain, -1)
+ for i, loc := range locs {
+ n, _ := strconv.Atoi(string(plain[loc[2]:loc[3]]))
+ end := len(plain)
+ if i+1 < len(locs) {
+ end = locs[i+1][0]
+ }
+ objects[n] = plain[loc[1]:end]
+ }
+
+ // Font object number -> CID -> text.
+ cmaps := map[int]map[uint16]string{}
+ toUnicodeRE := regexp.MustCompile(`/ToUnicode (\d+) 0 R`)
+ for n, body := range objects {
+ if !bytes.Contains(body, []byte("/Subtype /Type0")) {
+ continue
+ }
+ m := toUnicodeRE.FindSubmatch(body)
+ if m == nil {
+ t.Fatalf("font %d has no ToUnicode", n)
+ }
+ ref, _ := strconv.Atoi(string(m[1]))
+ cmaps[n] = parseCMap(t, objects[ref])
+ }
+
+ var forms []int
+ for n, body := range objects {
+ if bytes.Contains(body, []byte("/Subtype /Form")) {
+ forms = append(forms, n)
+ }
+ }
+ sort.Ints(forms)
+
+ fontRefRE := regexp.MustCompile(`/(fo\d+) (\d+) 0 R`)
+ var out strings.Builder
+ for _, n := range forms {
+ body := objects[n]
+ fontsByName := map[string]map[uint16]string{}
+ for _, m := range fontRefRE.FindAllSubmatch(body, -1) {
+ ref, _ := strconv.Atoi(string(m[2]))
+ fontsByName[string(m[1])] = cmaps[ref]
+ }
+ i := bytes.Index(body, []byte("stream\n"))
+ j := bytes.LastIndex(body, []byte("endstream"))
+ if i < 0 || j < i {
+ continue
+ }
+ content := body[i+len("stream\n") : j]
+ var cur map[uint16]string
+ for _, line := range bytes.Split(content, []byte("\n")) {
+ s := string(line)
+ switch {
+ case strings.HasSuffix(s, " Tf"):
+ name := strings.TrimPrefix(strings.Fields(s)[0], "/")
+ cur = fontsByName[name]
+ case strings.HasSuffix(s, " Tj") && strings.HasPrefix(s, "("):
+ raw := pdfLiteral(s[1:strings.LastIndex(s, ")")])
+ for k := 0; k+1 < len(raw); k += 2 {
+ cid := uint16(raw[k])<<8 | uint16(raw[k+1])
+ if cur != nil {
+ out.WriteString(cur[cid])
+ }
+ }
+ }
+ }
+ out.WriteByte('\n')
+ }
+ return out.String()
+}
+
+// parseCMap reads bfchar and bfrange entries out of a ToUnicode CMap.
+func parseCMap(t *testing.T, body []byte) map[uint16]string {
+ t.Helper()
+ m := map[uint16]string{}
+ hex := func(s string) []byte {
+ s = strings.Trim(s, "<>")
+ out := make([]byte, 0, len(s)/2)
+ for i := 0; i+1 < len(s); i += 2 {
+ v, err := strconv.ParseUint(s[i:i+2], 16, 8)
+ if err != nil {
+ t.Fatalf("cmap hex %q: %v", s, err)
+ }
+ out = append(out, byte(v))
+ }
+ return out
+ }
+ utf16be := func(b []byte) string {
+ var rs []rune
+ for i := 0; i+1 < len(b); i += 2 {
+ rs = append(rs, rune(uint16(b[i])<<8|uint16(b[i+1])))
+ }
+ return string(rs)
+ }
+ cid := func(b []byte) uint16 {
+ if len(b) != 2 {
+ t.Fatalf("cmap cid of %d bytes", len(b))
+ }
+ return uint16(b[0])<<8 | uint16(b[1])
+ }
+ lines := strings.Split(string(body), "\n")
+ mode := ""
+ for _, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ switch {
+ case strings.HasSuffix(ln, "beginbfchar"):
+ mode = "char"
+ case strings.HasSuffix(ln, "beginbfrange"):
+ mode = "range"
+ case ln == "endbfchar" || ln == "endbfrange":
+ mode = ""
+ case mode == "char":
+ f := strings.Fields(ln)
+ if len(f) == 2 {
+ m[cid(hex(f[0]))] = utf16be(hex(f[1]))
+ }
+ case mode == "range":
+ f := strings.Fields(ln)
+ if len(f) == 3 && strings.HasPrefix(f[2], "<") {
+ lo, hi := cid(hex(f[0])), cid(hex(f[1]))
+ dst := hex(f[2])
+ for c := lo; c <= hi; c++ {
+ m[c] = utf16be(dst)
+ dst = bytes.Clone(dst)
+ if len(dst) > 0 {
+ dst[len(dst)-1]++
+ }
+ if c == 0xFFFF {
+ break
+ }
+ }
+ }
+ }
+ }
+ return m
+}
+
+// pdfLiteral decodes the inside of a PDF literal string: the backslash
+// escapes and octal codes.
+func pdfLiteral(s string) []byte {
+ var out []byte
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c != '\\' || i+1 >= len(s) {
+ out = append(out, c)
+ continue
+ }
+ i++
+ switch e := s[i]; e {
+ case 'n':
+ out = append(out, '\n')
+ case 'r':
+ out = append(out, '\r')
+ case 't':
+ out = append(out, '\t')
+ case 'b':
+ out = append(out, '\b')
+ case 'f':
+ out = append(out, '\f')
+ case '0', '1', '2', '3', '4', '5', '6', '7':
+ v := 0
+ k := 0
+ for ; k < 3 && i+k < len(s) && s[i+k] >= '0' && s[i+k] <= '7'; k++ {
+ v = v*8 + int(s[i+k]-'0')
+ }
+ i += k - 1
+ out = append(out, byte(v))
+ default:
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+// inflateStreams returns pdf with every FlateDecode stream replaced by
+// its plain bytes. Streams are found by the direct /Length pdf-writer
+// always writes.
+func inflateStreams(t *testing.T, pdf []byte) []byte {
+ t.Helper()
+ var out []byte
+ marker := []byte(">>\nstream\n")
+ pos := 0
+ for {
+ i := bytes.Index(pdf[pos:], marker)
+ if i < 0 {
+ break
+ }
+ dictEnd := pos + i
+ start := dictEnd + len(marker)
+ dictStart := bytes.LastIndex(pdf[:dictEnd], []byte(" 0 obj"))
+ if dictStart < 0 {
+ dictStart = 0
+ }
+ dict := pdf[dictStart:dictEnd]
+ j := bytes.Index(dict, []byte("/Length "))
+ if j < 0 {
+ t.Fatal("stream dictionary without /Length")
+ }
+ digits := dict[j+8:]
+ k := 0
+ for k < len(digits) && digits[k] >= '0' && digits[k] <= '9' {
+ k++
+ }
+ n, err := strconv.Atoi(string(digits[:k]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ end := start + n
+ out = append(out, pdf[pos:start]...)
+ raw := pdf[start:end]
+ if bytes.Contains(dict, []byte("FlateDecode")) {
+ if z, err := zlib.NewReader(bytes.NewReader(raw)); err == nil {
+ if plain, err := io.ReadAll(z); err == nil {
+ out = append(out, plain...)
+ pos = end
+ continue
+ }
+ }
+ }
+ out = append(out, raw...)
+ pos = end
+ }
+ return append(out, pdf[pos:]...)
+}
diff --git a/internal/slides/pdfcore/pdfcore.go b/internal/slides/pdfcore/pdfcore.go
new file mode 100644
index 0000000..e6dfd14
--- /dev/null
+++ b/internal/slides/pdfcore/pdfcore.go
@@ -0,0 +1,318 @@
+// Package pdfcore hosts the Rust PDF module (core/pdf, `slides_pdf.wasm`)
+// under wazero: SVG pages and TTF faces in, one PDF out.
+//
+// It is the second module beside the CRDT core (internal/slides/core)
+// and keeps that host's rules, with one difference in shape. The CRDT
+// host holds a long-lived instance per document and rebuilds it by
+// replaying a log when it traps. This one has nothing to replay: a
+// render is one call in, one PDF out, so every Render instantiates a
+// fresh module, uses it once, and closes it. Nothing is shared between
+// two renders, which is what makes a Runtime safe to use from many
+// goroutines at once, and it is why a trap costs exactly the job that
+// caused it.
+//
+// # Containment
+//
+// usvg and svg2pdf are parsers over bytes a deck author controls (an
+// inlined image, a text run), and the module is built with
+// `panic = "abort"`, so a bug in either is a wasm trap rather than an
+// error. wazero turns a trap into an ordinary error from Call, the
+// instance is closed, and Render reports it. Separately, a render can
+// simply be slow — a very large PNG to decode, two hundred pages of
+// dense text — and the Runtime is built WithCloseOnContextDone so a
+// job that runs past its deadline (defaultGuestDeadline, sixty seconds,
+// as against the CRDT host's one second: a PDF is a job, not a request)
+// is terminated by wazero itself rather than left running after the
+// caller has given up on it.
+//
+// A clean refusal — a malformed SVG, an empty page list — comes back
+// from the module as a negative code with a message, wrapped here in
+// RejectedError so a caller can tell "your deck has a problem" from
+// "the renderer has a problem".
+package pdfcore
+
+import (
+ "context"
+ _ "embed"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "math"
+ "time"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+)
+
+//go:embed testdata/slides_pdf.wasm
+var moduleWASM []byte
+
+// abiVersion is the contract this host understands. A module reporting
+// anything else is refused at load, so a stale .wasm (see
+// scripts/build-pdf-wasm.sh and the copy into testdata) fails loudly
+// rather than behaving strangely. It matches core/pdf/src/lib.rs's
+// slides_pdf_abi_version.
+const abiVersion = 1
+
+// defaultGuestDeadline bounds one whole Render: instantiating the
+// module, writing the frames, the render call, reading the result.
+// Sixty seconds is a job's budget — spec §4.3 makes an export a job the
+// status page polls — and it is far above the measured cost (about 150
+// ms for 200 pages of text; decoding large images is what moves it).
+const defaultGuestDeadline = 60 * time.Second
+
+// maxFramesBytes bounds what one Render will write into the module: the
+// frame lengths are u32 and the module's memory is 32-bit, so a payload
+// past this could not be addressed even if the host were willing to
+// send it. Two hundred slides of inlined 2000 px images fit many times
+// over.
+const maxFramesBytes = 1 << 30
+
+// Option configures a Runtime. See WithGuestDeadline.
+type Option func(*Runtime)
+
+// WithGuestDeadline overrides defaultGuestDeadline for one Runtime, so
+// the budget can come from configuration, or a test can force a timeout
+// without waiting a minute for it.
+func WithGuestDeadline(d time.Duration) Option {
+ return func(r *Runtime) { r.deadline = d }
+}
+
+// Runtime owns the compiled module. Compiling is the expensive part
+// (about three quarters of a second) and instantiating is not, so one
+// Runtime is shared and every Render gets its own instance. Safe for
+// concurrent use.
+type Runtime struct {
+ rt wazero.Runtime
+ compiled wazero.CompiledModule
+ deadline time.Duration
+}
+
+// New compiles the embedded module. It refuses a module that imports
+// anything from the host (the zero-imports contract core/pdf's
+// no_host_imports test also holds) and one that reports an ABI version
+// this host does not understand.
+func New(ctx context.Context, opts ...Option) (*Runtime, error) {
+ r := &Runtime{deadline: defaultGuestDeadline}
+ for _, opt := range opts {
+ opt(r)
+ }
+
+ // WithCloseOnContextDone(true): without it, a deadline on a guest
+ // call only makes the caller stop waiting while the wasm keeps
+ // running to completion. With it, wazero terminates the call and
+ // closes the module when the context is done.
+ cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true)
+ r.rt = wazero.NewRuntimeWithConfig(ctx, cfg)
+ compiled, err := r.rt.CompileModule(ctx, moduleWASM)
+ if err != nil {
+ r.rt.Close(ctx)
+ return nil, fmt.Errorf("compiling pdf module: %w", err)
+ }
+ if imports := compiled.ImportedFunctions(); len(imports) != 0 {
+ m, n, _ := imports[0].Import()
+ r.rt.Close(ctx)
+ return nil, fmt.Errorf("pdf module imports %s.%s from the host; it must import nothing", m, n)
+ }
+ r.compiled = compiled
+
+ // Check the ABI once here rather than on every Render: the module
+ // bytes are embedded, so what it reports cannot change afterwards.
+ mod, err := r.instantiate(ctx)
+ if err != nil {
+ r.rt.Close(ctx)
+ return nil, err
+ }
+ mod.Close(ctx)
+ return r, nil
+}
+
+// Close releases the compiled module. Renders in flight are terminated.
+func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) }
+
+// instantiate creates a fresh instance and checks its ABI version.
+func (r *Runtime) instantiate(ctx context.Context) (api.Module, error) {
+ // An anonymous module is exempt from wazero's name-uniqueness check,
+ // so many renders can instantiate at once.
+ mod, err := r.rt.InstantiateModule(ctx, r.compiled, wazero.NewModuleConfig().WithName(""))
+ if err != nil {
+ return nil, fmt.Errorf("instantiating pdf module: %w", err)
+ }
+ res, err := mod.ExportedFunction("slides_pdf_abi_version").Call(ctx)
+ if err != nil {
+ mod.Close(ctx)
+ return nil, fmt.Errorf("reading pdf module abi version: %w", err)
+ }
+ if got := uint32(res[0]); got != abiVersion {
+ mod.Close(ctx)
+ return nil, fmt.Errorf("pdf module reports ABI %d, host understands %d", got, abiVersion)
+ }
+ return mod, nil
+}
+
+// RejectedError is a render the module cleanly refused — a malformed
+// SVG, no pages — with the module's own message. The renderer is fine;
+// the input is not. Use Rejected(err) rather than a type assertion.
+type RejectedError struct {
+ Code int32
+ Message string
+}
+
+func (e *RejectedError) Error() string {
+ return fmt.Sprintf("pdf module refused the render (%d): %s", e.Code, e.Message)
+}
+
+// Rejected reports whether err is a clean refusal by the module, as
+// opposed to a trap, a deadline, or a host mistake.
+func Rejected(err error) bool {
+ var r *RejectedError
+ return errors.As(err, &r)
+}
+
+// Render draws each SVG in svgs as one page of a PDF, in order, with
+// text set in the TTF faces in fonts (the SVG names a family; the
+// module resolves weight and style against the faces given, so send
+// every face the pages could use). Each SVG's width and height are the
+// page's size in points.
+//
+// A fresh module instance serves the call and is discarded afterwards,
+// whatever happened. The whole call runs under the Runtime's deadline
+// on top of whatever ctx carries.
+func (r *Runtime) Render(ctx context.Context, svgs, fonts [][]byte) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, r.deadline)
+ defer cancel()
+
+ svgFrames, err := frames(svgs)
+ if err != nil {
+ return nil, fmt.Errorf("pdf: pages: %w", err)
+ }
+ fontFrames, err := frames(fonts)
+ if err != nil {
+ return nil, fmt.Errorf("pdf: fonts: %w", err)
+ }
+
+ mod, err := r.instantiate(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // Whatever happens below — success, refusal, trap, deadline — the
+ // instance is not reused. A trap leaves linear memory as it was at
+ // the moment of the trap, and there is no log to rebuild from, so
+ // there is nothing to keep.
+ defer mod.Close(ctx)
+
+ svgPtr, err := write(ctx, mod, svgFrames)
+ if err != nil {
+ return nil, fmt.Errorf("pdf: writing pages: %w", err)
+ }
+ fontPtr, err := write(ctx, mod, fontFrames)
+ if err != nil {
+ return nil, fmt.Errorf("pdf: writing fonts: %w", err)
+ }
+
+ res, err := mod.ExportedFunction("slides_pdf_render").Call(ctx,
+ svgPtr, uint64(len(svgFrames)), fontPtr, uint64(len(fontFrames)))
+ if err != nil {
+ // A trap, a deadline, or the module closed under us: the call
+ // did not return, and the outcome is unknown.
+ return nil, fmt.Errorf("pdf: render did not complete: %w", err)
+ }
+ if rc := int32(res[0]); rc != 0 {
+ msg, rerr := read(ctx, mod, "err")
+ if rerr != nil {
+ return nil, fmt.Errorf("pdf: render refused (%d) and the message could not be read: %w", rc, rerr)
+ }
+ return nil, &RejectedError{Code: rc, Message: string(msg)}
+ }
+ pdf, err := read(ctx, mod, "out")
+ if err != nil {
+ return nil, fmt.Errorf("pdf: reading the result: %w", err)
+ }
+ if len(pdf) == 0 {
+ return nil, errors.New("pdf: render returned no bytes")
+ }
+ return pdf, nil
+}
+
+// frames encodes items as the module's input shape: each item as a
+// 4-byte big-endian length followed by its bytes.
+func frames(items [][]byte) ([]byte, error) {
+ total := 0
+ for _, it := range items {
+ if len(it) > math.MaxUint32 {
+ return nil, fmt.Errorf("frame of %d bytes is longer than a frame can say", len(it))
+ }
+ total += 4 + len(it)
+ if total > maxFramesBytes {
+ return nil, fmt.Errorf("frames exceed %d bytes", maxFramesBytes)
+ }
+ }
+ out := make([]byte, 0, total)
+ for _, it := range items {
+ out = binary.BigEndian.AppendUint32(out, uint32(len(it)))
+ out = append(out, it...)
+ }
+ return out, nil
+}
+
+// write copies data into the module's linear memory through
+// slides_pdf_alloc and returns the pointer. The module frees it with
+// the instance; nothing here calls slides_pdf_free because the instance
+// is discarded after one render.
+func write(ctx context.Context, mod api.Module, data []byte) (uint64, error) {
+ if len(data) == 0 {
+ // The module reads (0, 0) as no frames without touching the
+ // pointer (core/pdf/src/abi.rs, host_slice).
+ return 0, nil
+ }
+ res, err := mod.ExportedFunction("slides_pdf_alloc").Call(ctx, uint64(len(data)))
+ if err != nil {
+ return 0, fmt.Errorf("alloc: %w", err)
+ }
+ ptr := res[0]
+ if ptr == 0 {
+ return 0, fmt.Errorf("alloc(%d) failed", len(data))
+ }
+ if !mod.Memory().Write(uint32(ptr), data) {
+ return 0, fmt.Errorf("writing %d bytes at %d: out of range", len(data), ptr)
+ }
+ return ptr, nil
+}
+
+// read drains one of the module's _len/_read pairs ("out" or "err").
+func read(ctx context.Context, mod api.Module, pair string) ([]byte, error) {
+ res, err := mod.ExportedFunction("slides_pdf_" + pair + "_len").Call(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("%s_len: %w", pair, err)
+ }
+ n := int32(res[0])
+ if n < 0 {
+ return nil, fmt.Errorf("%s_len returned %d", pair, n)
+ }
+ if n == 0 {
+ return nil, nil
+ }
+ res, err = mod.ExportedFunction("slides_pdf_alloc").Call(ctx, uint64(n))
+ if err != nil {
+ return nil, fmt.Errorf("alloc for %s: %w", pair, err)
+ }
+ ptr := res[0]
+ if ptr == 0 {
+ return nil, fmt.Errorf("alloc(%d) for %s failed", n, pair)
+ }
+ res, err = mod.ExportedFunction("slides_pdf_"+pair+"_read").Call(ctx, ptr, uint64(n))
+ if err != nil {
+ return nil, fmt.Errorf("%s_read: %w", pair, err)
+ }
+ if got := int32(res[0]); got != n {
+ return nil, fmt.Errorf("%s_read wrote %d of %d bytes", pair, got, n)
+ }
+ view, ok := mod.Memory().Read(uint32(ptr), uint32(n))
+ if !ok {
+ return nil, fmt.Errorf("reading %d bytes at %d: out of range", n, ptr)
+ }
+ // The view aliases linear memory, which the deferred Close releases.
+ out := make([]byte, n)
+ copy(out, view)
+ return out, nil
+}
diff --git a/internal/slides/pdfcore/pdfcore_test.go b/internal/slides/pdfcore/pdfcore_test.go
new file mode 100644
index 0000000..da9a99d
--- /dev/null
+++ b/internal/slides/pdfcore/pdfcore_test.go
@@ -0,0 +1,283 @@
+package pdfcore_test
+
+import (
+ "bytes"
+ "compress/zlib"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/slides/internal/slides/fonts"
+ "amadan.net/oficina/slides/internal/slides/pdfcore"
+)
+
+// slide is one 1920 by 1080 page with a rectangle and a line of text in
+// a named face, which is the shape every page the SVG encoder writes
+// has.
+func slide(text, family string, weight int) []byte {
+ return fmt.Appendf(nil, `<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
+ <rect x="100" y="100" width="800" height="300" fill="#1e5aa8"/>
+ <text x="120" y="600" font-family="%s" font-weight="%d" font-size="96" fill="#111" xml:space="preserve">%s</text>
+</svg>`, family, weight, text)
+}
+
+// allFonts is what Export sends: the twelve Liberation faces.
+func allFonts() [][]byte {
+ var out [][]byte
+ for _, f := range fonts.All() {
+ out = append(out, fonts.Data(f))
+ }
+ return out
+}
+
+var runtimeOnce struct {
+ sync.Once
+ rt *pdfcore.Runtime
+ err error
+}
+
+// runtime compiles the module once for the whole package: compiling is
+// the expensive part (about three quarters of a second) and a Runtime is
+// safe to share, which is also what the app does.
+func runtime(t *testing.T) *pdfcore.Runtime {
+ t.Helper()
+ runtimeOnce.Do(func() {
+ runtimeOnce.rt, runtimeOnce.err = pdfcore.New(context.Background())
+ })
+ if runtimeOnce.err != nil {
+ t.Fatalf("New: %v", runtimeOnce.err)
+ }
+ return runtimeOnce.rt
+}
+
+func pageCount(pdf []byte) int {
+ return bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages"))
+}
+
+func TestRenderMakesOnePagePerSVG(t *testing.T) {
+ rt := runtime(t)
+ pdf, err := rt.Render(context.Background(), [][]byte{slide("Hello slides", "Liberation Serif", 400)}, allFonts())
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
+ t.Fatalf("not a PDF: %q", pdf[:min(16, len(pdf))])
+ }
+ if n := pageCount(pdf); n != 1 {
+ t.Errorf("pages = %d, want 1", n)
+ }
+ if !bytes.Contains(pdf, []byte("/MediaBox [0 0 1920 1080]")) {
+ t.Error("the page is not 1920 by 1080 pt")
+ }
+}
+
+func TestRenderTwoSVGsMakeTwoPages(t *testing.T) {
+ rt := runtime(t)
+ pdf, err := rt.Render(context.Background(), [][]byte{
+ slide("One", "Liberation Sans", 400),
+ slide("Two", "Liberation Sans", 400),
+ }, allFonts())
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ if n := pageCount(pdf); n != 2 {
+ t.Errorf("pages = %d, want 2", n)
+ }
+ if !bytes.Contains(pdf, []byte("/Count 2")) {
+ t.Error("the page tree does not count 2")
+ }
+}
+
+func TestRenderEmbedsTheFacesItWasGivenAsSubsets(t *testing.T) {
+ rt := runtime(t)
+ pdf, err := rt.Render(context.Background(), [][]byte{
+ slide("Serif here", "Liberation Serif", 400),
+ slide("Bold sans here", "Liberation Sans", 700),
+ }, allFonts())
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ if !bytes.Contains(pdf, []byte("/FontFile2")) {
+ t.Error("no embedded TrueType program (/FontFile2)")
+ }
+ if !bytes.Contains(pdf, []byte("/Subtype /Type0")) {
+ t.Error("no Type0 font: text was flattened to paths")
+ }
+ if !bytes.Contains(pdf, []byte("/ToUnicode")) {
+ t.Error("no ToUnicode CMap: the text would not extract")
+ }
+ // A subset carries a six-letter tag before the face's name, and the
+ // bold weight resolves to the bold face rather than a synthesised
+ // one.
+ for _, face := range []string{"LiberationSerif", "LiberationSans-Bold"} {
+ if !regexp.MustCompile(`/BaseFont /[A-Z]{6}\+` + face).Match(pdf) {
+ t.Errorf("no subset of %s in the PDF", face)
+ }
+ }
+ // The whole faces are 4 MB; a subset of two short lines is not.
+ if len(pdf) > 200_000 {
+ t.Errorf("PDF is %d bytes: the faces were embedded whole, not subset", len(pdf))
+ }
+ // The words are drawn with a text operator, not filled as paths.
+ if plain := inflate(t, pdf); !bytes.Contains(plain, []byte(" Tj")) && !bytes.Contains(plain, []byte(" TJ")) {
+ t.Error("no Tj/TJ in any content stream")
+ }
+}
+
+func TestAMalformedSVGIsAnErrorNotATrap(t *testing.T) {
+ rt := runtime(t)
+ ctx := context.Background()
+ _, err := rt.Render(ctx, [][]byte{[]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080"><rect x="1`)}, allFonts())
+ if err == nil {
+ t.Fatal("a malformed SVG rendered")
+ }
+ if !pdfcore.Rejected(err) {
+ t.Errorf("err = %v; want a clean refusal (Rejected)", err)
+ }
+ if !strings.Contains(err.Error(), "page 1") {
+ t.Errorf("err = %v; want it to name the page", err)
+ }
+
+ // Nothing shared between calls: the next render is untouched by the
+ // last one's refusal.
+ if _, err := rt.Render(ctx, [][]byte{slide("Still fine", "Liberation Sans", 400)}, allFonts()); err != nil {
+ t.Fatalf("render after a refusal: %v", err)
+ }
+}
+
+func TestNoPagesIsRefused(t *testing.T) {
+ rt := runtime(t)
+ _, err := rt.Render(context.Background(), nil, allFonts())
+ if err == nil || !pdfcore.Rejected(err) {
+ t.Fatalf("err = %v; want a refusal", err)
+ }
+}
+
+func TestADeadlineIsAnErrorNotAHang(t *testing.T) {
+ rt, err := pdfcore.New(context.Background(), pdfcore.WithGuestDeadline(time.Nanosecond))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ defer rt.Close(context.Background())
+ _, err = rt.Render(context.Background(), [][]byte{slide("Slow", "Liberation Sans", 400)}, allFonts())
+ if err == nil {
+ t.Fatal("rendered under a nanosecond deadline")
+ }
+ if pdfcore.Rejected(err) {
+ t.Errorf("a deadline is not a clean refusal: %v", err)
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("err = %v; want context.DeadlineExceeded", err)
+ }
+}
+
+func TestACancelledContextIsAnError(t *testing.T) {
+ rt := runtime(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := rt.Render(ctx, [][]byte{slide("Gone", "Liberation Sans", 400)}, allFonts()); err == nil {
+ t.Fatal("rendered under a cancelled context")
+ }
+}
+
+func TestRendersAreIndependentAndConcurrent(t *testing.T) {
+ rt := runtime(t)
+ var wg sync.WaitGroup
+ errs := make([]error, 8)
+ for i := range errs {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ pdf, err := rt.Render(context.Background(), [][]byte{slide(fmt.Sprintf("Page %d", i), "Liberation Sans", 400)}, allFonts())
+ if err == nil && pageCount(pdf) != 1 {
+ err = fmt.Errorf("%d pages", pageCount(pdf))
+ }
+ errs[i] = err
+ }()
+ }
+ wg.Wait()
+ for i, err := range errs {
+ if err != nil {
+ t.Errorf("render %d: %v", i, err)
+ }
+ }
+}
+
+func TestTwoHundredPagesRenderInUnderTenSeconds(t *testing.T) {
+ rt := runtime(t)
+ svgs := make([][]byte, 200)
+ for i := range svgs {
+ svgs[i] = slide(fmt.Sprintf("Slide %d of two hundred", i+1), "Liberation Sans", 400)
+ }
+ start := time.Now()
+ pdf, err := rt.Render(context.Background(), svgs, allFonts())
+ took := time.Since(start)
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ if n := pageCount(pdf); n != 200 {
+ t.Errorf("pages = %d, want 200", n)
+ }
+ if took > 10*time.Second {
+ t.Errorf("200 pages took %s, want under 10s", took)
+ }
+ t.Logf("200 pages: %s, %d bytes", took.Round(time.Millisecond), len(pdf))
+}
+
+// inflate returns pdf with every FlateDecode stream replaced by its
+// plain bytes, so text operators can be looked for. Streams are found
+// by the direct /Length pdf-writer always writes.
+func inflate(t *testing.T, pdf []byte) []byte {
+ t.Helper()
+ var out []byte
+ marker := []byte(">>\nstream\n")
+ pos := 0
+ for {
+ i := bytes.Index(pdf[pos:], marker)
+ if i < 0 {
+ break
+ }
+ dictEnd := pos + i
+ start := dictEnd + len(marker)
+ dictStart := bytes.LastIndex(pdf[:dictEnd], []byte(" 0 obj"))
+ if dictStart < 0 {
+ dictStart = 0
+ }
+ dict := pdf[dictStart:dictEnd]
+ j := bytes.Index(dict, []byte("/Length "))
+ if j < 0 {
+ t.Fatal("stream dictionary without /Length")
+ }
+ digits := dict[j+8:]
+ k := 0
+ for k < len(digits) && digits[k] >= '0' && digits[k] <= '9' {
+ k++
+ }
+ n, err := strconv.Atoi(string(digits[:k]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ end := start + n
+ out = append(out, pdf[pos:start]...)
+ raw := pdf[start:end]
+ if bytes.Contains(dict, []byte("FlateDecode")) {
+ if z, err := zlib.NewReader(bytes.NewReader(raw)); err == nil {
+ if plain, err := io.ReadAll(z); err == nil {
+ out = append(out, plain...)
+ pos = end
+ continue
+ }
+ }
+ }
+ out = append(out, raw...)
+ pos = end
+ }
+ return append(out, pdf[pos:]...)
+}
diff --git a/internal/slides/pdfcore/spike_test.go b/internal/slides/pdfcore/spike_test.go
deleted file mode 100644
index af2d2e1..0000000
--- a/internal/slides/pdfcore/spike_test.go
+++ /dev/null
@@ -1,190 +0,0 @@
-// Package pdfcore will host the Rust PDF module (core/pdf) under wazero.
-// This file is the slice 3 Task 1 spike: it proves the module built by
-// scripts/build-pdf-wasm.sh instantiates with no host imports, renders
-// one SVG with <text> in Liberation Serif, and returns a PDF whose text is
-// an embedded, subset TrueType font. The real host lands in Task 4.
-package pdfcore
-
-import (
- "bytes"
- "context"
- _ "embed"
- "encoding/binary"
- "fmt"
- "os"
- "testing"
- "time"
-
- "github.com/tetratelabs/wazero"
- "github.com/tetratelabs/wazero/api"
-)
-
-//go:embed testdata/slides_pdf.wasm
-var moduleWASM []byte
-
-const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
- <rect x="100" y="100" width="800" height="300" fill="#1e5aa8"/>
- <text x="120" y="600" font-family="Liberation Serif" font-size="96" fill="#111">Hello slides</text>
-</svg>`
-
-// frames encodes items as 4-byte big-endian length-prefixed frames, the
-// input shape of slides_pdf_render.
-func frames(items ...[]byte) []byte {
- var b bytes.Buffer
- for _, it := range items {
- binary.Write(&b, binary.BigEndian, uint32(len(it)))
- b.Write(it)
- }
- return b.Bytes()
-}
-
-func call(t *testing.T, ctx context.Context, mod api.Module, name string, params ...uint64) uint64 {
- t.Helper()
- res, err := mod.ExportedFunction(name).Call(ctx, params...)
- if err != nil {
- t.Fatalf("%s: %v", name, err)
- }
- if len(res) == 0 {
- return 0
- }
- return res[0]
-}
-
-// alloc copies data into the module's linear memory via slides_pdf_alloc
-// and returns the pointer.
-func alloc(t *testing.T, ctx context.Context, mod api.Module, data []byte) uint64 {
- t.Helper()
- ptr := call(t, ctx, mod, "slides_pdf_alloc", uint64(len(data)))
- if ptr == 0 {
- t.Fatalf("slides_pdf_alloc(%d) returned 0", len(data))
- }
- if !mod.Memory().Write(uint32(ptr), data) {
- t.Fatalf("writing %d bytes at %d: out of range", len(data), ptr)
- }
- return ptr
-}
-
-// read drains one of the _len/_read pairs.
-func read(t *testing.T, ctx context.Context, mod api.Module, pair string) []byte {
- t.Helper()
- n := int32(call(t, ctx, mod, "slides_pdf_"+pair+"_len"))
- if n < 0 {
- t.Fatalf("%s_len: %d", pair, n)
- }
- if n == 0 {
- return nil
- }
- ptr := call(t, ctx, mod, "slides_pdf_alloc", uint64(n))
- defer call(t, ctx, mod, "slides_pdf_free", ptr, uint64(n))
- got := int32(call(t, ctx, mod, "slides_pdf_"+pair+"_read", ptr, uint64(n)))
- if got != n {
- t.Fatalf("%s_read: wrote %d of %d", pair, got, n)
- }
- out, ok := mod.Memory().Read(uint32(ptr), uint32(n))
- if !ok {
- t.Fatalf("reading %d bytes at %d: out of range", n, ptr)
- }
- return bytes.Clone(out)
-}
-
-func TestSpikeRendersUnderWazero(t *testing.T) {
- font, err := os.ReadFile("../../../core/pdf/tests/fonts/LiberationSerif-Regular.ttf")
- if err != nil {
- t.Fatal(err)
- }
-
- ctx := context.Background()
- rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true))
- defer rt.Close(ctx)
-
- compileStart := time.Now()
- compiled, err := rt.CompileModule(ctx, moduleWASM)
- if err != nil {
- t.Fatalf("compile: %v", err)
- }
- compileTook := time.Since(compileStart)
-
- // The zero-imports contract, checked at the host too: wazero would
- // refuse to instantiate a module with unsatisfied imports, but saying
- // which import is clearer than "module not found".
- if imports := compiled.ImportedFunctions(); len(imports) != 0 {
- for _, f := range imports {
- m, n, _ := f.Import()
- t.Errorf("host import: %s.%s", m, n)
- }
- t.FailNow()
- }
-
- mod, err := rt.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName(""))
- if err != nil {
- t.Fatalf("instantiate: %v", err)
- }
- defer mod.Close(ctx)
-
- if v := call(t, ctx, mod, "slides_pdf_abi_version"); v != 1 {
- t.Fatalf("abi version %d, want 1", v)
- }
-
- svgs := frames([]byte(svg))
- fonts := frames(font)
- svgsPtr := alloc(t, ctx, mod, svgs)
- fontsPtr := alloc(t, ctx, mod, fonts)
-
- cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
- defer cancel()
- renderStart := time.Now()
- rc := int32(call(t, cctx, mod, "slides_pdf_render", svgsPtr, uint64(len(svgs)), fontsPtr, uint64(len(fonts))))
- renderTook := time.Since(renderStart)
- if rc != 0 {
- t.Fatalf("slides_pdf_render: %d: %s", rc, read(t, ctx, mod, "err"))
- }
- if errMsg := read(t, ctx, mod, "err"); len(errMsg) != 0 {
- t.Fatalf("err set on success: %s", errMsg)
- }
-
- pdf := read(t, ctx, mod, "out")
- if !bytes.HasPrefix(pdf, []byte("%PDF-1.7")) {
- t.Fatalf("not a PDF 1.7: %q", pdf[:min(16, len(pdf))])
- }
- pages := bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages"))
- if pages != 1 {
- t.Errorf("pages = %d, want 1", pages)
- }
- if !bytes.Contains(pdf, []byte("/MediaBox [0 0 1920 1080]")) {
- t.Error("page is not 1920x1080 pt")
- }
- if !bytes.Contains(pdf, []byte("/FontFile2")) && !bytes.Contains(pdf, []byte("/FontFile3")) {
- t.Error("no embedded font program")
- }
- if !bytes.Contains(pdf, []byte("/Subtype /Type0")) || !bytes.Contains(pdf, []byte("LiberationSerif")) {
- t.Error("no Type0 Liberation Serif font")
- }
- if !bytes.Contains(pdf, []byte("/ToUnicode")) {
- t.Error("no ToUnicode CMap")
- }
-
- // A second render on the same instance is a clean replacement of out.
- rc = int32(call(t, cctx, mod, "slides_pdf_render", svgsPtr, uint64(len(svgs)), fontsPtr, uint64(len(fonts))))
- if rc != 0 {
- t.Fatalf("second render: %d", rc)
- }
- if again := read(t, ctx, mod, "out"); !bytes.Equal(again, pdf) {
- t.Errorf("second render differs (%d vs %d bytes)", len(again), len(pdf))
- }
-
- // A truncated frame is a refusal with a message, never a trap.
- rc = int32(call(t, cctx, mod, "slides_pdf_render", svgsPtr, uint64(len(svgs)-3), fontsPtr, uint64(len(fonts))))
- if rc != -1 {
- t.Errorf("truncated frame: rc %d, want -1", rc)
- }
- if msg := read(t, ctx, mod, "err"); !bytes.Contains(msg, []byte("truncated")) {
- t.Errorf("truncated frame: err %q", msg)
- }
-
- if os.Getenv("SPIKE_WRITE_PDF") != "" {
- os.WriteFile(os.Getenv("SPIKE_WRITE_PDF"), pdf, 0o644)
- }
- fmt.Printf("module %d bytes (%.2f MiB); compile %s; render %s; PDF %d bytes, %d page(s); memory %d pages\n",
- len(moduleWASM), float64(len(moduleWASM))/(1<<20), compileTook.Round(time.Millisecond),
- renderTook.Round(time.Millisecond), len(pdf), pages, mod.Memory().Size()/65536)
-}
diff --git a/internal/slides/pdfcore/testdata/slides_pdf.wasm b/internal/slides/pdfcore/testdata/slides_pdf.wasm
index e4cb47d..d3ac9ff 100755
Binary files a/internal/slides/pdfcore/testdata/slides_pdf.wasm and b/internal/slides/pdfcore/testdata/slides_pdf.wasm differ