dsh-office-kit 0.4.3 → 0.4.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/assets/skills/excel-live-control/SKILL.md +1 -1
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +4 -3
- package/assets/skills/office-documents/scripts/docx_kit.py +68 -27
- package/assets/skills/office-documents/scripts/heading_audit.py +3 -4
- package/assets/skills/office-documents/scripts/internal_nav.py +3 -1
- package/assets/skills/office-documents/scripts/merge_docx_append.py +104 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +13 -3
- package/assets/skills/office-documents/scripts/watermark_add.py +6 -5
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +6 -4
- package/assets/skills/office-documents/tasks/watermarks_background.md +6 -6
- package/assets/skills/office-presentations/container_tools/preview_render.py +4 -1
- package/package.json +1 -1
- package/python/office_cli.py +38 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Drive a workbook that is already open in Excel or WPS Spreadsheets on Windows over COM: read cells, write values and formulas, style ranges, add charts and tables, recalculate, save, and report the exact ranges touched.
|
|
2
|
+
description: 'Drive a workbook that is already open in Excel or WPS Spreadsheets on Windows over COM: read cells, write values and formulas, style ranges, add charts and tables, recalculate, save, and report the exact ranges touched.'
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Live spreadsheets (Excel / WPS)
|
|
@@ -137,9 +137,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
137
137
|
print("error: %s" % exc, file=sys.stderr)
|
|
138
138
|
return 2
|
|
139
139
|
hits = 0
|
|
140
|
-
for
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
for part in doc.text_parts():
|
|
141
|
+
for paragraph in doc.paragraphs(part):
|
|
142
|
+
if tracked_replace(paragraph, old, new, args.author, stamp, counter):
|
|
143
|
+
hits += 1
|
|
143
144
|
applied.append({"old": old, "new": new, "paragraphs": hits})
|
|
144
145
|
|
|
145
146
|
settings = doc.xml("word/settings.xml")
|
|
@@ -250,7 +250,7 @@ class Docx:
|
|
|
250
250
|
while self.exists("word/%s%d.xml" % (kind, index)):
|
|
251
251
|
index += 1
|
|
252
252
|
part = "word/%s%d.xml" % (kind, index)
|
|
253
|
-
root = etree.Element(q(kind), nsmap={"w": W, "r": R, "wp": WP, "v": V})
|
|
253
|
+
root = etree.Element(q("hdr" if kind == "header" else "ftr"), nsmap={"w": W, "r": R, "wp": WP, "v": V})
|
|
254
254
|
etree.SubElement(etree.SubElement(root, q("p")), q("r"))
|
|
255
255
|
self.set_xml(part, root)
|
|
256
256
|
self.ensure_override(part, CT_BY_REL[rel_type])
|
|
@@ -459,45 +459,86 @@ def add_bookmark(parent, name: str, ident: int) -> None:
|
|
|
459
459
|
|
|
460
460
|
# -- watermarks -------------------------------------------------------------
|
|
461
461
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
462
|
+
WATERMARK_DESCR = "DSH Office watermark"
|
|
463
|
+
|
|
464
|
+
_WATERMARK_ANCHOR = """<wp:anchor xmlns:wp="%(wp)s" xmlns:a="%(a)s" xmlns:w="%(w)s" xmlns:wps="%(wps)s"
|
|
465
|
+
distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="251658240"
|
|
466
|
+
behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1">
|
|
467
|
+
<wp:simplePos x="0" y="0"/>
|
|
468
|
+
<wp:positionH relativeFrom="page"><wp:align>center</wp:align></wp:positionH>
|
|
469
|
+
<wp:positionV relativeFrom="page"><wp:align>center</wp:align></wp:positionV>
|
|
470
|
+
<wp:extent cx="5486400" cy="1371600"/>
|
|
471
|
+
<wp:effectExtent l="0" t="0" r="0" b="0"/>
|
|
472
|
+
<wp:wrapNone/>
|
|
473
|
+
<wp:docPr id="%(id)d" name="PowerPlusWaterMarkObject%(id)d" descr="%(descr)s"/>
|
|
474
|
+
<wp:cNvGraphicFramePr/>
|
|
475
|
+
<a:graphic><a:graphicData uri="%(wps)s"><wps:wsp>
|
|
476
|
+
<wps:cNvSpPr/>
|
|
477
|
+
<wps:spPr>
|
|
478
|
+
<a:xfrm rot="-2700000"><a:off x="0" y="0"/><a:ext cx="5486400" cy="1371600"/></a:xfrm>
|
|
479
|
+
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
|
480
|
+
<a:noFill/><a:ln><a:noFill/></a:ln>
|
|
481
|
+
</wps:spPr>
|
|
482
|
+
<wps:txbx><w:txbxContent><w:p><w:pPr><w:jc w:val="center"/></w:pPr>
|
|
483
|
+
<w:r><w:rPr><w:color w:val="%(color)s"/><w:sz w:val="120"/></w:rPr><w:t>%(text)s</w:t></w:r>
|
|
484
|
+
</w:p></w:txbxContent></wps:txbx>
|
|
485
|
+
<wps:bodyPr vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0"
|
|
486
|
+
anchor="ctr" anchorCtr="0"><a:noAutofit/></wps:bodyPr>
|
|
487
|
+
</wps:wsp></a:graphicData></a:graphic>
|
|
488
|
+
</wp:anchor>"""
|
|
489
|
+
|
|
490
|
+
_WPS_NS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
|
|
470
491
|
|
|
471
492
|
|
|
472
493
|
def watermark_paragraph(text: str, ident: int = 1, color: str = "#C0C0C0"):
|
|
473
|
-
"""A header paragraph holding one
|
|
494
|
+
"""A header paragraph holding one DrawingML text watermark.
|
|
495
|
+
|
|
496
|
+
Word stores watermarks as VML, but VML shapes are routinely dropped by other
|
|
497
|
+
renderers, so the shape here is DrawingML (wps) and marked with a stable
|
|
498
|
+
``descr`` so it can be found and removed again.
|
|
499
|
+
"""
|
|
500
|
+
from xml.sax.saxutils import escape
|
|
501
|
+
|
|
502
|
+
color = color.lstrip("#").upper() or "C0C0C0"
|
|
474
503
|
paragraph = etree.Element(q("p"))
|
|
475
504
|
run = etree.SubElement(paragraph, q("r"))
|
|
476
505
|
run_props = etree.SubElement(run, q("rPr"))
|
|
477
506
|
etree.SubElement(run_props, q("noProof"))
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
507
|
+
drawing = etree.SubElement(run, q("drawing"))
|
|
508
|
+
anchor = etree.fromstring(_WATERMARK_ANCHOR % {
|
|
509
|
+
"wp": WP,
|
|
510
|
+
"a": A,
|
|
511
|
+
"w": W,
|
|
512
|
+
"wps": _WPS_NS,
|
|
513
|
+
"id": ident,
|
|
514
|
+
"descr": WATERMARK_DESCR,
|
|
515
|
+
"color": color,
|
|
516
|
+
"text": escape(str(text)),
|
|
517
|
+
})
|
|
518
|
+
drawing.append(anchor)
|
|
489
519
|
return paragraph
|
|
490
520
|
|
|
491
521
|
|
|
522
|
+
def _paragraph_above(node):
|
|
523
|
+
while node is not None and node.tag != q("p"):
|
|
524
|
+
node = node.getparent()
|
|
525
|
+
return node
|
|
526
|
+
|
|
527
|
+
|
|
492
528
|
def watermark_texts(doc: Docx) -> list[dict]:
|
|
493
|
-
"""Every VML text watermark in the headers, with
|
|
529
|
+
"""Every VML or DrawingML text watermark in the headers, with its paragraph."""
|
|
494
530
|
found = []
|
|
495
531
|
for part in doc.names("word/header"):
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
532
|
+
root = doc.xml(part)
|
|
533
|
+
for textpath in root.iter(q("textpath", V)):
|
|
534
|
+
text = textpath.get("string")
|
|
535
|
+
if text:
|
|
536
|
+
found.append({"part": part, "kind": "vml", "text": text, "paragraph": _paragraph_above(textpath)})
|
|
537
|
+
for pr in root.iter(q("docPr", WP)):
|
|
538
|
+
if pr.get("descr") != WATERMARK_DESCR:
|
|
539
|
+
continue
|
|
540
|
+
text = "".join(node.text or "" for node in pr.getparent().iter(q("t")))
|
|
541
|
+
found.append({"part": part, "kind": "drawingml", "text": text, "paragraph": _paragraph_above(pr)})
|
|
501
542
|
return found
|
|
502
543
|
|
|
503
544
|
|
|
@@ -58,7 +58,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
58
58
|
for index, paragraph in enumerate(doc.paragraphs(), start=1):
|
|
59
59
|
text = kit.paragraph_text(paragraph).strip()
|
|
60
60
|
level = kit.heading_level(paragraph)
|
|
61
|
-
|
|
61
|
+
style = (kit.paragraph_style(paragraph) or "").strip().lower()
|
|
62
|
+
if style == "title":
|
|
62
63
|
outline.append({"index": index, "style": "Title", "text": text})
|
|
63
64
|
continue
|
|
64
65
|
if level:
|
|
@@ -71,9 +72,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
71
72
|
if manual_heading(paragraph):
|
|
72
73
|
findings.append({"kind": "manual_formatting", "index": index, "text": text})
|
|
73
74
|
previous = level
|
|
74
|
-
elif
|
|
75
|
-
continue
|
|
76
|
-
elif manual_heading(paragraph) and len(text) <= 120:
|
|
75
|
+
elif text and manual_heading(paragraph) and len(text) <= 120:
|
|
77
76
|
findings.append({"kind": "heading_not_styled", "index": index, "text": text})
|
|
78
77
|
|
|
79
78
|
counts = {}
|
|
@@ -131,8 +131,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
131
131
|
body.insert(position + offset, node)
|
|
132
132
|
|
|
133
133
|
if not args.no_back_to_toc:
|
|
134
|
+
anchors = {entry["anchor"] for entry in headings}
|
|
134
135
|
for paragraph_node in list(body.iter(kit.q("p"))):
|
|
135
|
-
|
|
136
|
+
start = paragraph_node.find(kit.q("bookmarkStart"))
|
|
137
|
+
if start is not None and start.get(kit.q("name")) in anchors:
|
|
136
138
|
back = paragraph("Caption")
|
|
137
139
|
back.append(link_run("Back to contents", TOC_BOOKMARK))
|
|
138
140
|
paragraph_node.addnext(back)
|
|
@@ -173,6 +173,101 @@ def remap_relationships(base: kit.Docx, append: kit.Docx, nodes: list) -> int:
|
|
|
173
173
|
return copied
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
NOTE_PARTS = (
|
|
177
|
+
("word/footnotes.xml", kit.REL_FOOTNOTES, "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", kit.q("footnote"), (kit.q("footnoteReference"),)),
|
|
178
|
+
("word/endnotes.xml", kit.REL_ENDNOTES, "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml", kit.q("endnote"), (kit.q("endnoteReference"),)),
|
|
179
|
+
("word/comments.xml", kit.REL_COMMENTS, "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", kit.q("comment"), (kit.q("commentReference"), kit.q("commentRangeStart"), kit.q("commentRangeEnd"))),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def remap_part_relationships(base: kit.Docx, append: kit.Docx, append_part: str, base_part: str, nodes: list) -> int:
|
|
184
|
+
"""Remap r:embed/r:id/r:link inside nodes, copying targets into the base package."""
|
|
185
|
+
source_rels = {entry["id"]: entry for entry in append.relationships(append_part)}
|
|
186
|
+
attributes = (kit.q("embed", kit.R), kit.q("id", kit.R), kit.q("link", kit.R))
|
|
187
|
+
mapping = {}
|
|
188
|
+
copied = 0
|
|
189
|
+
for node in nodes:
|
|
190
|
+
for element in node.iter():
|
|
191
|
+
for attribute in attributes:
|
|
192
|
+
rid = element.get(attribute)
|
|
193
|
+
if not rid:
|
|
194
|
+
continue
|
|
195
|
+
if rid not in mapping:
|
|
196
|
+
rel = source_rels.get(rid)
|
|
197
|
+
if rel is None:
|
|
198
|
+
mapping[rid] = rid
|
|
199
|
+
continue
|
|
200
|
+
if rel["mode"] == "External":
|
|
201
|
+
mapping[rid] = base.ensure_rel(base_part, rel["type"], rel["target"], external=True)
|
|
202
|
+
elif append.exists(rel["resolved"]):
|
|
203
|
+
target = copy_part(base, append, rel["resolved"])
|
|
204
|
+
relative = posixpath.relpath(target, posixpath.dirname(base_part))
|
|
205
|
+
mapping[rid] = base.ensure_rel(base_part, rel["type"], relative)
|
|
206
|
+
copied += 1
|
|
207
|
+
else:
|
|
208
|
+
mapping[rid] = rid
|
|
209
|
+
element.set(attribute, mapping[rid])
|
|
210
|
+
return copied
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def merge_note_part(base: kit.Docx, append: kit.Docx, part: str, rel_type: str, content_type: str, note_tag: str) -> dict[str, str]:
|
|
214
|
+
"""Merge a footnotes/endnotes/comments part into the base, returning old→new ids."""
|
|
215
|
+
if not append.exists(part):
|
|
216
|
+
return {}
|
|
217
|
+
source_root = append.xml(part)
|
|
218
|
+
if base.exists(part):
|
|
219
|
+
target_root = base.xml(part)
|
|
220
|
+
else:
|
|
221
|
+
target_root = etree.Element(source_root.tag, nsmap=source_root.nsmap)
|
|
222
|
+
for note in source_root.findall(note_tag):
|
|
223
|
+
raw = note.get(kit.q("id"))
|
|
224
|
+
if raw is not None and raw.lstrip("-").isdigit() and int(raw) <= 0:
|
|
225
|
+
target_root.append(etree.fromstring(etree.tostring(note)))
|
|
226
|
+
base.set_xml(part, target_root)
|
|
227
|
+
base.ensure_override(part, content_type)
|
|
228
|
+
base.ensure_rel("word/document.xml", rel_type, posixpath.relpath(part, "word"))
|
|
229
|
+
|
|
230
|
+
used = {
|
|
231
|
+
int(node.get(kit.q("id")))
|
|
232
|
+
for node in target_root.findall(note_tag)
|
|
233
|
+
if (node.get(kit.q("id")) or "").lstrip("-").isdigit()
|
|
234
|
+
}
|
|
235
|
+
next_id = max(used, default=0) + 1
|
|
236
|
+
mapping: dict[str, str] = {}
|
|
237
|
+
for note in source_root.findall(note_tag):
|
|
238
|
+
raw = note.get(kit.q("id"))
|
|
239
|
+
if raw is None or not raw.lstrip("-").isdigit() or int(raw) <= 0:
|
|
240
|
+
continue
|
|
241
|
+
clone = etree.fromstring(etree.tostring(note))
|
|
242
|
+
clone.set(kit.q("id"), str(next_id))
|
|
243
|
+
remap_part_relationships(base, append, part, part, [clone])
|
|
244
|
+
target_root.append(clone)
|
|
245
|
+
mapping[raw] = str(next_id)
|
|
246
|
+
next_id += 1
|
|
247
|
+
return mapping
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def bind_note_references(clones: list, ref_tags: tuple, mapping: dict[str, str], target_exists: bool) -> int:
|
|
251
|
+
"""Point body references at the merged notes; drop references that have no note."""
|
|
252
|
+
dropped = 0
|
|
253
|
+
for clone in clones:
|
|
254
|
+
for ref_tag in ref_tags:
|
|
255
|
+
for element in list(clone.iter(ref_tag)):
|
|
256
|
+
raw = element.get(kit.q("id"))
|
|
257
|
+
if raw is None:
|
|
258
|
+
continue
|
|
259
|
+
if raw in mapping:
|
|
260
|
+
element.set(kit.q("id"), mapping[raw])
|
|
261
|
+
elif target_exists and raw.isdigit() and int(raw) <= 0:
|
|
262
|
+
continue
|
|
263
|
+
else:
|
|
264
|
+
parent = element.getparent()
|
|
265
|
+
if parent is not None:
|
|
266
|
+
parent.remove(element)
|
|
267
|
+
dropped += 1
|
|
268
|
+
return dropped
|
|
269
|
+
|
|
270
|
+
|
|
176
271
|
def main(argv: list[str] | None = None) -> int:
|
|
177
272
|
parser = argparse.ArgumentParser(description="Append one .docx body to another, copying media, numbering and styles.")
|
|
178
273
|
parser.add_argument("base", help="Document to append to")
|
|
@@ -220,6 +315,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
220
315
|
if num_id is not None and num_id.get(kit.q("val")) in number_map:
|
|
221
316
|
num_id.set(kit.q("val"), number_map[num_id.get(kit.q("val"))])
|
|
222
317
|
media_copied = remap_relationships(base, append, clones)
|
|
318
|
+
notes_merged = 0
|
|
319
|
+
references_dropped = 0
|
|
320
|
+
for part, rel_type, content_type, note_tag, ref_tags in NOTE_PARTS:
|
|
321
|
+
exists = append.exists(part)
|
|
322
|
+
mapping = merge_note_part(base, append, part, rel_type, content_type, note_tag) if exists else {}
|
|
323
|
+
notes_merged += len(mapping)
|
|
324
|
+
references_dropped += bind_note_references(clones, ref_tags, mapping, exists)
|
|
223
325
|
copy_defaults(base, append)
|
|
224
326
|
|
|
225
327
|
body = base.body()
|
|
@@ -237,6 +339,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
237
339
|
"styles_copied": styles_copied,
|
|
238
340
|
"media_copied": media_copied,
|
|
239
341
|
"numbering_remapped": len(number_map),
|
|
342
|
+
"notes_merged": notes_merged,
|
|
343
|
+
"references_dropped": references_dropped,
|
|
240
344
|
}
|
|
241
345
|
)
|
|
242
346
|
return 0
|
|
@@ -98,7 +98,11 @@ def fix_table(table, target: int) -> None:
|
|
|
98
98
|
if not columns:
|
|
99
99
|
columns = [1]
|
|
100
100
|
total = sum(columns) or len(columns)
|
|
101
|
-
scaled = [max(1,
|
|
101
|
+
scaled = [max(1, target * value // total) for value in columns]
|
|
102
|
+
remainder = target - sum(scaled)
|
|
103
|
+
order = sorted(range(len(columns)), key=lambda index: (target * columns[index]) % total, reverse=True)
|
|
104
|
+
for index in range(max(0, remainder)):
|
|
105
|
+
scaled[order[index % len(order)]] += 1
|
|
102
106
|
grid = table.find(kit.q("tblGrid"))
|
|
103
107
|
if grid is None:
|
|
104
108
|
grid = etree.Element(kit.q("tblGrid"))
|
|
@@ -163,8 +167,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
163
167
|
fix_table(table, target)
|
|
164
168
|
out = args.out or args.docx
|
|
165
169
|
doc.save(out)
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
remaining = []
|
|
171
|
+
for index, info in enumerate((table_info(table) for table in tables), start=1):
|
|
172
|
+
if info["grid_total"] and info["grid_total"] > target:
|
|
173
|
+
remaining.append({"kind": "grid_wider_than_text", "table": index, "grid_total": info["grid_total"], "text_width": target})
|
|
174
|
+
if info["layout"] != "fixed":
|
|
175
|
+
remaining.append({"kind": "not_fixed_layout", "table": index})
|
|
176
|
+
kit.emit({"ok": not remaining, "fixed": True, "output": str(Path(out)), "tables": len(tables), "text_width": target, "findings": remaining})
|
|
177
|
+
return 0 if not remaining else 1
|
|
168
178
|
|
|
169
179
|
kit.emit({"ok": not findings, "text_width": target, "tables": rows, "findings": findings})
|
|
170
180
|
return 1 if findings else 0
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
|
-
"""Add a
|
|
2
|
+
"""Add a text watermark to the page background.
|
|
3
3
|
|
|
4
|
-
The watermark is a
|
|
5
|
-
|
|
6
|
-
page
|
|
4
|
+
The watermark is a DrawingML text box in the default header: Word rotates it
|
|
5
|
+
-45 degrees, while VML-only renderers that drop VML shapes still show the mark.
|
|
6
|
+
It prints behind the body on every page and can be removed again by deleting
|
|
7
|
+
the header paragraph.
|
|
7
8
|
|
|
8
9
|
Exit codes: 0 success, 2 bad input.
|
|
9
10
|
"""
|
|
@@ -26,7 +27,7 @@ for _stream in (sys.stdout, sys.stderr):
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def main(argv: list[str] | None = None) -> int:
|
|
29
|
-
parser = argparse.ArgumentParser(description="Add a
|
|
30
|
+
parser = argparse.ArgumentParser(description="Add a DrawingML text watermark to the document header.")
|
|
30
31
|
parser.add_argument("input_docx", help="Input .docx")
|
|
31
32
|
parser.add_argument("--out", required=True, help="Output .docx")
|
|
32
33
|
parser.add_argument("--text", required=True, help="Watermark text, e.g. CONFIDENTIAL")
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
|
-
"""Find and remove
|
|
2
|
+
"""Find and remove text watermarks from headers.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Both DrawingML watermarks (what ``watermark_add.py`` writes) and legacy VML
|
|
5
|
+
``v:textpath`` shapes are detected. A watermark that was meant for a draft can
|
|
6
|
+
end up in a signed document, and it is invisible in text extraction because it
|
|
7
|
+
lives in a shape, not in a paragraph run.
|
|
6
8
|
|
|
7
9
|
Exit codes: 0 clean, 1 watermarks found (report mode), 2 bad input.
|
|
8
10
|
"""
|
|
@@ -25,7 +27,7 @@ for _stream in (sys.stdout, sys.stderr):
|
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def main(argv: list[str] | None = None) -> int:
|
|
28
|
-
parser = argparse.ArgumentParser(description="Report or remove watermarks stored as VML shapes in headers.")
|
|
30
|
+
parser = argparse.ArgumentParser(description="Report or remove watermarks stored as DrawingML or VML shapes in headers.")
|
|
29
31
|
parser.add_argument("input_docx", help="Input .docx")
|
|
30
32
|
parser.add_argument("--mode", choices=("report", "remove"), default="report")
|
|
31
33
|
parser.add_argument("--contains", default=None, help="Only match watermarks containing this text")
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Watermarks and background marks
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A text watermark is a shape inside a header part, behind the body text: Word writes legacy VML (`v:shape` with `v:textpath`), while `watermark_add.py` writes DrawingML (`wps:wsp`) because VML is routinely dropped by non-Word renderers. That is why a watermark disappears when the header is replaced and can differ per section.
|
|
4
4
|
|
|
5
5
|
## Add
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
"<python>" "<skill>/scripts/watermark_add.py" "<file.docx>" --out "<file-draft.docx" --text "DRAFT"
|
|
8
|
+
"<python>" "<skill>/scripts/watermark_add.py" "<file.docx>" --out "<file-draft.docx>" --text "DRAFT"
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Writes the shape into the default header: light grey,
|
|
11
|
+
Writes the shape into the default header: light grey, centred on the page, tagged with `descr="DSH Office watermark"`. Word rotates it -45°; renderers that ignore the rotation still show a horizontal mark, so it is always visible. The script refuses to add a second watermark when it finds one, so remove the old one first. For a mark on every page across sections, each section's header needs the shape — loop over the sections.
|
|
12
12
|
|
|
13
13
|
## Find and remove
|
|
14
14
|
|
|
@@ -17,15 +17,15 @@ Writes the shape into the default header: light grey, rotated -45°, behind the
|
|
|
17
17
|
"<python>" "<skill>/scripts/watermark_audit_remove.py" "<file.docx>" --mode remove --out "<file-clean.docx"
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
The report lists every VML text path
|
|
20
|
+
The report lists every DrawingML watermark and every legacy VML text path, with the part it lives in, and exits 1 when any watermark exists. `--contains` narrows it to a substring, for the "template says Company Confidential" case. Remove deletes the enclosing paragraph from the header.
|
|
21
21
|
|
|
22
22
|
## Verify
|
|
23
23
|
|
|
24
|
-
Render and read the pages: the mark sits behind the text, inside the page, on every expected page. A
|
|
24
|
+
Render and read the pages: the mark sits behind the text, inside the page, on every expected page. A light grey drawing that covers a line of body text is still a defect; a mark missing on page two means that section has a different header.
|
|
25
25
|
|
|
26
26
|
## Pitfalls
|
|
27
27
|
|
|
28
28
|
- A watermark in a "different first page" header shows only on page one.
|
|
29
29
|
- Removing the header paragraph can leave an empty header that adds vertical space — check the top margin.
|
|
30
|
-
- An image watermark is `v:imagedata`, not a text
|
|
30
|
+
- An image watermark is `v:imagedata`, not a text shape, so the report will not name it; inspect the header XML.
|
|
31
31
|
- A watermark is not security (`tasks/protection_restrict_editing.md`).
|
|
@@ -19,6 +19,7 @@ Exit codes: 0 ok, 2 unreadable input, 3 nothing rasterised.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
|
+
import math
|
|
22
23
|
import sys
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
from typing import Any
|
|
@@ -254,7 +255,9 @@ def _draw_chart(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[st
|
|
|
254
255
|
for index, value in enumerate(values):
|
|
255
256
|
sweep = 360.0 * abs(value) / total
|
|
256
257
|
shade = shades[index % len(shades)]
|
|
257
|
-
|
|
258
|
+
# PyMuPDF 1.28 dropped Page.draw_pie; draw_sector has been stable since 1.16.
|
|
259
|
+
edge = fitz.Point(center.x + radius * math.cos(math.radians(start)), center.y + radius * math.sin(math.radians(start)))
|
|
260
|
+
page.draw_sector(center, edge, sweep, color=RULE, fill=(shade, shade, shade * 0.95 + 0.05), width=0.6)
|
|
258
261
|
start += sweep
|
|
259
262
|
return
|
|
260
263
|
|
package/package.json
CHANGED
package/python/office_cli.py
CHANGED
|
@@ -278,10 +278,42 @@ def export_pdf(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
|
|
|
278
278
|
fail(f"the LibreOffice kit reported a PDF export for {input_path.name} but wrote none")
|
|
279
279
|
return {
|
|
280
280
|
"pdf": str(produced),
|
|
281
|
+
"backend": report.get("backend"),
|
|
281
282
|
"missing_fonts": [str(name) for name in report.get("missingFonts") or []],
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
|
|
286
|
+
def xlsx_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
|
|
287
|
+
"""Render a workbook through PDF, because the kit's direct render prints ranges only.
|
|
288
|
+
|
|
289
|
+
The direct xlsx render path rasterises each used range and drops charts, images
|
|
290
|
+
and print areas; converting first lays the sheet out the way the app prints it.
|
|
291
|
+
"""
|
|
292
|
+
converted = export_pdf(input_path, out_dir, dpi)
|
|
293
|
+
pages = rasterize_pdf(converted["pdf"], out_dir, dpi)
|
|
294
|
+
missing = converted["missing_fonts"]
|
|
295
|
+
warnings = glyph_warnings(missing)
|
|
296
|
+
report_warnings(warnings)
|
|
297
|
+
return {
|
|
298
|
+
"ok": True,
|
|
299
|
+
"kind": "xlsx",
|
|
300
|
+
"command": "render",
|
|
301
|
+
"input": str(input_path),
|
|
302
|
+
"renderer": "libreoffice-kit",
|
|
303
|
+
"engine": converted.get("backend"),
|
|
304
|
+
"page_count": len(pages),
|
|
305
|
+
"pages": pages,
|
|
306
|
+
"images": [
|
|
307
|
+
{"page": index, "sheet": None, "range": None, "path": path}
|
|
308
|
+
for index, path in enumerate(pages, start=1)
|
|
309
|
+
],
|
|
310
|
+
"pdf": converted["pdf"],
|
|
311
|
+
"missing_fonts": missing,
|
|
312
|
+
"glyph_fallback": bool(missing),
|
|
313
|
+
"warnings": warnings,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
285
317
|
def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
|
|
286
318
|
"""Rasterise an Office file page by page through the kit.
|
|
287
319
|
|
|
@@ -769,8 +801,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
|
|
|
769
801
|
|
|
770
802
|
styles = getSampleStyleSheet()
|
|
771
803
|
body = ParagraphStyle("OfficeBody", parent=styles["BodyText"], fontName=font, fontSize=10.5, leading=15)
|
|
772
|
-
heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24)
|
|
773
|
-
heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20)
|
|
804
|
+
heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24, spaceBefore=16, spaceAfter=8)
|
|
805
|
+
heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20, spaceBefore=12, spaceAfter=6)
|
|
774
806
|
title = ParagraphStyle("OfficeTitle", parent=styles["Title"], fontName=font, fontSize=22, leading=28)
|
|
775
807
|
flowables = []
|
|
776
808
|
for block in blocks:
|
|
@@ -788,8 +820,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
|
|
|
788
820
|
flowables.append(Paragraph(escape(str(block.get("text", ""))), body))
|
|
789
821
|
counters["paragraphs"] += 1
|
|
790
822
|
elif block_type in {"bullets", "numbered"}:
|
|
791
|
-
for item in block.get("items", []):
|
|
792
|
-
prefix = "• " if block_type == "bullets" else "
|
|
823
|
+
for index, item in enumerate(block.get("items", []), start=1):
|
|
824
|
+
prefix = "• " if block_type == "bullets" else f"{index}. "
|
|
793
825
|
flowables.append(Paragraph(prefix + escape(str(item)), body))
|
|
794
826
|
counters["list_items"] += 1
|
|
795
827
|
elif block_type == "table":
|
|
@@ -1427,6 +1459,8 @@ def main() -> None:
|
|
|
1427
1459
|
out_dir = Path(args.out_dir)
|
|
1428
1460
|
if args.format == "pdf":
|
|
1429
1461
|
result = pdf_render(input_path, out_dir, args.dpi)
|
|
1462
|
+
elif args.format == "xlsx":
|
|
1463
|
+
result = xlsx_render(input_path, out_dir, args.dpi)
|
|
1430
1464
|
else:
|
|
1431
1465
|
result = render_office(input_path, args.format, out_dir, args.dpi, emit_pdf=bool(getattr(args, "pdf", False)))
|
|
1432
1466
|
elif args.command == "recalc":
|