# DOCX Render

> Use when an EXISTING .docx, .doc, .pptx or .xlsx has to become a PDF or PNG — 'convert docx to pdf', 'docx to pdf', 'render this Word doc', 'word to pdf', 'export docx as pdf', 'make a pdf of this docx', 'pdf from the docx', 'render the document to PDF', 'the PDF has the wrong page count', 'why did the layout reflow'. Use proactively before handing any Word document to a human as a PDF, and INSTEAD OF hand-rolling soffice/libreoffice, even when no one says 'render'. NEGATIVE ROUTING: NOT for editing docx content (use the generic 'docx' skill), NOT for building a docx from markdown (use 'law-review-docx' for Bluebook footnotes or 'law-econ-docx' for author-date), NOT for building one from a Typst source (use 'docx-typst'), NOT for a damaged docx (use 'docx-repair'), NOT for inspecting or diffing individual slides (use 'pptx-render').

- Skill: `edwinhu/docx-render` (Agent Skill)
- Install (CLI): `npx skillmds@latest add edwinhu/docx-render`
- Raw SKILL.md: https://api.skillmd.com/api/skills/edwinhu/docx-render/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: edwinhu (https://skillmd.com/u/edwinhu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/edwinhu/docx-render

---


**Announce:** "I'm using docx-render to convert this document to PDF via doc_render."

# DOCX → PDF/PNG rendering

Office docs → PDF/PNG go through the shared converter `scripts/doc_render.py`
(`convert()`), which picks the best engine and applies the right fixes. **Do not
hand-roll `soffice`/`libreoffice` or lean on the generic `docx` skill's own
export** — those skip the Word-fidelity path and the x2t kerning/table fixes.

## Default to `--renderer word` (Iron Law)

**For any PDF a human will read — a deliverable, an email attachment, a reading-list
file, anything you hand to the user — pass `--renderer word`.** The bare/`auto`
command is NOT the safe default: `auto` deliberately EXCLUDES Word (the check is
`if avail["word"] and allow_word`, and `allow_word` defaults False) and silently
uses x2t/LibreOffice, which **reflow the layout** (real case: a 5-page doc shipped
as 7). Word works even from a background/headless job via cmux dispatch (below), so
there is no reason to skip it for a deliverable.

Reserve bare `auto` for **parallel pipeline builds** (e.g. many law-review renders
at once) where headless/parallel-safety matters more than line-exact fidelity and
the docs are pipeline-generated (x2t/soffice already grid-faithful there).

**On Linux, `--renderer word` cannot work** — it drives `Word.app` through
AppleEvents. Use **`--renderer word-remote`** instead: the same real Word engine
in a Windows guest, driven over SSH. See "Word in a Windows guest" below.

**Always verify the engine actually used** via the PDF Producer before handing off
(see table below) — `auto` can fall back, and `--renderer word` only *raises* if
Word is truly unavailable.

## A legacy `.doc` goes STRAIGHT to Word — never convert it first

Both Word engines open `.doc` natively, and `WORD_SRC_SUFFIXES` admits it:

```bash
python3 scripts/doc_render.py IN.doc OUT.pdf --renderer word-remote
```

**Never `soffice --convert-to docx` a `.doc` and render the result.** That hop
silently corrupts the document, and because the damage is in the intermediate it
survives into a genuine Word render, so the Producer string still says Word and
the output still looks authoritative. Measured on a 23-page expert report:

- **every footnote gained a stray superscript `?`** — soffice writes the real
  `<w:footnoteRef/>` and then a literal `<w:t>?</w:t>` run beside it, where the
  legacy footnote mark used to be;
- **the page count grew by one** (23 → 24), so pagination no longer matched what
  the sender saw.

Rendering the `.doc` itself reproduced the sender's 23 pages with clean footnote
numbers. If a `?` opens every footnote, suspect the conversion, not the sender.

**A wrong extension fails silently.** Word dispatches on the suffix, so a `.doc`
staged under a `.docx` name makes it bail: the guest's scheduled task completes,
writes no PDF, and the only symptom is `guest render did not complete`. The
transport carries the real suffix through — keep it that way.

