'unoconv' for document conversion ease
If your application still depends on unoconv, start by checking its Python/UNO environment and converting a document whose contents you know. This guide covers an existing Debian 12 worker, a DOCX batch script, and the PDF checks needed before accepting its output.
Unoconv is deprecated, and its repository is archived. For new implementations, use unoserver. Keep this recipe for maintaining an existing unoconv workflow while you plan that migration.
Introduction to 'unoconv' and its open-source capabilities
Unoconv is an open-source command-line tool that calls LibreOffice through its UNO bindings. LibreOffice supplies the import and export filters; installing a Python script alone does not provide document conversion. Fonts also affect the resulting layout.
Setting up and installing 'unoconv' on your system
Prerequisites
Use a dedicated legacy worker with an already provisioned, pinned environment. The tested worker
used Debian 12 Bookworm, distribution Python 3.11.2, matching python3-uno, unoconv 0.7 from Debian
package 0.7-2, LibreOffice Writer 7.4.7.2, and Liberation Sans from fonts-liberation2. These are
tested versions for legacy maintenance; this page does not recommend installing an old stack for a
new application.
The worker needs a writable LibreOffice user profile and temporary directory. Run one batch at a
time, without a desktop LibreOffice session or shared listener using that profile. Use known,
trusted DOCX and ODT documents for the first conversion. Install the fonts your real documents use
before judging pagination. The inspection commands below require Poppler’s poppler-utils on the
machine where you inspect the PDFs. That can be the worker or another machine with access to the
same output files.
Installation on Linux (Ubuntu/Debian)
Debian’s Bookworm package supplies this legacy
unoconv version. Keep its distribution Python and UNO packages together. The following check assumes
python3 resolves to /usr/bin/python3, outside a virtual environment. It verifies each command
separately and checks that this interpreter can import UNO before conversion:
(
for tool in python3 unoconv libreoffice fc-match; do
command -v "$tool" >/dev/null || { printf 'Missing %s\n' "$tool" >&2; exit 1; }
done
python3 -c 'import uno, unohelper; print(uno.__file__)' &&
python3 --version &&
unoconv --version &&
libreoffice --version &&
fc-match -f '%{family}\n' 'Liberation Sans'
)
On the tested worker, UNO came from /usr/lib/python3/dist-packages/uno.py. Stop if the import fails
or the reported versions differ from your deployment’s verified image. This is a check of an
existing worker, not an installation recipe for current Ubuntu or Debian releases.
On the machine where you will inspect the output, check the PDF tools too. The checks here used Poppler 26.08.0:
(
for tool in pdfinfo pdftotext pdftoppm; do
command -v "$tool" >/dev/null || { printf 'Missing %s\n' "$tool" >&2; exit 1; }
done
pdfinfo -v
)
Installation on macOS
Homebrew lists unoconv as disabled. Installing LibreOffice alone does not establish a working unoconv/Python combination. This walkthrough does not verify a macOS installation; use the maintained successor’s installation guidance when moving away from a legacy macOS worker.
Installation on Windows
Native Windows and WSL installations are outside this guide’s tested scope. WSL does not itself supply the matching Python, UNO bindings, fonts, or LibreOffice filters. Do not treat switching to WSL as a fix for an unverified environment.
Step-by-step guide: converting documents from docx to PDF
Put a known DOCX document at document.docx and a known ODT document at notes.odt in a working
directory. Record their expected page counts, headings, final paragraphs, and any tables or graphics.
These raw commands write document.pdf and notes.pdf beside the inputs and can replace existing
PDFs, so use a directory without those destinations:
unoconv -f pdf ./document.docx &&
unoconv -f pdf ./notes.odt
Inspect the DOCX result with these commands. They write document.txt and numbered PNG files such
as document-page-1.png; repeating the check replaces those inspection files:
pdfinfo -box ./document.pdf &&
pdftotext -layout ./document.pdf ./document.txt &&
pdftoppm -cropbox -r 96 -png ./document.pdf ./document-page
Compare the page count and extracted text with the source, including the last paragraph on every
page. Open every rendered PNG and check tables, graphics, margins, and page breaks. Rendering the
CropBox shows the visible page, so content outside it cannot hide a clipping problem. Repeat the
inspection for notes.pdf using notes.txt and the notes-page prefix. A nonempty file, PDF
signature, or successful command does not prove that the right document was converted intact.
Automating document conversion tasks with scripts
For a sequential DOCX batch on that working installation, save this as convert_documents.py.
It selects regular files with a case-insensitive .docx suffix, skips directories and symlinks,
and requires a new output directory. Its basic archive check recognizes both Transitional and
Strict OOXML main-document namespaces; it is not a complete OOXML schema validator.
import os
from pathlib import Path
import posixpath
import subprocess
import sys
from tempfile import TemporaryDirectory
from urllib.parse import unquote
from xml.etree import ElementTree
from zipfile import BadZipFile, ZipFile
import zlib
def check_docx(path):
relationship_types = {
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
'http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument',
}
document_tags = {
'{http://schemas.openxmlformats.org/wordprocessingml/2006/main}document',
'{http://purl.oclc.org/ooxml/wordprocessingml/main}document',
}
with ZipFile(path) as archive:
if archive.testzip() is not None:
raise ValueError('Damaged DOCX member')
relationships = ElementTree.fromstring(archive.read('_rels/.rels'))
main_parts = [item for item in relationships
if item.get('Type') in relationship_types
and item.get('TargetMode', 'Internal') == 'Internal']
if len(main_parts) != 1:
raise ValueError('Missing main document relationship')
part = posixpath.normpath(unquote(main_parts[0].get('Target', '')).lstrip('/'))
types = ElementTree.fromstring(archive.read('[Content_Types].xml'))
content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'
if not any(item.get('PartName') == '/' + part
and item.get('ContentType') == content_type for item in types):
raise ValueError('Not a DOCX main part')
if ElementTree.fromstring(archive.read(part)).tag not in document_tags:
raise ValueError('Not a Word document')
def convert_documents(input_dir, output_dir):
source = Path(input_dir).resolve(strict=True)
files = sorted(path for path in source.iterdir()
if not path.is_symlink() and path.is_file()
and path.suffix.lower() == '.docx')
if not files:
print('No DOCX files found', file=sys.stderr)
return 1
output = Path(output_dir).absolute()
output.mkdir(mode=0o700)
failures = 0
for path in files:
try:
check_docx(path)
with TemporaryDirectory(prefix='.conversion-', dir=output) as temporary:
candidate = Path(temporary) / 'result.pdf'
subprocess.run(
['unoconv', '-f', 'pdf', '-o', str(candidate), str(path)],
check=True, timeout=120,
)
if not candidate.is_file() or candidate.stat().st_size == 0:
raise ValueError('No nonempty PDF was produced')
# Hard-link publication fails instead of replacing a destination that appeared.
os.link(candidate, output / (path.stem + '.pdf'))
print(f'Converted {path.name}')
except (OSError, ValueError, KeyError, BadZipFile, ElementTree.ParseError,
zlib.error, NotImplementedError, RuntimeError, subprocess.SubprocessError):
failures += 1
print(f'Conversion failed: {path.name}', file=sys.stderr)
return 1 if failures else 0
if __name__ == '__main__':
if len(sys.argv) != 3:
sys.exit('Usage: python3 convert_documents.py <input-directory> <new-output-directory>')
try:
sys.exit(convert_documents(sys.argv[1], sys.argv[2]))
except (OSError, ValueError):
sys.exit('Cannot prepare conversion directories; use an existing input and a new output directory')
Run it with your DOCX files in docs and an absent new-pdfs directory:
python3 convert_documents.py docs new-pdfs
The script attempts later files after a failure and returns status 1 if any file fails. It reports the failing filename, removes temporary candidates, and preserves existing destinations, including same-stem collisions. Successful PDFs remain available when another file fails. Inspect them with the text and rendering checks above before using them. Publication only means that the converter returned successfully and produced a nonempty candidate.
For a retry, keep previously published PDFs and use another new output directory. An empty selection returns status 1 without creating the output directory. The 120-second timeout bounds each client process; it does not guarantee that LibreOffice has stopped work. An interrupted worker can leave staging directories, so inspect its owned storage before retrying. This script is for a trusted local batch, not an untrusted upload endpoint. Such a worker needs its own process supervision, resource limits, and filesystem isolation.
Troubleshooting common issues
Python version conflicts
If the UNO import check fails, verify which Python is running and whether it matches the installed UNO bridge. Upstream documents this interpreter requirement. An arbitrary virtual environment is not sufficient: a default venv hides the distribution’s UNO module. Changing only the Python version does not install a matching bridge.
LibreOffice version compatibility
The tested Debian unoconv emits a distutils deprecation warning on Python 3.11; successful
conversion still requires checking the actual PDFs. Keep the verified Python/UNO/LibreOffice
combination in your legacy image. If a document fails, try opening and exporting that same document
in LibreOffice to distinguish an import/export problem from the unoconv client path.
Container deployment considerations
A container must provide writable profile, cache, data, runtime, and temporary directories for its
conversion user. Create the directories before starting LibreOffice when setting
XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, or XDG_RUNTIME_DIR. A missing writable
profile can stop startup before document conversion. Keep jobs sequential and stop the entire owned
worker when a client times out; killing only the client is not proof of cleanup.
Modern alternatives and comparison
| Deployment | Choice |
|---|---|
| Existing pinned legacy worker | Maintain unoconv while testing representative documents. |
| New deployment | Use unoserver. |
Unoserver (recommended)
Unoserver separates a persistent LibreOffice worker from its conversion client. Its server still needs a Python interpreter that can import UNO; it does not remove that dependency. Follow the unoserver installation instructions, then see document conversion from Rust for the maintained integration. This page’s tests cover unoconv, not that separate server workflow.
LibreOffice CLI
For a one-off conversion, LibreOffice also has a
command-line export option.
On the tested Debian worker, this writes document.pdf to the current directory:
libreoffice --headless --convert-to pdf ./document.docx
Use an absent destination and apply the same PDF inspection. LibreOffice can replace an existing PDF, and an exit status alone does not establish the resulting contents. This route bypasses the unoconv client, but still uses LibreOffice’s filters and fonts.
Cloud-based solutions
If you want an API boundary instead of operating an office worker, evaluate Transloadit’s Document Conversion service with your representative documents. That is a separate integration; the local commands above do not test a hosted service.
