inkhaven 1.3.13

Inkhaven — TUI literary work editor for Typst books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
# RFC PDF-1 — PDF Management and Imposition Pipeline

| | |
|---|---|
| **RFC** | PDF-1 |
| **Title** | PDF Management and Imposition Pipeline |
| **Status** | Draft |
| **Created** | 2026-06-07 |
| **Author** | Vladimir Ulogov |
| **Target version** | 1.3.0 |
| **Depends on** | none |
| **Supersedes** | none |

---

## 1. Summary

Add a self-contained PDF management subsystem to Inkhaven covering book imposition (signatures, creep compensation, fold/crop/spine marks), page operations (extract, split, merge, rotate, reorder), cover-and-spine generation with ISBN barcode, preflight checks, and outline injection from the tree. The subsystem operates primarily on PDFs Inkhaven itself produced (via `Ctrl+B O` or `inkhaven export pdf`), uses only pure-Rust crates, preserves the single-binary deployment model, and exposes its full surface through CLI subcommands, the TUI book-take pipeline, and an `ink.pdf.*` Bund stdlib.

The marquee feature is **signature imposition for hand-binding and small print-shop workflows**, with creep compensation and collation marks — capabilities that no terminal-native writing tool currently provides and that fit Inkhaven's audience precisely.

## 2. Motivation

Inkhaven today produces a single linear PDF: pages 1 through N in reading order, suitable for a screen reader, a POD printer, or a duplex office printer. That covers the digital-distribution and POD-publishing paths but stops short of two real workflows the Inkhaven audience cares about:

- **Hand bookbinding.** A writer who finishes a manuscript in Inkhaven and wants to print and bind it themselves — chapbooks, art editions, gift copies of finished novels — needs the linear PDF rearranged into signatures and printed duplex on large sheets, with marks to guide cutting, folding, and gathering.
- **Small print-shop output.** A self-publisher taking a manuscript to a local print shop (rather than KDP / IngramSpark / Lulu) is expected to deliver an imposed, marked, preflight-clean PDF. Shops will impose for a fee, but Inkhaven users are the demographic who would rather do it themselves.

POD platforms do their own imposition and have their own spec sheets; this RFC explicitly does **not** target POD-platform compliance as primary scope. That work (PDF/X-4, CMYK, KDP cover templates) is a possible follow-up RFC.

In addition to imposition, several PDF helpers have repeatedly come up in writer workflows — page extraction for samples, watermarks for review copies, ISBN barcodes, grayscale conversion, outline generation, metadata stamping — all of which compose cleanly with imposition and share the same underlying machinery.

## 3. Goals

1. **Imposition** of Inkhaven-authored PDFs into print-ready signatures for the four binding styles that cover the artisan / print-shop case: saddle-stitch, perfect-bound (multi-signature), concertina, and Japanese stab.
2. **Creep compensation** with paper-stock presets, two shift strategies, and explicit override.
3. **Printer marks**: crop, fold, registration, spine markers (collation bars), signature numbering, optional color bars.
4. **Page operations** at PDF level: extract, split, merge, rotate, reorder, delete-range.
5. **Cover-and-spine generation** with a spine-width calculator from project metadata + paper stock + page count.
6. **ISBN barcode** rendering (EAN-13 + optional 5-digit price add-on) embedded in cover or interior.
7. **Preflight checks** appropriate to Inkhaven-authored output: font embedding, image resolution at print size, page-size consistency, color usage, blank-page detection.
8. **Outline / bookmark injection** from the Book/Chapter/Subchapter tree.
9. **PDF metadata stamping** synchronized to project HJSON (title, author, subject, keywords).
10. **Single binary**: every dependency is pure Rust; no external tool invocation; no native libraries beyond what's already vendored (DuckDB, etc.).
11. **Three surfaces**: CLI subcommand tree (`inkhaven pdf …`), TUI integration into the book-take format list, and `ink.pdf.*` Bund stdlib.

## 4. Non-goals

The following are explicitly **out of scope** for PDF-1:

