Guide
Automating Reporting WorkflowsDeep dive

Convert Excel to PDF on Linux with LibreOffice

Headless LibreOffice converts a workbook to PDF with no Excel, licence or desktop session — with the profile, timeout and page-setup details that make it reliable in a container.

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.

Three ways to turn a workbook into a PDF Excel through COM produces the most faithful output but needs Windows and a licence, headless LibreOffice needs neither and is close enough, and rendering the data directly gives full control but rebuilds the layout. Excel via COM exact pagination Windows and a licence no container LibreOffice headless no licence, no desktop very close output one command render from data total control rebuilds the layout most work the container case has one practical answer

Prerequisites

Bash
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

Bash
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:

Python
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 workbook settings that decide whether the PDF is readable Enable fit-to-page on the sheet properties before setting fit-to-width, define the print area, repeat the header row on every page, and add a page footer. 1 Enable fitToPage fitToWidth does nothing without it 2 Set the print area otherwise every stray cell is included 3 Repeat the header row print_title_rows keeps page four readable 4 Add a footer Page &P of &N, and a generation date the converter is rarely the reason a PDF looks wrong

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.

Python
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.

Python
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

Dockerfile
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.

Python
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.

Bash
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

SymptomCauseFix
Conversion hangs indefinitelyA recovery or first-run dialog--norestore, a dedicated profile, and a subprocess timeout
Exit code 0 but no PDFLibreOffice failed silentlyAssert the output file exists
Fonts look wrong, layout reflowsMetric-compatible fonts missingInstall fonts-liberation, or embed the fonts you use
Columns split across pagesNo fit-to-width settingSet fitToPage and fitToWidth before converting
Concurrent conversions interfereShared user profileOne -env:UserInstallation directory per worker
First conversion of the day is slowCold profileWarm the profile during the image build

Performance and scale

Startup dominates the cost of a conversion Launching LibreOffice takes one to three seconds while converting a modest workbook takes a fraction of one, so converting thirty files in a single invocation is far cheaper than thirty invocations. 30 separate runs 30 startups batches of 10 3 startups one invocation 1 startup relative cost batching trades error granularity for wall-clock time

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.

Python
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.