Turns your Overleaf sources (LaTeX + references.bib + figures) into an
editable Word .docx — Times New Roman, justified, title page, running
header, real Word equations, numbered figure and table captions, clickable
cross-references, and a static ACS reference list with live DOI links.
No LaTeX install and no GPU: it uses pandoc, not a LaTeX compile.
app.py Streamlit UI — upload, convert, download. No logic.
tex2docx.py CLI front end (same pipeline as the app)
owlkit/
convert.py the one entry point: preflight → pandoc → postprocess
preflight.py validates the .tex and reports source line numbers
counters.py LaTeX counter machine: what number each \label prints
engine.py preprocess, figure resolution, docx postprocessing
floats.py where the compiled PDF actually placed each float
pagefit.py page-boundary matching (experimental — see below)
constants.py shared markers and asset paths
assets/
reference.docx the Word style template
american-chemical-society.csl
tests/test_owl.py regression tests for every bug listed below
Everything goes through owlkit.convert(), so the app and the CLI cannot
drift apart.
pip install -r requirements.txt
# system tools, once:
# macOS: brew install pandoc poppler
# Ubuntu: sudo apt-get install -y pandoc poppler-utils
streamlit run app.py # or: python tex2docx.py main.tex
python tests/test_owl.py # regression testsSet OWL_PASSWORD in Streamlit Secrets. There is deliberately no default
password in the code — if the secret is missing the app refuses to unlock
rather than falling back to a value that is public in this repository.
OWL checks the source before pandoc sees it, and reports a line number
and a fix for each problem. This exists because a single stray { used to
surface as
Error at "....pre.tex" (line 686, column 1): unexpected \end
— pointing at \end{document}, hundreds of lines from the actual typo.
It checks for: unbalanced braces (per paragraph), unbalanced environments,
citations with no .bib, figures that do not resolve, \textcolor inside
$…$, duplicate labels, and cross-references to labels that do not exist.
A missing .bib is an error, not a warning. Pandoc without a bibliography
converts successfully and silently discards every citation and the whole
reference list — a worse outcome than a clear refusal. Untick "Stop if the
LaTeX has problems" to convert anyway.
\includegraphics[width=…]{…}was never processed. Figure conversion matched the literal token\includegraphics{, so every call with an optional argument — i.e. essentially all of them — skipped PDF→PNG conversion, and raw PDFs got embedded into the.docx, which Word cannot display.\graphicspathand extension-less names were ignored. Only literal.pdf/.epsnames in the main directory resolved.- Cross-references to user-defined sectioning macros were dead. A macro
such as
\appsectionthat steps its own counter fell through the label map, so every\cref{app:…}printed the raw label text.counters.pynow reads the preamble the way LaTeX does —\newcounter,\the…,\crefname,\refstepcounterinside macro bodies — and simulates the counters. - Equation cross-references (
\cref{eq:…}) were unresolved for the same reason. - Comments were parsed as code, so an
\appsectionmentioned in a%comment shifted every appendix letter by one. - Supplementary figures were misnumbered. Captions were numbered by
position, ignoring
\setcounter{figure}{0}plus\renewcommand{\thefigure}{S\arabic{figure}}— so "Figure S1" came out as "Figure 6" while the cross-reference pointing at it said something else. - The password had a hard-coded fallback in the source of this repository.
If an .aux file from a real LaTeX run is available, pass --aux main.aux
and its numbers are used instead of OWL's reconstruction — LaTeX is always
right about its own numbering.
- Figures were the wrong size. Widths were collected by scanning every
\includegraphicsin the raw source, including one inside a%comment in the preamble and one whose file was missing. Both shift the positional mapping, so every picture got another picture's width — the first real figure fell back to its natural size, about 38% of the text width instead of the 85% the LaTeX asked for. Widths are now collected during the rewrite, only for figures that actually resolve, so the list cannot drift out of step with the images pandoc emits. Absolute units (width=2in,3cm,100pt) are honoured too. \textcolorwas dropped entirely. pandoc has no colour model for the LaTeX reader, so a document using red for open questions and green for settled ones arrived uniformly black.colors.pyreads\definecolor— respecting the case-sensitive difference between xcolor's{rgb}fractions and{RGB}integers — and re-applies realw:colorruns, including across runs that pandoc split for emphasis, maths or links.- Comments are stripped before preprocessing, so nothing commented out is ever treated as content.
Every Word page starts and ends on the same word as the compiled LaTeX PDF. This is on by default in the app and requires the PDF.
The PDF is not optional and cannot be worked around. Nothing in the .tex
says where the page breaks fall — they are the output of LaTeX's line-breaking
algorithm run against your fonts and margins, and only exist once the document
has been compiled.
from owlkit import convert
convert("main.tex", match_pdf="main.pdf", on_progress=print)Measured on the reference document (36-page PDF, 14 floats, 52 references):
| pages | 36 / 36, confirmed in Word |
| page starts matching the PDF | 31–35 of 36, depending on how strictly you count |
| total boundary error | 4 words |
| source prose present | 99.2% |
| wall clock | ~2.5 minutes |
Each page is treated as its own object — [float at top] + prose + [float at bottom] — rendered on its own, and asked whether it fits with an inch of
vertical room to spare. The settings are tightened down a ladder (margins
first, then figure width, then leading) until every page clears that bar; the
result is one uniform layout for the whole document.
The inch matters. Measurement happens in LibreOffice; the document is opened in Word; the two lay text out differently. Sizing each page to just fit gave 38 pages in Word, then 37. An inch of headroom gave 36.
LibreOffice, to render the page probes — hence libreoffice-writer in
packages.txt. It makes the build heavier and the conversion slower, which is
the price of the feature.
- Push this folder to a GitHub repo.
- share.streamlit.io → Create app → branch
main, main fileapp.py. - Advanced → Secrets:
OWL_PASSWORD = "your-password-here"
- Deploy.
packages.txt(apt) andrequirements.txt(pip) install automatically. Every push redeploys.
packages.txt deliberately does not install libreoffice-writer any
more: nothing in the conversion path used it, and it added hundreds of
megabytes to every build.
© 2026 David G. Schauer · All rights reserved. OWL — the app, workflow and code — is an original work of the author. It builds on the open-source pandoc and python-docx projects.