## Entry point

```bash
# CLI (faithful Word engine — DEFAULT for anything a human reads):
python3 ${CLAUDE_SKILL_DIR}/../../scripts/doc_render.py IN.docx OUT.pdf --renderer word
# auto engine (LibreOffice/x2t; NO Word) — ONLY for parallel pipeline builds:
python3 ${CLAUDE_SKILL_DIR}/../../scripts/doc_render.py IN.docx OUT.pdf
```
```python
import sys; sys.path.insert(0, "<plugin>/scripts")
from doc_render import convert
convert("in.docx", "out.pdf", renderer="word", allow_word=True)   # gold standard
convert("in.docx", "out.pdf")                                     # auto (headless)
```

**Agents without the `Skill` tool** (most workflow subagents): run the CLI above
directly — you don't need to invoke this skill, just call `doc_render.py`.

## Which engine, and why it matters

| Engine | Fidelity | Notes |
|--------|----------|-------|
| **Word** (`--renderer word`) | gold standard | native layout; **only engine that keeps an auto-wrapping table as a grid** in a *hand-authored* docx (LibreOffice collapses it to a stacked column). Recomputes Word fields (REF/NOTEREF/PAGEREF/TOC). **macOS only** — it drives `Word.app` over AppleEvents. |
| **word-remote** (`--renderer word-remote`) | gold standard | the same real Word engine, in a QEMU Windows guest over SSH. **The Word path on Linux**, where `--renderer word` cannot work at all. Also usable from macOS against a guest on that host. |
| **x2t** | good | OOXML-native; correct per-section footnote restart; doc_render injects GPOS/kern + EB-Garamond so it matches. |
| **LibreOffice** | good *except* | wrong for per-section/page footnote restart; collapses auto-wrapping tables not pre-broken upstream. |

`convert()` auto-falls-back Word → x2t/soffice. **Verify which ran** via the PDF
Producer: `macOS … Quartz PDFContext` = Word; `LibreOffice …` = LibreOffice.

> **Garamond documents on macOS need a one-time setup.** x2t mis-measures the
> macOS (Monotype) Garamond *italic* face badly enough to cram every upright
> Garamond run. `scripts/setup_garamond_render_override.py` writes a four-face
> override to `~/.config/x2t-render-fonts/garamond/` — macOS Garamond for
> regular/bold, EB Garamond for the slanted faces (`--all-eb` for the all-EB
> variant) — then `rm -rf ~/.cache/x2t-docfonts` to re-stage. Full measurements:
> `docs/investigations/2026-06-19_x2t-kerning-patch.md`, Part 2.

## Word from a background/headless job (the non-obvious part)

A detached Claude job is in a non-console GUI session without Word's TCC grant, so
direct AppleEvents fail with -600. `doc_render` transparently **dispatches the
render into a cmux pane** (console session, TCC-granted) and falls back to
x2t/LibreOffice if that's unavailable. Prereqs + full root-cause:
`docs/investigations/2026-06-22_word-render-cmux-dispatch.md`. Disable with
`$DOC_RENDER_NO_CMUX=1`.

### Driving the Mac's Word from another machine over SSH — doesn't work

The cmux rescue above assumes you are **on** the Mac. Invoking `--renderer word`
over SSH from another host (e.g. a Linux box rendering on `mbp`) fails
differently and has **no fallback** — cmux dispatch fails too, because there is
no console session on the far end to dispatch into:

```
doc_render: word renderer failed: Word direct render failed
  ([Errno 1] Operation not permitted:
   ~/Library/Containers/com.microsoft.Word/Data/wordrender/<uuid>);
  cmux dispatch also failed (…same…)
```

Note this is a **filesystem** permission error on Word's app container, not the
AppleEvents `-600` of the local case — an SSH session is outside the TCC grant
entirely. `launchctl asuser $(id -u) …` does **not** rescue it (`Could not
switch to audit session: Operation not permitted` — needs root).

Fixes, in order of preference:

1. **Use `word-remote`** (next section) — a Windows guest is the supported
   remote path; driving the Mac's Word from off-box is not.
