microsoft-word 0.0.0-stage → 0.1.4

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 @@
1
+ lxml>=4.9
@@ -0,0 +1,328 @@
1
+ """Shared, dependency-light helpers for the microsoft-word scripts.
2
+
3
+ Not a public API. Imported by inspect_docx.py, docx_replace.py and docx_sanitize.py,
4
+ which sit in the same folder (Python puts the script folder on sys.path).
5
+
6
+ Safety rules implemented here:
7
+ - XML is parsed with lxml, entity resolution off, DTD loading off, no network, no huge
8
+ trees. Those parser flags are the real control against entity attacks; the DOCTYPE
9
+ check (first 4096 bytes) and ENTITY check only turn an attempt into a clean refusal,
10
+ since OOXML parts never need either.
11
+ - Duplicate zip member names, and names that differ only by case, are refused: different
12
+ readers pick different copies, so the part we inspect might not be the part Word loads.
13
+ - Archives are refused above a total uncompressed size and member count, before parsing.
14
+ - Writes go to a temp file next to the output and are linked into place only if the
15
+ output does not exist yet, so an existing file is never replaced.
16
+ - lxml is used rather than xml.etree because ElementTree renames namespace prefixes
17
+ (ns0, ns1) and drops unused declarations, which leaves mc:Ignorable naming prefixes
18
+ that are no longer declared.
19
+ """
20
+ import os
21
+ import re
22
+ import sys
23
+ import tempfile
24
+ import zipfile
25
+ from posixpath import dirname, join, normpath
26
+
27
+ try:
28
+ from lxml import etree
29
+ except ImportError: # pragma: no cover
30
+ sys.stderr.write("error: lxml is required (pip install -r scripts/requirements.txt)\n")
31
+ sys.exit(3)
32
+
33
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
34
+ R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
35
+ PR = "http://schemas.openxmlformats.org/package/2006/relationships"
36
+ CT = "http://schemas.openxmlformats.org/package/2006/content-types"
37
+ NS = {"w": W, "r": R, "pr": PR, "ct": CT}
38
+
39
+ MAIN_TYPES = {
40
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml": "docx",
41
+ "application/vnd.ms-word.document.macroEnabled.main+xml": "docm",
42
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml": "dotx",
43
+ "application/vnd.ms-word.template.macroEnabledTemplate.main+xml": "dotm",
44
+ }
45
+ STORY_RELS = {"header", "footer", "footnotes", "endnotes", "comments"}
46
+
47
+ # C0, DEL, C1 (includes \x9b CSI), Arabic letter mark, zero-width, bidi embeddings,
48
+ # overrides and isolates, word joiner and BOM: they can hide or reorder injected text.
49
+ CTRL = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]")
50
+
51
+
52
+ class Refusal(Exception):
53
+ """Safety refusal: exit code 3."""
54
+
55
+
56
+ class Unsupported(Exception):
57
+ """Unsupported or unreadable input: exit code 2."""
58
+
59
+
60
+ def w(tag):
61
+ return f"{{{W}}}{tag}"
62
+
63
+
64
+ def clean(v, maxlen=200):
65
+ if v is None or isinstance(v, (int, float, bool)):
66
+ return v
67
+ s = CTRL.sub(" ", str(v))
68
+ return s if len(s) <= maxlen else s[:maxlen] + "...[truncated]"
69
+
70
+
71
+ def sniff(path):
72
+ """Return 'zip', 'doc' (legacy binary), 'encrypted' (OOXML inside OLE2), 'ole2', 'rtf' or 'other'."""
73
+ with open(path, "rb") as f:
74
+ head = f.read(8)
75
+ if head.startswith(b"PK\x03\x04"):
76
+ return "zip"
77
+ if head.startswith(b"{\\rtf"):
78
+ return "rtf"
79
+ if head.startswith(b"\xd0\xcf\x11\xe0"):
80
+ f.seek(0)
81
+ blob = f.read(4 * 1024 * 1024) # directory entries are near the start in practice
82
+ if "EncryptionInfo".encode("utf-16-le") in blob:
83
+ return "encrypted"
84
+ if "WordDocument".encode("utf-16-le") in blob:
85
+ return "doc"
86
+ return "ole2"
87
+ return "other"
88
+
89
+
90
+ _PARSER = etree.XMLParser(resolve_entities=False, no_network=True, huge_tree=False,
91
+ load_dtd=False, remove_blank_text=False)
92
+
93
+
94
+ def parse_xml(data):
95
+ if re.search(rb"<!DOCTYPE", data[:4096], re.I) or b"<!ENTITY" in data:
96
+ raise Refusal("XML part contains a DOCTYPE or ENTITY declaration (not valid in OOXML)")
97
+ try:
98
+ return etree.fromstring(data, _PARSER)
99
+ except etree.XMLSyntaxError as e:
100
+ raise Unsupported(f"malformed XML: {e}") from None
101
+
102
+
103
+ def serialize(root):
104
+ return etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
105
+
106
+
107
+ class Package:
108
+ """Read-only view of an OOXML Word package."""
109
+
110
+ def __init__(self, path, max_mb=200, max_members=5000):
111
+ kind = sniff(path)
112
+ if kind == "encrypted":
113
+ raise Unsupported("encrypted (password-protected) document: decrypt a copy with "
114
+ "msoffcrypto-tool using the password the user gives you")
115
+ if kind == "doc":
116
+ raise Unsupported("legacy binary .doc: convert a copy to .docx first (references/conversion.md)")
117
+ if kind == "rtf":
118
+ raise Unsupported("RTF: convert a copy to .docx first (references/conversion.md)")
119
+ if kind != "zip":
120
+ raise Unsupported("not an OOXML zip package")
121
+ try:
122
+ self.zip = zipfile.ZipFile(path)
123
+ except zipfile.BadZipFile as e:
124
+ raise Unsupported(f"corrupt zip: {e}") from None
125
+ infos = self.zip.infolist()
126
+ if len(infos) > max_members:
127
+ raise Refusal(f"{len(infos)} zip members exceeds the cap of {max_members}")
128
+ total = sum(i.file_size for i in infos)
129
+ if total > max_mb * 1024 * 1024:
130
+ raise Refusal(f"uncompressed size {total // (1024 * 1024)} MB exceeds --max-mb {max_mb}")
131
+ self.names = [i.filename for i in infos]
132
+ seen = {}
133
+ for n in self.names:
134
+ k = n.lower()
135
+ if k in seen:
136
+ what = "duplicate" if seen[k] == n else "case-colliding"
137
+ raise Refusal(f"{what} zip member names {seen[k]!r} and {n!r}: readers disagree on "
138
+ "which one is the real part, refusing")
139
+ seen[k] = n
140
+ self._cache = {}
141
+ if "[Content_Types].xml" not in self.names:
142
+ raise Unsupported("missing [Content_Types].xml: not an OOXML package")
143
+ self.overrides = {}
144
+ self.defaults = {}
145
+ ct = self.xml("[Content_Types].xml")
146
+ for o in ct.findall("ct:Override", NS):
147
+ self.overrides[o.get("PartName", "").lstrip("/")] = o.get("ContentType", "")
148
+ for d in ct.findall("ct:Default", NS):
149
+ self.defaults[d.get("Extension", "").lower()] = d.get("ContentType", "")
150
+ main = [r for r in self.rels("") if r["type"] == "officeDocument" and not r["external"]]
151
+ if not main:
152
+ raise Unsupported("no officeDocument relationship in _rels/.rels")
153
+ self.main = main[0]["target"]
154
+ if self.main not in self.names:
155
+ raise Unsupported(f"main part {self.main} is missing")
156
+ self.content_type = self.content_type_of(self.main)
157
+ self.kind = MAIN_TYPES.get(self.content_type)
158
+ if self.kind is None:
159
+ raise Unsupported(f"not a Word document (main content type {self.content_type!r})")
160
+
161
+ def content_type_of(self, name):
162
+ if name in self.overrides:
163
+ return self.overrides[name]
164
+ return self.defaults.get(name.rsplit(".", 1)[-1].lower(), "")
165
+
166
+ def read(self, name):
167
+ return self.zip.read(name)
168
+
169
+ def xml(self, name):
170
+ if name not in self._cache:
171
+ self._cache[name] = parse_xml(self.zip.read(name))
172
+ return self._cache[name]
173
+
174
+ def rels_name(self, part):
175
+ return join(dirname(part), "_rels", part.rsplit("/", 1)[-1] + ".rels") if part else "_rels/.rels"
176
+
177
+ def rels(self, part):
178
+ """Relationships of a part: list of dicts with id, type (short), target (resolved), external."""
179
+ rn = self.rels_name(part)
180
+ if rn not in self.names:
181
+ return []
182
+ out = []
183
+ for r in self.xml(rn).findall("pr:Relationship", NS):
184
+ ext = r.get("TargetMode") == "External"
185
+ tgt = r.get("Target", "")
186
+ if not ext:
187
+ tgt = normpath(join(dirname(part), tgt)).lstrip("/") if not tgt.startswith("/") else tgt.lstrip("/")
188
+ out.append({"id": r.get("Id"), "type": r.get("Type", "").rsplit("/", 1)[-1],
189
+ "full_type": r.get("Type", ""), "target": tgt, "external": ext})
190
+ return out
191
+
192
+ def story_parts(self, include_comments=False):
193
+ """Main document plus headers, footers, footnotes, endnotes (and comments if asked)."""
194
+ parts = [self.main]
195
+ for r in self.rels(self.main):
196
+ if r["external"] or r["target"] not in self.names:
197
+ continue
198
+ if r["type"] in STORY_RELS and (include_comments or r["type"] != "comments"):
199
+ if r["target"] not in parts:
200
+ parts.append(r["target"])
201
+ return parts
202
+
203
+ def glossary_parts(self):
204
+ """The glossary document (building blocks, AutoText) and its own headers, footers, notes.
205
+ Word never shows it in the body, but its text travels with the file."""
206
+ g = [r["target"] for r in self.rels(self.main)
207
+ if r["type"] == "glossaryDocument" and not r["external"] and r["target"] in self.names]
208
+ out = []
209
+ for gp in g:
210
+ out.append(gp)
211
+ for r in self.rels(gp):
212
+ if not r["external"] and r["target"] in self.names and r["type"] in STORY_RELS \
213
+ and r["target"] not in out:
214
+ out.append(r["target"])
215
+ return out
216
+
217
+
218
+ OFF = {"0", "false", "off"}
219
+ HIDE_TAGS = ("vanish", "specVanish", "webHidden")
220
+
221
+
222
+ def toggle(rpr, tag):
223
+ """True, False, or None (not set) for a toggle property such as w:vanish in an rPr."""
224
+ if rpr is None:
225
+ return None
226
+ x = rpr.find(f"w:{tag}", NS)
227
+ if x is None:
228
+ return None
229
+ return x.get(w("val"), "true").lower() not in OFF
230
+
231
+
232
+ class Hidden:
233
+ """Decides whether a run is hidden: direct w:vanish, w:specVanish or w:webHidden, else the
234
+ character style chain (rStyle, basedOn), else the paragraph style chain (pStyle or the
235
+ default paragraph style), else docDefaults. An explicit val="0" at a closer level wins.
236
+ Simplification: Word XORs toggle properties between style levels; this treats the
237
+ closest explicit setting as final, which errs toward reporting text as hidden."""
238
+
239
+ def __init__(self, pkg):
240
+ self.styles, self.default_para, self.doc_default = {}, None, {}
241
+ if "word/styles.xml" in pkg.names:
242
+ sx = pkg.xml("word/styles.xml")
243
+ dd = sx.find("w:docDefaults/w:rPrDefault/w:rPr", NS)
244
+ self.doc_default = {t: toggle(dd, t) for t in HIDE_TAGS}
245
+ for s in sx.findall("w:style", NS):
246
+ b = s.find("w:basedOn", NS)
247
+ self.styles[s.get(w("styleId"))] = (
248
+ b.get(w("val")) if b is not None else None,
249
+ {t: toggle(s.find("w:rPr", NS), t) for t in HIDE_TAGS})
250
+ if s.get(w("type")) == "paragraph" and s.get(w("default")) in ("1", "true", "on"):
251
+ self.default_para = s.get(w("styleId"))
252
+
253
+ def _chain(self, sid, tag):
254
+ seen = set()
255
+ while sid and sid in self.styles and sid not in seen:
256
+ seen.add(sid)
257
+ based, props = self.styles[sid]
258
+ if props[tag] is not None:
259
+ return props[tag]
260
+ sid = based
261
+ return None
262
+
263
+ def run_hidden(self, r):
264
+ rpr = r.find("w:rPr", NS)
265
+ st = rpr.find("w:rStyle", NS) if rpr is not None else None
266
+ p = next((a for a in r.iterancestors(w("p"))), None)
267
+ ps = p.find("w:pPr/w:pStyle", NS) if p is not None else None
268
+ pid = ps.get(w("val")) if ps is not None else self.default_para
269
+ for tag in HIDE_TAGS:
270
+ for v in (toggle(rpr, tag),
271
+ self._chain(st.get(w("val")), tag) if st is not None else None,
272
+ self._chain(pid, tag),
273
+ self.doc_default.get(tag)):
274
+ if v is not None:
275
+ if v:
276
+ return tag
277
+ break
278
+ return None
279
+
280
+
281
+ def write_package(src_zip, out_path, replaced, removed=()):
282
+ """Copy every member of src_zip to out_path, replacing the bytes of members in `replaced`
283
+ and dropping members in `removed`. Untouched members keep their bytes and order.
284
+ Refuses (Refusal) if out_path already exists; never replaces it."""
285
+ out_path = os.path.abspath(out_path)
286
+ if os.path.lexists(out_path):
287
+ raise Refusal(f"output exists, refusing to overwrite: {out_path}")
288
+ fd, tmp = tempfile.mkstemp(prefix=".tmp-", suffix=".docx", dir=os.path.dirname(out_path))
289
+ os.close(fd)
290
+ try:
291
+ with zipfile.ZipFile(tmp, "w") as dst:
292
+ for info in src_zip.infolist():
293
+ if info.filename in removed:
294
+ continue
295
+ data = replaced.get(info.filename)
296
+ if data is None:
297
+ data = src_zip.read(info.filename)
298
+ zi = zipfile.ZipInfo(info.filename, date_time=info.date_time)
299
+ zi.compress_type = zipfile.ZIP_DEFLATED if info.compress_type != zipfile.ZIP_STORED else zipfile.ZIP_STORED
300
+ # regular file, rw-r--r--: never copy symlink or setuid bits from the source
301
+ zi.external_attr = 0o100644 << 16
302
+ dst.writestr(zi, data)
303
+ try:
304
+ os.link(tmp, out_path) # atomic: fails if out_path appeared meanwhile
305
+ except FileExistsError:
306
+ raise Refusal(f"output exists, refusing to overwrite: {out_path}") from None
307
+ except OSError:
308
+ # filesystem without hard links: create the output exclusively, then copy into it
309
+ try:
310
+ fd = os.open(out_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
311
+ except FileExistsError:
312
+ raise Refusal(f"output exists, refusing to overwrite: {out_path}") from None
313
+ with os.fdopen(fd, "wb") as dst_f, open(tmp, "rb") as src_f:
314
+ while True:
315
+ chunk = src_f.read(1 << 20)
316
+ if not chunk:
317
+ break
318
+ dst_f.write(chunk)
319
+ finally:
320
+ if os.path.exists(tmp):
321
+ os.unlink(tmp)
322
+
323
+
324
+ def same_file(a, b):
325
+ try:
326
+ return os.path.samefile(a, b)
327
+ except FileNotFoundError:
328
+ return os.path.abspath(a) == os.path.abspath(b)
package/skill.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "microsoft-word",
3
+ "version": "0.1.4",
4
+ "description": "Use when reading, creating, editing, converting, comparing, redacting or automating Microsoft Word documents (.docx, .docm, .dotx, .dotm, .doc, .rtf): find and replace that misses text split across runs, python-docx, docx.js (docx npm), docxtpl, mammoth or pandoc output that loses text or formatting, stale TOC or REF fields, tracked changes and comments, headers and footers, numbering, content controls, mail merge, LibreOffice headless PDF export, Office.js, VBA or Microsoft Graph automation, SharePoint or OneDrive files, and safe handling of untrusted documents (macros, remote templates, DDE fields, hidden text and metadata before sharing). Not needed for plain text or Markdown files that never become a Word document.",
5
+ "entry": "SKILL.md",
6
+ "skill_md_url": "https://raw.githubusercontent.com/skillsaas/microsoft-word/main/SKILL.md",
7
+ "repository": "https://github.com/skillsaas/microsoft-word",
8
+ "license": "MIT",
9
+ "platforms": {
10
+ "scripts": "any OS with Python 3.9+ and lxml",
11
+ "docx_refresh.py": "Linux only (bubblewrap or unshare); refuses on macOS/Windows by design"
12
+ },
13
+ "requires": {
14
+ "python": ">=3.9",
15
+ "pip": [
16
+ "lxml"
17
+ ],
18
+ "pip_optional": [
19
+ "python-docx",
20
+ "mammoth"
21
+ ],
22
+ "system_optional": [
23
+ "pandoc",
24
+ "libreoffice-writer",
25
+ "bubblewrap"
26
+ ]
27
+ },
28
+ "published": {
29
+ "npm": true,
30
+ "github": true
31
+ },
32
+ "verification": {
33
+ "tested_on_box": [
34
+ "scripts/*.py (tests/smoke_test.py)",
35
+ "references/extraction.md matrix",
36
+ "LibreOffice 25.2.3.2 with bwrap"
37
+ ],
38
+ "documented_not_executed": [
39
+ "references/automation.md (Office.js type-checked with tsc and a mock, Graph checked against msgraph-metadata, VBA against docs; no live Word or tenant run)",
40
+ "references/word-ui.md (Word menus and keys)"
41
+ ]
42
+ },
43
+ "third_party_licenses": {
44
+ "lxml": "BSD-3-Clause",
45
+ "python-docx": "MIT",
46
+ "mammoth": "BSD-2-Clause",
47
+ "pandoc": "GPL-2.0-or-later",
48
+ "libreoffice-writer": "MPL-2.0",
49
+ "bubblewrap": "LGPL-2.0-or-later",
50
+ "docxtpl": "LGPL-2.1",
51
+ "docx (npm)": "MIT",
52
+ "msoffcrypto-tool": "MIT",
53
+ "openpyxl": "MIT"
54
+ }
55
+ }