- POD-platform-specific compliance (KDP, IngramSpark, Lulu). Future RFC if needed.
- PDF/X-1a / PDF/X-4 / PDF/A compliance modes.
- CMYK or ICC-profile color conversion.
- Rendering arbitrary external PDFs to images. Inkhaven-authored output is rendered via the existing `typst-render` path from source; arbitrary-PDF rasterization is deferred.
- OCR of scanned PDFs.
- Form filling, digital signatures, encryption with public-key infrastructure.
- Dust-jacket layout for hardcovers.
- Direct printer-driver / CUPS integration.
- A graphical placement editor; imposition is config-driven only.

## 5. Constraints

- **Single binary.** No new external tool dependencies. `pdftk`, Ghostscript, `qpdf`, `pdfium`, `mutool`, etc. are not options.
- **Pure Rust.** Every new dependency must be pure Rust or already vendored with C sources in the existing dep graph. No new native shared-library dependencies.
- **Primary input: Inkhaven-authored PDFs.** Features that depend on a parsed Typst source tree (outline injection, sample-by-chapter, preflight that knows expected page geometry) require Inkhaven authorship; features that operate purely on PDF object structure (imposition, extract, merge, rotate, metadata, watermark, barcode) work on any input PDF.
- **No source compromise.** PDF manipulation never modifies the Typst source. Imposition is always re-runnable from the same source by re-rendering.

## 6. Audience

Primary: artisan / hand binder, small print-shop self-publisher. These users are CLI-comfortable, value reproducibility, prefer terminal-native tools, and currently fall back on a patchwork of `pdfjam`, `bookbinder`, Affinity Publisher, or Adobe InDesign. None of those integrate with their writing tool.

Secondary: any Inkhaven user who occasionally needs to manipulate the output PDF — extract a sample, add a watermark, generate a cover, stamp metadata — without leaving the editor.

Out of scope as a primary audience: POD-platform users whose printer does its own imposition and supplies its own templates.

## 7. Design overview

### 7.1 Pipeline

The complete PDF subsystem is a pipeline that composes named stages, each taking and returning a `PdfDoc` value:

```
            ┌─────────────────────────────────────────────────┐
            │            Inkhaven PDF pipeline                │
            │                                                 │
  source ──▶│  ingest → ops → marks → impose → meta → emit    │──▶ output
            │     │      │      │       │       │             │
            │  load    extract  crop  signature  outline      │
            │  parse   split    fold  creep      author       │
            │  index   merge    spine n-up       title        │
            │          rotate   reg   blank-pad  keywords     │
            │          stamp                                  │
            │          watermark                              │
            └─────────────────────────────────────────────────┘
```

Each stage is independently invocable. A simple watermarking command runs only `ingest → ops → emit`. A full hand-binding print run runs the entire pipeline.

### 7.2 Module layout

```
src/pdf/
    mod.rs              -- public API, error types
    doc.rs              -- PdfDoc wrapper around lopdf::Document
    geometry.rs         -- mm/pt/in conversions, page-size presets
    paper.rs            -- paper-stock presets, thickness lookup
    ingest.rs           -- load and index input PDF
    ops.rs              -- extract, split, merge, rotate, delete, reorder
    stamp.rs            -- watermark and content stamps
    impose/
        mod.rs          -- coordinator
        layout.rs       -- signature math, page→position mapping
        creep.rs        -- creep compensation
        marks.rs        -- crop, fold, registration, spine, color
        sheet.rs        -- per-sheet rendering
    cover.rs            -- cover-and-spine PDF generation
    barcode.rs          -- ISBN EAN-13 generation
    preflight.rs        -- analysis and warnings
    outline.rs          -- inject bookmarks from tree
    meta.rs             -- PDF metadata read/write
    emit.rs             -- serialize PdfDoc to bytes
```

### 7.3 Surfaces

Three concurrent entry points, all routed through the same `pdf::*` library:

**CLI**: `inkhaven pdf <subcommand>` — full operation tree, scriptable.
**TUI**: book-take format list (`take.formats: [pdf, imposed_pdf, cover_pdf, …]`); `Ctrl+B O` produces all configured artifacts.
**Bund**: `ink.pdf.*` stdlib functions, sandbox-gated under existing `fs_write` capability.

## 8. Detailed design

### 8.1 `PdfDoc` — the value type

