docx skill
Purpose: Give the agent a deterministic, script-first way to create,
edit, and convert Microsoft Word .docx files so it does not have to
re-derive low-level OOXML constructs (page sizes, tables, tracked
changes, comment markers, image embeddings, template substitution) on
every task. Writing .docx by hand through docx-js or python-docx
calls is feasible but error-prone; the scripts here encode the
practical knowledge and make the common operations a single command.
1. Red Flags (Anti-Rationalization)
STOP and READ THIS if you are thinking:
- "I'll just assemble the docx inline with
docx-jscalls, the scripts are overkill." → WRONG.md2docx.jsalready handles page size, numbering, dual-width tables, and image alt-text — regressions from hand-rolled code usually show as silently broken tables or images on Word for Windows. - "I can skip
docx_fill_template.py's run-merge pass and just regex-replace{{placeholder}}." → WRONG. Word and Google Docs fracture a single placeholder across multiple<w:r>siblings after spell-check or autocorrect; a naïve regex won't match and the placeholder will ship unfilled. - "For tracked changes I'll just delete every
<w:del>block." → WRONG. That loses author attribution and breaks paragraph-level delete markers. Usedocx_accept_changes.pywhich drives LibreOffice's own accept dispatcher. - "The smart-quote entity rewrite in
office/unpack.pyis ugly, I'll turn it off." → WRONG. Different Word builds round-trip UTF-8 quotes inconsistently. Keeping them as’etc. on unpack and reversing on pack makes edits deterministic. - "I'll just regex-replace
\|in OOXML to extract or modify text." → WRONG. Raw XML regex operates on serialised bytes and breaks namespace prefixes, entity encoding, and split-run anchors. Usedocx_replace.py(anchor-and-action surgical edit) which works at the lxml element level after run canonicalisation. - "It's an Obsidian note but it's still Markdown — I'll just run it through
md2docx.js." → WRONG.md2docx.jsexits 0 and writes a valid.docxthat is missing content. Measured on a real vault note: the YAML frontmatter is stripped unconditionally (soparticipants, which live only there, vanish),![[embed]]and[[wikilink]]reachmarkedastexttokens and land in the document as literal![[…]]strings, and a bare space in an image path also lexes as text — every vault path has spaces. Exit 0 guarantees nothing here. Usemd2docx.js --obsidian(orobsidian2md.jsfirst). - "The note has no
![[embeds]], so plainmd2docx.jsis fine." → WRONG if it has frontmatter. D1 fires on every note: the frontmatter is dropped silently and metadata that lives nowhere else goes with it. - "I need to edit a
.docxand thedocx2md → md2docxround-trip loses styling." → Usedocx_replace.py --replaceinstead. It operates at run level, preserving bold/italic/colour/numbering. Round-trip through markdown is lossy: inline formatting, list numbering schemas, and table borders all flatten. - "The doc has
$x^2$in it,md2docx.jswill just write it as text and that's fine." → WRONG before TASK 031.$…$/$$…$$used to reach the document as literal characters, exit 0, no warning — the same silent-loss class as the Obsidian defects above. As of TASK 031, math is on by default: it converts to a native OOXML<m:oMath>object, editable in Word like any equation authored with the equation editor. Only--no-mathrestores the old literal-text behaviour.
2. Capabilities
- Surgical anchor-and-action edit of a live
.docx(no template required) viadocx_replace.py— find a text anchor and--replace TEXT/--insert-after PATH/--delete-paragraphwithout a lossydocx2md → md2docxround-trip. Run-level formatting (bold, italic, colour, numbering) is preserved.--insert-afterautomatically relocates images, charts, OLE objects, SmartArt diagrams, and numbered/bulleted lists from the MD source into the base document (docx-6.5 + docx-6.6 v2 relocators, 2026-05-12). Honest scope:--replacerequires the anchor to fit within a single<w:t>after run-merge (cross-run anchors not supported, R10.a);--insert-afterand--delete-paragraphwork at paragraph granularity. Anchor not found → exit 2AnchorNotFound; last-paragraph delete refused → exit 2LastParagraphCannotBeDeleted. - Convert Markdown to
.docxwith headings, lists, tables, images, and optional mermaid diagrams viamd2docx.js. - Render inline
$…$and display$$…$$math as native OOXML<m:oMath>objects viamd2docx.js— on by default, editable in Word like an equation authored with its own equation editor, not a raster image. Pipeline: KaTeX renders TeX to MathML in-process,mathml2ommlconverts to OMML, spliced into the paragraph as a fresh object per occurrence. A standalone$$…$$alone between blank lines (or the sole content of a table cell) gets a dedicated centred paragraph; a 4-column table whose 1st/3rd cells are blank — the Pandoc| | $$…$$ | | (N) |equation-with-number convention — gets asymmetric column widths so the formula isn't clipped to 1/4 of the page width. A formula that fails to render degrades to its literal source text plus a stderr warning (never a silent drop);--strict-mathturns that into a hard exit 1 instead.--no-mathrestores pre-TASK-031 literal-text behaviour exactly. Honest scope: no Word-native automatic equation numbering (a source document's own numbers, sitting outside the$$…$$delimiters, convert unchanged); math inside**bold**/*italic*/a link still renders as a real object but without that styling; math in an image's alt text renders as literal text (theImageRun.altTextfield has nowhere to splice a run into). - Convert an Obsidian vault note to
.docxin one command viamd2docx.js --obsidian [--vault-root DIR], or to clean CommonMark viaobsidian2md.js(both share_obsidian_lib.js). Closes four measured silent-loss defects: YAML frontmatter dropped,![[embed]]and[[wikilink]]unrecognised, and spaces in image paths. Frontmatter is re-emitted as a two-column table by default (--frontmatter table|render|strip) — a GFM table, so it goes through the sametabletoken path as every other table and carries the identical borders and header shading. Attachments resolve in Obsidian's own order (.obsidian/app.json→ note directory → literal path → NFC-folded vault-wide index); callouts (> [!warning] Title),==highlight==,%%comment%%,#inline-tags, and- [ ]task boxes are normalised; fenced/indented/inline code is masked and passes through byte-identical, so a note documenting Obsidian syntax is not rewritten. Opt-in--transcludeinlines![[Note]]with cycle protection (depth ≤ 3, visited-set) and confines the target to the vault — inlining text from outside it would be an arbitrary-file read driven by a note somebody else wrote. Missing attachment → placeholder + stderr warning (exit 0), or exit 8AssetNotFoundunder--strict-assets. Honest scope: math ($…$) still reaches the document as text (backlogdocx-11); a|300width hint is an upper bound and never upscales, unlike Obsidian itself;.webpis named as a file rather than embedded, because the docx image layer cannot carry it; and an indented code block nested inside a list is treated as list content, so Obsidian syntax inside one is still rewritten. - Convert HTML / web archives to
.docxviahtml2docx.js— accepts.html/.htm, Chrome.mhtml/.mht, and Safari.webarchive(sub-resources extracted to a temp dir). Confluence, GitLab wiki, and CMS chrome (ARIA-role-tagged headers/sidebars/footers, namespaced<ac:*>macros,tablesorter<button>wrappers) is stripped automatically before walking. Inline SVG diagrams (drawio/mermaid/PlantUML) are rastered to PNG via a two-tier renderer: Tier 1 = headless Chrome / Chromium / Edge / Brave (auto-detected from conventional install paths orHTML2DOCX_BROWSER) gives pixel-perfect CSS layout including foreignObject labels and word-wrap; Tier 2 =@resvg/resvg-jsfallback for hosts without a browser, with foreignObject → SVG<text>conversion that preserves drawio's centring math, synthesises a viewBox on canvas-clipped diagrams (5% expansion absorbs drawio's edge-overshoot artifact), and word-wraps long Cyrillic/Latin labels at the wrapper'swidth:Npxso text fits inside its box.--reader-modeopt-in: stripped CMS chrome via a curated candidate list (#main-content,.entry,.post-content,article) with per-candidate min-text filter — useful for browser-saved news / blog pages where Confluence-priority selectors fall through to<body>. - Extract
.docxcontent back to Markdown preserving tables, lists, and embedded images viadocx2md.js. Comments and tracked changes (which mammoth strips) are pulled into a JSON sidecar (<output>.docx2md.json) — useful for contract audits where the audit trail must accompany the converted markdown. Footnotes/endnotes are converted to pandoc-style[^fn-N]/[^en-N]markers with definitions appended at the end. Schema versioned (v: 1); the sidecar'sunsupportedfield reports counts of revision types not yet captured (rPrChange,pPrChange,moveFrom,moveTo,cellIns,cellDel) so callers know what was lost. Opt-out via--no-metadata(skip sidecar) and--no-footnotes(skip pandoc conversion). Sidecar is not written when the source has no comments, no revisions, and zero unsupported counts — clean docs stay clean. - Fill
.docxtemplates containing{{placeholder}}or{{nested.key}}markers from a JSON payload with safe run-merging. - Accept all tracked changes in a
.docxvia headless LibreOffice without leaving artefacts in the user's profile. - Unpack and repack
.docxarchives for raw OOXML editing, with smart-quote entity round-tripping and run canonicalisation. - Structurally validate a
.docx: relationships, content types, tracked-change/<w:delText>integrity, comment marker pairing, package-layout allow-list (every ZIP entry must live under[Content_Types].xml,_rels/,word/,docProps/, orcustomXml/per ECMA-376 §11.3.10 — catches scratch-file leaks that Word refuses to open while LibreOffice silently tolerates), and optional XSD binding. With--strict, the package-layout warning is promoted to exit 1. - Reject password-protected and legacy
.doc(CFB-container) inputs early with a clear remediation message (exit 3) instead of aBadZipFiletraceback. - Detect macro-enabled inputs (
.docm, withvbaProject.bin) and warn when the chosen output extension would silently drop the macros (docm→docx). - Render any
.docx/.docm/.pdf(or peer-skill.xlsx/.pptx) into a single PNG-grid preview viapreview.py(LibreOffice + Poppler). - Emit failures as machine-readable JSON to stderr with
--json-errors(uniform across all four office skills). - Set or remove a password on a
.docx/.xlsx/.pptx(MS-OFB Agile, Office 2010+) viaoffice_passwd.py— three modes:--encrypt PASSWORD,--decrypt PASSWORD,--check(exit 0 encrypted / 10 clean / 11 missing). - Insert a Word review comment anchored on a text substring via
docx_add_comment.py— wires<w:commentRangeStart>/<w:commentRangeEnd>/<w:commentReference>markers, appends toword/comments.xml, and patches[Content_Types].xml+ relationships. Supports threaded replies (--parent N) via<w15:commentEx w15:paraIdParent=…>incommentsExtended.xml; reply-to-reply chains are flattened to the conversation root to match Word's review-pane render. Multi-paragraph bodies via\nin--commentare split into separate<w:p>per ECMA-376 §17.13.4.2. Opt-in library mode (--unpacked-dir DIR) operates in-place on an already-unpacked tree (skips unpack/pack/encryption-check) — not reentrant: do not run two processes against the same tree concurrently, no file locking. Malformed OOXML side-parts surface asMalformedOOXMLenvelope (not a traceback). Seereferences/add-comment-howto.mdfor verification steps and §6 troubleshooting for failure modes. - Merge N
.docxfiles into one viadocx_merge.py(VDD iter-2 real-world hardened). Appends body content + styles + numbering + media + relationships into the first input (base), with full reference relocation: image rIds renumbered,r:embed/r:link/r:idin body remapped,<w:bookmarkStart/End w:id>bumped past base's max,<w:abstractNum>/<w:num>shifted with<w:numId w:val>body refs rewritten, missingDefault Extensionentries pulled into[Content_Types].xml. Strips paragraph-level<w:sectPr>from extras (their header/footer references would dangle). Inserts new<w:abstractNum>BEFORE first<w:num>per ECMA-376 §17.9.20 schema-order. Honest scope (still not merged, warned when extras have content): footnotes / endnotes / headers / footers / comments.
3. Execution Mode
- Mode:
script-first. - Why this mode: Each operation is a small, deterministic CLI wrapping a specific OOXML-aware library. Inline assembly in the agent's text is slower and regresses on details (page size, DXA units, namespace ordering). Scripts also make the skill reusable from any shell environment.
4. Script Contract
- Commands:
node scripts/md2docx.js INPUT.md OUTPUT.docx [--header "TEXT"] [--footer "TEXT"] [--page-size A4|Letter] [--landscape] [--margins T,R,B,L] [--no-math] [--strict-math]— math ($…$/$$…$$→ native<m:oMath>) is on by default;--no-mathdisables it entirely (literal text, pre-TASK-031 behaviour);--strict-mathexits 1 on the first formula KaTeX cannot render instead of the default degrade-to-literal-text-plus-warning.node scripts/md2docx.js INPUT.md OUTPUT.docx --obsidian [--vault-root DIR] [--frontmatter table|render|strip] [--lang ru|en|auto] [--links text|italic] [--inline-tags strip|keep] [--transclude] [--strict-assets]— the one-command Obsidian route.--vault-rootdefaults to the nearest ancestor of the note containing.obsidian/, else the note's own directory. Every Obsidian flag requires--obsidian; using one without it exits 1 with--vault-root requires --obsidian(exit 1 ismd2docx.js's code for every usage error, unchanged since TASK 019). Flag order is irrelevant.--strict-assetsexits 8, in plain text —md2docx.jshas no--json-errors.node scripts/obsidian2md.js INPUT.md OUTPUT.md [--vault-root DIR] [--frontmatter table|render|strip] [--lang ru|en|auto] [--links text|italic] [--inline-tags strip|keep] [--transclude] [--strict-assets] [--json-errors]— Obsidian-flavoured Markdown → CommonMark, no OOXML involved. Defaults:--frontmatter table,--lang auto(reads the note'slang:key, elseen),--links text,--inline-tags strip. Exit codes: 0 success, 1 I/O, 2 invalid argument / usage, 6 same-path self-overwrite refused (SelfOverwriteRefused), 8 attachment not found under--strict-assets(AssetNotFound).--json-errorsemits the standard{v:1, error, code, type, details}envelope on every non-zero path. Output is idempotent: a second run over the same input is byte-identical. A CommonMark document with no Obsidian syntax passes through byte-identical.node scripts/docx2md.js INPUT.docx OUTPUT.md [--metadata-json PATH] [--no-metadata] [--no-footnotes] [--json-errors]node scripts/html2docx.js INPUT OUTPUT.docx [--header "TEXT"] [--footer "TEXT"] [--reader-mode] [--json-errors]— INPUT may be.html/.htm,.mhtml/.mht, or.webarchive; sub-resources in archives are extracted to a temp dir automatically (cleaned up on exit incl. SIGINT/SIGTERM).--reader-modeswaps the article-root candidate list for a CMS/blog-specific one (#main-content/.entry/.post-content/articlewith per-candidate min-text filter; bare<main>deliberately omitted as it wraps whole-site chrome on news sites). Override SVG renderer withHTML2DOCX_BROWSER=/path/to/chromeor set it to a non-existent path to force the resvg-js fallback for CI determinism. SetHTML2DOCX_ALLOW_NO_SANDBOX=1only inside a trusted CI container — default leaves Chrome's sandbox enabled.python3 scripts/docx_fill_template.py TEMPLATE.docx DATA.json OUTPUT.docx [--strict]python3 scripts/docx_accept_changes.py INPUT.docx OUTPUT.docx [--timeout 120]python3 scripts/office/unpack.py INPUT.docx OUTDIR/ [--no-pretty] [--no-escape-quotes] [--no-merge-runs]python3 scripts/office/pack.py INDIR/ OUTPUT.docx [--no-unescape-quotes] [--no-condense]python3 scripts/office/validate.py INPUT.docx [--strict] [--json] [--schemas-dir PATH] [--compare-to ORIGINAL.docx]python3 scripts/preview.py INPUT OUTPUT.jpg [--cols 3] [--dpi 110] [--gap 12] [--padding 24] [--label-font-size 14] [--soffice-timeout 240] [--pdftoppm-timeout 60]python3 scripts/office_passwd.py INPUT [OUTPUT] (--encrypt PASSWORD | --decrypt PASSWORD | --check)— pass-as PASSWORD to read it from stdin.python3 scripts/docx_add_comment.py INPUT.docx OUTPUT.docx --anchor-text TEXT --comment BODY [--author NAME] [--initials AB] [--date ISO] [--all]python3 scripts/docx_add_comment.py INPUT.docx OUTPUT.docx --parent N --comment BODY [--author NAME]— reply to comment N (inherits its anchor range; threads viacommentsExtended.xml).python3 scripts/docx_add_comment.py --unpacked-dir DIR --anchor-text TEXT --comment BODY [...]— library mode: edit an already-unpacked tree in-place (combine with--parentto add a reply over the same tree).python3 scripts/docx_replace.py INPUT.docx OUTPUT.docx --anchor TEXT (--replace TEXT | --insert-after PATH_OR_DASH | --delete-paragraph) [--all] [--unpacked-dir DIR] [--scope=LIST] [--json-errors]— Tier B (script-first); positional INPUT OUTPUT;--anchor TEXTis always required; exactly one of--replace/--insert-after/--delete-paragraphis required (mutex).--allreplaces/acts on every match instead of first.--unpacked-dir DIRoperates on an already-unpacked tree in-place (library mode — skips unpack/pack/encryption-check).--insert-after -reads the markdown body from stdin.--scope=LIST(docx-6.7) restricts anchor-search to a subset of OOXML parts: comma-separatedbody,headers,footers,footnotes,endnotes,all(default:all). Example:--scope=bodylimits edits toword/document.xml, leaving header/footer boilerplate untouched. Order within the requested set is deterministic (document → headers → footers → footnotes → endnotes). Exit codes: 0 success, 1 I/O or OOXML error, 2 anchor-not-found / last-paragraph-delete refused / invalid--scopevalue, 3 encrypted/password-protected input, 6 same-path self-overwrite refused, 7 post-validate failure.--json-errorsemits failures as{v:1, error, code, type, details}JSON on stderr (cross-5 envelope parity). Honest scope:--replaceanchor must fit within a single<w:t>after run-merge;--insert-afterconverts markdown to OOXML viamd2docx.js(requires Node.js in PATH).python3 scripts/docx_merge.py OUTPUT.docx INPUT1.docx INPUT2.docx [...] [--page-break-between] [--no-merge-styles]- All scripts above accept
--json-errorsto emit failures as a single line of JSON on stderr ({v, error, code, type?, details?}). The schema versionvis currently1; argparse usage errors are routed through the same envelope (type:"UsageError").
- Inputs: positional paths only; optional flags per command.
- Outputs: a single file at the named output path;
office/unpack.pyproduces a directory tree;office/validate.pyprints a report (or JSON with--json).docx2md.jsadditionally creates<stem>_images/next to the Markdown output when the document has embedded images. - Failure semantics: non-zero exit on missing input, invalid JSON, unresolved placeholders (with
--strict), unreadable ZIP, or soffice errors. Error detail goes to stderr. - Idempotency: repeated runs with the same inputs produce equivalent output files (byte-exact for the XML parts after pretty-print and entity normalisation).
docx_accept_changes.pyis idempotent on an already-accepted document. Exception:office_passwd.py --encryptis intentionally non-deterministic — Office encryption uses a fresh random salt per run, so the encrypted bytes differ each time even with the same password. The decrypted output, however, is byte-equal to the pre-encryption input (lossless round-trip). - Dry-run support: not applicable; operations are file-to-file and reversible by re-running.
5. Safety Boundaries
- Allowed scope: only the input/output paths named on the command line and, for Python scripts, modules under
scripts/. Never modify files the user did not name. - Read scope,
--obsidian/obsidian2md.jsonly (containment is compared onrealpath, not on path strings — a directory symlink shipped inside a vault walks straight through a prefix check): resolving an Obsidian note additionally reads (never writes) files the command line did not name — attachments under the vault root,<vault>/.obsidian/app.json, and, under--transclude, sibling notes. The vault root is--vault-root, else the nearest ancestor of the note containing.obsidian/, else the note's directory..obsidian/,.git/,node_modules/and.trash/are never walked, directory symlinks are never followed, and the index is built once per run.attachmentFolderPathis untrusted input that travels with a vault: an absolute value, or one resolving outside the vault, is refused with a warning. A note may still name an absolute path in its own link — the same capability plain Markdownalready has, and it is not confined. - Default exclusions: never overwrite the input in place unless the same path is also passed as output; never fetch remote images silently; never install npm/pip packages globally.
- Destructive actions:
docx_accept_changes.pyrewrites the destination; always use a distinct output path unless the user explicitly asks for in-place replacement.office/pack.pyoverwrites the destination archive. - Optional artifacts: the
office/schemas/directory is optional;office/validate.pyruns structural checks without it and only warns about missing XSDs unless--strict.
6. Validation Evidence
- Local verification:
cd skills/docx/scripts && npm install— installs docx, marked, mammoth, turndown, image-size, turndown-plugin-gfm intoscripts/node_modules/.python3 -m venv .venv && source .venv/bin/activate && pip install -r scripts/requirements.txt— installs python-docx, lxml, defusedxml.node scripts/md2docx.js examples/fixture-simple.md /tmp/out.docx && unzip -l /tmp/out.docx | grep word/document.xml— exits 0 and lists the document part.node scripts/docx2md.js /tmp/out.docx /tmp/back.md && test -s /tmp/back.md— round-trips non-empty Markdown. On a docx with comments / tracked changes, also writes/tmp/back.docx2md.json(schemav:1); on a docx with footnotes/endnotes, the markdown gets pandoc-style[^fn-N]markers + definitions block at the end. Seereferences/docx2md-sidecar.mdfor the full schema and--metadata-json/--no-metadata/--no-footnotesflags. Same-path (docx2md.js foo.docx foo.docx) is refused with exit 6 (SelfOverwriteRefused) so the input is never destroyed.node scripts/md2docx.js examples/fixture-obsidian-vault/note.md /tmp/obs.docx --obsidian --vault-root examples/fixture-obsidian-vault && unzip -l /tmp/obs.docx | grep -cE 'word/media/.*\.(png|jpg)'— the committed mini-vault note: exit 0, 5 embedded images, frontmatter inside a<w:tbl>. Full acceptance set (A1-A16) inscripts/tests/test_obsidian2md.py, 90 cases.node scripts/md2docx.js examples/fixture-math.md /tmp/math.docx && unzip -p /tmp/math.docx word/document.xml | grep -o '<m:oMath' | wc -l— exit 0, ≥4 native math objects.test_md2docx_math.py(26 cases).python3 scripts/office/validate.py /tmp/out.docx— exits 0, printsOKor only warnings.python3 scripts/office/unpack.py /tmp/out.docx /tmp/unpacked && python3 scripts/office/pack.py /tmp/unpacked /tmp/repack.docx && unzip -p /tmp/repack.docx word/document.xml | head -c 100— unpack/pack round trip.python3 scripts/docx_add_comment.py /tmp/out.docx /tmp/commented.docx --anchor-text "Quarterly" --comment "Verify this number" --author "QA Bot" && python3 -m office.validate /tmp/commented.docx— adds a Word comment anchored on the substring; output validates and contains a<w:comment>part. Seereferences/add-comment-howto.mdfor the full verification protocol.python3 scripts/docx_merge.py /tmp/merged.docx /tmp/out.docx /tmp/out.docx --page-break-between && python3 -m office.validate /tmp/merged.docx— merges N inputs (here, two copies ofout.docx) with a hard page break between them; output validates clean.
- Expected evidence:
/tmp/out.docx,/tmp/back.md,/tmp/unpacked/tree,/tmp/repack.docx. All reproducible fromexamples/fixture-simple.md. - CI signal:
python3 ../../.claude/skills/skill-creator/scripts/validate_skill.py skills/docx— exits 0 when the skill conforms to the Gold Standard (frontmatter, required sections, non-empty examples).
7. Instructions
Use graduated language: steps marked MUST are safety-critical; the rest are behavioural defaults with their rationale.
7.1 Select the right script before touching low-level APIs
- Look up the task in §10 Quick Reference first. If a script exists, run it — it captures accumulated OOXML knowledge that inline code tends to get wrong.
- Drop to
docx-jsorpython-docxcalls only when the user's requirement genuinely is not covered (e.g. inserting a custom content control, manipulating a specific style's inheritance chain).
7.2 Install dependencies locally
- MUST run
bash scripts/install.shonce. It createsscripts/.venv/andscripts/node_modules/locally (nothing installed globally), prints a warning for any missing system tool, runs a smoke-test of the documented commands before declaring success, and is idempotent. - Invocation —
python3is safe. The Python CLIs self-bootstrap intoscripts/.venv:python3 scripts/X.py …and./.venv/bin/python scripts/X.py …are equivalent — on a host wherepython3is not the venv (pyenv / conda / system Python), the script re-execs itself intoscripts/.venv. If the venv is missing, you get arun: bash scripts/install.shhint instead of a rawModuleNotFoundError. (Node CLIs resolvescripts/node_modules, also local.) - External system tools (checked by
install.sh, installed manually per project plan §3.3 "внешние инструменты — не бандлятся"):- LibreOffice (
soffice) — required bydocx_accept_changes.py. macOS:brew install --cask libreoffice. Debian:sudo apt install libreoffice --no-install-recommends. Fedora:sudo dnf install libreoffice. Commands that need it fail with a clear error until it's installed.
- LibreOffice (
7.3 Creating .docx from Markdown
- Prefer
md2docx.jsover hand-writingnew Document({...}). - Page size / orientation / margins are exposed as flags — default US Letter portrait (backward-compatible):
--page-size A4|Letter,--landscape,--margins T,R,B,L(dxa, each value optionallymm-suffixed). Content width, tables, and images/Mermaid diagrams all auto-derive from the chosen page, so they never overflow A4 — no manualword/document.xmlpatching needed. Example:node scripts/md2docx.js report.md report.docx --page-size A4. - For headers/footers use
--header "…"/--footer "…". Multi-line headers are a single string with\n—md2docx.jssplits on the newline.
7.4 Extracting text from .docx to Markdown
- Use
docx2md.jsfor GFM output with tables preserved. If the document contains images, a sibling<stem>_images/directory is created — tell the user so they know to ship it alongside the Markdown. - For docs that carry comments or tracked changes, the converter writes a JSON sidecar
<stem>.docx2md.jsonnext to the Markdown. Tell the user to ship the sidecar alongside the markdown if the audit trail matters; the sidecar's absence on a clean doc is a feature, not a missing artefact. Field semantics + theunsupportedcounters are documented inreferences/docx2md-sidecar.md. - For docs with footnotes/endnotes, the markdown body gets pandoc-style
[^fn-N]/[^en-N]markers and definitions appended at the end. Pass--no-footnotesif downstream tooling can't parse pandoc syntax. - MUST NOT invoke
docx2md.jswith the same input and output path — the cross-7 H1 guard exits 6 (SelfOverwriteRefused), but better to never tempt it. - For plain text without any formatting,
pandoc -t plainis a valid alternative but external — document this choice to the user rather than silently switching tools.
7.5 Filling a template
- MUST ensure the template contains placeholders in the exact form
{{key}}or{{nested.key}}. Spaces inside the braces ({{ key }}) are tolerated; anything else ([key],${key},<%=key%>) is not recognised. - Provide
data.jsonas an object at the top level. - Pass
--strictwhen the template must be fully resolved (legal documents, customer-facing letters). Without--strict, unresolved placeholders remain in the output and are listed on stderr.
7.6 Accepting tracked changes
docx_accept_changes.pyrequires LibreOffice. Check_soffice.pylocates it before telling the user it "just works" — on some CI images LibreOffice is absent.- Output must be a new path; do not overwrite the input unless asked. Even then, prefer an intermediate file and move on success.
- LibreOffice 26.2 limitation: the CLI
macro:///dispatch this script relies on is unreliable on 26.2 (dropped on cold profiles while soffice exits 0). The script self-verifies: if revision marks survive, it removes the bogus output and exits 1 withAcceptChangesVerificationError— tell the user to accept the changes in a desktop Word/LibreOffice session or use LibreOffice 25.x. It never silently hands back an unaccepted copy.
7.7 Raw OOXML editing
python3 scripts/office/unpack.py doc.docx work/first — this pretty-prints, merges adjacent runs, and entity-encodes smart quotes, so editing is safe.- Edit XML under
work/word/,work/ppt/, orwork/xl/(docx/pptx/xlsx respectively). python3 scripts/office/pack.py work/ out.docxrepackages and reverses the smart-quote entities.python3 scripts/office/validate.py out.docxconfirms basic structural sanity before shipping.
8. Workflows (Optional)
Create a new document from Markdown:
- [ ] Prepare input.md (YAML frontmatter optional)
- [ ] `cd scripts && npm install` if first run
- [ ] `node scripts/md2docx.js input.md output.docx`
- [ ] `python3 scripts/office/validate.py output.docx`
- [ ] Open in Word/LibreOffice for a spot-check
Round-trip an existing document:
- [ ] `node scripts/docx2md.js input.docx extracted.md`
- [ ] Edit `extracted.md`
- [ ] `node scripts/md2docx.js extracted.md back.docx`
- [ ] `python3 scripts/office/validate.py back.docx`
Fill a template with JSON data:
- [ ] Prepare template.docx with `{{placeholders}}`
- [ ] Prepare data.json matching the placeholder keys
- [ ] `python3 scripts/docx_fill_template.py template.docx data.json out.docx --strict`
- [ ] Confirm no unresolved placeholders on stderr
9. Best Practices & Anti-Patterns
| DO THIS | DO NOT DO THIS |
|---|---|
Use md2docx.js for Markdown → .docx; it handles page size, numbering, dual-width tables, and image alt-text. |
Assemble docx inline with new Document({...}) on every task — you'll regress on paged-media details. |
Keep placeholders exactly {{key}}; pass --strict for legal/customer-facing outputs. |
Roll your own regex replace on the extracted XML; split runs will defeat you. |
office/unpack.py → edit → office/pack.py for raw XML edits. |
Edit the .zip in place with external tools — smart-quote round-trips will break. |
docx_accept_changes.py (LibreOffice) for applying tracked changes. |
Delete <w:del> / <w:ins> blocks directly — you'll lose author attribution and break paragraph-delete markers. |
Rationalization Table
| Agent Excuse | Reality / Counter-Argument |
|---|---|
| "I know docx-js, I'll just write the assembly inline." | md2docx.js already encodes the fixes for Word's quirks; inline code regresses on tables, images, and numbering. |
| "The user wants a tiny change, unpacking is overkill." | Unpacking is three seconds; skipping it and patching the ZIP by hand routinely breaks smart-quote round-trips. |
"I don't need --strict, a missing placeholder is obvious." |
Missing placeholders ship as literal {{customer.name}} in customer emails. --strict is exactly for customer-facing output. |
"Schemas aren't bundled, so validate.py is useless." |
The structural layer (relationships, duplicate IDs, comment pairing) runs without schemas. XSD binding is a bonus. |
10. Quick Reference
| Task | Command |
|---|---|
Markdown → .docx |
node scripts/md2docx.js input.md output.docx |
Markdown → A4 .docx |
node scripts/md2docx.js input.md output.docx --page-size A4 [--landscape] [--margins T,R,B,L] |
.docx → Markdown |
node scripts/docx2md.js input.docx output.md |
HTML / .webarchive / .mhtml → .docx |
node scripts/html2docx.js input.html output.docx [--header ...] [--footer ...] [--reader-mode] [--json-errors] |
Web page / archive → .docx (reader mode, strips chrome) |
node scripts/html2docx.js page.webarchive article.docx --reader-mode |
| Fill template | python3 scripts/docx_fill_template.py template.docx data.json out.docx [--strict] |
| Accept tracked changes | python3 scripts/docx_accept_changes.py in.docx out.docx |
| Unpack for raw editing | python3 scripts/office/unpack.py in.docx unpacked/ |
| Repack | python3 scripts/office/pack.py unpacked/ out.docx |
| Structural validate | python3 scripts/office/validate.py file.docx [--json] [--strict] |
| Compare tracked changes vs original | python3 scripts/office/validate.py edited.docx --compare-to ORIGINAL.docx |
| Preview as PNG-grid | python3 scripts/preview.py file.docx preview.jpg [--cols 3] [--dpi 110] |
| Set password | python3 scripts/office_passwd.py clean.docx encrypted.docx --encrypt PASSWORD (use - to read from stdin) |
| Remove password | python3 scripts/office_passwd.py encrypted.docx clean.docx --decrypt PASSWORD |
| Detect password | python3 scripts/office_passwd.py file.docx --check (exit 0 encrypted / 10 clean / 11 missing) |
| Surgical text replace (no round-trip) | python3 scripts/docx_replace.py in.docx out.docx --anchor "phrase" --replace "new text" |
| Insert paragraph after anchor | python3 scripts/docx_replace.py in.docx out.docx --anchor "phrase" --insert-after body.md |
| Delete paragraph at anchor | python3 scripts/docx_replace.py in.docx out.docx --anchor "phrase" --delete-paragraph |
| Add review comment | python3 scripts/docx_add_comment.py in.docx out.docx --anchor-text "phrase" --comment "body" --author "Reviewer" |
| Reply to comment | python3 scripts/docx_add_comment.py in.docx out.docx --parent N --comment "reply body" --author "Dev" |
| Edit unpacked tree | python3 scripts/docx_add_comment.py --unpacked-dir DIR --anchor-text "phrase" --comment "body" (combine with --parent for replies in-place) |
| Merge N docx | python3 scripts/docx_merge.py merged.docx a.docx b.docx c.docx [--page-break-between] |
| Obsidian note → .docx | node scripts/md2docx.js "<vault>/note.md" out.docx --obsidian --page-size A4 |
| Obsidian note → clean .md | node scripts/obsidian2md.js "<vault>/note.md" clean.md |
Markdown with $…$/$$…$$ → .docx (math on by default) |
node scripts/md2docx.js input.md output.docx |
| Markdown → .docx, math disabled | node scripts/md2docx.js input.md output.docx --no-math |
| Machine-readable failures | append --json-errors to any of the above |
11. Examples (Few-Shot)
Full fixture: examples/fixture-simple.md.
Input — user request:
Convert
report.mdto a.docxand check it's structurally sound.
Output — agent action (abbreviated):
cd skills/docx/scripts && npm install # one-time setup
node scripts/md2docx.js report.md report.docx
python3 scripts/office/validate.py report.docx
Report the exit codes and a one-line summary to the user ("report.docx created; validator OK").
Input — user request:
Fill
invoice.docxwith the data incustomer.json, fail if anything is missing.
Output — agent action:
python3 scripts/docx_fill_template.py invoice.docx customer.json invoice-filled.docx --strict
On non-zero exit, surface the Unresolved placeholders: … stderr line to the user.
12. Resources
- references/ooxml-basics.md —
.docxas a ZIP of XML parts, minimum viable structure, namespace table. - references/docx-js-gotchas.md —
docx-jspitfalls (A4 default, dual widths, numbering, image altText, TOC, rsids). - references/tracked-changes.md —
<w:ins>/<w:del>syntax, paragraph-delete markers, author attribution. - references/templating.md — why adjacent-run merging matters, supported placeholder grammar, anti-patterns.
- scripts/office/validators/redlining.py — RedliningValidator: compare edited
.docxagainst the original, report unmarked deletions/insertions/rewrites and author-attribution gaps. Driven by--compare-toflag onoffice/validate.py. - scripts/office/shim/ — LD_PRELOAD / DYLD_INSERT_LIBRARIES shim that lets LibreOffice start inside seccomp-tightened sandboxes that block AF_UNIX.
_soffice.pyauto-detects and auto-compiles. See scripts/office/tests/test_shim.md for nsjail/Docker end-to-end validation. - scripts/obsidian2md.js — Obsidian note → CommonMark CLI (TASK 030). Thin argv/exit-code wrapper; all rules live in
_obsidian_lib.js. - scripts/_obsidian_lib.js — the conversion core, required in-process by both
obsidian2md.jsandmd2docx.js --obsidian. Knows nothing about OOXML, so the rules are testable as text-in/text-out and stay reusable by peer skills. - examples/fixture-obsidian-vault/ — committed mini-vault driving
scripts/tests/test_obsidian2md.py:app.json, embeds with size hints, a%-in-name attachment, a space-named directory, and a mutual-transclusion pair. - scripts/_math_lib.js —
$…$/$$…$$extraction, sentinel substitution, and KaTeX → MathML →mathml2ommlOMML batch rendering (TASK 031). Required in-process bymd2docx.js; knows nothing aboutdocx-js or OOXML beyond the OMML string it returns. - examples/fixture-math.md — committed fixture driving
scripts/tests/test_md2docx_math.py: inline math, a standalone display formula, a Pandoc-style numbered equation table, math inside**bold**, currency/escaped-dollar negative cases, a formula inside a fenced code block, and one intentionally-malformed formula. - scripts/docx_replace.py — surgical anchor-and-action editor:
…(truncated)