bun-docx 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [![Watch: Claude filling out and redlining an NDA](https://cdn.loom.com/sessions/thumbnails/da70269a970f42caa138fb3389b4b9cc-f477402396d4154d-full-play.gif#t=0.1)](https://www.loom.com/share/da70269a970f42caa138fb3389b4b9cc)
4
4
 
5
- **Let your AI agent review Word documents the way a human would.** Leave comments, suggest redlines, never break the formatting or remove content. You accept or reject in Word.
5
+ **A `.docx` CLI built for AI agents.** Leave comments, suggest redlines, and edit Word documents without breaking the formatting or losing content a human accepts or rejects in Word afterward.
6
6
 
7
7
  - Hand a `.docx` to Claude or Codex and get back a redlined copy with comments — open it in Word, accept or reject as usual.
8
- - Agents see a structured AST with stable character offsets (`p3:5-20`); humans see normal Word formatting on disk.
9
- - Custom styles, theme colors, embedded objects — all of it survives. We mutate XML in place rather than re-emitting from a lossy model.
8
+ - Agents address text by **stable locators** with character offsets (`p3:5-20`); humans see normal Word formatting on disk.
9
+ - Custom styles, theme colors, embedded objects — all of it survives. The CLI mutates XML in place rather than re-emitting from a lossy model.
10
10
 
11
11
  ## Install
12
12
 
@@ -38,19 +38,19 @@ bunx bun-docx read doc.docx
38
38
  The repo includes a Common Paper Mutual NDA template at `tests/fixtures/mnda.docx`. Below are the primitives an agent would compose to fill in the cover page and leave redline edits — the same flow shown in the video above. Every command was verified end-to-end against the fixture:
39
39
 
40
40
  ```sh
41
- # Make a copy first — there's no undo
41
+ # Make a copy first — there's no undo (git is the history; the CLI overwrites in place)
42
42
  cp tests/fixtures/mnda.docx mnda-filled.docx
43
43
 
44
44
  # Read the cover-page table so the agent knows what placeholders exist
45
- docx read mnda-filled.docx --to t1
45
+ docx read mnda-filled.docx --from t1 --to t1
46
46
 
47
47
  # Fill the yellow-highlighted bracketed placeholders
48
- docx replace mnda-filled.docx "Fill in: todays date" "May 6, 2026"
48
+ docx replace mnda-filled.docx "Fill in: today's date" "May 6, 2026"
49
49
  docx replace mnda-filled.docx "fill in state and/or county" "California"
50
50
  docx replace mnda-filled.docx "fill in state" "California"
51
51
  docx replace mnda-filled.docx "Fill in, if any." "None."
52
52
 
53
- # Verify nothing's left to fill
53
+ # Verify nothing's left to fill (bare locator lines, one per match; nothing → exit 0)
54
54
  docx find mnda-filled.docx '\[(Fill|fill)[^]]*\]' --regex --all
55
55
 
56
56
  # Flip on tracked changes for the redline pass
@@ -61,76 +61,128 @@ docx replace mnda-filled.docx \
61
61
  "having a reasonable need to know" \
62
62
  "with a documented need to know"
63
63
 
64
- # Leave a comment for the human reviewer
65
- docx comments add mnda-filled.docx --range p7:0-30 \
64
+ # Leave a comment for the human reviewer — addresses an existing span with --at
65
+ docx comments add mnda-filled.docx --at p7:0-30 \
66
66
  --text "Should we narrow 'representatives' to a named list?"
67
67
  ```
68
68
 
69
- Open `mnda-filled.docx` in Word: tracked changes and comments appear in the review pane, ready to accept, reject, or reply. Or run `docx track-changes accept --all mnda-filled.docx` to bake them in from the CLI.
69
+ Open `mnda-filled.docx` in Word: tracked changes and comments appear in the review pane, ready to accept, reject, or reply. Or run `docx track-changes accept mnda-filled.docx --all` to bake them in from the CLI.
70
70
 
71
- ## Commands
71
+ ## `docx <command> --help` is the authoritative contract
72
+
73
+ > **Agents: run `docx <command> --help` before composing a call.** Every command's `--help` is the source of truth for its flags, locator forms, and exact output shape — this README is a map, not the territory. Two more must-reads:
74
+ >
75
+ > - **`docx info locators`** — the canonical locator grammar (`--json` for a machine-readable form). The top-level `docx --help` says it outright: *"It is highly recommended to agents to run `docx info locators` to understand their capabilities."*
76
+ > - **`docx info schema`** — the AST type definitions (`--ts` for TypeScript source) that `read --ast` emits.
77
+
78
+ ## Command reference
79
+
80
+ `docx <verb>` and `docx <noun> <verb>`. Every command has `--help`. Two groups: **read/query** commands print data to stdout; **mutate** commands change the file (and accept `--dry-run`, `-o/--output PATH`, `-v/--verbose`).
81
+
82
+ ### Read & query (print to stdout, never write the file)
83
+
84
+ ```sh
85
+ docx read FILE [--from LOC] [--to LOC] [--accepted | --baseline | --current] [--comments]
86
+ docx read FILE --ast # JSON-AST instead of Markdown (disables the markdown-only flags)
87
+ docx find FILE QUERY [--regex] [--ignore-case] [--all] [--nth N] [--current | --baseline] [--exact] [--json]
88
+ docx find FILE (--highlight COLOR|any | --color HEX | --bold | --italic | --underline) [--all] [--json] # find by formatting (no QUERY)
89
+ docx wc FILE [LOCATOR] [--accepted | --baseline | --current] [--json]
90
+ docx outline FILE [--style-prefix S] [--json]
91
+ docx styles FILE [--used] [--at STYLEID] [--json] # the style catalog (not in the body) — what --style NAMEs exist
92
+ docx render FILE [--out DIR] [--engine word|libreoffice|auto] [--dpi N] [--pages 1-N] [--format png|jpg]
93
+
94
+ docx comments list FILE [--include-resolved] [--thread cN]
95
+ docx footnotes list FILE
96
+ docx endnotes list FILE
97
+ docx images list FILE
98
+ docx hyperlinks list FILE
99
+ docx track-changes list FILE
100
+
101
+ docx info schema [--ts]
102
+ docx info locators [--json]
103
+ ```
104
+
105
+ `docx read` surfaces structural facts the Markdown body can't show as HTML-comment
106
+ annotations (`<!-- docx:TYPE … -->`). These are **read-time visibility hints** — the
107
+ agent can SEE the structure, but the importer drops them (the structure survives
108
+ normal edits in place, `read --ast` is the lossless view, and `docx columns` /
109
+ `insert --section` / `docx tables …` manage it). They're emitted **deviation-only**
110
+ (only when a value differs from the document default, so a plain document stays
111
+ clean):
112
+
113
+ - **Section breaks** render as `<!-- docx:section sN cols="2" type="continuous" -->`
114
+ on their own line — never a bare `---` (that's a thematic break, and emitting it
115
+ for a section silently turned layout into border paragraphs). A hand-authored
116
+ `---` now unambiguously means a thematic break.
117
+ - **Page geometry** rides a leading `<!-- docx:page orientation="landscape"
118
+ size="…in" margins="…in" text-width="…in" -->` note when the page deviates from
119
+ US-Letter-portrait-1″ — `text-width` is the usable column width. Exact twips are
120
+ in `read --ast` (on each section break: `pageWidth`/`pageHeight`/`pageOrientation`/
121
+ `margin*`).
122
+ - **Tables** carry a leading `<!-- docx:table t0 widths="1,2,3in" borders="double" -->`
123
+ when columns are uneven or borders deviate from the default, plus a per-cell
124
+ `<!-- docx:cell t0:r0c0 gridSpan="2" vMerge="continue" shading="FFE699" -->`
125
+ note on merged/shaded cells — so structure invisible in GFM is visible
126
+ (`Table.borders` / `TableCell.shading` in `read --ast`).
127
+ - **Images** trail a `<!-- docx:image img0 size="6.2x4.1in" float="yes" wrap="square" align="center" overflow="yes" -->`
128
+ note: `size` always (the `![](hash)` alone doesn't say "6in wide"), and
129
+ `float`/`wrap`/`align`/`overflow` only when they deviate (an inline, in-bounds
130
+ image shows just its size). `overflow` flags an image wider than the usable text
131
+ column (`ImageRun.floating`/`wrap`/`align` + EMU extents in `read --ast`).
132
+
133
+ ### Mutate (change FILE in place; `--dry-run`, `-v` everywhere; `-o PATH` on every mutator except `create`, whose positional FILE is already the output)
72
134
 
73
135
  ```sh
74
- docx create FILE [--title T] [--author A] [--text "..."]
75
- docx read FILE [--from pN] [--to pN] [--accepted | --baseline | --current] [--comments]
76
- docx read FILE --ast # JSON-AST opt-in (every other read flag is markdown-only)
77
- docx insert FILE --after p3 --text "..." [--style HeadingN] [--color HEX] [--bold] [--italic] [--url URL]
78
- docx insert FILE --after p3 --runs '[{"type":"text","text":"X","bold":true}]'
79
- docx insert FILE --after p3 --page-break | --column-break
80
- docx insert FILE --after p3 --section [--columns N] [--type continuous|nextPage|evenPage|oddPage|nextColumn]
81
- docx insert FILE --after p3 --table --rows N --cols N [--widths "A,B,C"] [--table-width 100%] [--borders single|none|double] [--layout autofit|fixed]
82
- docx insert FILE --after p3 --image SRC [--alt TEXT] [--width INCHES] [--height INCHES] # SRC = path, data: URI, or http(s) URL
83
- docx insert FILE --after p3 --code "..." [--language LANG] # one CodeBlock paragraph per \n; --language → syntax highlight
84
- docx insert FILE --after p3 --code-file PATH [--language LANG] | --code-file - # same, from file or stdin
85
- docx insert FILE --after p3 --task checked|unchecked --text "..." [--list-level N] # GFM task list item
86
- docx insert FILE --after p3 --list bullet|ordered --text "..." [--list-level N] # plain list item
87
- docx insert FILE --after p3 --equation "x^2 + y^2" [--display] # LaTeX OMML via temml + own MathML→OMML adapter
88
- docx edit FILE --at p3 --task checked|unchecked # flip an existing task's state; track-changes emits checkboxToggle
89
- docx edit FILE --at eq3 --equation "x^3" [--display|--inline] # replace equation content and/or toggle display mode
90
- docx edit FILE --at p3 --text "..." [--no-formatting] # word-level diff preserves bold/italic on unchanged words
91
- docx edit FILE --at p3 --runs '[...]'
92
- docx edit FILE --at p3 --code "..." [--language LANG] # replace paragraph with a code block (expands to N lines)
93
- docx edit FILE --at p3 --code-file PATH [--language LANG]
94
- docx edit FILE --at p2-p5 --text "..." # range replace: collapse paragraph range to one paragraph
95
- docx edit FILE --at p2-p5 --code-file new.go --language go # range replace with a fresh code block
96
- docx edit FILE --at s0 [--columns N] [--type T] # mutate section properties
97
- docx delete FILE --at p3
98
- docx delete FILE --at p2-p5 # range delete: drop a contiguous paragraph span
99
- docx delete FILE --at s0 # strip an inline sectPr
100
-
101
- docx find FILE QUERY [--regex] [--ignore-case] [--all] [--nth N] [--current | --baseline] [--exact]
102
- docx replace FILE PATTERN REPLACEMENT [--regex] [--ignore-case] [--all] [--limit N] [--current | --baseline] [--exact] [--dry-run]
103
-
104
- docx wc FILE [LOCATOR] [--accepted | --baseline | --current]
105
- docx outline FILE
106
-
107
- docx comments add FILE --range p3:5-20 --text "..." [--author NAME] [--current | --baseline]
136
+ docx create FILE [--title T] [--author A] [--text "..." | --from PATH.md | --from -] [--force]
137
+ docx insert FILE (--after | --before) LOCATOR <content> # LOCATOR = pN | tN | sN | tN:rRcC:pK
138
+ docx edit FILE --at LOCATOR <content> # LOCATOR = pN | pN:S-E | pN-pM | sN | eqN | tN:rRcC:pK[:S-E]
139
+ docx delete FILE --at LOCATOR # LOCATOR = pN | pN-pM | tN | sN
140
+ docx columns FILE --at LOCATOR --count N [--type T] # LOCATOR = pN-pM | pN (wrap a range) | sN (recount an existing section)
141
+ docx replace FILE PATTERN REPLACEMENT [--regex] [--ignore-case] [--all] [--limit N] [--current | --baseline] [--exact] [--track] [--dry-run]
142
+
143
+ # Batch apply many changes from ONE read (no re-reading between edits). Keys
144
+ # on each JSONL line mirror the command's flags; all locators address the doc as
145
+ # read. insert/edit also accept --batch - to read JSONL from stdin.
146
+ docx edit FILE --batch fills.jsonl # { at, <one of: text|clear|markdown|runs|code|task>, style?, }
147
+ docx insert FILE --batch additions.jsonl # { after|before, <content>, style?, color?, }
148
+ docx replace FILE --batch script.jsonl # { pattern, replacement, regex?, all?, limit?, } applied in order
149
+ docx delete FILE --batch drop.jsonl # { at } per line whole blocks (pN/tN/cell), resolved live-first
150
+
151
+ # All four of insert/edit/delete/replace accept --track to record that one
152
+ # invocation as a tracked change even when the doc's track-changes toggle is off.
153
+ #
154
+ # insert/edit content selectors (run "docx insert --help" / "docx edit --help" for the full list):
155
+ # --text "..." [--style NAME] [--alignment A] [--color HEX] [--bold] [--italic] [--url URL]
156
+ # (a newline in --text becomes a line break <w:br/>, a tab becomes <w:tab/> verse/addresses stay line-per-line)
157
+ # --runs '[{"type":"text","text":"X","bold":true}]'
158
+ # --markdown "..." | --markdown-file PATH # GFM + math + CriticMarkup + inline HTML formatting → blocks
159
+ # --code "..." | --code-file PATH [--language LANG]
160
+ # --equation "x^2 + y^2" [--display] (insert; edit also accepts --inline)
161
+ # --clear bold,italic,highlight,color,size,font,…|all (edit; strip run formatting, keep text)
162
+ # --task checked|unchecked | --list bullet|ordered [--list-level N] (insert)
163
+ # --task checked|unchecked (edit, flip in place)
164
+ # --table --rows N --cols N [--widths "A,B,C"] [--table-width V] [--borders S] [--layout L] (insert)
165
+ # --image SRC [--alt T] [--width IN] [--height IN] [--caption "Figure 1: …"] (insert; SRC = path, data: URI, or http(s) URL; --caption adds a Word "Caption"-styled line under the figure)
166
+ # --page-break | --column-break | --section [--columns N] [--type T] (insert)
167
+
168
+ docx comments add FILE --at LOCATOR --text "..." [--author NAME] [--current | --baseline]
108
169
  docx comments add FILE --anchor "phrase" --text "..." [--occurrence N]
109
- docx comments add FILE --batch reviews.jsonl # JSONL: { range | anchor (+occurrence), text, author? }
110
- docx comments reply FILE --to c0 --text "..."
111
- docx comments resolve FILE --id c0 [--unset]
112
- docx comments resolve FILE --id c1 --id c3 [--unset] # repeatable
113
- docx comments resolve FILE --batch resolutions.jsonl
114
- docx comments delete FILE --id c0
115
- docx comments delete FILE --id c1 --id c3 # repeatable
116
- docx comments delete FILE --batch removals.jsonl
117
- docx comments list FILE [--include-resolved] [--thread c0]
118
-
119
- docx footnotes add FILE --at pN[:offset] --text "..."
120
- docx footnotes edit FILE --id fnN --text "..."
121
- docx footnotes delete FILE --id fnN
122
- docx footnotes list FILE
123
- docx endnotes add FILE --at pN[:offset] --text "..."
124
- docx endnotes edit FILE --id enN --text "..."
125
- docx endnotes delete FILE --id enN
126
- docx endnotes list FILE
127
-
128
- docx images list FILE
129
- docx images extract FILE --to ./media [--id imgN]
170
+ docx comments add FILE --batch reviews.jsonl # JSONL: { at | anchor (+occurrence), text, author? }
171
+ docx comments reply FILE --at cN --text "..."
172
+ docx comments resolve FILE --at cN [--at cM ...] [--unset] | --batch resolutions.jsonl
173
+ docx comments delete FILE --at cN [--at cM ...] | --batch removals.jsonl
174
+
175
+ docx footnotes add FILE --at pN[:offset] (--text "..." | --runs JSON | --markdown TEXT)
176
+ docx footnotes edit FILE --at fnN (--text "..." | --runs JSON | --markdown TEXT)
177
+ docx footnotes delete FILE --at fnN
178
+ docx endnotes add FILE --at pN[:offset] (--text "..." | --runs JSON | --markdown TEXT)
179
+ docx endnotes edit FILE --at enN (--text "..." | --runs JSON | --markdown TEXT)
180
+ docx endnotes delete FILE --at enN
181
+
182
+ docx images extract FILE --to DIR [--at imgN] # --to = output directory; --at picks one image
130
183
  docx images replace FILE --at imgN --with ./new.png
131
184
  docx images delete FILE --at imgN
132
185
 
133
- docx hyperlinks list FILE
134
186
  docx hyperlinks add FILE --at pN:S-E --url URL
135
187
  docx hyperlinks replace FILE --at linkN --with URL
136
188
  docx hyperlinks delete FILE --at linkN
@@ -144,97 +196,146 @@ docx tables merge FILE --at tN:rR1cC1-rR2cC2
144
196
  docx tables unmerge FILE --at tN:rRcC
145
197
  docx tables borders FILE --at tN [--style single|double|none] [--size N] [--color HEX]
146
198
 
147
- docx track-changes FILE on|off
148
- docx track-changes list FILE
199
+ docx track-changes on|off FILE
149
200
  docx track-changes accept FILE (--at tcN [--at tcM ...] | --all)
150
201
  docx track-changes reject FILE (--at tcN [--at tcM ...] | --all)
151
- docx info schema [--ts]
152
- docx info locators [--json]
153
202
  ```
154
203
 
155
- Every command has `--help`. Mutating commands accept `--dry-run`, `-o/--output PATH` (write to a parallel file instead of overwriting `FILE`), and `-v/--verbose` (print the JSON ack — see "Quiet by default" below).
204
+ > **One rule to memorize: addressing an existing thing is always `--at`.**
205
+ > `comments reply/resolve/delete`, `footnotes/endnotes edit/delete`, `images extract/replace/delete`, `hyperlinks replace/delete`, `tables *`, `track-changes accept/reject`, `edit`, and `delete` all take `--at LOCATOR`. The exceptions are positional or directional by nature: `insert` uses `--after`/`--before LOCATOR`; `read` slices with `--from`/`--to LOCATOR`; `wc` takes a positional `[LOCATOR]`; `find`/`replace` take a positional `QUERY`/`PATTERN`. `images extract --to DIR` is an *output directory*, not a locator.
156
206
 
157
- **Quiet by default.** Mutators (`create`, `insert`, `edit`, `delete`, `replace`, `comments add/reply/resolve/delete`, `footnotes add/edit/delete`, `endnotes add/edit/delete`, `images replace/delete`, `hyperlinks add/replace/delete`, `tables *`, `track-changes` toggle/accept/reject) print nothing on success and exit 0. Errors always print as `{ok: false, code, error, hint}`. Pass `-v`/`--verbose` to get the full JSON ack. Read commands (`read`, `find`, `wc`, `outline`, `info *`, `*-list`) print their data unconditionally. Batch operations that mint new ids — `comments add --batch`, `comments delete --batch`, `comments resolve --batch`, and `comments delete/resolve` with multiple `--id` — always print the affected ids since the agent can't reconstruct them otherwise.
207
+ ## Output contract
158
208
 
159
- ### Markdown rendering
209
+ The CLI is built for non-interactive agents. **Exit code is the success signal**, output is data:
160
210
 
161
- `docx read FILE` renders the document body as GitHub-flavored Markdown — the default since v0.9. Useful when an LLM wants to skim a doc without parsing the AST. Each rendered paragraph is followed by an HTML comment with its locator (`<!-- p3 -->`) so the markdown is invisible-pinned: humans see clean prose in a renderer, agents parse the locators from raw text. Pass `--ast` for the structured JSON when programmatic walks need the typed tree (`docx read FILE --ast | jq '.blocks[] | select(.type == "paragraph")'`).
211
+ | Exit | Meaning | Error codes |
212
+ | ---- | ------- | ----------- |
213
+ | `0` | success | — |
214
+ | `2` | usage / bad locator | `USAGE`, `INVALID_LOCATOR` |
215
+ | `3` | addressed thing not found | `FILE_NOT_FOUND`, `PART_NOT_FOUND`, `BLOCK_NOT_FOUND`, `COMMENT_NOT_FOUND`, `IMAGE_NOT_FOUND`, `HYPERLINK_NOT_FOUND`, `TRACKED_CHANGE_NOT_FOUND`, `MATCH_NOT_FOUND` |
216
+ | `1` | general failure | `NOT_A_ZIP`, `TRACKED_CHANGE_CONFLICT`, `TABLE_STRUCTURE`, `IMAGE_SOURCE`, `RENDER_ENGINE`, `RENDER_FAILED`, `UNHANDLED` |
162
217
 
163
- `--from LOC` and `--to LOC` slice by top-level block (both inclusive). Accepts paragraph, table, cell, span, and range locators; cell/span/range collapse to their enclosing top-level block.
218
+ **Errors** print `{code, error, hint?}` JSON to stdout with a nonzero exit note there is **no `ok` field**; the exit code plus `code` are the unambiguous signal.
164
219
 
165
- **Tracked changes three views.** Default is the **accepted** view: `<w:del>` and `<w:moveFrom>` runs are dropped; `<w:ins>` and `<w:moveTo>` runs render as plain text. Three mutually-exclusive flags select the view (the default needs no flag):
220
+ **The `ok` field appears in exactly one place: the `--verbose` success ack** (`{ok:true, operation, path, …}`). Without `-v`, success output is shaped for the next command:
166
221
 
167
- - `--accepted` explicit alias for the default. Post-accept view.
168
- - `--baseline` pre-change view: drops `<w:ins>` and `<w:moveTo>`, renders `<w:del>` and `<w:moveFrom>` as plain text.
169
- - `--current`raw concatenation, with diff markup. Additive wrappers render as CriticMarkup `{++text++}[^tcN]` and subtractive as `{--text--}[^tcN]`, with `[^tcN]: …` definitions appended at end.
222
+ | Command class | Default stdout on success | `--verbose` |
223
+ | ------------- | ------------------------- | ----------- |
224
+ | **Mutator that mints a new handle** `comments add`→`cN`, `comments reply`→`cN`, `footnotes/endnotes add`→`fnN`/`enN`, `hyperlinks add`→`linkN`, `insert`→the new `pN` | the bare locator(s), **one per line** (a multi-block `--markdown` insert prints several) | full `{ok:true,…}` ack |
225
+ | **Mutator with no new handle** — `edit`, `delete`, `replace`, `create`, `comments resolve/delete`, `images replace/delete`, `hyperlinks replace/delete`, `footnotes/endnotes edit/delete`, `tables *`, `track-changes accept/reject` & toggle | **one-line confirmation** — `<operation> <target>` (e.g. `edit t1:r0c1:p0`, `edit 7 changes`, `replace 0 occurrences replaced`) (exit `0`) | full `{ok:true,…}` ack |
226
+ | `find` | matched span locators, one per line (no matches → nothing, exit `0`) | `--json` → `{ totalMatches, query, view, matches:[…], normalizedQuery? }` |
227
+ | `wc` | the bare count (whole-doc adds a tab-separated `sections` column, like `wc`) | `--json` → `{ words, scope, view, sections? }` |
228
+ | `outline` | indented `LOCATOR⇥TEXT` tree (two spaces per level) | `--json` → nested `[{ id, locator, level, style, text, children }]` |
229
+ | `read` | GFM Markdown, each paragraph trailed by `<!-- pN -->` | `--ast` → the JSON AST body (`docx info schema`) |
230
+ | `render` | image paths, one per line | `--verbose` → `{ok, operation, path, engine, output, pages}` |
231
+ | `* list` (all six `list` verbs) | a **bare JSON array**; each item's `id` is its `--at` handle | — |
170
232
 
171
- The same `--current`/`--baseline` flags apply to `find`, `replace`, `wc`, and `comments add` so offsets stay consistent across commands (default everywhere is the accepted view). `comments add` uses the same view to resolve `--anchor` matches and `--range` offsets agents can pipe `find` output to `comments add` without coordinate translation.
233
+ `--dry-run` always prints a preview object (no `ok`) and writes nothing; it wins over `-o/--output`.
172
234
 
173
- `docx wc` also accepts `sN` as a locator — the count covers every paragraph and table in that section's content range. Whole-document `wc` returns a `sections` count alongside `words`.
235
+ ## Discovering ids
174
236
 
175
- `find` and `replace` auto-normalize their query/pattern by default: balanced markdown emphasis around non-whitespace (`**X**`, `__X__`, `*X*`, `` `X` ``) is stripped; smart and straight quotes are equivalent; em-dash and en-dash collapse to a hyphen. Pass `--exact` to disable normalization. `--regex` is always verbatim. `find`'s response surfaces `normalizedQuery` / `normalizationApplied` when normalization changed the query so the agent sees what was actually searched.
237
+ Locators come in two flavors. **Positional block ids** (`pN`, `tN`, `sN`) are derived from document order and **shift after structural edits** — re-read between non-trivial mutations. **Entity ids** (`cN`, `imgN`, `linkN`, `fnN`, `enN`, `tcN`, `eqN`) are surfaced by a `list` verb (or `read --ast`) and are what you pass to `--at`:
176
238
 
177
- `--comments` appends a GFM footnote reference (`[^cN]`) at the end of each commented span and emits one footnote definition per comment at the end of the output. Footnotes/endnotes (the document's own `<w:footnoteReference>` / `<w:endnoteReference>`) are rendered unconditionally as `[^fnN]` / `[^enN]`. Run `docx info schema` for the full mapping table.
239
+ | Id | Discover with | Used by |
240
+ | -- | ------------- | ------- |
241
+ | `pN` / `tN` / `sN` (block ids) | `docx read FILE` (the `<!-- pN -->` trailers), `docx read FILE --ast`, `docx outline FILE` (heading `pN`s), or `docx render` page images | `read`, `edit`, `insert`, `delete`, `wc`, `find` results |
242
+ | `cN` (comment) | `docx comments list FILE` | `comments reply/resolve/delete --at` |
243
+ | `fnN` / `enN` (foot/endnote) | `docx footnotes list FILE` / `docx endnotes list FILE` | `footnotes/endnotes edit/delete --at` |
244
+ | `imgN` (image) | `docx images list FILE` | `images extract/replace/delete --at` |
245
+ | `linkN` (hyperlink) | `docx hyperlinks list FILE` | `hyperlinks replace/delete --at` |
246
+ | `tcN` (tracked change) | `docx track-changes list FILE` | `track-changes accept/reject --at` |
247
+ | `eqN` (equation) | `docx read FILE --ast` (run `latex` field) | `edit --at eqN --equation` |
178
248
 
179
- ### Bulk + anchored comments
249
+ Each `list` verb prints a bare JSON array where every item's `id` is exactly the handle you feed back to `--at` — pipe through `jq` to filter (`docx comments list doc.docx | jq '.[] | select(.author=="Jane")'`).
180
250
 
181
- `comments add --anchor "phrase"` resolves the phrase via the same matcher as `docx find` (default accepted view, query normalization on) and anchors the comment to that match. Pass `--occurrence N` (1-indexed) to disambiguate when the phrase matches more than once; without it, an ambiguous anchor errors atomically before any writes.
251
+ ## Locators
182
252
 
183
- `comments add --batch FILE.jsonl` (or `--batch -` for stdin) takes one JSON object per line: `{range | anchor (+ optional occurrence), text, author?}`. The whole batch validates against the pre-mutation tree first and aborts cleanly if any entry fails. `comments delete` and `comments resolve` accept `--batch FILE.jsonl` (`{"id": "cN"}` per line) or repeated `--id c1 --id c3`. Atomic in all cases — no partial writes on error.
253
+ `docx info locators` (`--json` for machine-readable) is the canonical reference. The grammar in brief:
184
254
 
185
- ### Formatting preservation
255
+ ```
256
+ pN paragraph N pN:S-E chars S..E within paragraph N
257
+ pN-pM whole-paragraph range pN:S-pM:E cross-paragraph character range
258
+ sN section break N tN table N
259
+ tN:rRcC cell at row R, col C tN:rRcC:pK paragraph K of that cell (chainable)
260
+ tN:rR / tN:cC table row R / column C tN:rR1cC1-rR2cC2 rectangular cell region (merge)
261
+ cN imgN linkN fnN enN tcN eqN entity ids (comment / image / hyperlink /
262
+ footnote / endnote / tracked-change / equation)
263
+ ```
186
264
 
187
- `docx edit --at pN --text "..."` runs a word-level diff between the existing paragraph's text and the new text, preserving `<w:rPr>` formatting (bold, italic, color, etc.) on unchanged words. New words inherit formatting via position-pairing with the deleted span (Kth inserted word inherits from the Kth deleted word, falling back to neighboring kept words when the edit group has no deletes). Pass `--no-formatting` to fall back to a single fresh run with no formatting; explicit `--color`/`--bold`/`--italic` also bypass preservation and apply uniformly to the new paragraph. Under tracking, the result is per-word `<w:del>`/`<w:ins>` markers (the same shape Word produces when an author edits a few words mid-tracking) instead of whole-paragraph del+ins.
265
+ **Offset semantics: character offsets are 0-based, start-inclusive, end-exclusive** `p3:5-20` is the 15 characters at indices 5..19 of paragraph 3. Offsets count the *visible* text of the paragraph in the selected view (accepted by default).
188
266
 
189
- ### Atomic batch accept/reject
267
+ **Nested tables chain the same syntax** arbitrarily deep — `t0:r2c1:t0:r0c0:p0` is the first paragraph of the (0,0) cell of the first table nested inside the (2,1) cell of the document's first table.
190
268
 
191
- `track-changes accept --at tc1 --at tc2 --at tc3` resolves all targets against the pre-mutation tree, deduplicates, and applies in a single call. Mid-batch renumbering doesn't shift the still-pending ids out from under the agent. Mutually exclusive with `--all`. Same shape for `reject`.
269
+ **Not every command accepts every form** each command's `--at`/`--from`/positional help lists exactly what it takes. The shapes:
192
270
 
193
- ### Locators
271
+ | Form | Accepted by |
272
+ | ---- | ----------- |
273
+ | `pN`, `tN`, `sN`, `tN:rRcC:pK` (blocks) | `read --from/--to`, `insert --after/--before`, `wc`, `comments add` |
274
+ | `pN`, `pN:S-E`, `pN-pM`, `sN`, `eqN`, `tN:rRcC:pK`, `tN:rRcC:pK:S-E` | `edit --at` (span/cell forms strip or replace just that range) |
275
+ | `pN`, `pN-pM`, `tN`, `sN` | `delete --at` |
276
+ | `pN:S-E`, `pN:S-pM:E`, `tN:rRcC:pK:S-E` (spans) | `comments add --at`, `hyperlinks add --at` (single paragraph), `find`/`wc` results |
277
+ | `pN[:offset]` (point) | `footnotes/endnotes add --at` |
278
+ | `cN` / `fnN` / `enN` / `imgN` / `linkN` / `tcN` (entities) | the matching noun's `--at` (the `c`/`fn`/`en`/`img`/`link`/`tc` prefix is optional) |
279
+ | `tN`, `tN:rR`, `tN:cC`, `tN:rRcC`, `tN:rR1cC1-rR2cC2` | the `tables` verbs |
194
280
 
195
- ```
196
- pN paragraph N (e.g., p3)
197
- pN-pM paragraph range (whole paragraphs pN..pM as a unit)
198
- pN:S-E characters S..E within paragraph N
199
- pN:S-pM:E cross-paragraph character range
200
- tN table N; tN:rRcC for cell at row R, col C
201
- sN section break N column count, type (continuous /
202
- nextPage / nextColumn / evenPage / oddPage)
203
- cN, imgN, linkN, tcN comment / image / hyperlink / tracked-change ids
281
+ ## Common workflows
282
+
283
+ **find comment.** `find` emits bare locators that drop straight into `comments add --at` (same default view, so offsets line up — no coordinate translation):
284
+
285
+ ```sh
286
+ docx comments add doc.docx --at "$(docx find doc.docx 'fatally flawed' | head -1)" \
287
+ --text "Cite a source here?"
288
+ # or anchor by phrase directly:
289
+ docx comments add doc.docx --anchor "fatally flawed" --text "Cite a source here?"
204
290
  ```
205
291
 
206
- Run `docx info locators` for the full reference.
292
+ **read → edit markdown round-trip.** `read` emits a markdown dialect that `edit --markdown` re-parses, so render → LLM-rewrite → splice-back is lossless for paragraphs/lists/quotes:
207
293
 
208
- ## How It Works
294
+ ```sh
295
+ docx read doc.docx --from p3 --to p3 # → markdown (with <!-- p3 --> trailer)
296
+ # … hand to an LLM, get a revised block back …
297
+ docx edit doc.docx --at p3 --markdown-file revised.md # multi-block source expands naturally
298
+ ```
209
299
 
210
- **In-place XML mutation.** The AST returned by `read` is a _view_ over the parsed XML tree, not a separate model. When you `edit` or `comments add`, we mutate the underlying XML nodes directly and serialize back. Anything we don't model in the AST (custom styles, theme colors, schema extensions) survives because we never re-emit untouched regions.
300
+ Use `--markdown-file` (not `--markdown TEXT`) when the source starts with `-` Node's `parseArgs` rejects leading-dash flag values.
211
301
 
212
- **JSX for emitters.** Constructing OOXML fragments imperatively (`<w:rPr>` `<w:b/>` → `<w:color w:val="800080"/>`) gets verbose. We write emitters in JSX with a custom factory: `<w.rPr><w.b/><w.color w-val="800080"/></w.rPr>` becomes the right `XmlNode` tree. Component names are PascalCase (`<Paragraph>`, `<RunProperties>`); they return `XmlNode | null` so empty wrappers get omitted automatically.
302
+ **track-changes review loop.** Toggle tracking on, make edits (they auto-emit `<w:ins>`/`<w:del>`), then inventory and resolve:
213
303
 
214
- **Span-aware comments.** `comments add --range p3:5-20` finds the runs that contain offsets 5 and 20, splits them at the boundaries (preserving rPr formatting on both halves), and inserts `<w:commentRangeStart>` / `<w:commentRangeEnd>` markers between the slices.
304
+ ```sh
305
+ docx track-changes doc.docx on
306
+ docx replace doc.docx "old phrasing" "new phrasing" --all
307
+ docx track-changes list doc.docx # → JSON array of { id:tcN, kind, author, text, … }
308
+ docx read doc.docx --current # → CriticMarkup {++ins++}[^tcN] / {--del--}[^tcN]
309
+ docx track-changes accept doc.docx --at tc0 --at tc2 # or --all
310
+ ```
215
311
 
216
- **ParaId auto-injection.** Comments authored by tools like mammoth or older Word versions lack `w14:paraId`, which `commentsExtended.xml` requires for resolve/reply. We detect this on resolve/reply and inject a fresh paraId, also adding the `xmlns:w14` namespace declaration to the `<w:comments>` root if missing.
312
+ `read` has three tracked-change views: default **`--accepted`** renders clean text drops subtractive edits and inlines additive ones (the post-accept document); **`--current`** shows CriticMarkup with `[^tcN]` footnotes; **`--baseline`** does the reverse of accepted (the pre-change document). `find`, `replace`, `wc`, and `comments add` honor the same `--accepted`/`--baseline`/`--current` flags so offsets stay consistent across commands. Add `--comments` to `read` to append `[^cN]` footnotes for comment spans.
313
+
314
+ ## How It Works
217
315
 
218
- **Cross-format image replacement.** `images replace --at img0 --with new.png` detects the new MIME type via `Bun.file().type`, renames the part (`word/media/image1.jpeg` `word/media/image1.png`), rewrites the relationship `Target`, and ensures `[Content_Types].xml` has a `<Default>` for the new extension.
316
+ **In-place XML mutation.** The AST returned by `read` is a _view_ over the parsed XML tree, not a separate model. When you `edit` or `comments add`, the CLI mutates the underlying XML nodes directly and serializes back. Anything not modeled in the AST (custom styles, theme colors, schema extensions) survives because untouched regions are never re-emitted. Never delete a relationship something still references — that corrupts the file — so part/relationship pruning is gated on a reference scan; unreferenced orphans are left in place.
219
317
 
220
- **GFM task lists.** Paragraphs in a list whose leading content is a Word checkbox content control (`<w:sdt><w14:checkbox/></w:sdt>`) surface as `paragraph.taskState: "checked" | "unchecked"` in the AST and render as `- [ ]` / `- [x]` in markdown. The reader strips the SDT subtree and its trailing space run from `runs` so the AST carries only the task text; the SDT survives untouched in the underlying `XmlNode` tree so a read→edit→save round-trip preserves every checkbox. The reader also recognizes the **Word-for-Web Checklist** shape — a bulleted list whose level-0 bullet character is Wingdings ☐ (U+F0A8), with `<w:strike>` on the paragraph-mark marking "done" — since Web silently strips SDT content controls when it authors new task lists. Both shapes were confirmed empirically against Microsoft Word for Mac desktop, Word for Web, and LibreOffice. On the emit side we always produce SDT. **Authoring**: `insert --task checked|unchecked --text "..."` creates a fresh task line (inherits the anchor's numId if it's already a list, otherwise allocates a new bullet list); `--list-level N` nests; `--list bullet|ordered` makes a plain list item without a checkbox. `edit --at pN --task checked|unchecked` flips an existing task's state in place. **Tracked toggles** (checking or unchecking under track-changes) surface as the `checkboxToggle` tracked-change kind in `track-changes list`; accept keeps the new state, reject restores the prior glyph AND flips `w14:checked` back (inferred from the kept glyph, since Word stores no separate prior-value record).
318
+ **JSX for emitters.** Constructing OOXML fragments imperatively (`<w:rPr>` `<w:b/>` `<w:color w:val="800080"/>`) is verbose, so fresh XML is authored in JSX with a custom factory: `<w.rPr><w.b/><w.color w-val="800080"/></w.rPr>` becomes the right `XmlNode` tree.
221
319
 
222
- **Math equations.** `<m:oMath>` and `<m:oMathPara>` (the Office Math markup Word, Pandoc, and LibreOffice all emit) surface as `EquationRun.latex` in the AST reconstructed LaTeX, not the legacy plaintext concatenation. Markdown render emits `$…$` inline and `$$…$$` for display equations, so a doc full of academic equations round-trips through Pandoc with high fidelity. The walker handles fractions, super/sub/mixed scripts, roots (incl. nth roots), n-ary operators (sum/product/integral/contour), accents (hat/bar/vec — both Word's combining-diacritic and Pandoc's spacing-overscript encodings), delimited expressions, matrices, aligned equation arrays, and function operators (`\sin`/`\lim`/`\log` promoted from upright-styled runs). Unrecognized OMML constructs degrade per-subtree to plaintext (`EquationRun.text` is kept as a fallback) so a niche construct doesn't corrupt the surrounding equation. **Authoring**: `insert --equation "x^2 + y^2 = r^2" [--display]` inserts an inline or display equation from LaTeX (parsed by [temml](https://github.com/ronkok/Temml) for the LaTeX-side spec coverage, then walked into OMML by our own MathML → OMML adapter — no LGPL deps). `edit --at eqN --equation NEW_LATEX` replaces the content; `--display` / `--inline` toggle the mode. `eqN` locators address equations in document order.
320
+ **Span-aware comments & hyperlinks.** `comments add --at p3:5-20` (and `hyperlinks add`) find the runs containing offsets 5 and 20, split them at the boundaries (preserving `<w:rPr>` on both halves), and insert markers between the slices. Comments authored by older tools that lack `w14:paraId` (required by `commentsExtended.xml`) get a fresh paraId injected automatically on resolve/reply.
223
321
 
224
- **Code blocks + inline code.** `insert --code TEXT` (or `--code-file PATH`, `-` for stdin) splits content on `\n` and emits one `<w:p>` per source line, all styled `CodeBlock` (Courier New, indent, adjacent-paragraph spacing collapse) with runs styled `Code` (monospace character style defensive in case Word doesn't cascade the paragraph font). Both styles get provisioned in `styles.xml` automatically. `--language LANG` syntax-highlights via [lowlight](https://github.com/wooorm/lowlight) (highlight.js); 37 common languages are bundled `bash`, `c`, `cpp`, `csharp`, `css`, `diff`, `go`, `graphql`, `ini`, `java`, `javascript`, `json`, `kotlin`, `less`, `lua`, `makefile`, `markdown`, `objectivec`, `perl`, `php`, `php-template`, `plaintext`, `python`, `python-repl`, `r`, `ruby`, `rust`, `scss`, `shell`, `sql`, `swift`, `typescript`, `vbnet`, `wasm`, `xml`, `yaml`. Unknown languages degrade silently to uncolored runs. The palette is GitHub-light inspired (keywords red, strings dark-blue, comments gray, ) and unmapped highlight.js classes fall through with no color. For an inline ``code`` span inside a normal paragraph, use `--runs` JSON with `runStyle: "Code"` (the S8 markdown walker will make this ergonomic via the `\`code\`` shorthand). On `docx read`, consecutive `CodeBlock` paragraphs collapse into one GFM fenced block (`` ``` `` `` ``` ``); inline `runStyle: "Code"` runs render with backticks.
322
+ **Tracked changes.** With `<w:trackChanges/>` set, `insert`/`edit`/`delete`/`replace` emit native `<w:ins>`/`<w:del>` (attributed via `--author`, `$DOCX_AUTHOR`, or `Reviewer`); pass `--track` to one of those commands (or the `tables` verbs / `images delete`) to track just that invocation even when the doc toggle is off. `edit --at pN --text` runs a word-level diff so unchanged words keep their formatting and only changed words are wrapped the same shape Word produces mid-tracking. `accept`/`reject` handle run-level ins/del/moveFrom/moveTo, `sectPrChange`, paragraph-mark ins/del, and the table-structural revisions (rowIns/rowDel, cellIns/cellDel, tblGridChange, tcPrChange). OOXML has no tracked-change construct for hyperlink edits or image swaps, so under tracking those emit a `[docx-cli]` audit comment instead of a fake revision (image *deletion* is honest removal it wraps a real `<w:del>`).
225
323
 
226
- **Image insertion.** `insert --image SRC` resolves SRC from a file path, a `data:` URI, or an `http(s)` URL (bounded fetch: 10s timeout, 25 MB cap streamed per-chunk so the limit holds even if `Content-Length` lies), writes the bytes to `word/media/imageN.ext`, mints an `image` relationship, and registers the extension's content-type `<Default>`. Pixel dimensions are read from the PNG/JPEG/GIF header and converted to EMU (1px = 9525 EMU at 96 dpi) for `<wp:extent>`; `--width`/`--height` override in inches, and supplying one alone scales the other to preserve aspect. The drawing is a standard inline `<w:drawing><wp:inline>` picture (`a:`/`pic:` namespaces declared on the subtree). Under track-changes the inserted run is wrapped in `<w:ins>` like any other inserted content. **HEIC/HEIF** input (common from iPhones) is transcoded to JPEG before embedding — Word can't render HEIC so students can drop a `.heic` straight in; detection is by file header, not just extension. **SVG input is sanitized** before embedding (`<script>`, `on*` handlers, `<foreignObject>`, animation events, external `href`/`xlink:href`, and `data:image/svg+xml` self-references are stripped; XXE is rejected at parse time by `fast-xml-parser`) so an attacker-controlled SVG can't smuggle active content into the doc. **Remote fetches block non-public addresses** private, loopback, link-local, and cloud-metadata ranges are refused, and HTTP redirects are followed manually with the same check at every hop, so an agent steered into `http://169.254.169.254/...` or `http://10.0.0.1/admin` is short-circuited.
324
+ **Rich content.** Images insert from a path, `data:` URI, or `http(s)` URL (bounded fetch; HEIC→JPEG transcode; SVG sanitized; non-public/metadata addresses refused at every redirect hop). Equations round-trip OOXML `<m:oMath>` LaTeX (reconstructed, not legacy plaintext) authored via temml (LaTeX→MathML) plus an in-house MathML→OMML adapter, no LGPL deps. Code blocks emit one `CodeBlock`-styled paragraph per line with optional lowlight syntax highlighting (37 bundled languages); they collapse back to a GFM fenced block on read. GFM task lists round-trip Word's checkbox content control (and the Word-for-Web Wingdings-glyph variant), surfacing as `taskState` in the AST. Tables operate on a merge-aware logical grid so `gridSpan`/`vMerge` cells map onto physical `<w:tc>`, and structural edits refuse to bisect an existing merge.
227
325
 
228
- **Image deletion.** `images delete --at imgN` removes the inline drawing and its run, pruning the media part and relationship when nothing else references them. Under track-changes it wraps the run in a real `<w:del>` instead (accept removes, reject restores), keeping the part until the change is accepted.
326
+ **Markdown dialect.** `create --from`, `insert/edit --markdown`, and the note bodies all parse the same GFM + math + CriticMarkup + inline-HTML-formatting dialect (remark + remark-gfm + remark-math + an in-house inline-surgery transform), composing the existing OOXML emitters. `read` emits a compatible dialect, so the read → edit → write loop round-trips (lossless for paragraphs, lists, and nested blockquotes; code/tables/math/headings inside a blockquote intentionally escape to top level on import). `read --ast` is the fully lossless JSON form.
229
327
 
230
- **Hyperlink CRUD.** `hyperlinks list` enumerates `<w:hyperlink>` elements with positional `linkN` ids; `hyperlinks add --at p3:5-20 --url URL` wraps an existing span (splitting runs at offsets); `hyperlinks replace --at link0 --with URL` updates the rels `Target`, allocating a new rId if the existing one is shared so siblings stay pointed at the original URL; `hyperlinks delete --at link0` unwraps the link (text survives) and prunes the rels entry when no longer referenced.
328
+ **Run formatting beyond bold/italic.** Properties markdown has no native syntax for — text color, theme color, highlight, shading, underline (all 18 styles + color), super/subscript, small/all caps, font, and size — are emitted as the **HTML a markdown reader actually renders**, so the output looks right in GitHub, VS Code, Obsidian, and browsers (Pandoc `[text]{…}` spans render as literal brackets in all of those). `read` emits semantic tags where they exist — `<mark>overdue</mark>`, `<sup>x</sup>`, `<sub>2</sub>` — a `<span style="color:#C00000">…</span>` for the CSS-expressible properties, and `data-*` attributes for the OOXML-only ones CSS can't express (theme colors, underline styles); `insert/edit --markdown` parses them back losslessly, and a leading `<!-- docx:base font="Arial" size="8pt" -->` note declares the document's dominant font/size once so the body isn't buried in per-run repetition. Bold/italic/strike/code/links stay native (`**`/`*`/`~~`/`` ` ``/`[](…)`). Because the inline-surgery transform scans whole sibling sequences, a CriticMarkup marker or span can straddle other formatting — `{++**bold insertion**++}` is tracked correctly. An invalid enum value (e.g. a bogus highlight name) fails with a clear error rather than silently vanishing. Inserted plain content inherits the surrounding paragraph's font/size so it blends in.
231
329
 
232
- **Table restructuring.** The `tables` verbs operate on a merge-aware logical grid: `gridSpan` (horizontal) and `vMerge` (vertical) cells map to physical `<w:tc>` elements so row/column locators (`tN:rR`, `tN:cC`, region `tN:rR1cC1-rR2cC2`) resolve correctly. `merge`/`unmerge` reshape `gridSpan`/`vMerge`; `set-widths` rewrites `<w:tblGrid>` plus per-cell `<w:tcW>`; structural edits refuse to bisect or orphan an existing merge (with a hint to `unmerge` first). Under track-changes, the tracked representation was verified against Microsoft Word (accept/reject), since Word not the ECMA-376 schema — decides what actually round-trips. Row insert/delete emit native `<w:trPr><w:ins>`/`<w:del>`; column insert/delete emit per-cell `<w:tcPr><w:cellIns>`/`<w:cellDel>` (paired with a `<w:tblGridChange>` on insert); `set-widths` emits `<w:tblGridChange>` plus a per-cell `<w:tcPrChange>` (which is what Word's reject actually reverts) all addressable as `tcN` and resolvable via `track-changes accept`/`reject` (which resyncs the grid). Cell merges and border changes are *not* tracked by Word (it warns "this action won't be marked as a change" and applies them immediately), so `merge`/`unmerge`/`borders` match that — applied in place with a `[docx-cli]` audit comment (mirroring hyperlink/image edits).
330
+ **Visual verification.** `docx render` is the only command that needs an external runtime: it drives Microsoft Word (macOS via `osascript`, Windows via PowerShell COM the ground-truth renderer) or LibreOffice (`soffice`, cross-platform) to produce a PDF, then rasterizes in-process via the bundled `@hyzyla/pdfium` WASM packageno poppler/pdftoppm/ImageMagick needed. Agents that consume PNGs use this to verify edits, diff accept/reject before-vs-after, or generate screenshots.
233
331
 
234
332
  ## Stack
235
333
 
236
334
  - **Runtime**: Bun (`node:util` parseArgs, JSX with custom factory, native zlib)
237
335
  - **Parser**: [`jszip`](https://www.npmjs.com/package/jszip) + [`fast-xml-parser`](https://www.npmjs.com/package/fast-xml-parser) + [`fast-xml-builder`](https://www.npmjs.com/package/fast-xml-builder)
336
+ - **Markdown**: [`unified`](https://www.npmjs.com/package/unified) + [`remark-parse`](https://www.npmjs.com/package/remark-parse) + [`remark-gfm`](https://www.npmjs.com/package/remark-gfm) + [`remark-math`](https://www.npmjs.com/package/remark-math)
337
+ - **Math**: [`temml`](https://www.npmjs.com/package/temml) (MIT) compiles LaTeX → MathML; an in-house MathML → OMML adapter handles the OOXML side bidirectionally
338
+ - **Render**: [`@hyzyla/pdfium`](https://www.npmjs.com/package/@hyzyla/pdfium) (MIT wrapper + Apache-2.0 PDFium-as-WASM) for the PDF → PNG/JPG step, plus [`pngjs`](https://www.npmjs.com/package/pngjs) / [`jpeg-js`](https://www.npmjs.com/package/jpeg-js) for image encoding
238
339
  - **Images**: [`heic-convert`](https://www.npmjs.com/package/heic-convert) (wasm libheif) transcodes HEIC/HEIF input to JPEG on insert
239
340
  - **Quality**: Biome + Knip + tsc; LibreOffice headless for round-trip integration tests
240
341
  - **Standard**: ECMA-376 Part 1 §17 (WordprocessingML), Transitional profile