```rust
pub struct PdfDoc {
    inner: lopdf::Document,
    page_ids: Vec<ObjectId>,
    page_sizes: Vec<Rect>,     // cached; PDFs can vary per page
    source: PdfSource,         // Inkhaven | External
    project_ref: Option<ProjectRef>,
}

pub enum PdfSource {
    Inkhaven {
        source_typst_root: PathBuf,
        tree_snapshot: TreeSnapshotId,  // for outline injection
    },
    External,
}
```

`PdfDoc` is the lingua franca: every operation takes `&mut PdfDoc` or `&PdfDoc` and returns `Result<PdfDoc>`. The `source` field gates features that require knowledge of the originating Typst tree (outline injection, sample-by-chapter).

### 8.2 Imposition

#### 8.2.1 Configuration

```hjson
imposition: {
    style: "perfect_bound"          // saddle_stitch | perfect_bound | concertina | stab
    sheets_per_signature: 4         // ignored for saddle_stitch | concertina | stab
    target_sheet_size: "A3"         // any preset or { width_mm, height_mm }
    pages_per_sheet_side: 2         // 2 for octavo-style; 4 only for special cases
    orientation: "auto"             // auto | portrait | landscape

    margins: {
        bleed_mm: 3
        crop_offset_mm: 5
        fold_mark_length_mm: 8
        gutter_mm: 0                // additional, on top of source PDF gutter
        outer_margin_mm: 0
    }

    creep: {
        enabled: true
        paper_stock: "uncoated_80gsm"   // preset; resolves to thickness_mm
        thickness_mm_override: null     // null = use preset
        strategy: "shingle"             // shingle | pushout | none
    }

    marks: {
        crop: true
        fold: true
        registration: true
        spine_marker: true              // collation bars
        signature_number: true          // small numeral on spine fold
        color_bar: false                // for color jobs only
    }

    blank_page_policy: "append"         // prepend | append | balance | error

    output: {
        filename_template: "<book>-imposed-<YYYYDDMM>-<HHMM>.pdf"
    }
}
```

This block is mergeable into `inkhaven.hjson` under a top-level `imposition:` key, with the same merge semantics as existing config.

#### 8.2.2 Algorithm

The core imposition primitive is a function from `(signature_index, sheet_index, position)` to `source_page_number`. For a perfect-bound book with `S` sheets per signature (so 4S pages per signature):

For signature `g` (0-indexed) and sheet `i` (1-indexed, 1 = outermost), pages are positioned as:

```
sheet front side (side A):
    left  position: g·4S + (4S - 2i + 2)
    right position: g·4S + (2i - 1)
sheet back side (side B):
    left  position: g·4S + (2i)
    right position: g·4S + (4S - 2i + 1)
```

For saddle-stitch: a single signature with `S = total_pages / 4` sheets. For concertina: pages emitted in source order on alternating sides of a long strip (one "sheet" of `N` panels). For stab binding: each leaf is its own sheet; source page `n` goes to leaf `⌈n/2⌉`, side `A` for odd `n`, side `B` for even `n`.

The implementation:

```rust
pub fn impose(src: &PdfDoc, cfg: &ImpositionConfig) -> Result<PdfDoc> {
    let padded = pad_to_signature_multiple(src, cfg)?;
    let layout = compute_layout(&padded, cfg);            // SheetPlan list
    let mut dst = PdfDoc::new(target_sheet_size(cfg));
    for sheet in layout.sheets() {
        emit_sheet(&mut dst, &padded, &sheet, cfg)?;
    }
    Ok(dst)
}
```

`emit_sheet` is where lopdf's Form XObject capability does the heavy lifting. Each source page becomes a Form XObject (a reusable, transformable PDF graphics block); the destination sheet's content stream places those XObjects at the computed coordinates with the appropriate `cm` (concatenate-matrix) operator for translation, rotation, and creep offset. This is the standard imposition technique and is robust to source-PDF complexity (fonts, images, vector content all carried correctly).

#### 8.2.3 Creep compensation

Two strategies, both controlled by paper thickness `t` and signature half-position `δ = (S - i)` where `i` is sheet index from outermost.

- **Shingle**: x-shift per source page = `δ × t × 2`, applied inward (toward spine), measured along the page width direction.
- **Push-out**: same magnitude, but applied to *content* via a clip-and-shift transform on each XObject placement; trim stays aligned to the outermost sheet's edge.

