# PDF-1 — implementation plan
_Inkhaven-grounded build plan for [RFC PDF-1](PDF-1.md) (PDF management &
imposition). The RFC is the design of record; this maps it to the actual
codebase, fixes the integration seams, calls the dependency decision, and
sequences the work to inkhaven's release cadence. Lands under the 1.3
**"From Draft to Submission"** theme as its print-production pillar._
## Where it plugs in (verified against the tree)
| PDF source (the linear book PDF) | `typst-pdf 0.14` via `typst_compile` (`Ctrl+B B`) | nothing — the subsystem *consumes* this output |
| CLI `inkhaven pdf …` | `cli/mod.rs` `Command` enum + dispatch (mirror `Command::Facts(FactsCommand)`) | `Command::Pdf(PdfCommand)` + `src/cli/pdf.rs` |
| TUI book-take | `Ctrl+B O` → `OutputConfig.extra_formats` (`markdown`/`tex`/`epub` today, in `src/export/`) | `imposed_pdf` / `cover_pdf` entries; `Ctrl+B I` imposition-preview overlay |
| Bund stdlib | `src/scripting/stdlib/` already uses `ink.*` (`ink.node.*`, `ink.review.*`) via `vm.register_inline` | `src/scripting/stdlib/pdf.rs` → `ink.pdf.*` |
| Outline injection | `assemble::assemble_book()` emits the combined `.typ` | emit `#metadata((node_id: …))` near each heading (P0) |
| Imposition config | `Config::load_layered` cascade (project + global `~/.config/inkhaven`) | top-level `imposition:` / `cover:` / `preflight:` keys (same merge semantics) |
So all three RFC surfaces have a precedent to copy — no new architecture,
just a new `src/pdf/` library wired into established seams.
## Dependency decision (the one real exception to make)
The subsystem needs a PDF object library; inkhaven has none. Per the RFC,
**`lopdf`** (pure-Rust, MIT) for parse/write + page ops + imposition via
Form XObjects, and **`barcoders`** (pure-Rust, MIT/Apache) for EAN-13 —
or ~150 lines of hand-rolled EAN-13 to avoid even that. `printpdf` is
rejected (lopdf alone covers cover layout).
This is a **deliberate exception to the project's zero-new-deps habit** —
justified because there is no in-tree way to manipulate PDF objects and
both crates are pure Rust, so the single-binary promise holds.
### Audit result (2026-06-11) — GREEN ✓
Empirically resolved `lopdf 0.41.0` + `barcoders 2.0.0` against a fresh
tree and diffed against inkhaven's:
- **No external-app dependency, no native-library build.** The *only*
`-sys` crate anywhere in the combined tree is `core-foundation-sys`,
which is **already in inkhaven** and is a pure-Rust macOS OS-FFI shim
(not an external app/lib). No `cc`/`cmake`/`bindgen`/`pkg-config`/
`openssl`/`freetype`/`pdfium`/`ghostscript`. Confirmed by building
`lopdf` + `barcoders` from scratch with no external tool, and a runtime
round-trip (load → save → reload a PDF) + EAN-13 generation, both clean.
- **10 new crates, all pure Rust, all permissive (MIT or MIT/Apache):**
`lopdf`, `barcoders`, the RustCrypto set pulled for encrypted-PDF
reading (`aes`, `cbc`, `ecb`, `cipher`, `block-padding`, `inout`),
`rangemap`, `stringprep`. The crypto crates are non-optional (can't be
feature-gated off) but are tiny, well-audited RustCrypto.
- **Everything else lopdf needs is already vendored** — `flate2` +
`miniz_oxide` + `zlib-rs` (pure-Rust compression), `nom`, `weezl`,
`encoding_rs`, `md-5`, `linked-hash-map`, `rayon`, `time`.
- **Trim option:** lopdf's default features pull three date libs
(`chrono` + `jiff` + `time`); add it with `default-features = false`
and reuse inkhaven's existing `time` (or `chrono`) to avoid `jiff`.
- **Verdict:** meets the constraint exactly — solves the PDF problem with
pure-Rust crates, zero external-app dependency. inkhaven already
vendors far heavier native deps (DuckDB via `libduckdb-sys`, ONNX via
`ort-sys`); `lopdf` adds none. **Cleared to add.** (Still run
`cargo deny` at add-time for ongoing advisory/license tracking.)
**`barcoders` is kept** (project decision) rather than hand-rolling
EAN-13 — the crate is clean and audited; a hand-rolled encoder would be
avoidable tech debt.
## Module layout
`src/pdf/` exactly as the RFC §7.2 (`doc`, `geometry`, `paper`, `ingest`,
`ops`, `stamp`, `impose/{layout,creep,marks,sheet}`, `cover`, `barcode`,
`preflight`, `outline`, `meta`, `emit`). `mod pdf;` in `main.rs`. The
pure-math pieces (`geometry`, `paper`, `impose::layout`, `impose::creep`,
`barcode`) are unit-testable with no I/O — build and test those first
inside each phase, the way `replace.rs` separated the matcher from the
walk.
## Configuration — HJSON through the cascade (firm)
All PDF-subsystem configuration is HJSON, deserialized through the
existing `Config::load_layered` cascade — **the same path as every other
setting**, so it merges defaults → project `inkhaven.hjson` → global
`~/.config/inkhaven/config.hjson` + `conf/*.hjson`, with the global
layer winning (1.2.20). That means:
- **`imposition:`** (binding style, sheets-per-signature, sheet size,
margins, creep + paper stock, marks, blank-page policy), **`cover:`**
(paper stocks, spine text, barcode), **`preflight:`** (target DPI,
profile) are top-level keys, each supporting **named profiles** (RFC
App. A — e.g. `default`, `chapbook`) selected by name from the CLI
(`--config <key>`) / book-take (`imposed_pdf_config`) / Bund.
- A user can keep a house imposition profile in their **global** config
and have it apply to every project — exactly the override pattern the
1.2.20 cascade was built for.
- New `ImpositionConfig` / `CoverConfig` / `PreflightConfig` serde
structs land in `config.rs` in **P1/P2**, with `#[serde(default)]` so a
project that sets nothing still gets sane defaults.
No imposition parameter is hard-coded or CLI-only; the config block is
the source of truth, the CLI/TUI/Bund just select a profile or override
individual fields.
## Stability-bar requirements (1.2.15 standard applies)
- **Atomic emit.** `pdf::emit` writes the output PDF through
`io_atomic::write` (never a torn PDF on crash). Same for cover output.
- **No panic surfaces.** `PdfDoc` ops return `Result`; no `unwrap`/index
panics on malformed input PDFs (external PDFs are untrusted bytes —
treat every `lopdf` access as fallible, map to a `pdf::Error`).
- **No source compromise** (RFC §5): the PDF path never touches the Typst
source; imposition is always re-runnable from a fresh render.
- **Broken-pipe-safe CLI** (1.2.23): `inkhaven pdf preflight … | head`
must not crash — already handled by the panic-hook fix, just don't
re-introduce a custom output path that bypasses it.
## Phased build (mapped to inkhaven releases)
The RFC's ~10-week / one-dev estimate is a *large* subsystem — bigger than
a single point release at this cadence. Each RFC phase is independently
shippable, so map them to successive 1.3.x cuts:
### P0 — Foundations → **1.3.0**
`PdfDoc` + `PdfSource`, `geometry`, `paper`, `ingest`, `emit` (atomic),
`meta`, `ops` (extract/split/merge/rotate/reorder/delete), `outline`
injection **including the `assemble` `#metadata` marker change**. Surfaces:
`Command::Pdf` with the page-op + metadata + outline subcommands;
`src/scripting/stdlib/pdf.rs` for those words. Tests: geometry/paper
unit tests, page-spec parsing, **a corpus test of `lopdf` round-tripping
real inkhaven typst-pdf output** (with images, embedded fonts, vector
content) — this is the make-or-break risk gate (RFC §14); if `lopdf`
mangles typst output, the whole RFC is blocked, so P0 proves it first.
### P1 — Imposition → **1.3.1**
`impose/` complete: layout math (4 binding styles), creep (shingle/
pushout), all marks, sheet emission via Form XObjects. `inkhaven pdf
impose`; the `Ctrl+B I` preview overlay (ratatui pane + ASCII schematic,
RFC App.D); `imposed_pdf` book-take format; `ink.pdf.impose`. Tests:
**property tests** (every source page appears once; pairs share a sheet;
creep monotonic; signature sums) + the `--test` numbered-page mode for
hand-fold visual verification.
### P2 — Cover · barcode · preflight → **1.3.2**
`cover` (spine-width calc from page-count + paper stock), `barcode`
(EAN-13 + check digit), `preflight` (the image-DPI check is the
highest-value one). CLI subcommands; `cover_pdf` book-take format; the
`pdf sample` convenience command.
### P3 — Polish → **1.3.3**
`grayscale`, `optimize`, `metadata strip`, watermark/stamp variants;
`Documentation/Tutorials/65-hand-binding.md`; KEYBINDING (`Ctrl+B I`),
CONFIGURATION (`imposition:`/`cover:`/`preflight:`).
(Or compress P0+P1 into 1.3.0 if velocity allows — they're the marquee.
The point is each cut is preflight-clean and self-contained.)
## Inkhaven-specific risks / decisions to settle before P0
1. **`lopdf` ↔ typst-pdf fidelity** — *the* gating risk. De-risk in the
first days of P0 with the corpus round-trip test, before building on
top. If gaps: upstream patch or a carried patch set (RFC §14).
2. **The `assemble` `#metadata` change touches a shipped feature.** It
must be additive + invisible to existing output (markers are inert to
the renderer) and gated so hand-written `.typ` (no markers) degrades to
an unannotated PDF + a `--no-outline` note, never an error.
3. **`extra_formats` ordering dependency.** `imposed_pdf` requires `pdf`
first; the take engine must enforce build-then-impose ordering (RFC
§10.3) and let an optional format fail without aborting the take (the
existing extras behaviour already does per-format error isolation).
4. **Bund PDF value semantics** (RFC open-Q 3) — `ink.pdf.load` returns a
ref-counted handle, not a copied document, with explicit `ink.pdf.clone`.
Confirm the Adam VM supports opaque handle values.
5. **Zero-new-deps stance** — adding `lopdf` is the first runtime dep in
several cycles; worth an explicit OK, since it's a standing project
value (see the release/stability memory).
## Relationship to the rest of "From Draft to Submission"
PDF-1 is the **print-production** pillar. The other Draft→Submission
pieces are *separate* tracks (not in this RFC), to scope independently:
- **Standard manuscript format** export (double-spaced, 12pt, running
headers, `#` scene breaks, rounded word count) — a new `export` target,
not a PDF-object operation.
- **Submission package** — RAG-grounded query-letter / synopsis / comp-
title helpers (a great whole-book AI use) + a lightweight tracker.
These compose with PDF-1 but don't depend on it; PDF-1 can lead because it
has a complete RFC.
## Implementation log
### P0.1 — deps added + fidelity gate PASSED (landed)
- `lopdf 0.41` (`default-features = false`, `features = ["chrono",
"rayon", "time"]` — drops the redundant `jiff`) + `barcoders 2` added
to `Cargo.toml`; both compile cleanly into inkhaven.
- New `src/pdf/` module (`mod pdf;`), so far just the **fidelity gate**:
`src/pdf/mod.rs` `corpus_tests::lopdf_round_trips_typst_pdf_output`.
It compiles a rich typst body (heading + bold/italic prose + `#line` /
`#rect` / `#circle` vector + a real PNG `#image` + a `#pagebreak`) to
genuine typst-pdf bytes via `InkhavenWorld::in_memory` + `typst_pdf::pdf`
(the same path `Ctrl+B B` uses), then asserts `lopdf` (1) parses it,
(2) sees the embedded font subset (`FontDescriptor` + `FontFile*`),
(3) sees the image XObject (`Subtype /Image`), and (4) round-trips
(load → save → reload) preserving the 2-page tree.
- `#[ignore]`d (compiles typst, per the existing convention), run with
`cargo test --bin inkhaven -- --ignored lopdf_round_trips`. **Result:
PASS** — RFC §14's make-or-break risk is cleared; `lopdf` handles real
typst-pdf output (fonts, images, vector, multi-page). Normal suite
1190 + 1 ignored gate.
Next P0: `PdfDoc`/`PdfSource`, `geometry`, `paper`, `ingest`, `emit`
(atomic), `meta`, `ops`, then `outline` (with the `assemble` `#metadata`
change), wired to `Command::Pdf` + `ink.pdf.*`.
### P0.2 — geometry + paper + PdfDoc (landed)
The pure-math core + the value type, all unit-tested:
- **`pdf::geometry`** — `mm`/`pt`/`in` conversions, `Size` (points, with
portrait/landscape), `Rect` (points, PDF `MediaBox` order, corner-
normalising), and `page_size(name)` presets (ISO A3–A6/B5, US
Letter/Legal/Tabloid, trade trims 6×9 / 5.5×8.5 / pocket). 4 tests.
- **`pdf::paper`** — `PaperStock { name, thickness_mm }` + the RFC §8.2.4
preset table, case-insensitive `paper_stock(name)`, `DEFAULT_INTERIOR`
/ `DEFAULT_COVER`, `binding_compensation_mm`. 4 tests.
- **`pdf::doc`** — `PdfDoc` (wraps `lopdf::Document`, caches page order +
inherited `MediaBox` sizes) and `PdfSource` (`Inkhaven { typst_root }`
/ `External`). `load` / `load_mem` / `from_document`, `page_count` /
`page_ids` / `page_size`, `to_bytes`, and **`save` via `io_atomic`**
(atomic emit — no torn PDF). `page_mediabox` walks the `Parent` chain
(inheritable attr) with a cycle guard + Letter fallback. 3 tests
(build a minimal n-page PDF → load → assert pages/sizes/source →
round-trip).
- **`pdf::Error`/`Result`** (mod.rs): `Lopdf` / `Io` / `NotInkhavenSource`
/ `Other`, with `From<lopdf::Error>` + `From<io::Error>`.
Module-level `#![allow(dead_code)]` (the library is built ahead of its
`Command::Pdf` / `ink.pdf.*` wiring; removed when the first caller lands).
Suite 1190 → 1201.
Next P0: `ingest` (richer page indexing), `ops` (extract/split/merge/
rotate/reorder/delete via the lopdf page tree), `meta`, then `outline`
(with the `assemble` `#metadata` change).
### P0.3 — ops + meta (landed)
- **`pdf::ops`** — page operations on the lopdf page tree:
- `PageSpec` (`All`/`Single`/`Range`/`List`, 1-based) with a CLI
parser (`1,3,5-8,12` / `all`) and `resolve(count)` (sorted, deduped,
clamped, reversed-range tolerant).
- `extract` (clone + delete-complement + prune; source untouched),
`delete` (in place, guards emptying), `rotate` (`Rotation` 90/180/270,
accumulates `/Rotate`), `reorder` (0-based permutation, validated),
`split` (`EveryNPages` / `OnPages`, via `extract`).
- **`merge`** reparents each source's page **subtree** under a fresh
top `Pages` node (object-IDs renumbered via `renumber_objects_with`)
rather than flattening — so inherited `MediaBox` / `Resources`
survive. Validated on *real typst output*: merging two typst PDFs
keeps the image XObjects (new `#[ignore]` corpus gate
`merge_preserves_typst_resources`).
- 7 unit tests + the merge corpus gate.
- **`pdf::meta`** — `PdfMetadata` (title/author/subject/keywords/creator/
producer) over the `Info` dictionary: `read_metadata`, `write_metadata`
(creates `Info` if absent; `None`/empty removes), `strip_metadata`.
PDF text strings are ASCII-literal or UTF-16BE-with-BOM (non-ASCII
titles round-trip). 3 unit tests (round-trip, strip, unicode title).
- Shared `test_support::minimal_pdf` fixture (used by doc/ops/meta).
Normal suite 1201 → 1211; 2 ignored corpus gates (run with
`cargo test --bin inkhaven -- --ignored`).
Next P0: `outline` injection (the additive `assemble` `#metadata` change),
then the surfaces — `Command::Pdf` (`src/cli/pdf.rs`) + `ink.pdf.*`
(`scripting/stdlib/pdf.rs`) — which retire the module `dead_code` allow.
### P0.4 — outline injection + assemble marker + feature-proof (landed)
- **`pdf::outline`** — `OutlineItem { title, page, children }`,
`inject_outline` (builds a `/Outlines` bookmark tree with proper
First/Last/Next/Prev/Parent/Count linking + `/Dest [page /Fit]`,
page-index clamped, replaces any existing outline) and `read_outline`
(reads it back; handles `/Dest` arrays *and* `/A /GoTo /D` actions, so
it also reads typst's own heading bookmarks). 3 unit tests
(nested round-trip, unicode title + page clamp, empty).
- **Assemble marker (additive)** — `build_branch_index` now emits a
zero-size `#metadata((node_id: "<uuid>"))` at each branch's body start.
`#metadata` is query-only — invisible to layout, absent from the PDF —
and carries no label (so branches can't collide). The hook for the
future node↔page correlation (introspector query at compile time);
nothing consumes it yet, so output is byte-for-byte the same to the
reader.
- **End-to-end feature-proof** (`full_pdf_feature_proof`, `#[ignore]`d) —
compiles a synthetic multi-chapter `.typ` *including the `#metadata`
marker* (proving it compiles inertly) and exercises the whole
manipulation set on the real PDF: meta write/read/strip, outline
inject/read round-trip, extract, split (counts sum back), merge (image
survives), rotate, delete, reorder, and an atomic `save`→`load`.
The PDF *manipulation* feature set (P0 minus the surfaces) is now complete
and proven on real typst output. Normal suite 1211 → 1214; 3 ignored
corpus gates.
Next: the **surfaces** — `Command::Pdf` (`src/cli/pdf.rs`) + `ink.pdf.*`
(`scripting/stdlib/pdf.rs`) — which make all this usable and retire the
module `dead_code` allow.
### P0.5 — `Command::Pdf` CLI surface (landed)
`inkhaven pdf <subcommand>` (`src/cli/pdf.rs` + `PdfCommand` in
`cli/mod.rs`) over the P0 library:
- `info` (pages, page-1 size in pt+mm, source, title/author, outline
size), `extract --pages`, `delete --pages`, `rotate --pages --degrees`,
`reorder --mapping` (1-based), `split --every|--at [--out-dir]`,
`merge <inputs>... --out`, `metadata` (read / `--title|--author|
--subject|--keywords` set / `--strip`), `outline` (list bookmarks).
- Output via a `write_pdf` helper: **atomic** (`PdfDoc::save` →
`io_atomic`) and **creates the parent dir**; a mutating op never
silently overwrites its input — defaults to a `<stem>-<op>.pdf` sibling.
**Live-verified** end-to-end on a real **169-page** typst-built book
(`inkhaven build --compile` → `inkhaven pdf …`): `info` (A4, 3 bookmarks),
`outline` correctly read typst's own heading bookmarks (Ch1→p.1, Ch2→p.56,
Ch3→p.111 — proving `read_outline` handles typst's `/A /GoTo` actions),
metadata set/read (creator `Typst 0.14.2`), extract, merge→171pp, rotate,
reorder — no crash artifacts. One bug found + fixed: `split` into a
non-existent `--out-dir` (now created first).
The library now has real callers — `doc`/`ops`/`meta`/`outline` are live;
the module `dead_code` allow covers only the P1/P2-bound remainder
(`paper`, imposition geometry, `inject_outline`, the `ink.pdf.*` layer).
Suite 1214 (+ 3 ignored corpus gates).
P0 (foundations) is functionally complete bar the `ink.pdf.*` Bund layer.
### P0.6 — `ink.pdf.*` Bund stdlib (landed)
`src/scripting/stdlib/pdf.rs`: PDFs held as opaque **handles** (ints) in
a thread-local registry (RFC §9 open-Q 3 — no copy on every word
boundary). Words: `ink.pdf.load` ( path -- h ), `save` ( h path -- ),
`pages` ( h -- n ), `extract` ( h spec -- h' ), `delete` / `rotate` /
`reorder` ( … -- h, in place), `merge` ( h1 h2 -- h', consumes inputs),
`title` / `set_title` / `set_author` / `strip_metadata`. Thin wrappers
over the proven `pdf` lib.
Sandbox: only the disk-crossing words are categorised in `policy.rs` —
`ink.pdf.load` → `fs_read` (default-allowed, path-confined to the project
root), `ink.pdf.save` → `fs_write` (**default-denied**); both `load`/
`save` confine paths via the shared `helpers::resolve_fs_path` (moved out
of `fs.rs`). The in-memory ops stay uncategorised (allowed; they persist
only via `save`). Drift-guard test pins `save → fs_write`.
Verified: words register + error cleanly (unknown handle, stack
underflow), `save` denied-by-default at init, `load` path-confined.
Suite 1214 → 1215. **VM round-trip — verified.** A `bund` script on a
real typst-built PDF (`ink.pdf.load` → `set_title` → `save`, with
`fs_write` enabled) wrote the output, and an independent
`inkhaven pdf metadata` read back the exact title — proving the full
load → mutate → save chain round-trips through the Adam VM.
**P0 (Foundations) is complete** — deps + fidelity gate, geometry/paper,
PdfDoc, ops, meta, outline, the `inkhaven pdf` CLI, and the `ink.pdf.*`
Bund layer. Next: **P1 — imposition** (the marquee feature).
### P1.1 — imposition layout + creep math (landed)
The pure, unit-tested core of the marquee feature (no I/O), opening P1:
- **`impose` enums** — `BindingStyle` (saddle-stitch / perfect-bound /
concertina / stab, with `parse` + `columns_per_side` + `is_folded`),
`BlankPolicy` (prepend / append / balance / error), `CreepStrategy`
(none / shingle / pushout).
- **`impose::layout`** — `plan(style, sheets_per_signature, source_pages,
blank) -> Layout` of `Sheet { signature, sheet_in_sig, front, back }`
with `Slot { page: Option<usize>, column }`. Folded styles use the RFC
§8.2.2 signature formula (`folded_positions`: front-left `4S-2i+2`,
front-right `2i-1`, back-left `2i`, back-right `4S-2i+1`, offset by
`g·4S`); stab/concertina are sequential 1-up (leaf k = pages 2k-1 / 2k).
Padding blanks placed per `BlankPolicy`.
- **`impose::creep`** — `creep_shift_mm(strategy, i, thickness_mm)`: zero
on the outermost sheet, growing one caliper per nested sheet toward the
spine (shingling); `max_creep_mm` for the preview. Direction applied at
emission; shingle/pushout share the magnitude.
- 13 tests, incl. the **RFC §13 correctness property** (every source page
appears exactly once) swept over all 4 styles × 10 page counts × 3
blank policies, plus the classic 4-/8-page saddle layouts pinned by
hand, multi-signature perfect-bound, and creep monotonicity.
Suite 1215 → 1228. Next P1: `impose::sheet` (Form-XObject placement of
each slot, with creep offset + the target sheet geometry), then `marks`,
then the `imposition:` config + `inkhaven pdf impose` + `Ctrl+B I`
preview + `ink.pdf.impose`.
### P1.2 — sheet emission (Form XObjects) (landed)
`impose::sheet::emit(src, layout, params)` + the `impose::impose(src,
ImpositionParams)` coordinator turn a linear PDF into print-ready sheets:
- Each **source page → a Form XObject** (`lopdf::xobject::form`): BBox =
the page MediaBox, content = `get_page_content` (decoded), `/Resources`
= the page's effective resources (inline dict cloned, or the nearest
reference up the page→parent chain — so fonts/images/vector carry).
- The imposed pages (front + back per sheet, **duplex order**) are sized
to `params.sheet_size`; each `Slot` places its XObject with a
`q 1 0 0 1 tx ty cm /Pg<n> Do Q` op. `place()` centres the 2-up block
(1-up centres one page) and applies the **creep** offset toward the
spine (left page +creep, right page −creep).
- A fresh page tree + catalog roots at the imposed pages; `prune_objects`
drops the orphaned source pages (the XObjects embed the content + hold
the resources alive).
- `ImpositionParams { style, sheets_per_signature, blank, sheet_size,
creep, paper_thickness_mm }`.
3 unit tests (8-pp saddle → 4 sides, 5-pp stab → 6 sides, creep
placement) + a **real-typst corpus gate** (`imposition_preserves_typst_
content`, ignored): imposing a 4-page typst doc keeps the Form XObjects,
the **embedded font, and the image** through the conversion.
Imposition produces valid, content-preserving PDFs on real output. Suite
1228 → 1231 (+ 4 ignored corpus gates). Next P1: `impose::marks` (crop /
fold / registration / spine collation-bars / signature numbers), then the
`imposition:` config + `inkhaven pdf impose` + `Ctrl+B I` preview +
`ink.pdf.impose`.
### P1.3 — printer marks (landed)
`impose::marks` — pure PDF content-stream ops (no SVG), appended to each
imposed sheet side by `sheet::emit`:
- **crop** — four L-marks at the trim-box corners, offset out by
`crop_offset_mm`, 5 mm long.
- **fold** — dashed segments across the spine fold at block top + bottom
(`fold_mark_length_mm`); folded styles only.
- **registration** — a printer's cross (crosshair + a 4-Bézier circle)
centred above + below the block.
- **spine_marker** — the collation bar across the spine fold, its y
**descending monotonically with the signature** (`spine_bar_y`) so the
gathered spine shows a staircase; folded styles only.
- **signature_number** — the 1-based numeral near the fold, via a base-14
`/F1` Helvetica resource the emitter adds when the mark is on.
- **color_bar** — a grayscale calibration strip (off by default).
`MarkConfig` (default: all but color_bar) + `MarkGeometry`; the emitter
computes the layout-wide block geometry and the per-sheet signature.
`ImpositionParams` gains `marks` / `crop_offset_mm` / `fold_mark_length_mm`.
6 mark unit tests (crop = 8 segments, fold dashed + folded-only,
registration crosshair+circle, **staircase monotonicity**, signature
text, default config) — all pure. The real-typst imposition corpus gate
still passes with marks drawn. Suite 1231 → 1237.
Next P1: the **surfaces** — the `imposition:` HJSON config (named
profiles via the cascade), `inkhaven pdf impose`, the `Ctrl+B I` preview
overlay, and `ink.pdf.impose`.
### P1.4 — imposition config + `inkhaven pdf impose` (landed)
- **`impose::config`** — the HJSON `imposition:` block: `ImpositionConfig
{ profiles }` (default `default` = perfect-bound/A3/shingle + `chapbook`
= saddle/A4/no-creep), `ImpositionProfile` (style / sheets_per_signature
/ target_sheet_size [preset name *or* `{width_mm,height_mm}`, untagged]
/ orientation / margins / creep [enabled + stock + override + strategy]
/ marks / blank_page_policy). `resolve(profile) -> ImpositionParams`
parses the enums, resolves the sheet size + orientation (auto →
landscape for 2-up), and the paper caliper (override → stock → 0.1).
- **Config wiring** — `Config.imposition` (`#[serde(default)]`), so
profiles merge through `Config::load_layered` (project + global) like
every other setting — the HJSON-configurable requirement, honoured.
- **`inkhaven pdf impose <input> [--config <profile>] [--out <file>]`** —
loads the cascade config, resolves the profile, imposes, writes
atomically; reports signatures / sheets / pages. Unknown profile →
clean error listing the available ones.
5 config tests (default + chapbook resolve, unknown-profile error, JSON
deserialize with custom sheet + thickness override, bad-style error).
**Live-verified** on a real 56-page A4 book: `--config default` →
4 signatures, 16 sheets → 32 A3-landscape imposed pages (correct
signature math), no crash artifacts. Suite 1237 → 1242.
Imposition is now usable end-to-end from the CLI. Next P1: the `Ctrl+B I`
preview overlay + `ink.pdf.impose` Bund word (and the `imposed_pdf`
book-take format).
### P1.5 — `ink.pdf.impose` Bund word (landed)
`ink.pdf.impose` ( handle profile -- handle' ) over the existing handle
registry: resolves the named profile from `active_config().imposition`
(project + global cascade) — falling back to the built-in `default` /
`chapbook` profiles when no project is registered — then runs the proven
`impose::impose`, returning a fresh handle. In-memory (no disk I/O →
uncategorised, allowed); `load`/`save` still gate the disk crossing.
Verified: registered + clean errors (unknown profile resolved against the
fallback config, unknown handle, stack underflow), and a **full VM
round-trip on a real 56-page book** — `load → impose "default" → save`
produced 32 A3-landscape imposed pages (matching the CLI). Suite 1242.
The RFC §9 example release script now works: `… ink.pdf.load …
imposition-profile ink.pdf.impose "…-imposed.pdf" ink.pdf.save`. P1 is
functionally complete bar the `Ctrl+B I` TUI preview overlay + the
`imposed_pdf` book-take format.
### P1.6a — imposition preview generator + `pdf impose --dry-run` (landed)
`impose::preview` — pure: `build(profile, source_pages, params) ->
ImpositionPreview` runs the layout + creep math and `lines()` renders the
RFC App. D content: profile / source pages / style / signature size /
signatures (imposed pages + blanks) / sheet size (pt + mm) / paper +
creep (max shift) / marks, plus the first sheet's schematic
(`Front: [ 8 ][ 1 ]` / `Back: [ 2 ][ 7 ]`, blanks as `[ — ]`).
3 tests (saddle summary + schematic, padding blanks, stab 1-up).
`inkhaven pdf impose --dry-run` prints it without imposing — the same
generator the `Ctrl+B I` overlay will render. Suite 1242 → 1245.
**TUI chord note:** `Ctrl+B I` is already `OpenBookInfo` and `Ctrl+B
Shift+I` is taken (`ViewEditEventMetadata`), so the overlay needs a
different chord or to fold into book-info — a product decision parked for
the user. The preview *content* + generator are done and reusable.
### P1.6b — `Ctrl+B Q` imposition-preview overlay (landed)
`Ctrl+B I` was taken (book-info), so the overlay gets a dedicated chord
**`Ctrl+B Q`** — *Q for quire* (a gathering of folded sheets = exactly
what imposition makes). `Action::OpenImpositionPreview` (label/help/entry
in keybind.rs, `run_action` arm).
`open_imposition_preview` resolves the current book's built PDF
(`resolve_artefacts_dir`/`<slug>.pdf` — the open_book_info path), loads it
for the page count, resolves the `default` profile from `self.cfg.
imposition`, builds the preview, and opens `Modal::ImpositionPreview {
source, out, profile, params, lines }`. The modal renders the App. D
preview text (`draw_imposition_preview_modal`); **Enter** imposes (load →
`impose::impose` → atomic save to `<slug>-imposed.pdf`, status report),
**Esc** cancels. Missing PDF → "build the book first (Ctrl+B B)".
Suite 1245 (compiles + keybind registry green; the pure preview content is
unit-tested). Interactive overlay — the on-screen click-through isn't
headless-verifiable (same caveat as the find/replace review modal); the
engine + generator + open/apply paths are proven.
**P1 (imposition) is functionally complete**: layout · creep · sheet
emission · marks · config · `inkhaven pdf impose` (+`--dry-run`) ·
`ink.pdf.impose` · `Ctrl+B Q` preview. Remaining P1 nicety: the
`imposed_pdf` book-take format (a `Ctrl+B O` extra-format entry).
### P1.7 — `imposed_pdf` book-take format (landed)
`imposed_pdf` in `output.extra_formats` makes `Ctrl+B O` impose the
just-built PDF as part of the take, writing `<stem>-imposed.pdf` next to
it. Unlike markdown/tex/epub (which convert the `.typ` *source*), it
operates on the built PDF, so it's special-cased in `take_extra_formats`:
`take_imposed_pdf(pdf_dest)` resolves the `output.imposed_pdf_config`
profile (default `default`) from `cfg.imposition`, loads the built PDF,
runs `impose::impose`, and saves atomically. Per-format error isolation
(an impose failure → `✗`, never aborts the take or the other formats).
New `output.imposed_pdf_config` config field (default `default`). Suite
1245 (compiles + serde defaults; the Ctrl+B O take is interactive, the
underlying impose is CLI-verified on a real book). The CLI counterpart is
`inkhaven pdf impose`.
**P1 (imposition) is complete** — engine (layout / creep / sheet / marks)
+ all four surfaces (config, `inkhaven pdf impose`, `ink.pdf.impose`,
`Ctrl+B Q` preview) + the `imposed_pdf` book-take. The marquee feature
ships from CLI, Bund, the TUI overlay, and the book take.
### P2.1 — ISBN EAN-13 barcode (landed)
`pdf::barcode` (activates the `barcoders` crate): `BarcodeSpec { isbn,
height_mm, module_width_mm, include_human_readable }`, `check_digit`
(mod-10), `render_ean13 -> RenderedBarcode { ops, width_pt, height_pt }`
— `barcoders` encodes the 95-module pattern (validating ISBN + check
digit), and each run of dark modules becomes a filled PDF rectangle (no
PNG round-trip → any DPI), with the 13 human-readable digits below via
`/F1`. `build_barcode_pdf` wraps it in a standalone 1-page PDF with a
quiet-zone margin. 4 tests (known check digit, bars+text render + width,
bad-ISBN rejection incl. wrong check digit, 1-page PDF round-trip).
Suite 1245 → 1249.
### P2.2 — cover + spine generation (landed)
`pdf::cover`: `spine_width_mm(page_count, interior, cover)` (RFC §8.4:
interior stack ÷2 + cover wrap ×2 + binding allowance) and `build_cover`
— a single landscape page `[ bleed | back | spine | front | bleed ]`
generated natively (no Typst). Places an optional **front image** (hand-
built Flate image XObject from the in-tree `image` crate + `Stream::
compress` — no lopdf image feature, no `flate2`/duplicate-`image` dep),
**spine text** rotated 90° (`Tm 0 1 -1 0`), **back text**, the **EAN-13
barcode** (back, bottom-right), and **crop marks** at the trim corners.
`CoverSpec` / `SpineText`. 3 tests (spine width for a 200-page novel =
11.6 mm, cover page dimensions + round-trip, front-image XObject
embedded). Suite 1249 → 1252.
### P2.3 — preflight (landed)
`pdf::preflight` (RFC §8.6). The flagship check is **effective image
DPI**: walk each page's content stream tracking the CTM (`q`/`Q`/`cm`),
and at every image `Do` divide pixel size by the placed size in inches,
so a screenshot blown up to full page is flagged regardless of its file
resolution. Plus font embedding (FontDescriptor/FontFile* walk incl.
Type0 `DescendantFonts`), page-size consistency, blank pages, colour-image
detection. `PreflightProfile` = HandBinding / PrintShop / Strict. Fixed
a latent bug: image `Width`/`Height` + `cm` operands were read with
`as_f32` (Real-only) so the common Integer form read as 0 — switched to
`as_float`. Tests: matrix product; blank pages on `minimal_pdf`; a 16×24
image stretched on a cover flagged sub-50-dpi with the pixel size pinned.
### P2.4 — surfaces for cover / barcode / preflight (landed)
Wire the three engines to every surface. CLI: `pdf preflight`
(`--profile`/`--dpi`), `pdf barcode <isbn>`, `pdf cover --pages N`. HJSON:
`cover:` (`CoverConfig`, `build_spec` computes the spine from page count +
stocks) and `preflight:` (`PreflightConfig::resolve`). Bund:
`ink.pdf.cover` / `.barcode` / `.preflight` (pure-compute, no new fs
gates). Book-take: `cover_pdf` in `output.extra_formats` →
`<stem>-cover.pdf` on `Ctrl+B O`, parallel to `imposed_pdf`.
### P3.1 — grayscale + optimize (landed)
`pdf::transform` (RFC §8.7). **grayscale** desaturates without
re-rendering: it *neutralizes* colour operands in each page's content
stream (`r g b rg` → `y y y rg`; `c m y k k` → `0 0 0 (1-y) k`), so the
operator and colour space are preserved and the stream stays valid;
DeviceRGB/CMYK 8-bpc image XObjects convert to DeviceGray (JPEG / exotic
spaces left as-is by design). **optimize** prunes orphan objects +
Flate-compresses every uncompressed stream, returning an `OptimizeReport`.
### P3.2 — watermark / stamp (landed)
`pdf::watermark`. Stamp text and/or an image onto a page range, appended
as a self-contained `q … Q` block under a constant-alpha ExtGState so it
never disturbs the body. Text centred + rotatable (45° `DRAFT` default);
image centred + aspect-scaled to a page-width fraction. `WmPosition`
(center / four corners), optional `PageSpec` range.
### P3.3 — `pdf sample` + P3 surfaces (landed)
`ops::sample(src, n)` — n evenly-spaced pages (first + last always), a
quick-proof subset built on `extract`. CLI `pdf grayscale` / `optimize`
/ `watermark` / `sample`; Bund `ink.pdf.grayscale` / `.optimize` /
`.watermark` / `.sample`.
### P3.5 — docs + `Ctrl+B Q` scope fix (landed)
Tutorial 65 (hand-binding); KEYBINDING (`Ctrl+B Q` tree-scope) and
CONFIGURATION (`imposition:` / `cover:` / `preflight:`) references. Fixed
a chord regression: imposition preview was `Scope::Any` and shadowed the
editor's 1.2.13 `Ctrl+B Q` (TranslateToInvented) since `resolve_in` takes
the first match — re-scoped to `Scope::Tree` (its natural book-level home)
with a regression test pinning both resolutions.
**PDF-1 complete (P0–P3).** Suite 1252 → 1270.