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.
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +129 -0
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/SKILL.md +177 -0
- package/package.json +62 -4
- package/references/automation.md +74 -0
- package/references/conversion.md +53 -0
- package/references/extraction.md +63 -0
- package/references/fallbacks.md +61 -0
- package/references/libraries.md +125 -0
- package/references/ooxml.md +111 -0
- package/references/recipes.md +144 -0
- package/references/security.md +88 -0
- package/references/troubleshooting.md +23 -0
- package/references/word-ui.md +40 -0
- package/scripts/docx_refresh.py +423 -0
- package/scripts/docx_replace.py +321 -0
- package/scripts/docx_sanitize.py +507 -0
- package/scripts/inspect_docx.py +723 -0
- package/scripts/requirements.txt +1 -0
- package/scripts/wordpkg.py +328 -0
- package/skill.json +55 -0
|
@@ -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
|
+
}
|