Axiomatic ToolsFree Fast Essential
All posts

LibreOffice will hand you an empty PowerPoint and exit 0

Our PDF-to-PPTX converter returned a valid, openable, completely slide-less deck as a success. The exit code was zero and the file existed, which is exactly why nobody noticed.

Axiomatic Tools Teamengineeringlibreofficeoffice

Our PDF to PowerPoint tool ran this:

soffice --headless \
        --infilter=writer_pdf_import \
        --convert-to "pptx:Impress MS PowerPoint 2007 XML" \
        --outdir <tmp> <tmp>/in.pdf

Then it checked that in.pptx existed and returned it to the user. The file existed. It was a structurally valid .pptx. It opened without complaint. It had no slides in it.

Two filters that cannot meet

LibreOffice registers one PDF importer per application, in sdext/source/pdfimport/config/pdf_import_filter.xcu:

writer_pdf_import   -> com.sun.star.text.TextDocument
draw_pdf_import     -> com.sun.star.drawing.DrawingDocument
impress_pdf_import  -> com.sun.star.presentation.PresentationDocument

We asked for writer_pdf_import, which loads the PDF as a Writer text document. Then we asked to export it with the filter Impress MS PowerPoint 2007 XML, whose DocumentService is declared as com.sun.star.presentation.PresentationDocument.

A PowerPoint export filter does not apply to a text document. The mistake was a copy-paste from the sibling PDF-to-Word handler, where writer_pdf_import is exactly right.

Why it succeeded anyway

This is the interesting half. Reading sd/source/filter/eppt at the libreoffice-25-2 branch — the one we actually deploy, which differs from master here:

epptbase.hxx:390        void exportPPT(...)      <- void. It cannot report failure.
pptx-epptooxml.cxx:453  exportPPT(aProperties);  <- return value ignored
pptx-epptooxml.cxx:484  commitStorage();         <- unconditional
pptx-epptooxml.cxx:493  return true;             <- unconditional

exportPPT gives up early when InitSOIface() cannot get an XDrawPagesSupplier out of the model — which a Writer text document never provides — and every one of its bail-outs is a bare return;. It is declared void, so it has no way to tell its caller that anything went wrong. exportDocument ignores it, commits the OPC container that was already open, and returns true.

The result: a valid zip with [Content_Types].xml, _rels, ppt/presentation.xml, a theme, and no ppt/slides/ at all. soffice exits 0.

It is worth knowing that soffice exits 0 on failed conversions generally — up to and including 25.8. Only from 26.2 does main return GetAllSucceeded() ? EXIT_SUCCESS : EXIT_FAILURE. And even that would not have caught this one, because nothing here fails.

Fixing the filter is not enough

Swapping to impress_pdf_import fixes the mismatch. It does not fix the blindness: the handler still treats "the file exists" as "the conversion worked", and every early return in exportPPT still commits storage and returns true.

So the check is now on the contents, not the existence:

def _has_slides(pptx: Path) -> bool:
    try:
        with zipfile.ZipFile(pptx) as z:
            return any(n.startswith("ppt/slides/slide") for n in z.namelist())
    except zipfile.BadZipFile:
        return False

That part name comes from the exporter itself (pptx-epptooxml.cxx:1342), not from guesswork.

Two things we deliberately do not check. Slide count against PDF page count: the import promises no 1:1 correspondence, so that would invent failures. And file size: a .pptx embeds no fonts by default, so its size tracks nothing useful about the input.

The other thing in that directory

While we were in there: LibreOffice keeps its settings in one user profile and derives a named IPC pipe from an MD5 of the profile path (desktop/source/app/officeipcthread.cxx:758). A second soffice with the same profile fails to create that pipe, so it opens it instead and hands its arguments to the instance already running — then exits. Returning 0.

Our worker runs jobs with asyncio.to_thread at a concurrency of 2, so two conversions are two threads of one process sharing one HOME, and none of our six soffice call sites passed -env:UserInstallation. Every one now gets a throwaway profile directory of its own. Keying it off the process id would not have helped: both threads have the same pid.

The shape of the bug

An error that reaches the user is a bug report. A success that is wrong is a silence. This one produced a downloadable file with the right extension, the right MIME type and nothing in it — which is close to the worst possible failure mode, and survived from the first commit precisely because it never once looked like a failure.