microsoft-word 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/SKILL.md +141 -0
- package/package.json +62 -4
- package/references/automation.md +72 -0
- package/references/conversion.md +53 -0
- package/references/extraction.md +58 -0
- package/references/fallbacks.md +59 -0
- package/references/libraries.md +125 -0
- package/references/ooxml.md +111 -0
- package/references/recipes.md +134 -0
- package/references/security.md +87 -0
- package/references/troubleshooting.md +23 -0
- package/scripts/docx_refresh.py +399 -0
- package/scripts/docx_replace.py +321 -0
- package/scripts/docx_sanitize.py +506 -0
- package/scripts/inspect_docx.py +564 -0
- package/scripts/requirements.txt +1 -0
- package/scripts/wordpkg.py +328 -0
- package/skill.json +27 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Update fields and tables of contents with LibreOffice, then export PDF (default), DOCX or ODT.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python docx_refresh.py IN OUTFILE [--to pdf|docx|odt] [--force] [--timeout S] [--soffice PATH]
|
|
6
|
+
[--json] [--allow-local-links TOKEN] [--sandboxed]
|
|
7
|
+
python docx_refresh.py --check (JSON: soffice, bwrap, unshare, what can run here)
|
|
8
|
+
|
|
9
|
+
Why: python-docx, docx.js and hand-edited XML write field results as stored text. PAGE and
|
|
10
|
+
NUMPAGES are laid out at render time, but a TOC keeps its old entries until something updates
|
|
11
|
+
it. A plain `soffice --convert-to pdf` updated PAGE and REF but kept a stale TOC in our test
|
|
12
|
+
(LibreOffice 25.2). This script loads the file in a throwaway LibreOffice profile, updates
|
|
13
|
+
every index (TOC, lists of figures) and refreshes text fields, and stores a new file.
|
|
14
|
+
|
|
15
|
+
Fidelity: --to docx re-exports the whole package through LibreOffice. Styles, numbering,
|
|
16
|
+
content controls, comments ranges and complex layout can change. Use it to get updated field
|
|
17
|
+
results to compare or to ship as PDF; keep the original .docx as the master, or update fields
|
|
18
|
+
in Word (F9, or Office.js / VBA) when the .docx itself must keep full fidelity.
|
|
19
|
+
|
|
20
|
+
Safety:
|
|
21
|
+
- A .docx/.docm/.dotx/.dotm input first passes the same package checks as the other scripts
|
|
22
|
+
(200 MB uncompressed and 5000 member caps, duplicate or case-colliding names, DOCTYPE),
|
|
23
|
+
so a zip bomb never reaches LibreOffice.
|
|
24
|
+
- Runs on a private copy of the input with HOME, TMPDIR and cwd in a temp folder and a
|
|
25
|
+
minimal environment. The profile sets macro security to very high, disables document
|
|
26
|
+
macro execution and sets Writer Content/Update/Link to 0, which LibreOffice's code reads as
|
|
27
|
+
NEVER (sources in CHANGELOG.md, 0.1.2 note). The only macro that runs is this script's own refresh routine in
|
|
28
|
+
the throwaway profile; the document loads with MacroExecutionMode NEVER_EXECUTE.
|
|
29
|
+
- Isolation, best first:
|
|
30
|
+
bwrap (bubblewrap): no network, new PID/IPC/UTS namespaces, and a filesystem that holds
|
|
31
|
+
only /usr, the lib and bin links, fonts and a few /etc entries (read-only) and the
|
|
32
|
+
temp folder; no home, no /etc/passwd, no /opt except a LibreOffice installed there. Linked images, INCLUDETEXT
|
|
33
|
+
and INCLUDEPICTURE cannot read the user's files. Tested: a linked local image was
|
|
34
|
+
embedded in the PDF in every Link mode without bwrap, and was not with it.
|
|
35
|
+
unshare -n or -rn: no network, but every file the user can read is visible. Used only
|
|
36
|
+
when bwrap is missing; then the script refuses a document with external
|
|
37
|
+
relationships or INCLUDE/LINK fields. The refusal lists every target and prints a
|
|
38
|
+
token; --allow-local-links TOKEN runs it after the user has seen the list and agreed
|
|
39
|
+
(a different document gives a different token). It also refuses .doc and .rtf input (old parsers, long CVE
|
|
40
|
+
history: install bubblewrap).
|
|
41
|
+
Where neither works the script refuses (exit 2).
|
|
42
|
+
- --sandboxed is for a human or operator who has already isolated the network and the
|
|
43
|
+
filesystem. An agent must never pass it on its own, and never --allow-local-links without
|
|
44
|
+
the user's agreement. Even with --sandboxed, the script refuses if a test connection
|
|
45
|
+
succeeds.
|
|
46
|
+
- Never replaces OUTFILE unless --force; the result is moved into place only after success.
|
|
47
|
+
Platforms: Linux only. On macOS and Windows there is no bwrap or unshare, so it always refuses;
|
|
48
|
+
update fields in Word (F9, Office.js, VBA) or use Microsoft Graph for PDF instead.
|
|
49
|
+
Proxy: bwrap and unshare remove the network entirely, so proxies do not matter there. The
|
|
50
|
+
--sandboxed check is a direct TCP test, which a proxy-only network passes, so --sandboxed also
|
|
51
|
+
refuses while HTTP_PROXY, HTTPS_PROXY or ALL_PROXY is set.
|
|
52
|
+
Exit codes: 0 ok, 1 LibreOffice ran but produced no output, 2 setup error or refusal
|
|
53
|
+
(no soffice, no isolation, output exists, bad input, local links without bwrap), 3 timeout or
|
|
54
|
+
package safety refusal (size cap, duplicate entries, DOCTYPE).
|
|
55
|
+
"""
|
|
56
|
+
import argparse
|
|
57
|
+
import hashlib
|
|
58
|
+
import os
|
|
59
|
+
import shutil
|
|
60
|
+
import signal
|
|
61
|
+
import socket
|
|
62
|
+
import subprocess
|
|
63
|
+
import sys
|
|
64
|
+
import tempfile
|
|
65
|
+
import zipfile
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
import json
|
|
69
|
+
|
|
70
|
+
import wordpkg as wp
|
|
71
|
+
from wordpkg import sniff
|
|
72
|
+
|
|
73
|
+
FILTERS = {"pdf": "writer_pdf_Export", "docx": "MS Word 2007 XML", "odt": "writer8"}
|
|
74
|
+
INPUT_EXT = {".docx", ".docm", ".dotx", ".dotm", ".doc", ".rtf", ".odt"}
|
|
75
|
+
|
|
76
|
+
XCU = """<?xml version="1.0" encoding="UTF-8"?>
|
|
77
|
+
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
78
|
+
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item>
|
|
79
|
+
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="DisableMacrosExecution" oor:op="fuse"><value>true</value></prop></item>
|
|
80
|
+
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
|
|
81
|
+
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>0</value></prop></item>
|
|
82
|
+
<item oor:path="/org.openoffice.Office.Common/Misc"><prop oor:name="FirstRun" oor:op="fuse"><value>false</value></prop></item>
|
|
83
|
+
</oor:items>
|
|
84
|
+
"""
|
|
85
|
+
# Writer Content/Update/Link: SwMasterUsrPref::SetUpdateLinkMode stores the value as the enum in
|
|
86
|
+
# sw/inc/linkenum.hxx (NEVER=0, MANUAL=1, AUTOMATIC=2), and the options dialog in
|
|
87
|
+
# sw/source/ui/config/optload.cxx writes NEVER for the "never" button. So 0 is Never.
|
|
88
|
+
# The labels in officecfg/.../Writer.xcs ("0 Always, 1 On Request, 2 Never") disagree with
|
|
89
|
+
# the code; the code is what runs. Checked against the libreoffice-25-2 branch, Oct 13, 2026.
|
|
90
|
+
|
|
91
|
+
MODULE = """<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
|
|
93
|
+
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Refresh" script:language="StarBasic">
|
|
94
|
+
Sub Run
|
|
95
|
+
On Error Goto Fail
|
|
96
|
+
Dim a(1) As New com.sun.star.beans.PropertyValue
|
|
97
|
+
a(0).Name = "Hidden" : a(0).Value = True
|
|
98
|
+
a(1).Name = "MacroExecutionMode" : a(1).Value = 0
|
|
99
|
+
oDoc = StarDesktop.loadComponentFromURL("{IN}", "_blank", 0, a())
|
|
100
|
+
If IsNull(oDoc) Then GoTo Fail
|
|
101
|
+
oIdx = oDoc.getDocumentIndexes()
|
|
102
|
+
For i = 0 To oIdx.getCount() - 1
|
|
103
|
+
oIdx.getByIndex(i).update()
|
|
104
|
+
Next i
|
|
105
|
+
oDoc.getTextFields().refresh()
|
|
106
|
+
oIdx = oDoc.getDocumentIndexes()
|
|
107
|
+
For i = 0 To oIdx.getCount() - 1
|
|
108
|
+
oIdx.getByIndex(i).update()
|
|
109
|
+
Next i
|
|
110
|
+
Dim o(0) As New com.sun.star.beans.PropertyValue
|
|
111
|
+
o(0).Name = "FilterName" : o(0).Value = "{FILTER}"
|
|
112
|
+
oDoc.storeToURL("{OUT}", o())
|
|
113
|
+
oDoc.close(True)
|
|
114
|
+
StarDesktop.terminate()
|
|
115
|
+
Exit Sub
|
|
116
|
+
Fail:
|
|
117
|
+
On Error Resume Next
|
|
118
|
+
StarDesktop.terminate()
|
|
119
|
+
End Sub
|
|
120
|
+
</script:module>
|
|
121
|
+
"""
|
|
122
|
+
SCRIPT_XLB = """<?xml version="1.0" encoding="UTF-8"?>
|
|
123
|
+
<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">
|
|
124
|
+
<library:library xmlns:library="http://openoffice.org/2000/library" library:name="Standard" library:readonly="false" library:passwordprotected="false">
|
|
125
|
+
<library:element library:name="Refresh"/>
|
|
126
|
+
</library:library>
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
JSON = "--json" in sys.argv
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def die(code, msg):
|
|
134
|
+
msg = msg.rstrip()
|
|
135
|
+
if JSON:
|
|
136
|
+
print(json.dumps({"ok": False, "error": msg.removeprefix("error: "), "exit": code}))
|
|
137
|
+
else:
|
|
138
|
+
sys.stderr.write(msg + "\n")
|
|
139
|
+
sys.exit(code)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
SYSTEM_DIRS = ("/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/cache/fontconfig")
|
|
143
|
+
# Only what LibreOffice needs from /etc (fonts, linker cache, alternatives). Not passwd, hosts, ssl.
|
|
144
|
+
ETC_ENTRIES = ("fonts", "ld.so.cache", "ld.so.conf", "ld.so.conf.d", "alternatives", "libreoffice",
|
|
145
|
+
"localtime")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def bwrap_prefix(tmp, soffice):
|
|
149
|
+
"""bubblewrap command that shows LibreOffice only system folders (read-only) and tmp."""
|
|
150
|
+
b = shutil.which("bwrap")
|
|
151
|
+
if not b or not sys.platform.startswith("linux"):
|
|
152
|
+
return None
|
|
153
|
+
args = [b, "--unshare-all", "--die-with-parent", "--new-session", "--proc", "/proc", "--dev", "/dev",
|
|
154
|
+
"--tmpfs", "/tmp", "--tmpfs", "/run"]
|
|
155
|
+
for d in SYSTEM_DIRS:
|
|
156
|
+
if os.path.islink(d):
|
|
157
|
+
args += ["--symlink", os.readlink(d), d]
|
|
158
|
+
elif os.path.isdir(d):
|
|
159
|
+
args += ["--ro-bind", d, d]
|
|
160
|
+
for e in ETC_ENTRIES:
|
|
161
|
+
if os.path.exists("/etc/" + e):
|
|
162
|
+
args += ["--ro-bind", "/etc/" + e, "/etc/" + e]
|
|
163
|
+
# An install outside /usr (for example /opt/libreoffice25.2) is bound alone, not all of /opt.
|
|
164
|
+
inst = os.path.dirname(os.path.dirname(os.path.realpath(soffice)))
|
|
165
|
+
if not any(inst == d or inst.startswith(d + "/") for d in SYSTEM_DIRS):
|
|
166
|
+
args += ["--ro-bind", inst, inst]
|
|
167
|
+
args += ["--bind", str(tmp), str(tmp), "--chdir", str(tmp)]
|
|
168
|
+
try:
|
|
169
|
+
if subprocess.run(args + ["true"], capture_output=True, timeout=10).returncode == 0:
|
|
170
|
+
return args
|
|
171
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
172
|
+
pass
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def local_link_reasons(src):
|
|
177
|
+
"""What LibreOffice could read: external relationships (not hyperlinks) and INCLUDE/LINK fields.
|
|
178
|
+
|
|
179
|
+
Returns (kinds, targets). targets are "type: target" strings for the user to review.
|
|
180
|
+
"""
|
|
181
|
+
import inspect_docx
|
|
182
|
+
rep = inspect_docx.inspect(str(src), show_links=True)
|
|
183
|
+
kinds = sorted({t for t in rep["external_relationships"]["by_type"] if t != "hyperlink"})
|
|
184
|
+
targets = [f'{i["type"]}: {i["target"]}' for i in rep["external_relationships"]["items"]
|
|
185
|
+
if i["type"] != "hyperlink"]
|
|
186
|
+
for f in rep["fields"]["risky"]:
|
|
187
|
+
if f["type"] in ("INCLUDETEXT", "INCLUDEPICTURE", "INCLUDE", "IMPORT", "LINK"):
|
|
188
|
+
kinds.append(f["type"])
|
|
189
|
+
targets.append(f'{f["type"]} field: {f["instr"]}')
|
|
190
|
+
return sorted(set(kinds)), sorted(set(targets))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def links_token(targets):
|
|
194
|
+
"""Short hash of the target list; --allow-local-links must repeat it, so the list was seen."""
|
|
195
|
+
return hashlib.sha256("\n".join(targets).encode("utf-8")).hexdigest()[:12]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def capabilities(soffice):
|
|
199
|
+
"""What this machine can do, without converting anything (--check)."""
|
|
200
|
+
with tempfile.TemporaryDirectory(prefix="docx-refresh-check-") as tmp:
|
|
201
|
+
bw = bwrap_prefix(Path(tmp), soffice) is not None if soffice else False
|
|
202
|
+
un = no_network_prefix() is not None
|
|
203
|
+
iso = "bwrap" if bw else ("unshare (network only)" if un else None)
|
|
204
|
+
return {"ok": bool(soffice and iso), "platform": sys.platform, "soffice": soffice,
|
|
205
|
+
"bwrap": bw, "unshare": un, "isolation": iso,
|
|
206
|
+
"can_refresh_docx": bool(soffice and iso),
|
|
207
|
+
"can_refresh_doc_rtf": bool(soffice and bw),
|
|
208
|
+
"linked_documents": "isolated" if bw else ("refused unless --allow-local-links" if iso else "refused"),
|
|
209
|
+
"next": None if soffice and iso else
|
|
210
|
+
("install LibreOffice Writer" if not soffice else
|
|
211
|
+
"install bubblewrap (apt install bubblewrap) or ask the user to update fields in Word")}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def no_network_prefix():
|
|
215
|
+
unshare = shutil.which("unshare")
|
|
216
|
+
if not unshare or not sys.platform.startswith("linux"):
|
|
217
|
+
return None
|
|
218
|
+
for prefix in ([unshare, "-n"], [unshare, "-rn"]):
|
|
219
|
+
try:
|
|
220
|
+
if subprocess.run(prefix + ["true"], capture_output=True, timeout=10).returncode == 0:
|
|
221
|
+
return prefix
|
|
222
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
223
|
+
pass
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def network_reachable(timeout=1.0):
|
|
228
|
+
for host, port in (("1.1.1.1", 443), ("8.8.8.8", 53), ("9.9.9.9", 443)):
|
|
229
|
+
try:
|
|
230
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
231
|
+
return True
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main():
|
|
238
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
239
|
+
ap.add_argument("input", nargs="?")
|
|
240
|
+
ap.add_argument("output", nargs="?", help="output file path, for example out/report.pdf")
|
|
241
|
+
ap.add_argument("--check", action="store_true",
|
|
242
|
+
help="report soffice and isolation availability as JSON, convert nothing")
|
|
243
|
+
ap.add_argument("--to", choices=sorted(FILTERS), help="output format (default: from OUTFILE extension, else pdf)")
|
|
244
|
+
ap.add_argument("--force", action="store_true", help="replace OUTFILE if it exists (no backup is kept)")
|
|
245
|
+
ap.add_argument("--timeout", type=int, default=180, help="seconds before LibreOffice is killed (default 180)")
|
|
246
|
+
ap.add_argument("--soffice", help="path to soffice")
|
|
247
|
+
ap.add_argument("--json", action="store_true", help="print a JSON result with ok and error keys")
|
|
248
|
+
ap.add_argument("--allow-local-links", metavar="TOKEN",
|
|
249
|
+
help="without bwrap, run even though the document links to files. TOKEN is printed "
|
|
250
|
+
"by the refusal together with the list of targets; pass it only after the user "
|
|
251
|
+
"has seen that list and agreed")
|
|
252
|
+
ap.add_argument("--sandboxed", action="store_true",
|
|
253
|
+
help="OPERATOR ONLY: skip unshare because a human already isolated the network. "
|
|
254
|
+
"Agents must never pass this flag.")
|
|
255
|
+
a = ap.parse_args()
|
|
256
|
+
if a.check:
|
|
257
|
+
print(json.dumps(capabilities(a.soffice or shutil.which("soffice") or shutil.which("libreoffice")),
|
|
258
|
+
indent=2))
|
|
259
|
+
sys.exit(0)
|
|
260
|
+
if not a.input or not a.output:
|
|
261
|
+
ap.error("IN and OUTFILE are required (or use --check)")
|
|
262
|
+
|
|
263
|
+
src = Path(a.input)
|
|
264
|
+
if not src.is_file():
|
|
265
|
+
die(2, f"error: input not found: {src}")
|
|
266
|
+
if src.suffix.lower() not in INPUT_EXT:
|
|
267
|
+
die(2, f"error: unsupported input extension {src.suffix!r}")
|
|
268
|
+
kind = sniff(src)
|
|
269
|
+
if kind == "encrypted":
|
|
270
|
+
die(2, "error: encrypted document; decrypt a copy with msoffcrypto-tool and the user's password first")
|
|
271
|
+
if kind in ("other", "ole2"):
|
|
272
|
+
die(2, "error: input is not a Word, RTF or ODF document (bad magic bytes)")
|
|
273
|
+
links, targets = [], []
|
|
274
|
+
if kind == "zip":
|
|
275
|
+
try:
|
|
276
|
+
wp.Package(str(src)).zip.close() # caps, duplicate names, content type: before LibreOffice
|
|
277
|
+
links, targets = local_link_reasons(src)
|
|
278
|
+
except wp.Refusal as e:
|
|
279
|
+
die(3, f"error: refused before LibreOffice: {e}")
|
|
280
|
+
except wp.Unsupported as e:
|
|
281
|
+
if src.suffix.lower() != ".odt":
|
|
282
|
+
die(2, f"error: {e}")
|
|
283
|
+
try:
|
|
284
|
+
with zipfile.ZipFile(src) as z:
|
|
285
|
+
if len(z.infolist()) > 5000 or sum(i.file_size for i in z.infolist()) > 200 * 1024 * 1024:
|
|
286
|
+
die(3, "error: refused before LibreOffice: ODT above 200 MB or 5000 members")
|
|
287
|
+
except zipfile.BadZipFile:
|
|
288
|
+
die(2, "error: input is not a valid zip package")
|
|
289
|
+
except (zipfile.BadZipFile, OSError) as e:
|
|
290
|
+
die(2, f"error: input is not a valid zip package (corrupt .docx?): {e}")
|
|
291
|
+
fmt = a.to or (Path(a.output).suffix.lower().lstrip(".") if Path(a.output).suffix.lower().lstrip(".") in FILTERS else "pdf")
|
|
292
|
+
out = Path(a.output).resolve()
|
|
293
|
+
if out.exists() and not a.force:
|
|
294
|
+
die(2, f"error: {out} exists; refusing to replace it (use --force to replace, no backup is kept)")
|
|
295
|
+
if out.exists() and out.samefile(src):
|
|
296
|
+
die(2, "error: output is the input file")
|
|
297
|
+
if not out.parent.is_dir():
|
|
298
|
+
die(2, f"error: output folder does not exist: {out.parent}")
|
|
299
|
+
soffice = a.soffice or shutil.which("soffice") or shutil.which("libreoffice")
|
|
300
|
+
if not soffice:
|
|
301
|
+
die(2, "error: LibreOffice (soffice) not found; install it or pass --soffice")
|
|
302
|
+
|
|
303
|
+
with tempfile.TemporaryDirectory(prefix="docx-refresh-") as tmp:
|
|
304
|
+
tmp = Path(tmp)
|
|
305
|
+
isolation = None
|
|
306
|
+
if a.sandboxed:
|
|
307
|
+
proxies = [k for k in os.environ if k.lower() in ("http_proxy", "https_proxy", "all_proxy")]
|
|
308
|
+
if proxies:
|
|
309
|
+
die(2, f"error: --sandboxed was given but {', '.join(sorted(proxies))} is set; a direct test "
|
|
310
|
+
"connection cannot prove a proxy-only network is closed. Refusing.")
|
|
311
|
+
if network_reachable():
|
|
312
|
+
die(2, "error: --sandboxed was given but a test connection to the internet succeeded; refusing.")
|
|
313
|
+
prefix, isolation = [], "operator (--sandboxed)"
|
|
314
|
+
else:
|
|
315
|
+
prefix = bwrap_prefix(tmp, soffice)
|
|
316
|
+
if prefix is not None:
|
|
317
|
+
isolation = "bwrap"
|
|
318
|
+
else:
|
|
319
|
+
prefix = no_network_prefix()
|
|
320
|
+
if prefix is None:
|
|
321
|
+
die(2, "error: cannot isolate LibreOffice here (no bwrap, unshare missing, not Linux, or user "
|
|
322
|
+
"namespaces blocked, for example by AppArmor on Ubuntu 24.04+). Refusing. Agents: stop "
|
|
323
|
+
"and tell the user; do not add --sandboxed yourself.")
|
|
324
|
+
isolation = "unshare (network only)"
|
|
325
|
+
if kind in ("doc", "rtf"):
|
|
326
|
+
die(2, f"error: {kind} input needs bwrap (filesystem isolation); unshare hides only the "
|
|
327
|
+
"network. Install bubblewrap, or ask the user to open and resave it in Word.")
|
|
328
|
+
if links and a.allow_local_links != links_token(targets):
|
|
329
|
+
shown = "; ".join(targets[:20]) + (f"; and {len(targets) - 20} more" if len(targets) > 20 else "")
|
|
330
|
+
die(2, f"error: the document links to outside content ({', '.join(links)}) and only network "
|
|
331
|
+
"isolation is available, so LibreOffice could read these local files or paths "
|
|
332
|
+
f"into the output: {shown}. Install bubblewrap, or show this list to the user and, "
|
|
333
|
+
f"if they agree, rerun with --allow-local-links {links_token(targets)}"
|
|
334
|
+
+ (" (the token given does not match this document)" if a.allow_local_links else ""))
|
|
335
|
+
if links:
|
|
336
|
+
sys.stderr.write("note: LibreOffice may read: " + "; ".join(targets) + "\n")
|
|
337
|
+
work = tmp / "work"
|
|
338
|
+
work.mkdir()
|
|
339
|
+
inp = work / ("input" + src.suffix.lower())
|
|
340
|
+
shutil.copyfile(src, inp)
|
|
341
|
+
res = work / ("result." + fmt)
|
|
342
|
+
prof = tmp / "profile"
|
|
343
|
+
env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "HOME": str(tmp), "TMPDIR": str(tmp),
|
|
344
|
+
"LANG": os.environ.get("LANG", "C.UTF-8"), "SAL_USE_VCLPLUGIN": "svp"}
|
|
345
|
+
base = prefix + [soffice, f"-env:UserInstallation={prof.as_uri()}", "--headless", "--invisible",
|
|
346
|
+
"--norestore", "--nologo", "--nodefault"]
|
|
347
|
+
# First start creates the profile; files written before it would be overwritten.
|
|
348
|
+
try:
|
|
349
|
+
subprocess.run(base + ["--terminate_after_init"], cwd=work, env=env, capture_output=True,
|
|
350
|
+
timeout=a.timeout)
|
|
351
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
352
|
+
die(2, f"error: cannot initialise a LibreOffice profile: {e}")
|
|
353
|
+
basic = prof / "user" / "basic" / "Standard"
|
|
354
|
+
xcu = prof / "user" / "registrymodifications.xcu"
|
|
355
|
+
if not basic.is_dir() or not xcu.is_file():
|
|
356
|
+
die(2, "error: LibreOffice did not create its profile; is soffice working?")
|
|
357
|
+
items = XCU.split("<oor:items", 1)[1].split(">", 1)[1].replace("</oor:items>", "")
|
|
358
|
+
xcu.write_text(xcu.read_text(encoding="utf-8").replace("</oor:items>", items + "</oor:items>"),
|
|
359
|
+
encoding="utf-8")
|
|
360
|
+
# Paths are fixed names under mkdtemp, so nothing from the caller reaches the Basic source.
|
|
361
|
+
(basic / "Refresh.xba").write_text(
|
|
362
|
+
MODULE.replace("{IN}", inp.as_uri()).replace("{OUT}", res.as_uri())
|
|
363
|
+
.replace("{FILTER}", FILTERS[fmt]), encoding="utf-8")
|
|
364
|
+
(basic / "script.xlb").write_text(SCRIPT_XLB, encoding="utf-8")
|
|
365
|
+
cmd = base + ["macro:///Standard.Refresh.Run"]
|
|
366
|
+
try:
|
|
367
|
+
p = subprocess.Popen(cmd, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
368
|
+
text=True, start_new_session=True)
|
|
369
|
+
except OSError as e:
|
|
370
|
+
die(2, f"error: cannot start LibreOffice: {e}")
|
|
371
|
+
try:
|
|
372
|
+
_, err = p.communicate(timeout=a.timeout)
|
|
373
|
+
except subprocess.TimeoutExpired:
|
|
374
|
+
try:
|
|
375
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
376
|
+
except OSError:
|
|
377
|
+
pass
|
|
378
|
+
p.communicate()
|
|
379
|
+
die(3, f"error: LibreOffice did not finish within {a.timeout} s; killed. Nothing written.")
|
|
380
|
+
if not res.is_file() or res.stat().st_size == 0:
|
|
381
|
+
die(1, "error: LibreOffice produced no output (corrupt or unsupported input?). "
|
|
382
|
+
+ (err or "").strip()[-400:])
|
|
383
|
+
fd, staged = tempfile.mkstemp(prefix=".tmp-", dir=out.parent)
|
|
384
|
+
os.close(fd)
|
|
385
|
+
shutil.copyfile(res, staged)
|
|
386
|
+
os.replace(staged, out)
|
|
387
|
+
note = "LibreOffice rewrote the package, compare with the original before shipping" if fmt == "docx" else ""
|
|
388
|
+
if a.json:
|
|
389
|
+
print(json.dumps({"ok": True, "output": str(out), "format": fmt, "isolation": isolation,
|
|
390
|
+
"engine": "LibreOffice", "links_in_input": links,
|
|
391
|
+
"may_have_read": targets if isolation == "unshare (network only)" else [],
|
|
392
|
+
"note": note}))
|
|
393
|
+
else:
|
|
394
|
+
print(f"ok: {out} ({fmt}); indexes updated and fields refreshed by LibreOffice; isolation {isolation}"
|
|
395
|
+
+ (f"; {note}" if note else ""))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
main()
|