Default strategy is **shingle**. It's geometrically simpler, easier to verify visually with the crop marks, and what most print shops expect.

#### 8.2.4 Paper stock presets

Initial preset table (extensible via HJSON):

| Preset name | Description | Thickness (mm) |
|---|---|---|
| `bible_70gsm` | Thin bible / dictionary paper | 0.060 |
| `uncoated_70gsm` | Lightweight uncoated | 0.085 |
| `uncoated_80gsm` | Standard novel interior | 0.100 |
| `uncoated_90gsm` | Heavier uncoated | 0.115 |
| `uncoated_100gsm` | Premium uncoated | 0.130 |
| `uncoated_120gsm` | Heavy uncoated | 0.160 |
| `coated_matte_80gsm` | Coated matte | 0.080 |
| `coated_matte_100gsm` | Coated matte premium | 0.105 |
| `coated_gloss_100gsm` | Coated gloss | 0.100 |
| `cover_250gsm` | Cover stock | 0.300 |
| `cover_300gsm` | Heavy cover stock | 0.360 |

Default: `uncoated_80gsm` for interior. Override via `thickness_mm_override` for exotic stocks.

#### 8.2.5 Marks

Each mark is a vector primitive drawn as a small content stream appended to the sheet's existing content:

- **Crop marks**: four L-shapes at the corners of each source-page region, offset outside the trim by `crop_offset_mm`, length `5mm`, stroke `0.25pt` black.
- **Fold marks**: a short dashed segment crossing the spine fold at the top and bottom edges of the sheet, length `fold_mark_length_mm`.
- **Registration marks**: a small crosshair-and-circle figure (the conventional printer's-cross) centered above and below the imposed area, used by the printer for plate alignment.
- **Spine markers (collation bars)**: a thick black bar (3 × 6 mm typical) crossing the spine fold, positioned at a y-offset that increases monotonically with signature number. When signatures are gathered and viewed from the spine edge, the bars form a descending staircase. Misaligned bar = misordered signature.
- **Signature number**: a small numeral (8 pt) printed on the spine fold of each signature, in addition to the spine bar.
- **Color bar** (off by default): standard color reference strip at the sheet edge for color-printing calibration.

All marks are rendered using lopdf primitives directly; no SVG conversion required.

### 8.3 Page operations

Pure-Rust operations on `PdfDoc`, all implemented via lopdf's page-tree manipulation:

```rust
pub fn extract(src: &PdfDoc, pages: &PageSpec) -> Result<PdfDoc>;
pub fn split(src: &PdfDoc, mode: SplitMode) -> Result<Vec<PdfDoc>>;
pub fn merge(docs: &[PdfDoc]) -> Result<PdfDoc>;
pub fn rotate(doc: &mut PdfDoc, pages: &PageSpec, degrees: Rotation) -> Result<()>;
pub fn delete(doc: &mut PdfDoc, pages: &PageSpec) -> Result<()>;
pub fn reorder(doc: &mut PdfDoc, mapping: &[usize]) -> Result<()>;

pub enum PageSpec {
    All,
    Single(usize),
    Range(usize, usize),         // inclusive
    List(Vec<PageSpec>),
    ByChapter(ChapterRef),       // Inkhaven-source only
}

pub enum SplitMode {
    EveryNPages(usize),
    ByBookmark,                  // requires existing outline
    ByChapter,                   // Inkhaven-source only; uses tree
    OnPages(Vec<usize>),
}
```

`PageSpec::ByChapter` and `SplitMode::ByChapter` use the tree-snapshot reference in `PdfDoc::source` to look up which page ranges correspond to which chapters. For external PDFs these variants return `Err(NotInkhavenSource)`.

### 8.4 Cover and spine generation

```rust
pub struct CoverSpec {
    pub front_width_mm: f32,
    pub front_height_mm: f32,
    pub spine_width_mm: f32,     // computed by spine_calc
    pub bleed_mm: f32,
    pub front_image: Option<PathBuf>,
    pub spine_text: SpineText,   // title + author at correct rotation
    pub back_text: Option<String>,
    pub barcode: Option<BarcodeSpec>,
}

pub fn spine_width_mm(
    page_count: usize,
    interior_paper: PaperStock,
    cover_paper: PaperStock,
) -> f32 {
    page_count as f32 * interior_paper.thickness_mm * 0.5
        + cover_paper.thickness_mm * 2.0
        + cover_paper.binding_compensation_mm()
}

pub fn build_cover(spec: &CoverSpec) -> Result<PdfDoc>;
```

The cover PDF is a single-page document with three logical regions (back / spine / front), trim marks, and optional barcode placement. Generated as native PDF via lopdf — no Typst pass required, since cover layout is purely geometric.

### 8.5 ISBN barcode

```rust
pub struct BarcodeSpec {
    pub isbn: String,            // 13 digits, validated checksum
    pub price_addon: Option<String>,  // 5-digit price code, optional
    pub position: BarcodePosition,
    pub height_mm: f32,
    pub include_human_readable: bool,
}

pub fn render_barcode(spec: &BarcodeSpec) -> Result<Vec<PdfPathOp>>;
```

EAN-13 generation in pure Rust (~150 lines including check-digit validation; no crate strictly required, but `barcoders` is acceptable if review confirms it's pure-Rust and dependency-light). Output is a list of PDF path operations that get appended to the cover's content stream — no PNG round-trip, scales perfectly to any DPI.

### 8.6 Preflight

For Inkhaven-authored PDFs we have ground truth (the Typst source); preflight becomes verification rather than guessing.

```rust
pub struct PreflightReport {
    pub page_count: usize,
    pub page_size_consistency: Consistency,
    pub fonts: Vec<FontReport>,           // (name, embedded, subset)
    pub images: Vec<ImageReport>,         // (page, effective_dpi, format)
    pub color_pages: Vec<usize>,          // pages with non-grayscale ink
    pub blank_pages: Vec<usize>,
    pub warnings: Vec<Warning>,
}

pub fn preflight(doc: &PdfDoc, profile: PreflightProfile) -> PreflightReport;

pub enum PreflightProfile {
    HandBinding { target_dpi: u32 },     // 300 default
    PrintShop { target_dpi: u32, paper_stock: PaperStock },
    Strict,
}
```

The image-DPI check is the highest-value preflight: it catches the single most common "my printed book looks bad" failure (a 72-dpi screenshot pasted into a manuscript at full page size). For each image, compute `effective_dpi = pixel_dimension / placed_size_inches`, flag if below `target_dpi`.

### 8.7 Outline injection

```rust
pub fn inject_outline(doc: &mut PdfDoc, tree: &Tree) -> Result<()>;
```

For Inkhaven-authored output: walk the tree, find the page number of each chapter and subchapter (looked up from the Typst-side bookmark annotations the compiler emits when given the right `#outline` invocation), build a hierarchical `/Outlines` dictionary, attach to the document catalog. The Typst-side change needed is small: the existing assemble step (`Ctrl+B A`) emits `#set heading(...)` directives; we additionally emit `#metadata((node_id: "..."))` near each heading so the outline injector can correlate. This change to the assemble step is part of P0.

### 8.8 Metadata

```rust
pub struct PdfMetadata {
    pub title: Option<String>,
    pub author: Option<String>,
    pub subject: Option<String>,
    pub keywords: Vec<String>,
    pub creator: String,           // always "Inkhaven <version>"
    pub producer: String,          // typst version + inkhaven version
}

pub fn read_metadata(doc: &PdfDoc) -> PdfMetadata;
pub fn write_metadata(doc: &mut PdfDoc, m: &PdfMetadata);
pub fn strip_metadata(doc: &mut PdfDoc);  // for privacy
```

On book-take, metadata is auto-populated from project HJSON (`book.title`, `book.author`, `book.keywords`) unless explicitly disabled.

## 9. Bund stdlib

All operations exposed as Bund words under `ink.pdf.*`, sandbox-gated:

| Word | Sandbox category | Description |
|---|---|---|
| `ink.pdf.load` | `fs_read` | Load PDF into a Bund value |
| `ink.pdf.save` | `fs_write` | Write PDF to disk |
| `ink.pdf.pages` | none | Page count |
| `ink.pdf.extract` | none | Extract page range, returns new doc |
| `ink.pdf.split` | none | Split into multiple docs |
| `ink.pdf.merge` | none | Concatenate docs |
| `ink.pdf.rotate` | none | Rotate pages |
| `ink.pdf.delete` | none | Delete pages |
| `ink.pdf.reorder` | none | Permute pages |
| `ink.pdf.stamp` | none | Add watermark/stamp |
| `ink.pdf.impose` | none | Run imposition pipeline |
| `ink.pdf.cover` | none | Build cover PDF |
| `ink.pdf.barcode` | none | Render ISBN barcode |
| `ink.pdf.preflight` | none | Run preflight |
| `ink.pdf.outline` | none | Inject outline from tree |
| `ink.pdf.metadata.get` | none | Read metadata |
| `ink.pdf.metadata.set` | none | Write metadata |
| `ink.pdf.metadata.strip` | none | Clear metadata |

A typical Bund release script becomes:

```
: release ( -- )
    "./build/book.pdf" ink.pdf.load
    book.metadata ink.pdf.metadata.set
    ink.tree.current ink.pdf.outline
    dup imposition.config ink.pdf.impose
    "./build/book-imposed.pdf" ink.pdf.save
    "./build/book.pdf" ink.pdf.save
;
hook.on_book_take : release ;
```

## 10. Surfaces — CLI, TUI, Book-Take

### 10.1 CLI

```
inkhaven pdf impose      <input> [--config <key>] [--out <file>]
inkhaven pdf extract     <input> --pages <spec> [--out <file>]
inkhaven pdf split       <input> --mode <mode> [--out-dir <dir>]
inkhaven pdf merge       <inputs>... --out <file>
inkhaven pdf rotate      <input> --pages <spec> --degrees <90|180|270>
inkhaven pdf reorder     <input> --mapping <a,b,c,…>
inkhaven pdf cover       --pages <n> [--barcode <isbn>] --out <file>
inkhaven pdf barcode     <isbn> [--addon <price>] --out <file>
inkhaven pdf preflight   <input> [--profile <profile>]
inkhaven pdf outline     <input> --out <file>   # Inkhaven-source only
inkhaven pdf metadata    <input> [get|set|strip] [--key=val …]
inkhaven pdf sample      [--first <n>] [--chapters <range>] [--watermark <text>] --out <file>
inkhaven pdf grayscale   <input> --out <file>
inkhaven pdf optimize    <input> --target <web|archive|print>
```

All commands take `--project <path>` for project-aware operations and default to operating on the most recent book-take PDF if no input is specified.

### 10.2 TUI

The book-take format list (`take.formats`) gains new entries:

```hjson
book: {
    take: {
        formats: [pdf, imposed_pdf, cover_pdf]
        imposed_pdf_config: "default"        // refers to imposition: key
        cover_pdf_isbn: "978-...."
    }
}
```

`Ctrl+B O` now produces all configured artifacts in one pass, with a per-format status line in the take overlay. Failures of optional formats (e.g. cover generation when ISBN is missing) do not block the primary PDF.

A new chord `Ctrl+B I` opens an **Imposition preview** overlay: a ratatui pane showing the imposition plan (sheet count, signature breakdown, blank-page padding, creep amount) and a small ASCII-art schematic of the first sheet's layout. Enter triggers the impose; Esc cancels. Useful sanity check before printing.

### 10.3 Book-take integration

The book-take pipeline already covered in the earlier export discussion gains PDF-specific orchestration. The `imposed_pdf` format depends on `pdf` (you can't impose what hasn't been compiled), and the engine enforces this ordering automatically.

## 11. Dependency selection

All new crates are pure Rust:

| Crate | Purpose | License | Pure Rust |
|---|---|---|---|
| `lopdf` | Core PDF parsing/writing | MIT | Yes |
| `printpdf` | Optional, for cover gen | MIT | Yes |
| `barcoders` | EAN-13 generation | MIT/Apache | Yes |

Already in `Cargo.toml` and reused: `typst-render`, `resvg`, `image`, `zip`.

Not used: pdfium, mupdf, ghostscript bindings, qpdf, pdftk wrappers. None of these meet the single-binary constraint.

A judgment call exists between `lopdf` and `printpdf`: `lopdf` reads existing PDFs and is what we need for imposition / page operations; `printpdf` is generative-only and could be used for cover layout. We can do everything with `lopdf` alone (cover layout is a 1-page document, easy to build directly). Decision: **use `lopdf` exclusively**; reject `printpdf` to minimize dep surface.

## 12. Implementation phases

**P0 — Foundations (3 weeks).** `PdfDoc`, `geometry`, `paper`, `ingest`, `emit`, `meta`, `ops` (extract/split/merge/rotate/reorder/delete), `outline` injection (including Typst assemble-step changes for `#metadata` markers). CLI subcommands for these. Bund stdlib for these. Tests with golden PDFs.

**P1 — Imposition (4 weeks).** `impose/` module complete: layout math, four binding styles, creep compensation, all marks, sheet emission via Form XObjects. CLI `pdf impose`. TUI `Ctrl+B I` preview. `imposed_pdf` book-take format. Bund stdlib. Property tests for permutation correctness.

**P2 — Cover, barcode, preflight (2 weeks).** `cover`, `barcode`, `preflight` modules. CLI subcommands. `cover_pdf` book-take format. Sample-generation convenience command.

**P3 — Polish (1 week).** Grayscale conversion, optimize-for-web pass, metadata strip, watermark/stamp variations, documentation, tutorial in `Documentation/Tutorials/HAND_BINDING.md`.

Total: ~10 weeks for a single developer. Each phase is shippable in isolation.

## 13. Testing strategy

- **Unit tests**: geometry conversions, paper-stock lookups, EAN-13 check digits, page-spec parsing.
- **Property tests** for imposition: every source page appears exactly once in output; pairs are on the same sheet; signature totals sum correctly; creep offsets are monotonic.
- **Golden-PDF tests**: a small corpus of fixture PDFs (4pp, 16pp, 64pp, 200pp) with expected output PDFs checked in. Comparison via lopdf's object-tree diff, not byte-equality (PDF serialization isn't deterministic across versions).
- **Roundtrip tests**: load → identity-op → save produces semantically identical PDF.
- **Integration test** with the existing book-take pipeline: a full project compiles, imposes, and verifies the imposed output is preflight-clean.

## 14. Risks and alternatives

**Risk: `lopdf` doesn't handle every PDF feature Inkhaven's Typst output uses.** Mitigation: P0 includes a corpus test against current Inkhaven-produced PDFs (with images, embedded fonts, vector content, outline annotations). If gaps exist, contribute upstream or carry a small patch set.

**Risk: imposition correctness is subtle and visually verifiable only.** Mitigation: property tests on the permutation; a small "test print" mode (`inkhaven pdf impose --test`) emits a numbered, marked PDF where every page is just "Page N" in 200pt — visually trivial to fold by hand and verify.

**Risk: creep compensation defaults are wrong for many users.** Mitigation: paper-stock presets are explicit, the default (`uncoated_80gsm`, `shingle`) is the most common case, and the imposition preview overlay surfaces the computed creep amount so users see what's about to happen.

**Risk: Typst source assumptions break outline injection on hand-written `.typ` files.** Mitigation: `#metadata` markers are emitted only by the assemble step; users editing raw `.typ` get the unannotated PDF and a `--no-outline` warning in book-take.

**Alternative considered: shell out to `pdfjam` / Ghostscript.** Rejected per the no-external-tools constraint and the single-binary promise.

**Alternative considered: render entire imposition via Typst itself** (Typst can compose pages from other Typst-compiled documents). Rejected because (a) it requires the user's original Typst source to be re-compilable, (b) it doesn't work for already-built PDFs that have been hand-edited, and (c) it doesn't generalize to the external-PDF cases where we still want page operations to work.

**Alternative considered: defer all PDF work to a follow-up release.** Rejected because hand-binding is a clearly missing feature in the Inkhaven workflow and the underlying machinery (`lopdf`) is mature enough to ship.

## 15. Open questions

1. **Bookmarks vs. tagged-PDF.** The outline injection generates `/Outlines` (the legacy bookmark format). Should P0 also generate tagged-PDF structure trees for accessibility? Probably not for P0; reconsider in PDF-2.
2. **Cover-image color space.** If the user supplies an RGB JPEG for the front cover, do we convert to CMYK on cover-PDF emission? Out of scope per the no-CMYK non-goal; document as RGB-only.
3. **Bund return-value semantics for PDFs.** Should `ink.pdf.load` return a handle (light value, ref-counted in the VM) or a full document value (heavy, copied on every word boundary)? Recommendation: handle, with explicit `ink.pdf.clone` for branching workflows.
4. **Imposition over external PDFs with non-uniform page sizes.** Define behavior: error out by default, with a `--rescale` flag that forces all pages to the modal size.
5. **Spine marker visibility under heavy ink coverage.** Position the bars in the trim margin, not the bleed, so they remain visible if the spine is trimmed flush.

## 16. Appendices

### A. Full HJSON config schema

```hjson
imposition: {
    profiles: {
        default: {
            style: "perfect_bound"
            sheets_per_signature: 4
            target_sheet_size: "A3"
            pages_per_sheet_side: 2
            orientation: "auto"
            margins: { bleed_mm: 3, crop_offset_mm: 5, fold_mark_length_mm: 8, gutter_mm: 0, outer_margin_mm: 0 }
            creep: { enabled: true, paper_stock: "uncoated_80gsm", thickness_mm_override: null, strategy: "shingle" }
            marks: { crop: true, fold: true, registration: true, spine_marker: true, signature_number: true, color_bar: false }
            blank_page_policy: "append"
        }
        chapbook: {
            style: "saddle_stitch"
            target_sheet_size: "A4"
            // creep disabled because saddle-stitch on ≤32pp doesn't need it
            creep: { enabled: false }
            marks: { crop: true, fold: true, registration: false, spine_marker: false, signature_number: false }
            blank_page_policy: "balance"
        }
    }
}

cover: {
    profiles: {
        default: {
            interior_paper: "uncoated_80gsm"
            cover_paper: "cover_250gsm"
            bleed_mm: 3
            spine_text: { include_title: true, include_author: true, font_size_pt: 10 }
            barcode: { position: "back_bottom_right", height_mm: 25, include_human_readable: true }
        }
    }
}

preflight: {
    target_dpi: 300
    profile: "hand_binding"
}
```

### B. Imposition formula derivation

For a folded sheet with `2k+1`-th and `2k+2`-th source pages (1-indexed `k`), nesting `S` sheets gives a 4S-page signature. Sheet `i` (from outermost) carries:

- Outside: pages `2i-1` and `4S - 2i + 2`
- Inside: pages `2i` and `4S - 2i + 1`

Left/right of each side determined by recto-verso convention (right = odd page number, except when otherwise constrained by gutter direction). Multi-signature offsets by `4S × (g-1)` where `g` is signature index.

### C. Module interaction diagram

```
                ┌──── pdf::ingest ─── pdf::doc ───┐
                │                                  │
                ▼                                  ▼
        pdf::ops ◄── pdf::geometry      pdf::preflight
        pdf::stamp                                 │
                │                                  │
                ▼                                  ▼
        pdf::impose ◄── pdf::paper            (report)
        ├ layout
        ├ creep
        ├ marks
        └ sheet
                │
                ▼
        pdf::outline ◄── tree (Inkhaven)
        pdf::meta ◄── project HJSON
                │
                ▼
        pdf::emit
                │
                ▼
            (PDF on disk)

        pdf::cover ── pdf::barcode (independent leg)
```

### D. Sample TUI imposition preview

```
┌─ Imposition preview (default profile) ────────────────────────────┐
│  Source           : ./build/my-novel.pdf                          │
│  Pages            : 248                                           │
│  Style            : perfect_bound                                 │
│  Signature size   : 16 pages (4 sheets)                           │
│  Signatures       : 16  (256 imposed pages, +8 blanks)            │
│  Sheet size       : A3 landscape (420×297 mm)                     │
│  Paper stock      : uncoated_80gsm (0.10 mm)                      │
│  Creep            : shingle, max shift 1.5 mm at innermost sheet  │
│  Marks            : crop · fold · registration · spine            │
│  Output           : ./my-novel-imposed-20260607-1421.pdf          │
│                                                                   │
│  Sheet 1 (signature 1, outermost):                                │
│    Front: [page 16 ][ page 1 ]                                    │
│    Back:  [page  2 ][ page 15]                                    │
│                                                                   │
│  ─────────────────────────────────────────────────────────────    │
│  Enter: impose      I: edit profile      Esc: cancel              │
└───────────────────────────────────────────────────────────────────┘
```

---

**End of RFC PDF-1.**