165 lines
4.7 KiB
Python
165 lines
4.7 KiB
Python
"""Convert Office documents to PDF for downstream parsing."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import platform
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
_PPT_EXTENSIONS = {".ppt", ".pptx", ".ppsx"}
|
||
|
|
_WORD_EXTENSIONS = {".doc", ".docx"}
|
||
|
|
|
||
|
|
|
||
|
|
def _find_soffice() -> Path | None:
|
||
|
|
for candidate in (
|
||
|
|
"soffice",
|
||
|
|
r"C:\Program Files\LibreOffice\program\soffice.exe",
|
||
|
|
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
||
|
|
"/usr/bin/libreoffice",
|
||
|
|
"/usr/bin/soffice",
|
||
|
|
):
|
||
|
|
if candidate in ("soffice", "libreoffice"):
|
||
|
|
found = shutil.which(candidate)
|
||
|
|
if found:
|
||
|
|
return Path(found)
|
||
|
|
elif Path(candidate).is_file():
|
||
|
|
return Path(candidate)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _convert_via_libreoffice(source: Path, out_dir: Path) -> Path:
|
||
|
|
soffice = _find_soffice()
|
||
|
|
if soffice is None:
|
||
|
|
raise RuntimeError("LibreOffice (soffice) not found")
|
||
|
|
|
||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
subprocess.run(
|
||
|
|
[
|
||
|
|
str(soffice),
|
||
|
|
"--headless",
|
||
|
|
"--convert-to",
|
||
|
|
"pdf",
|
||
|
|
"--outdir",
|
||
|
|
str(out_dir),
|
||
|
|
str(source.resolve()),
|
||
|
|
],
|
||
|
|
check=True,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=180,
|
||
|
|
)
|
||
|
|
pdf_path = out_dir / f"{source.stem}.pdf"
|
||
|
|
if not pdf_path.is_file():
|
||
|
|
raise RuntimeError(f"LibreOffice did not produce PDF: {pdf_path}")
|
||
|
|
return pdf_path
|
||
|
|
|
||
|
|
|
||
|
|
def _convert_via_powerpoint(source: Path, out_dir: Path) -> Path:
|
||
|
|
if platform.system() != "Windows":
|
||
|
|
raise RuntimeError("PowerPoint COM conversion is only available on Windows")
|
||
|
|
|
||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
pdf_path = out_dir / f"{source.stem}.pdf"
|
||
|
|
src = str(source.resolve())
|
||
|
|
dst = str(pdf_path.resolve())
|
||
|
|
script = f"""
|
||
|
|
$ErrorActionPreference = 'Stop'
|
||
|
|
$pp = New-Object -ComObject PowerPoint.Application
|
||
|
|
try {{
|
||
|
|
$pres = $pp.Presentations.Open('{src.replace("'", "''")}', $true, $true, $false)
|
||
|
|
try {{
|
||
|
|
$pres.SaveAs('{dst.replace("'", "''")}', 32)
|
||
|
|
}} finally {{
|
||
|
|
$pres.Close()
|
||
|
|
}}
|
||
|
|
}} finally {{
|
||
|
|
$pp.Quit()
|
||
|
|
}}
|
||
|
|
"""
|
||
|
|
subprocess.run(
|
||
|
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
||
|
|
check=True,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=180,
|
||
|
|
)
|
||
|
|
if not pdf_path.is_file():
|
||
|
|
raise RuntimeError(f"PowerPoint did not produce PDF: {pdf_path}")
|
||
|
|
return pdf_path
|
||
|
|
|
||
|
|
|
||
|
|
def _convert_via_word(source: Path, out_dir: Path) -> Path:
|
||
|
|
if platform.system() != "Windows":
|
||
|
|
raise RuntimeError("Word COM conversion is only available on Windows")
|
||
|
|
|
||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
pdf_path = out_dir / f"{source.stem}.pdf"
|
||
|
|
src = str(source.resolve())
|
||
|
|
dst = str(pdf_path.resolve())
|
||
|
|
script = f"""
|
||
|
|
$ErrorActionPreference = 'Stop'
|
||
|
|
$word = New-Object -ComObject Word.Application
|
||
|
|
$word.Visible = $false
|
||
|
|
try {{
|
||
|
|
$doc = $word.Documents.Open('{src.replace("'", "''")}', $false, $true)
|
||
|
|
try {{
|
||
|
|
$doc.SaveAs2('{dst.replace("'", "''")}', 17)
|
||
|
|
}} finally {{
|
||
|
|
$doc.Close([ref]$false)
|
||
|
|
}}
|
||
|
|
}} finally {{
|
||
|
|
$word.Quit()
|
||
|
|
}}
|
||
|
|
"""
|
||
|
|
subprocess.run(
|
||
|
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
||
|
|
check=True,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=180,
|
||
|
|
)
|
||
|
|
if not pdf_path.is_file():
|
||
|
|
raise RuntimeError(f"Word did not produce PDF: {pdf_path}")
|
||
|
|
return pdf_path
|
||
|
|
|
||
|
|
|
||
|
|
def convert_to_pdf(source: Path, out_dir: Path) -> Path:
|
||
|
|
"""Convert an Office file to PDF. Tries LibreOffice, then format-specific COM on Windows."""
|
||
|
|
source = source.resolve()
|
||
|
|
if not source.is_file():
|
||
|
|
raise FileNotFoundError(source)
|
||
|
|
|
||
|
|
errors: list[str] = []
|
||
|
|
try:
|
||
|
|
return _convert_via_libreoffice(source, out_dir)
|
||
|
|
except Exception as exc: # noqa: BLE001 — collect and try next backend
|
||
|
|
errors.append(f"libreoffice: {exc}")
|
||
|
|
|
||
|
|
ext = source.suffix.lower()
|
||
|
|
fallbacks: list = []
|
||
|
|
if ext in _PPT_EXTENSIONS:
|
||
|
|
fallbacks.append(_convert_via_powerpoint)
|
||
|
|
elif ext in _WORD_EXTENSIONS:
|
||
|
|
fallbacks.append(_convert_via_word)
|
||
|
|
|
||
|
|
for converter in fallbacks:
|
||
|
|
try:
|
||
|
|
return converter(source, out_dir)
|
||
|
|
except Exception as exc: # noqa: BLE001
|
||
|
|
errors.append(f"{converter.__name__}: {exc}")
|
||
|
|
|
||
|
|
detail = "; ".join(errors) if errors else "no converter available"
|
||
|
|
raise RuntimeError(
|
||
|
|
f"Failed to convert {source.name} to PDF. "
|
||
|
|
f"Install LibreOffice or Microsoft Office. Details: {detail}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def is_presentation(path: Path) -> bool:
|
||
|
|
return path.suffix.lower() in _PPT_EXTENSIONS
|
||
|
|
|
||
|
|
|
||
|
|
def is_word_document(path: Path) -> bool:
|
||
|
|
return path.suffix.lower() in _WORD_EXTENSIONS
|