1"""Office/HTML/image -> PDF with LibreOffice, one soffice call per document family."""
2from __future__ import annotations
3
4import os
5import shutil
6import subprocess
7import tempfile
8
9
10FAMILIES = {
11 'writer': ('writer_pdf_Export', {'.doc', '.docx', '.docm', '.dot', '.dotx', '.odt', '.ott', '.rtf', '.txt',
12 '.wpd', '.html', '.htm', '.xhtml', '.md'}),
13 'calc': ('calc_pdf_Export', {'.xls', '.xlsx', '.xlsm', '.xlsb', '.ods', '.ots', '.csv'}),
14 'impress': ('impress_pdf_Export', {'.ppt', '.pptx', '.pptm', '.pps', '.ppsx', '.odp', '.otp'}),
15 'draw': ('draw_pdf_Export', {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tif', '.tiff', '.webp', '.svg', '.odg',
16 '.vsd', '.vsdx'}),
17}
18EXT_FAMILY = {ext: fam for fam, (_, exts) in FAMILIES.items() for ext in exts}
19SUPPORTED_EXT = set(EXT_FAMILY)
20
21BY_CONTENT_TYPE = {
22 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
23 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
24 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
25 'application/msword': '.doc', 'application/vnd.ms-excel': '.xls', 'application/vnd.ms-powerpoint': '.ppt',
26 'application/vnd.oasis.opendocument.text': '.odt', 'application/vnd.oasis.opendocument.spreadsheet': '.ods',
27 'application/vnd.oasis.opendocument.presentation': '.odp', 'application/rtf': '.rtf', 'text/rtf': '.rtf',
28 'text/html': '.html', 'text/plain': '.txt', 'text/csv': '.csv', 'image/png': '.png', 'image/jpeg': '.jpg',
29 'image/tiff': '.tiff', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg',
30}
31
32
33class ConversionError(Exception):
34 pass
35
36
37def detect_ext(path: str, name_hint: str = '', content_type: str = '') -> str:
38 for candidate in (name_hint, path):
39 ext = os.path.splitext(candidate.split('?')[0])[1].lower()
40 if ext in SUPPORTED_EXT:
41 return ext
42 ct = content_type.split(';')[0].strip().lower()
43 if ct in BY_CONTENT_TYPE:
44 return BY_CONTENT_TYPE[ct]
45 with open(path, 'rb') as f:
46 head = f.read(8)
47 if head.startswith(b'%PDF'):
48 raise ConversionError('the file is already a PDF')
49 if head.startswith(b'\x89PNG'):
50 return '.png'
51 if head.startswith(b'\xff\xd8'):
52 return '.jpg'
53 if head.startswith(b'{\\rtf'):
54 return '.rtf'
55 if head.startswith(b'\xd0\xcf\x11\xe0'):
56 return '.doc'
57 raise ConversionError(f'unsupported file type (name {name_hint!r}, content type {content_type!r})')
58
59
60ZIP_EXT = {'.docx', '.docm', '.dotx', '.xlsx', '.xlsm', '.xlsb', '.pptx', '.pptm', '.ppsx', '.odt', '.ott', '.ods',
61 '.ots', '.odp', '.otp', '.odg', '.vsdx'}
62OLE_EXT = {'.doc', '.dot', '.xls', '.ppt', '.pps', '.vsd'}
63
64
65def check_signature(path: str, ext: str) -> None:
66 """LibreOffice imports unreadable binaries as plain text; refuse them instead of producing (and charging for) junk."""
67 with open(path, 'rb') as f:
68 head = f.read(8)
69 if ext in ZIP_EXT and not head.startswith(b'PK'):
70 raise ConversionError(f'not a valid {ext} file (damaged or wrong extension)')
71 if ext in OLE_EXT and not head.startswith(b'\xd0\xcf\x11\xe0') and not head.startswith(b'PK'):
72 raise ConversionError(f'not a valid {ext} file (damaged or wrong extension)')
73 if ext == '.rtf' and not head.startswith(b'{\\rtf'):
74 raise ConversionError('not a valid .rtf file (damaged or wrong extension)')
75
76
77def _filter(family: str, pdfa: bool) -> str:
78 name = FAMILIES[family][0]
79 if pdfa:
80 return f'pdf:{name}:{{"SelectPdfVersion":{{"type":"long","value":"2"}}}}'
81 return f'pdf:{name}'
82
83
84def _soffice(paths: list[str], family: str, outdir: str, pdfa: bool, timeout: int) -> None:
85 soffice = shutil.which('soffice') or shutil.which('libreoffice')
86 if not soffice:
87 raise ConversionError('LibreOffice is not available')
88 profile = tempfile.mkdtemp(prefix='lo-profile-')
89 try:
90 subprocess.run([soffice, f'-env:UserInstallation=file://{profile}', '--headless', '--norestore',
91 '--convert-to', _filter(family, pdfa), '--outdir', outdir, *paths],
92 capture_output=True, timeout=timeout)
93 finally:
94 shutil.rmtree(profile, ignore_errors=True)
95
96
97def convert_all(inputs: list[tuple[str, str]], outdir: str, *, pdfa: bool = False) -> dict[str, str | ConversionError]:
98 """inputs: (path, ext) with unique base names. Returns path -> output PDF path or ConversionError."""
99 results: dict[str, str | ConversionError] = {}
100 groups: dict[str, list[str]] = {}
101 for path, ext in inputs:
102 groups.setdefault(EXT_FAMILY[ext], []).append(path)
103 for family, paths in groups.items():
104 try:
105 _soffice(paths, family, outdir, pdfa, timeout=60 + 20 * len(paths))
106 except subprocess.TimeoutExpired:
107
108 for p in paths:
109 if not os.path.exists(_out(p, outdir)):
110 try:
111 _soffice([p], family, outdir, pdfa, timeout=180)
112 except subprocess.TimeoutExpired:
113 results[p] = ConversionError('conversion timed out (file too large or damaged)')
114 for p in paths:
115 if p in results:
116 continue
117 out = _out(p, outdir)
118 results[p] = out if os.path.exists(out) and os.path.getsize(out) > 0 else ConversionError(
119 'LibreOffice could not open this file (damaged, password-protected or not the stated format)')
120 return results
121
122
123def _out(path: str, outdir: str) -> str:
124 return os.path.join(outdir, os.path.splitext(os.path.basename(path))[0] + '.pdf')
125
126
127def pdf_pages(path: str) -> int:
128 from pypdf import PdfReader
129 return len(PdfReader(path).pages)
130
131
132def merge(paths: list[str], dest: str) -> int:
133 from pypdf import PdfWriter
134 writer = PdfWriter()
135 for p in paths:
136 writer.append(p)
137 with open(dest, 'wb') as f:
138 writer.write(f)
139 return len(writer.pages)