Convert Excel to PDF on Linux with LibreOffice
Converting a workbook to PDF without Excel is the requirement that stops most Linux reporting pipelines, and headless LibreOffice is the answer that works: no licence, no desktop session, one command. This guide, part of Exporting Excel Reports to PDF, covers the invocation, the page setup that makes the output usable, and the container details that decide whether it is reliable.
Prerequisites
sudo apt-get install -y libreoffice-calc fonts-liberation
libreoffice-calc alone is enough — the full suite is several hundred megabytes more for no benefit
here. fonts-liberation supplies metric-compatible substitutes for Arial, Times New Roman and
Courier New, which is what stops the output reflowing when those fonts are missing.
The command
soffice --headless --convert-to pdf --outdir /out /in/report.xlsx
That is the whole conversion. Wrapping it in Python adds the error handling that a scheduled job needs:
import shutil
import subprocess
from pathlib import Path
def to_pdf(source: Path, out_dir: Path, profile: Path, timeout: int = 180) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
binary = shutil.which("soffice") or shutil.which("libreoffice")
if binary is None:
raise RuntimeError("LibreOffice is not installed or not on PATH")
result = subprocess.run(
[binary,
f"-env:UserInstallation=file://{profile}",
"--headless", "--norestore", "--nolockcheck",
"--convert-to", "pdf:calc_pdf_Export",
"--outdir", str(out_dir), str(source)],
capture_output=True, text=True, timeout=timeout,
)
target = out_dir / f"{source.stem}.pdf"
if result.returncode != 0 or not target.exists():
raise RuntimeError(f"conversion failed ({result.returncode}): {result.stderr.strip()}")
return target
Three flags earn their place. -env:UserInstallation gives this process its own profile directory,
which is what allows concurrent conversions. --norestore stops LibreOffice trying to recover a
document after a previous crash — a recovery dialog in a headless process is a hang. And checking
that the output file exists matters because LibreOffice sometimes returns zero having produced
nothing.
Setting up the page before converting
The single biggest determinant of output quality is not the converter but the workbook. A sheet with no print area and no fit-to-width setting becomes a PDF spread across nine pages with columns split down the middle.
from openpyxl import load_workbook
book = load_workbook("report.xlsx")
sheet = book["Summary"]
sheet.page_setup.orientation = "landscape"
sheet.page_setup.fitToWidth = 1
sheet.page_setup.fitToHeight = 0 # as many pages tall as needed
sheet.sheet_properties.pageSetUpPr.fitToPage = True
sheet.print_area = f"A1:H{sheet.max_row}"
sheet.print_title_rows = "1:1" # repeat the header on every page
sheet.oddFooter.center.text = "Page &P of &N"
book.save("report-print-ready.xlsx")
fitToPage = True on the sheet properties is required for fitToWidth to take effect — setting the
width alone silently does nothing, which is the same trap as Excel's zoom-versus-fit interaction.
print_title_rows is what keeps the header visible on page four, and it is the difference between a
PDF somebody reads and one they scroll back and forth in.
Set the Print Area and Page Setup with openpyxl
covers the full property set.
Converting only some sheets
LibreOffice converts every sheet with a print area. To produce a PDF of one tab, remove the others first — from a copy, obviously.
from openpyxl import load_workbook
def single_sheet_copy(source: str, keep: str, target: str) -> str:
book = load_workbook(source)
for name in list(book.sheetnames):
if name != keep:
del book[name]
book.save(target)
return target
to_pdf(Path(single_sheet_copy("report.xlsx", "Summary", "summary-only.xlsx")), ...)
Deleting sheets from a loaded copy is cheaper and more predictable than trying to control the conversion, and it works identically on every platform — which the alternative, Convert Only Selected Sheets of a Workbook to PDF, discusses in the Excel-driven case.
Running it in a container
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice-calc fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
ENV LO_PROFILE=/tmp/loprofile
RUN mkdir -p $LO_PROFILE && \
soffice -env:UserInstallation=file://$LO_PROFILE --headless \
--convert-to pdf --outdir /tmp /usr/share/doc/*/copyright 2>/dev/null || true
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
WORKDIR /app
CMD ["python", "report.py"]
The warm-up line is the one people leave out and then spend an afternoon on: the first LibreOffice run builds a profile and takes ten to twenty seconds, so doing it at build time means the first report of the day is not the slow one. The broader containerisation argument is in Run a Python Excel Report in Docker.
Checking the PDF before it goes out
A conversion that returns successfully can still produce something nobody should send — a single blank page because the print area was empty, or forty pages because it was not set. Two checks catch both, and neither needs a PDF library beyond a small one.
from pypdf import PdfReader
def check_pdf(path, min_pages=1, max_pages=20, min_bytes=5_000):
size = path.stat().st_size
if size < min_bytes:
raise ValueError(f"{path.name} is only {size} bytes — probably empty")
pages = len(PdfReader(str(path)).pages)
if not (min_pages <= pages <= max_pages):
raise ValueError(f"{path.name} has {pages} pages, expected {min_pages}-{max_pages}")
first = PdfReader(str(path)).pages[0].extract_text() or ""
if len(first.strip()) < 40:
raise ValueError(f"{path.name} page 1 has almost no text — check the print area")
return pages
The page-count bound is the most useful of the three, because a report whose length is stable month to month makes an excellent canary: a jump from four pages to forty means the print area was lost, and a drop to one means the data did not arrive. Wiring that into the pre-send checks described in Validate an Excel Report Before Sending It turns a silent formatting regression into a failed run.
Fonts, and why the output moves between machines
The most frustrating difference between a conversion on a laptop and one in a container is font substitution. A workbook specifying Calibri renders with Calibri locally and with whatever the container decided is closest otherwise — and because the substitute has different metrics, columns reflow and the page count changes.
Two fixes work. Install metric-compatible fonts, which is what fonts-liberation provides for the
Microsoft core set, and add fonts-crosextra-carlito for a Calibri equivalent. Or standardise the
workbook on a font that is present everywhere, which is less satisfying and completely reliable.
apt-get install -y fonts-liberation fonts-crosextra-carlito fonts-crosextra-caladea
fc-list | grep -i carlito # confirm it was picked up
Carlito is metric-compatible with Calibri and Caladea with Cambria, which between them cover the
default fonts of every Excel template anybody is likely to hand you. Confirming with fc-list during
the image build is worth the line — a font package that installed but was not registered produces
exactly the same reflow as one that was never installed.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Conversion hangs indefinitely | A recovery or first-run dialog | --norestore, a dedicated profile, and a subprocess timeout |
| Exit code 0 but no PDF | LibreOffice failed silently | Assert the output file exists |
| Fonts look wrong, layout reflows | Metric-compatible fonts missing | Install fonts-liberation, or embed the fonts you use |
| Columns split across pages | No fit-to-width setting | Set fitToPage and fitToWidth before converting |
| Concurrent conversions interfere | Shared user profile | One -env:UserInstallation directory per worker |
| First conversion of the day is slow | Cold profile | Warm the profile during the image build |
Performance and scale
LibreOffice startup dominates: roughly one to three seconds per invocation once the profile is warm, against a fraction of a second for the conversion itself on a modest workbook. Converting thirty reports therefore benefits enormously from passing them to one invocation rather than thirty.
subprocess.run(
[binary, f"-env:UserInstallation=file://{profile}", "--headless", "--norestore",
"--convert-to", "pdf", "--outdir", str(out_dir), *[str(p) for p in paths]],
check=True, capture_output=True, timeout=900,
)
The trade is error granularity — one failure in a batch is harder to attribute — so a reasonable middle ground is batches of five or ten with a per-batch timeout. For genuine parallelism, run several processes each with its own profile directory; sharing one is the reliable way to produce intermittent, unreproducible failures.
Conclusion
soffice --headless --convert-to pdf converts a workbook without Excel, a licence or a desktop, and
it is the standard answer for a Linux reporting container. Give every process its own profile
directory, pass --norestore and a timeout, assert the output file exists rather than trusting the
exit code, and — most importantly — set the print area and fit-to-width on the workbook before
converting, because that is what decides whether the PDF is readable.
Frequently asked questions
Does LibreOffice render exactly like Excel? Close, not identical. Page breaks, some fonts and a few chart styles differ. For a report you generate yourself, setting the page setup explicitly — fit to width, defined print area, standard fonts — removes almost all of the visible difference.
Why does the first conversion take so long? LibreOffice builds a user profile on first run. Point it at a dedicated profile directory with the -env switch and warm it once during the container build so the first real conversion is not the slow one.
Can I convert several files at once? You can pass several paths to one invocation, which amortises the startup cost. What you cannot do is run several soffice processes sharing one user profile — they collide. Give each concurrent worker its own profile directory.
Is a headless LibreOffice licence-free for server use? Yes, it is free software under the Mozilla Public License, with no per-seat or server licensing. That is the main reason it is the standard answer for PDF conversion in a container, where an Excel licence would not be.
Related
- Up one level: Exporting Excel Reports to PDF — the wider set of conversion routes.
- Convert an Excel File to PDF with Python — the Windows and Excel-driven alternatives.
- Convert Only Selected Sheets of a Workbook to PDF — controlling what ends up in the file.
- Add Headers, Footers and Page Numbers to an Excel PDF — the page furniture this conversion carries through.
- Run a Python Excel Report in Docker — packaging the converter with the job.