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
.amadan/ci.d/10-gate+28 −0AGENTS.md+15 −0core/pdf/src/abi.rs+27 −3core/pdf/tests/render.rs+29 −0internal/slides/export.go+476 −0internal/slides/export_test.go+579 −0internal/slides/pdfcore/pdfcore.go+318 −0internal/slides/pdfcore/pdfcore_test.go+283 −0internal/slides/pdfcore/spike_test.go+0 −190internal/slides/pdfcore/testdata/slides_pdf.wasmbinary
Intent
<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>
<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 |