2. Run the render from a terminal **inside the Mac's GUI login session** (then
   the normal local path, incl. cmux dispatch, applies).
3. Grant Full Disk Access to `/usr/libexec/sshd-keygen-wrapper` in System
   Settings → Privacy & Security, after which headless SSH renders work.

**Do not** paper over this by falling back silently — an explicit
`--renderer word` deliberately raises rather than downgrading.

## Word in a Windows guest (`word-remote`) — the Linux path

`--renderer word` is macOS-only. `word-remote` runs the same Word engine in a
Win11 guest and drives it over SSH, so Linux gets gold-standard fidelity:

```bash
python3 scripts/doc_render.py IN.docx OUT.pdf --renderer word-remote
```

Provisioned by the `programs.wordRender` nix module — `word-render` and
`word-render-install-fonts` on PATH, transport at
`~/.local/share/word-render/word_render_remote.sh` (override with
`$WORD_RENDER_REMOTE`). Full setup: `~/nix/modules/shared/word-render/README.md`.

**On Linux the guest is a docker container, not QEMU/qcow2.** `vm/start-winvm.sh`
and `~/.local/share/winvm/*.qcow2` are the **macOS** path. Linux uses
dockur/windows — the image Omarchy's `omarchy-windows-vm` drives — as container
`omarchy-windows`, disk at `~/.windows/data.img`, compose at
`~/.config/windows/docker-compose.yml`. There is no qcow2 to find here.

```bash
docker start omarchy-windows        # or: omarchy-windows-vm launch -k
until ssh -o ConnectTimeout=5 -o BatchMode=yes word@winvm exit; do sleep 15; done
```

**A cold boot takes several minutes before sshd answers**, and the error changes
as it comes up: `Connection refused` (container down) → `Connection reset by
peer` / `timed out during banner exchange` (Windows still booting) → success.
Only the first means something is wrong. Wait on the `until` loop rather than
concluding the guest is broken; `docker logs --tail 20 omarchy-windows` shows
boot progress.

Selection rules:

- **Explicit** `--renderer word-remote` always runs it (and raises rather than
  falling back, like every explicit engine).
- **`auto` picks it only with `allow_word=True`**, and only when local Word is
  unavailable — i.e. it is the Linux stand-in for `renderer="word"`, preferred
  over the lower-fidelity engines rather than silently downgrading.
- **`auto` never reaches it in the fallback cascade.** Booting/using a VM is not
  something best-effort should do behind the caller's back.

Availability is a **file check on the transport script, not an SSH probe** — a
reachability test would cost a round-trip (and can hang on a suspended VM) on
every `convert()`. A down guest surfaces as a render error naming the fix.

**A fresh guest silently renders the wrong fonts.** Word substitutes
Cambria/Calibri for any font it can't resolve *and still exits 0*, so the render
"succeeds" with wrong typography. Run `word-render-install-fonts` once per
guest, then verify with `pdffonts` — never trust the exit code. (Stock
`lmodern` does not work: Word won't render CFF-flavoured OpenType, and it
matches families on name ID 1. The nix module ships a converted set.)

## Google Docs exports

A docx exported from Google Docs can carry OOXML package corruption (case-broken
`customXML` part paths) that makes Word pop a "recover unreadable content" modal
on open — fatal to a headless render. The Word path **auto-repairs** it via a
preflight (`scripts/docx_repair.py`); you'll see `Word preflight — repaired
Google-export package …` on stderr. Repair a docx standalone with
`python3 scripts/docx_repair.py in.docx [out.docx]`.

## Related skills

Part of the **[document skill group](../../references/document-skills.md)**
(extract → create → repair → build → render → verify):
- **law-review-docx** — *builds* a .docx from markdown (template + pandoc), then renders.
- **docx** (generic) — *edits* docx content (tracked changes, comments, text).
- **docx-repair** — repairs a cloud-editor-damaged .docx (package/XML wiring + footnote markup).
- **xlsx** recalc / **pptx-render** — spreadsheet recalc / slide inspection.

