Skip to content

papers-for-counsel-typst

Build a papers-for-counsel / inventory-of-papers bundle PDF whose cover and index are rendered in Typst in the in-house report style (clinical serif, monogram box, metadata grid, running header, centred page counter), with the source paper PDFs spliced in and hyperlinked (index row -> extract start; extract cover -> index; cover -> index) plus grey centred page-number footers and a _paged twin. Use when the user asks to create a papers-for-counsel bundle, an inventory-of-papers index PDF with a Typst-styled cover/index, a hyperlinked bundle for counsel, or wants the okf-inventory output restyled from the plain Palatino render into the Typst house style. Reads the same config schema as okf-inventory (court/adjudication/arbitration/counsel families); stages 0-1 (verbatim/highlight) do not apply.

Source: .opencode/skills/papers-for-counsel-typst/SKILL.md — site rebuilt 2026-09-05.


Typst Papers-for-Counsel Bundle Builder

Renders a papers-for-counsel / inventory-of-papers index in Typst (in-house report style) and splices the source paper PDFs into one hyperlinked bundle. This is the Typst-styled alternative to okf-inventory's plain Palatino renderer — same config schema, same deploy discipline, no highlighting stage.

When to run

  • The user asks for a papers-for-counsel bundle / inventory of papers with a Typst-styled cover and index (monogram box, serif, metadata grid).
  • The user wants the okf-inventory output restyled into the Typst house style (typst-report-generator look) while keeping the hyperlinked index + page-number footers.
  • Counsel papers need an index whose page ranges are the ABSOLUTE page numbers of the assembled bundle.

Tooling

One config JSON (same schema as okf-inventory): examples/config_counsel_sample.json is a self-contained fictitious template.

