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.