| name | docx-typst |
| description | Use this skill to BUILD a Word document from a TYPST source file, to CONVERT an existing Word manuscript into Typst for the first time, and to bring a returned .docx back into the repo. Triggers: 'build the docx from the typ', 'typst to Word', 'send them a Word version of this paper', 'I have a Word manuscript, give me Typst', 'convert this docx to typst', 'move my paper off Word', 'start a Typst repo from this Word draft', 'my coauthor sent back the docx', 'they returned the Word file with edits', 'reconcile their edits with my source', 'merge the docx changes back', 'what did they change in the Word file', 'pull the comments out of the docx', 'get their comments from the Google Doc', 'is this file canonical', 'the source and the docx have diverged'. ALSO owns TYPST CITATION RENDERING (assets/bluebook.typ + scripts/expand_citations.py): 'render Bluebook citations in Typst', 'supra note numbering', 'my supra notes point at the wrong footnote', 'footnote numbers do not renumber when I insert one', 'Id. and supra in typst', 'small-caps reporters in typst', 'hayagriva cannot do Bluebook', 'typst bibliography style for a law review'. NOT 'law-review-docx' or 'law-econ-docx' (those build a docx from MARKDOWN — different input format), NOT 'docx-repair' (which fixes OOXML damage from a cloud round trip), NOT 'docx-render' (which only converts an existing .docx to PDF). |
| user-invocable | true |
DOCX ↔ Typst Bridge
Typst source is the thing the repo keeps. Word is the thing coauthors edit. This skill
moves a document across that boundary in both directions and reconciles what comes
back.
The load-bearing fact: typ → docx → typ reaches a fixed point after one pass. So
the pipe's output is itself valid Typst, the canonical form can be committed, and
reconciling a coauthor's returned file collapses from "read two documents side by side"
to git merge-file.
existing.docx ──canonicalize.py --from-docx──> body.typ + media/ (bootstrap, once)
│
main.typ ──typst compile──> PDF │
│ │
│ #include ▼
▼
body.typ ──build.py──> paper.docx ──email──> coauthor edits in Word
▲ │
│ │ sends back
└──── reconcile.py <── merged body.typ <── returned.docx
│
└──comments.py──> comments.json
Scripts
All under ${CLAUDE_SKILL_DIR}/scripts/. Each is self-contained and prints --help.
Run them with uv run --script, not uv run python3. Four of the five declare
lxml in a PEP 723 header, and uv run python3 <path> ignores that header and fails
with ModuleNotFoundError: No module named 'lxml'. --script (or executing the file
directly, since the shebang is uv run) reads the header and provisions the dependency.
| Script | Direction | Does |
|---|
build.py | typ → docx | Convert with --reference-doc styles and stamp provenance, in one step |
canonicalize.py | docx → typ | Bootstrap an existing Word manuscript (--from-docx); put a body file on its fixed point; --check gates it; --lint guards the body/main split |
reconcile.py | docx → typ | Resolve the ancestor, three-way merge a returned file against the repo source |
comments.py | docx or Drive → JSON | Extract comments with their anchor text, resolved state, and threading |
provenance.py | — | Read/write the stamp directly (build.py already applies it) |
expand_citations.py | typ → typ | Freeze every computed reference — #cite(...) and @label — into the literal body the docx path needs |
make_redline.py | docx × docx → docx | Rebuild a coauthor's untracked edits as real tracked changes, against a baseline (stdlib + LibreOffice; no PEP 723 header) |
The main.typ / body.typ split
main.typ #import / #let / #show / #set, then #include "body.typ" ← typst compiles this
body.typ pure markup: = headings, prose, #emph, #footnote ← pandoc reads this
Both paths see the same prose and neither degrades the other. The split is not stylistic
tidiness — see the first fact row.
Bootstrap: you already have a Word manuscript
The first thing most people need, and the only direction that starts from a document
this skill never produced. One command:
uv run --script "${CLAUDE_SKILL_DIR}/scripts/canonicalize.py" \
--from-docx 'paper.docx' --output body.typ --media-dir media
--media-dir is required for any document with figures and the script refuses
without it — see the images fact row. Verified end to end on a 1.2M Word manuscript
(7 top-level headings, 67 footnotes, 26 tables, 7 figures): 1.3s, all 7 figures
recovered to media/, --check clean on the result.
Then write the main.typ that body.typ is included from, and gate the source:
uv run --script "${CLAUDE_SKILL_DIR}/scripts/canonicalize.py" body.typ --check
git add body.typ media/ && git commit -m "bootstrap from paper.docx"
Two manual steps the conversion cannot make for you:
- Delete the recovered table of contents. Word's TOC arrives as a run of
#link(<...>) lines, and the ones pointing at Word bookmarks rather than headings
reference labels that do not exist — 13 of them in that manuscript, and typst compile stops at the first. A Typst document generates its TOC with #outline() in
main.typ, so the recovered block is redundant as well as broken. Removing it is not
a loss and does not affect the docx round trip, which reads those links fine.
- Move styling into
main.typ. The recovery emits pure markup by construction, but
anything you add must respect the split below; --lint enforces it.
Forward: build a Word file
uv run --script "${CLAUDE_SKILL_DIR}/scripts/build.py" body.typ \
-o paper.docx \
--reference-doc "${CLAUDE_SKILL_DIR}/../writing-legal/templates/law_review_template.docx"
Produces real Heading1/Heading2/FirstParagraph Word styles, and stamps
SourceSHA256, SourcePath, SourceGitSHA, StampVersion into docProps/custom.xml.
Commit the canonical form before sending. canonicalize.py body.typ --in-place, then
commit. Sending from an uncommitted or non-canonical source is what strands the
reconciliation later.
Reverse: reconcile what comes back
uv run --script "${CLAUDE_SKILL_DIR}/scripts/reconcile.py" returned.docx --source body.typ \
--media-dir media
Writes body.merged.typ + body.merged.typ.diff, prints JSON, exits 1 on conflict.
Ancestor resolution, in preference order:
- Tracked changes in the returned file —
--track-changes=reject reconstructs the
pre-edit document, accept gives the edited one. One file yields both sides, so this
works even for a file that was renamed or routed through a third party.
--base-docx sent.docx — the file that was actually sent, if it was kept.
- The provenance stamp —
git cat-file on the recorded blob sha.
If none resolves, the script stops. Pass --base-docx or --base.
When the coauthor edited with track changes OFF
reconcile.py merges into Typst. When the human wants to review the edits in
Word instead — one at a time, accept/reject — use make_redline.py.
python3 "${CLAUDE_SKILL_DIR}/scripts/make_redline.py" \
baseline.docx returned.docx outdir/ --label "Coauthor"
This is the common case, not an edge case: one real round carried 22 comments
against only 4 tracked revisions. Rejecting every tracked change still left
~25 touched paragraphs. Word's review pane showed almost nothing, because the
prose was typed with recording off. The script accepts any real tracked changes
first (so the compare reflects the coauthor's final text), then compares against
the baseline to reconstitute every difference as a tracked change.
It emits two files, and the second is the important one. LibreOffice's
CompareDocuments does not diff footnote-internal text. On a footnote-heavy
document that is the dangerous failure, not a cosmetic one: the body redline
silently carries the baseline's footnotes, so every footnote edit reads as
"unchanged" — in one case hiding a coauthor's fix to a broken ttps:// URL. So
footnotes are lifted into body paragraphs and compared separately, with baseline
footnotes relabelled to the revised file's numbering (otherwise a single
inserted footnote renumbers everything after it and buries ~12 real edits under
~128 spurious ones).
Three traps, all of which cost a debugging cycle:
- Argument order is not what you'd guess. LibreOffice treats the loaded
document as current and the compared file as the older one, so the revised
file must be the one opened. Backwards silently inverts every insertion and
deletion — a coauthor's typo fix renders as them introducing the typo.
soffice crashes partway through a full-article compare often enough to need
a retry on a fresh process and profile; the script does this.
- A stale
.~lock. file makes loadComponentFromURL return None rather than
raise.
supra note N renumbering shows up as an edit in the footnote redline — those
are cached NOTEREF display strings, not edits. See §Live citations.
Live citations and cross-references
Only needed for a manuscript whose numbers must maintain themselves —
supra note N, Section IV.B in a law review article. Skip it otherwise.
These are one problem, not two. pandoc lowers #cite(<Key>) to [Key] and
@sec-remedies to [sec-remedies], discarding in both cases the number typst
assigned during layout. So both are frozen by the same pass, through the same
<bb-out> stream, and nothing numbered is ever typed by hand:
body-src.typ #cite(<Key>), @label, <labels>, prose ZERO literal numbers
│ expand_citations.py one query, one positional splice
body.typ supra note 8 · Id. · Section IV.B · infra note 195 — all frozen
bluebook.rule handles citations; bluebook.ref-rule handles cross-references.
Install both in main.typ. supra versus infra is not authored — it is the
direction from the citing site to the target, read off the footnote counter
(not x/y: a footnote's marker sits in the body while its content is laid out at
the foot of the page, so comparing positions calls a forward reference supra).
typst reads CSL, but hayagriva 0.10.1 (linked into the typst binary) cannot
render Bluebook. A minimal probe style emitting nothing but the contested
variables:
3 SUPRA-NOTE-NUM=[] SMALLCAPS=[ The Specter of the Giant Three]
pdffonts: LibertinusSerif-Regular <- no small-caps face, no synthesis
first-reference-note-number is never populated, font-variant="small-caps" is
ignored, and BibLaTeX shortjournal is not mapped to container-title-short.
Position tracking does work — Id. renders correctly. assets/bluebook.typ
supplies the three missing pieces as a #show cite: rule.
bluebook.typ implements the short-form rules; it does not state them. When the
question is what a citation should look like rather than how to make typst emit
it, the bluebook skill is the authority — references/short-forms.md for
supra/id./hereinafter, references/abbreviations.md for reporter and
journal abbreviations.
The citation data comes from the .bib
scripts/bib_to_entries.py generates the entries module bluebook.typ reads:
bib_to_entries.py --bib sources.bib --csl bluebook.csl -o cite-data.typ
bib_to_entries.py --bib sources.bib --csl bluebook.csl --diff cite-data.typ
bib_to_entries.py --bib sources.bib --csl bluebook.csl --audit
It runs citeproc rather than parsing BibTeX. That is the whole design: the
strings have to match what is already on the page, and a hand-written renderer
would have to reproduce citeproc's quirks byte for byte — Lucian A Bebchuk
with no period, .; between adjacent groups — with every normalization silently
rewording a live citation. Running the same engine over the same CSL gets the
quirks by construction. Verified on a 117-entry .bib against 36 live entries:
every entry-level string reproduced, no citation reworded.
--diff never writes. Regenerating on top of live citation data is how a
citation gets changed without anyone reading it. Diff, review every delta, apply
by hand.
--audit reports the .bib defects that render as plausible output. Every
check exists because the defect it catches produced a citation that looked
fine — nothing errored, so nothing was noticed:
- A name field separated by
& or ; instead of and. BibTeX's only
separator is and, so the whole field becomes ONE name read as
Last, First, which moves the first author to the end:
{L. Bebchuk, A. Cohen & S. Hirst} renders Alma Cohen & Scott Hirst Lucian A. Bebchuk, short form Lucian A. Bebchuk. Depth-aware, so an institutional
{{Gibson, Dunn & Crutcher LLP}} is not flagged.
- Keys differing only in punctuation or case —
execorder14366_2025 /
execorder143662025, secGuidance2019 / secguidance2019. One source, several
records, and the extras are usually cited nowhere.
- Two works sharing a short form. The defect that silently reworks a
citation: citeproc derives the short from the AUTHORS, so three Kahan & Rock
articles all render
Kahan & Rock and supra note 8 cannot say which.
Downstream this surfaces only as audit_crossrefs.py's OK_AMBIG. Found 18
such groups in a 192-entry .bib, including one short shared by five works.
Resolve it with --shorts (below); the warning clears as each is resolved.
It reports, never fixes — a name field, a cite key and a short form are all
authorial. Always warns; --audit makes it a gate by exiting non-zero. It does
not change generated output.
Hand-authored short forms
bib_to_entries.py --bib sources.bib --csl bluebook.csl \
--shorts short-forms.toml -o cite-data.typ
[shorts]
kahan2008 = "Kahan & Rock, #emph[Hanging Chads]"
gao2016 = "GAO Report"
Two Bluebook rules need something bibliographic data cannot supply: 4.2(a)
wants a shortened italic title when an author has more than one work in
the piece, and 4.2(b) wants the [hereinafter X] form the author declared at
the first full cite. Which words of a title to keep is an authorial choice, so
it is stated once here instead of typed at every citation site.
Values are typst source, like every other field in the module, so #emph
italicizes the title as Rule 4.2 requires — no schema change was needed for
this, because short was already eval'd as markup.
Overrides apply before the audit, so a resolved collision stops being
reported. An override naming a key not in the .bib is fatal: a stale
override reads as though the disambiguation was handled while the citation it
was meant to fix still renders bare.
Only keys that are actually short-cited need an entry — bluebook.typ reaches
_short-form only on a repeated key, so a collision between two
cited-exactly-once keys is noise. On a 182-entry .bib, 11 overrides took the
audit from 11 problems to 8, and all 8 survivors were that kind of noise.
entries schema — pincites are site-level
"Key": (full: "…up to the pin insertion point", date: " (2019)",
pin-sep: ", " | " ", short: "Bebchuk & Hirst" | none) // all required
Bluebook puts a first reference's pincite inside the citation, before the
date — 2029, tbl.1 (2019), never 2029 (2019), tbl.1. A flat full string
cannot express that, so full stops at the seam and date carries the rest.
All four fields are required; a pre-split entry panics rather than rendering
until the first pincite and then misplacing it.
The generator does not infer that seam, it asks citeproc — one probe round
cites every key with a numeric sentinel locator, and full/pin-sep/date are
read off wherever the style put it. Inference was tried and was wrong three ways:
a case whose parenthetical carries a court (123 F.3d 456 (2d Cir. 2019)) has no
bare (YYYY) to split on, an entry with no date at all put the pincite after the
URL, and deriving the separator from the BibTeX entry type mislabels books —
Berle (no page) really does take Rule 15's bare space, but Lund & Robertson
(a book with a page) takes ", ", and an @BOOK test cannot tell them apart.
The sentinel must be numeric: this style treats a non-numeric locator as an
appendage and renders it after the date.
An earlier schema baked the first site's pin into full, and _full-form took
no pin at all while _short-form and _id-form both did. That asymmetry was
invisible while the data was frozen and fatal the moment it came from a .bib: a
generated entry has no pin to bake, so every first-reference pincite would have
vanished. Supplying a pin to an entry with no date field now panics rather
than appending it after the date.
That forces two body files, because their requirements are incompatible:
body-src.typ editing surface, live #cite(<Key>) <- main.typ compiles this
| expand_citations.py (typst query <bb-out>)
body.typ canonical artifact, citations rendered
| <- build.py, reconcile.py
.docx
// main.typ
#import "bluebook.typ"
#show cite: it => bluebook.rule(it, entries: entries, id-overrides: overrides)
uv run --script "${CLAUDE_SKILL_DIR}/scripts/expand_citations.py" \
--main main.typ --src body-src.typ --out body.typ --check
Verified on a 59-page law review manuscript: 67 citation sites, 38 keys, every
citation byte-identical to what pandoc-citeproc produced, and the generated
body.typ byte-identical to the pre-existing canonical file. Inserting one
footnote shifted every reference (supra note 1 → 2, 16 → 17) with no
edits.
The cost: reconcile.py merges a coauthor's edits into body.typ, the
literal form. Carrying them back to body-src.typ is manual, and a coauthor who
edits inside a citation string has to be reconciled by hand.
Comments
uv run --script "${CLAUDE_SKILL_DIR}/scripts/comments.py" --from-docx returned.docx
uv run --script "${CLAUDE_SKILL_DIR}/scripts/comments.py" --from-drive <fileId>
Both backends emit one schema — {id, author, created, modified, text, quoted, resolved, replies[]} — so nothing downstream branches on where the document came from. Drive is
read-only here by design; there is no write path back.
Facts
-
Show rules in the file pandoc reads collapse = Heading into a bold paragraph.
Pandoc evaluates them before writing the docx, the build still succeeds, and the damage
surfaces only when someone opens Word's navigation pane and finds it empty. build.py
refuses a body carrying #show/#set/#let/#import for this reason. Reaching for
--allow-styling to get past the error ships a headingless document to a coauthor —
the opposite of the help that motivated skipping the split.
-
A live #cite can never reach the canonical fixed point. pandoc's docx
writer emits a Cite node as the bare text [Key], so typ → docx → typ turns
#cite(<Bebchuk2019-uq>) into \[Bebchuk2019-uq\]. Everything else can work
— the PDF, the docx, the Word render — and --check still fails, which is how
this surfaces: late, after the build is green. Separating the symbolic source
from the generated literal body is the only arrangement that satisfies both
the citation automation and reconcile.py.
-
A custom function name in a body file is a HARD pandoc error, not a degraded
render. #cite-bb(...) gives "body.typ" (line 1, column 18): Identifier "cite-bb" not found and NO docx is produced. pandoc does understand the
built-in #cite(<Key>) and @Key, lowering them to real Cite nodes, so a
custom citation renderer must be a #show cite: rule over the built-in rather
than a new function. This is why the split above uses the built-in spelling in
the body and keeps the renderer in main.typ.
-
Footnote numbers are a LAYOUT property, so a citation renderer cannot ask
for them directly. typst assigns them during layout while citation
processing is a prepass — which is very likely why hayagriva never populates
first-reference-note-number, and why patching hayagriva would not fix it.
The way through is to defer: emit each site as #metadata, then resolve with
query() and counter(footnote).at(site.location()). Non-cyclical, and it
survives an inserted footnote because nothing is hard-coded.
-
typst GROUPS adjacent cites and swallows the separator between them.
#cite(<a>); #cite(<b>) is one citation group, and the disappears —
a stacked footnote renders with nothing
between the two sources. This is invisible in the source and only shows up
in the PDF, in the construct law review footnotes use most. Wrapping the
separator in a content block — — breaks the
grouping; and a bare do not.
dissolves those wrappers on the way out, because is a layout device
with no meaning in the literal body and the docx round trip flattens it —
leaving it would make fail on a file
calls up to date.
Red Flags — STOP
| Action | Why wrong | Do instead |
|---|
About to point pandoc at main.typ | Its #show rules destroy heading semantics in the docx | Point it at body.typ |
About to pass --allow-styling to clear a lint error | Ships a headingless Word file | Move the directives into main.typ |
About to hand-edit the returned .docx and call it reconciled | The repo source still diverges; the next build overwrites the edits | Run reconcile.py and merge into the source |
About to resolve <<<<<<< markers by deleting one side wholesale | Discards a coauthor's edit unreviewed | Read both sides; ask the user when the prose choice is theirs |
About to commit a merge without reading .merged.typ.diff | The merge is a claim about someone else's edits, unverified | Read the diff, then commit |
About to send a .docx built from an uncommitted source | Fallback 3 needs a committed blob; the ancestor is unrecoverable | Canonicalize, commit, then build |
About to run --from-docx without --media-dir because the error is in the way | Every figure is dropped and the output still looks complete | Name the sidecar directory; it is one argument |
About to invoke a script with uv run python3 <path> | The PEP 723 header is ignored and the lxml scripts die on import | uv run --script <path> |
About to hand-fix Officers" in a recovered file | The converter did it, not the source; hand-fixes are re-corrupted next pass | Re-recover with current canonicalize.py, which restores ’ |
| About to name a custom citation function in the body file | pandoc dies with Identifier not found and no docx is produced | #show cite: over the built-in #cite(<Key>) |
About to put live #cite in the file merges into |
Verifying a change to this skill
./scripts/check-tests.sh docx_typst
tests/docx_typst_test.py pins the pandoc behaviors this skill rests on — the fixed
point, reference-doc styles, tracked-changes ancestry, comment extraction, and the three
defects above. They are properties of an external binary this repo does not pin, so they
are asserted rather than trusted.
Two of those tests pin a BUG rather than a feature: test_pandoc_still_emits_an_ unparseable_single_column_table and test_pandoc_still_misreads_a_word_final_apostrophe
fail when pandoc FIXES the defect. That is the intended signal — it is how the
normalization gets retired instead of quietly outliving its reason.
A second model reviews this skill, and it earns its keep. The codex and gemini passes
over the change above both independently found the figure-naming defect (an unedited
return read as having every figure edited), the unprotected columns: rewrite corrupting
quoted source, and the image( call counted from inside a code sample. All three
reproduced and are pinned in C12/C13. One reported finding — "an authored one-cell callout
box is flattened" — did NOT reproduce as written, because a single-line cell never matched
the line shape; the predicate was tightened anyway, since a multi-line one would have.
Verify a third-party finding before acting on it, and pin the ones that survive.
Test against real Word output, not only the fabricated fixtures. Every defect in the
list above survived a green suite, because a docx pandoc wrote does not contain the
structures Word writes. A change to the conversion path is not verified until it has run
over an actual Word manuscript with tables and figures in it.
Scope
Owns typst → docx, and Bluebook citation rendering in typst — the renderer lives
here rather than in bluebook because it is co-designed with expand_citations.py, which
reads the <bb-out> tag it emits, and that two-file split exists only because of the docx
round trip. bluebook owns the RULES; this skill owns making typst emit them.
law-review-docx and law-econ-docx own markdown → docx; different input format, no
overlap — a markdown manuscript gets Bluebook from pandoc-citeproc and the bundled CSL, and
needs nothing here. docx-repair fixes OOXML damage from a cloud round trip and composes
cleanly before reconcile.py when a returned file is also damaged.