{
  "workspace": "C:\\...\\bundle workspace (paper PDFs live here)",
  "deploy_to": "H:\\My Drive\\<matter folder>",
  "output": "PAPERS_FOR_COUNSEL_<MATTER>_<DATE>.pdf",
  "inventory_title": "PAPERS FOR COUNSEL",
  "index_subtitle": "Inventory of Papers for Counsel - <A v B>",
  "matter": "<A v B>",
  "counsel": "<Counsel, Stable>",
  "property": "<Property>",
  "date": "<date>",
  "reference": "<ref>",
  "doc_start": 1,
  "cover_lines": [ {"text": "PAPERS FOR COUNSEL", "size": 18, "bold": true, "align": "center"}, ... ],
  "index": {
    "heading": "INVENTORY OF PAPERS FOR COUNSEL",
    "doc_label": "NO.",
    "name_label": "PAPER",
    "pages_label": "PAGES",
    "pages_format": "plain"
  },
  "sections": [ {"at": 1, "label": "01 LETTER OF INSTRUCTION"}, ... ],
  "extracts": [ {"file": "letter.pdf", "name": "Letter of Instruction ..."}, ... ]
}
  • matter / counsel / property / date / reference feed the cover metadata grid and the running header.
  • logo — optional path (relative to the workspace) of a logo PNG/JPEG; when present it replaces the monogram box on the cover, rendered GREYSCALE via #image-grayscale(path(logo_file), width: 6cm, height: 2.4cm, fit: "contain") (house style; never a colour #image(...)).
  • sections draw full-width bold band paragraphs between the per-section index tables (1-based row positions in the extracts list).
  • extracts are plain source PDFs (no highlighting — that is okf-inventory's court stage); names may contain [ ] # & £ safely.

Run:

python tools/typst_papers_bundle.py <config.json> [--output NAME] [--deploy-to PATH]

Outputs <output> + <output>_paged.pdf twin in the workspace, deploys a copy to deploy_to.

Workflow

  1. Stage the PDFs. Copy every paper PDF into the workspace (flattened filenames; the config file values are relative to workspace).
  2. Config. Copy the example, swap in the true parties/names, set sections bands and the extracts list in bundle order (letter first, then appendices, authorities, expert reports, opponent correspondence, pre-action).
  3. Run the builder. It measures extract page counts, renders the Typst cover+index, computes absolute page ranges (two-pass: pass 1 measures index page count, pass 2 recompiles with absolute ranges, loops until stable), splices the extracts, adds links and footers, deploys.
  4. Verify (never deploy-blind): page count; search_for row hit count == extract count (WARN lines mean a row was not found); link targets: index row 1 -> first extract start page, extract cover -> index page 2; footer page numbers on extract pages. Then deploy.

Verification checklist

  • typst failed: — fix the template generation (see Traps) and re-run.
  • Index shows 4 - 10-style ABSOLUTE ranges (cover=1, index pages included), not 1 - 7 relative.
  • No replacement characters in the index text layer.
  • WARN: could not locate row N — the doc number was not found in the doc-number column (x < 160pt); check doc_start / doc_numbers overlap.
  • Link count: extract count + extract covers + 1 cover link, plus any links preserved from the source PDFs.

Traps (learned)

Typst rendering

  • Never write string literals inside [...] markup blocks. #text(...)["02 APPENDICES"] renders the " quote glyphs as U+FFFD (missing glyph in the embedded font subset). Pass strings as code-mode arguments instead: #text(weight: "bold", size: 10pt, "02 APPENDICES"), or use an expression that yields a string: [#variable].
  • Table cells from loops. #table(... for r in rows [[#r.no][#r.name][#r.pages]]) renders nested blocks WITH literal brackets ([1]). Generate the cells in Python instead: comma-separated string literals in code mode — "NO.", "PAPER", "PAGES", "1", "Alpha", "2 - 4" — strings are safe cell values. (Adjacent [...][...] blocks in code mode are a function-call parse error; for inside #table(...) needs NO # — but pre-generating cells in Python is the robust path.)
  • Smart quotes are automatic. Typst converts straight quotes to U+201C/U+201D in output. A plain-text extraction may show for them (console codepage artifact) — verify with get_text('rawdict') char codepoints before assuming a bad glyph.
  • grayness package: image-grayscale takes a path()-type or raw bytes, NOT an image(...) element ("imagedata must be raw bytes or given as path") — but it accepts width/height/fit directly.
  • #include "x.typ" runs in a fresh scopecl, #lets, #show rules from the main file are NOT visible inside it. Use #eval(read("x.typ")) (which does see the current scope) or append the fragment into the main file programmatically.
  • BOM is fatal to eval(read(...)): PowerShell Set-Content -Encoding UTF8 and the opencode Write tool emit a UTF-8 BOM (EF BB BF) — eval fails with "the character \uFEFF is not valid in code". Write Typst fragments via Python io.open(..., newline="") (no BOM) or strip it (utf-8-sig).
  • Probe fonts first (..\_typst-manual\probe-fonts.ps1): this machine has Libertinus Serif + Arial and grayness 0.7.0 cached; keep leading font stack members to installed faces.
  • Two-pass absolute ranges are mandatory. The index page count is unknown until compiled; extract page ranges must include cover + index pages.
  • Row-hash link placement: find each row by search_for(doc_no) filtered to the doc-number column (x0 < 160pt) — the PAGES column contains the same digits.
  • Bands are full-width paragraphs between per-section tables (no colspan in this Typst build); do not attempt in-table band rows.
  • Greyscale mandate: cover logo must be #image-grayscale(...); never plain #image(...) for the logo; verify with a pixel sample (R==G==B within tolerance) — 0 non-greyscale samples expected.

Extraction and QA

  • Use get_text('blocks') for paragraph boundaries when re-typesetting a PDF letter/pleading — raw get_text() has no blank lines within a page (blanks only at page boundaries, next to "Page N of M" markers).
  • Scanned text layers map £ and curly apostrophes to U+FFFD: restore by context (\ufffd(?=\d) -> £, else -> ') after normalising U+2018/19/1C/1D and U+2013.
  • Hyphenated line breaks: join lines ending in - OR U+00AD (soft hyphen); strip U+00AD after joining.
  • Verify by text extraction, not by eye — and check probes on the FINAL assembled PDF (headers/footers/links included).
  • Cross-references: when renumbering clauses (e.g., deleting 4.1-4.3), grep the whole file for dangling "see clause N" pointers and update them.

PDF shrinking (bundle bloat)

  • No Ghostscript on this machine — use PyMuPDF: page.replace_image(xref, stream=jpeg_bytes) + doc.save(out, garbage=4, deflate=True). Capture WARN lines with full output (do not truncate).
  • 1:1 pixel-mapped scans defeat display-rect heuristics: scanned pages can be 4000x3000 pt with the image drawn at pixel size, so get_image_rects() returns the pixel dimensions and a display-based target never triggers. Always apply a hard pixel cap (2000px long side) in addition to any DPI target.
  • Re-encoding already-optimised 300dpi scans at q75 often produces LARGER files (noisy scans) — skip when the new stream >= the old; the wins are: 4000px photos (4.5 MB -> ~0.6 MB), hard caps, and greyscale conversion.
  • xrefs are renumbered after garbage=4 — analyse the OUTPUT file for verification, never the input.
  • replace_image preserves links/annotations — verify link count and page count after shrinking (633 pp / 311 links observed).
  • 112 MB photo/scan bundle -> ~69 MB compact (2000px cap, greyscale, q72) is the practical floor without Ghostscript.

Shell / scripting

  • PowerShell 5.1 mangles "-containing arguments to python.exe — never pass quoted text as an inline --text argument; write to a file and use --file, or write the script with the Write tool.
  • #eval(read(...)) BOM pitfall (above) also bites shell-written fragments.
  • Deploy discipline: verify page count + link count + probes BEFORE deploying; always refresh the _paged twin; report the deployed byte size.

Extending

  • pages_format: "bracketed" renders (pp. a - b) (arbitration style); "plain" renders a - b.
  • doc_numbers in the index block overrides sequential numbering (A1/B1, gaps).
  • Cover monogram and imprint are constants at the top of typst_papers_bundle.py (MONOGRAM, IMPRINT).