@@ -0,0 +1,164 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user