Imported from okou-ai/vm0-skills (
office-files/SKILL.md). Install upstream withnpx skills add okou-ai/vm0-skills --skill office-files. Copyright stays with the author.
Setup — run these two lines first
pip install --break-system-packages --quiet pypandoc_binary typst openpyxl python-docx
export PATH="$(python3 -c 'import pypandoc,os;print(os.path.dirname(pypandoc.get_pandoc_path()))'):$PATH"
Takes about 6 seconds; nothing is preinstalled. Both lines are required — the system Python is PEP 668 externally managed, and the wheel ships the pandoc binary inside the package directory rather than on PATH.
Install only what the flow needs, and know which package is which. pypandoc_binary and typst cover the PDF flow and the prose-to-docx render; openpyxl is only for xlsx; python-docx is only for the docx steps that build or edit a reference doc. Installing just the first two is fine — until a later docx or xlsx step in the same session dies with ModuleNotFoundError: No module named 'docx' (or openpyxl). Install all four unless the task certainly never touches docx or xlsx.
export PATH lasts for one shell only, so re-run it in every new shell. Each Bash call starts a fresh shell, so a single setup at the top of a run does not carry forward and later calls fail with pandoc: command not found. Prefix the export line to each command that uses pandoc, or re-export it at the start of every shell.
If the install fails, deliver Markdown or a hosted HTML view instead and say the toolchain was unavailable. Never ship a worse format without saying so.
Pick your flow
Every deliverable is rendered from a source you author. You never write a .docx or a .pdf directly:
prose → you write doc.md → pandoc → .docx
→ pandoc -t typst → .pdf
data → you build rows in Python → openpyxl → .xlsx
| Situation | Flow |
|---|---|
| Word document, user supplied a template | pandoc doc.md --reference-doc=theirs.docx -o out.docx — see docx |
| Word document, no template | pandoc doc.md -o out.docx — see docx |
| Word document needing a header, footer or page number, no template | build a reference doc first — see docx |
| PDF, no template | pandoc doc.md -t typst -s -V papersize=a4 then typst.compile — see PDF |
| PDF carrying the user's branding | render the docx with --reference-doc, then convert that docx — see PDF |
| Spreadsheet | openpyxl — see xlsx |
| Edit a file the user sent | Start from the user's file first, then the matching row above |
Styling never comes from the source you author — it comes from a .docx passed to pandoc, or from openpyxl. Never author a spreadsheet as a Markdown table, and never hand-build docx XML.
Start from the user's file
pandoc theirs.docx -t markdown --wrap=none > doc.md
Edit the Markdown, then render it back with the same .docx as --reference-doc so their styling survives the round trip. For xlsx, read with openpyxl — pandoc lists xlsx as an input format but fails on many real files.
docx
Step 1 — write the content as Markdown into doc.md. Headings become Word heading styles, Markdown tables become Word tables, and **bold** becomes bold. This file is the source of truth; the docx is a render of it.
Step 2 — render it:
pandoc doc.md --reference-doc=theme.docx -o out.docx
pandoc doc.md --reference-doc=theme.docx --toc --toc-depth=2 -o out.docx # with a table of contents
--reference-doc is where headers, footers, page numbers, margins, paper size, fonts and numbering come from: the output inherits word/header1.xml and word/footer1.xml, including a live PAGE field. Never write headers, footers or page numbers into the Markdown.
- User supplied a Word file — use it as the reference doc. Their branding comes across for free.
- No template — omit the flag. The result is clean but unbranded, with no header, footer or page number. Say that in one line when you deliver it, and offer to match their house style if they send you a Word file.
- No template, but the request needs a header, footer or page numbers — build one from pandoc's own default, never from a blank document:
pandoc --print-default-data-file reference.docx > theme.docx
Then open theme.docx with python-docx, set section.header, add a PAGE field to section.footer, save, and pass it with --reference-doc. A blank python-docx.Document() lacks the styles pandoc emits, so Word silently renders them as Normal.
Same doc.md as the docx flow — write the Markdown first, then render through typst:
pandoc doc.md -t typst -s -V papersize=a4 -o doc.typ
python3 -c "import typst; typst.compile('doc.typ', output='doc.pdf')"
For Chinese, Japanese or Korean text, add the matching font — -V mainfont="Noto Sans CJK SC" (use JP or KR for those languages).
Three things that fail quietly here:
-sis mandatory for any-Vto apply. Without it pandoc emits a fragment whose.typcontains noset pageorset textat all, so every variable you pass is silently dropped.- Pin
papersize. Without-syou get typst's own default of A4; with-sand nopapersizeyou get pandoc's template default ofus-letter. Same document, different paper. - CJK without
mainfontrenders in the wrong script. Chinese text falls back toNotoSansCJKjp, so you get Japanese glyph forms with no error and no missing characters. Verify withpdffonts doc.pdf— the embedded name must end inscfor Simplified Chinese.
One message from this flow is loud but harmless, and it is the opposite of the three above: pdfinfo on a typst-built PDF prints Syntax Error: Suspects object is wrong type (boolean) to stderr. It is a poppler bug and not a broken file: poppler mis-reports the valid /Suspects false that typst writes for tagged output (fixed upstream after poppler 25.11.0), while PDFs built by LibreOffice omit that key and so stay quiet. The same PDF still reports its page count, page size and fonts correctly and rasterises fine. Do not debug it and do not re-render because of it.
Variable names come from pandoc --print-default-template=typst: mainfont, mathfont, codefont, fontsize, papersize.
--reference-doc does not apply to PDF — only docx, pptx and ODT support it, so the typst flow above always produces an unbranded PDF. When the user needs their branding on a PDF, render the docx with their reference doc first, then convert that docx:
soffice --headless -env:UserInstallation=file:///tmp/lo --convert-to pdf --outdir . out.docx 2>&1 | grep -q writer_pdf_Export \
|| { sudo apt-get update -qq && sudo apt-get install -y -qq libreoffice-writer
soffice --headless -env:UserInstallation=file:///tmp/lo --convert-to pdf --outdir . out.docx; }
Convert first and install only on failure. Three things make that ordering necessary:
libreoffice-writeris absent from the image. Only-impressand-drawship, so the Writer filters do not exist and a baresofficefails withError: source file could not be loaded. Installing it pulls 9 packages. Budget about 180 MB of disk: 52 MB of package files, 13 MB of downloaded debs, and — the part that dominates — 111 MB of package indexes written into/var/lib/apt/lists/by theapt-get updatethe line above requires.apt-get updatehas to come first./var/lib/apt/lists/ships empty, so installing without it reportsPackage 'libreoffice-writer' has no installation candidate, which reads like the package is missing from the archive rather than uncached.- The root filesystem can be rolled back mid-run. Observed in this sandbox: an install that worked earlier in a run was gone later, and
pip --userpackages with it. Only/home/user/workspacewas unaffected. Re-running the line above recovers.
The install needs passwordless sudo and the Ubuntu archive. Where either is missing the convert keeps failing with Error: source file could not be loaded; deliver the docx together with the unbranded typst PDF and say the branded export was unavailable, rather than quietly handing over the unbranded one.
Fonts, line spacing and justification all survive the conversion, so the result is good enough to deliver. The w:header and w:footer offsets in pgMar do not: converting a template whose source puts the running head at 47.62pt, LibreOffice placed it at 49.85pt. That is measured against the source document, not against Word — nothing in this section has been checked in Word itself.
xlsx
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Q3"
ws.append(["Region", "Q2", "Q3", "Delta"])
ws.append(["APAC", 120, 148])
ws["D2"] = "=C2-B2" # formulas are written as strings
wb.create_sheet("Notes")
wb.save("book.xlsx")
Excel evaluates formulas when the file opens, so when the file itself must already carry computed values, compute them yourself and write both the formula and the number.
Deliver
okou web upload-file. When prose is final and being sent onward, attach both the PDF and the docx source: the recipient gets something fixed and something they can still edit.
Never use
sofficeon a docx before installinglibreoffice-writer, or on an xlsx at all — the image ships onlylibreoffice-impressandlibreoffice-draw, so the Writer and Calc filters do not exist and the convert fails withError: source file could not be loaded. For docx to PDF, install Writer first — see PDF. For spreadsheets, use openpyxl.chromium --headless --print-to-pdf— produces no file.weasyprintas a pandoc--pdf-engine— exits non-zero.pandoc -o out.xlsx— pandoc has no xlsx writer.
Worked example — one report delivered as docx and PDF
pandoc --print-default-data-file reference.docx > theme.docx
python3 - <<'PY'
import docx
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
d = docx.Document("theme.docx")
s = d.sections[0]
s.header.paragraphs[0].text = "ACME — Internal"
p = s.footer.paragraphs[0]
p.text = "Page "
r = p.add_run()
f = OxmlElement("w:fldSimple")
f.set(qn("w:instr"), "PAGE")
r._r.addnext(f)
d.save("theme.docx")
PY
pandoc report.md --reference-doc=theme.docx --toc --toc-depth=2 -o report.docx
pandoc report.md -t typst -s -V papersize=a4 -o report.typ
python3 -c "import typst; typst.compile('report.typ', output='report.pdf')"