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.
@@ -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 = &quot;Hidden&quot; : a(0).Value = True
98
+ a(1).Name = &quot;MacroExecutionMode&quot; : a(1).Value = 0
99
+ oDoc = StarDesktop.loadComponentFromURL(&quot;{IN}&quot;, &quot;_blank&quot;, 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 = &quot;FilterName&quot; : o(0).Value = &quot;{FILTER}&quot;
112
+ oDoc.storeToURL(&quot;{OUT}&quot;, 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()