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/referencefeed 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(...)).sectionsdraw full-width bold band paragraphs between the per-section index tables (1-based row positions in theextractslist).extractsare 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¶
- Stage the PDFs. Copy every paper PDF into the workspace (flattened filenames; the config
filevalues are relative toworkspace). - Config. Copy the example, swap in the true parties/names, set
sectionsbands and theextractslist in bundle order (letter first, then appendices, authorities, expert reports, opponent correspondence, pre-action). - 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.
- Verify (never deploy-blind): page count;
search_forrow 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), not1 - 7relative. - 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); checkdoc_start/doc_numbersoverlap.- 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;forinside#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 withget_text('rawdict')char codepoints before assuming a bad glyph. - grayness package:
image-grayscaletakes apath()-type or raw bytes, NOT animage(...)element ("imagedata must be raw bytes or given as path") — but it acceptswidth/height/fitdirectly. #include "x.typ"runs in a fresh scope —cl,#lets,#showrules 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(...)): PowerShellSet-Content -Encoding UTF8and the opencode Write tool emit a UTF-8 BOM (EF BB BF) —evalfails with "the character \uFEFF is not valid in code". Write Typst fragments via Pythonio.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 samplesexpected.
Extraction and QA¶
- Use
get_text('blocks')for paragraph boundaries when re-typesetting a PDF letter/pleading — rawget_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--textargument; 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
_pagedtwin; report the deployed byte size.
Extending¶
pages_format: "bracketed"renders(pp. a - b)(arbitration style);"plain"rendersa - b.doc_numbersin theindexblock overrides sequential numbering (A1/B1, gaps).- Cover monogram and imprint are constants at the top of
typst_papers_bundle.py(MONOGRAM,IMPRINT).