dsh-office-kit 0.4.3 → 0.4.5
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-pdf/scripts/forms.py +209 -5
- package/assets/skills/office-pdf/scripts/pdf_fonts.py +67 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +24 -4
- package/assets/skills/office-pdf/tasks/forms.md +9 -0
- package/assets/skills/office-pdf/tasks/stamp.md +2 -1
- package/assets/skills/office-presentations/container_tools/preview_render.py +4 -1
- package/package.json +1 -1
- package/python/office_cli.py +131 -8
|
@@ -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`).
|
|
@@ -15,6 +15,7 @@ Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
|
+
import io
|
|
18
19
|
import json
|
|
19
20
|
import sys
|
|
20
21
|
from pathlib import Path
|
|
@@ -31,6 +32,8 @@ except ImportError: # pragma: no cover - pypdf is a required dependency
|
|
|
31
32
|
IndirectObject = None # type: ignore[assignment]
|
|
32
33
|
NameObject = None # type: ignore[assignment]
|
|
33
34
|
|
|
35
|
+
from pdf_fonts import find_cjk_font, has_non_latin, register_cjk_font
|
|
36
|
+
|
|
34
37
|
|
|
35
38
|
def emit(value, out=None):
|
|
36
39
|
text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
@@ -49,6 +52,114 @@ def fail(message, code=1, detail=None):
|
|
|
49
52
|
raise SystemExit(code)
|
|
50
53
|
|
|
51
54
|
|
|
55
|
+
def rebuild_cjk_appearances(path, values, font_file):
|
|
56
|
+
"""Replace the appearance of text widgets whose value is non-Latin.
|
|
57
|
+
|
|
58
|
+
pypdf can only generate appearance streams with the base-14 fonts, so a
|
|
59
|
+
Chinese value would paint as question marks even though /V is correct.
|
|
60
|
+
Each such widget gets a fresh form XObject drawn with a real CJK font,
|
|
61
|
+
with the field border and background kept.
|
|
62
|
+
"""
|
|
63
|
+
from pypdf import PdfReader, PdfWriter
|
|
64
|
+
from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, NumberObject, StreamObject
|
|
65
|
+
from reportlab.lib.colors import Color
|
|
66
|
+
from reportlab.pdfbase import pdfmetrics
|
|
67
|
+
from reportlab.pdfgen import canvas as rl_canvas
|
|
68
|
+
|
|
69
|
+
reader = PdfReader(str(path))
|
|
70
|
+
report = inspect_document(reader)
|
|
71
|
+
targets = []
|
|
72
|
+
for widget in report["widgets"]:
|
|
73
|
+
name = widget["name"]
|
|
74
|
+
value = values.get(name)
|
|
75
|
+
if not has_non_latin(value) or widget["field_type"] != "/Tx":
|
|
76
|
+
continue
|
|
77
|
+
rect = widget["rect"]
|
|
78
|
+
if len(rect) != 4:
|
|
79
|
+
continue
|
|
80
|
+
width = float(rect[2]) - float(rect[0])
|
|
81
|
+
height = float(rect[3]) - float(rect[1])
|
|
82
|
+
if width <= 1 or height <= 1:
|
|
83
|
+
continue
|
|
84
|
+
targets.append({"page": widget["page"], "name": name, "value": value, "width": width, "height": height})
|
|
85
|
+
if not targets:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
font_path = find_cjk_font(font_file)
|
|
90
|
+
except FileNotFoundError as exc:
|
|
91
|
+
fail(str(exc), code=2)
|
|
92
|
+
if font_path is None:
|
|
93
|
+
fail("values contain non-Latin text but no CJK font was found; pass --font-file", code=2)
|
|
94
|
+
try:
|
|
95
|
+
font_name = register_cjk_font(font_path, "FormCJK")
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
fail(f"cannot use font file {font_path}: {exc}", code=2)
|
|
98
|
+
|
|
99
|
+
overlay = io.BytesIO()
|
|
100
|
+
page = rl_canvas.Canvas(overlay)
|
|
101
|
+
for target in targets:
|
|
102
|
+
width, height = target["width"], target["height"]
|
|
103
|
+
page.setPageSize((width, height))
|
|
104
|
+
page.setFillColor(Color(1, 1, 1))
|
|
105
|
+
page.rect(0, 0, width, height, stroke=0, fill=1)
|
|
106
|
+
page.setStrokeColor(Color(0.55, 0.55, 0.55))
|
|
107
|
+
page.setLineWidth(0.5)
|
|
108
|
+
page.rect(0.25, 0.25, width - 0.5, height - 0.5, stroke=1, fill=0)
|
|
109
|
+
size = min(10.0, max(height - 3, 4.0))
|
|
110
|
+
while size > 4 and pdfmetrics.stringWidth(target["value"], font_name, size) > width - 4:
|
|
111
|
+
size -= 0.5
|
|
112
|
+
page.setFillColor(Color(0, 0, 0))
|
|
113
|
+
page.setFont(font_name, size)
|
|
114
|
+
baseline = (height - size) / 2 + size * 0.22
|
|
115
|
+
page.drawString(2, baseline, target["value"])
|
|
116
|
+
page.showPage()
|
|
117
|
+
page.save()
|
|
118
|
+
overlay.seek(0)
|
|
119
|
+
overlay_reader = PdfReader(overlay)
|
|
120
|
+
|
|
121
|
+
writer = PdfWriter()
|
|
122
|
+
writer.clone_document_from_reader(reader)
|
|
123
|
+
original_pages = len(writer.pages)
|
|
124
|
+
writer.append(overlay_reader)
|
|
125
|
+
appended = writer.pages[original_pages:]
|
|
126
|
+
|
|
127
|
+
for index, target in enumerate(targets):
|
|
128
|
+
overlay_page = appended[index]
|
|
129
|
+
contents = overlay_page.get_contents()
|
|
130
|
+
data = contents.get_data() if contents is not None else b""
|
|
131
|
+
resources = overlay_page.get("/Resources")
|
|
132
|
+
stream = StreamObject()
|
|
133
|
+
stream.set_data(data)
|
|
134
|
+
stream[NameObject("/Type")] = NameObject("/XObject")
|
|
135
|
+
stream[NameObject("/Subtype")] = NameObject("/Form")
|
|
136
|
+
stream[NameObject("/FormType")] = NumberObject(1)
|
|
137
|
+
stream[NameObject("/BBox")] = ArrayObject(
|
|
138
|
+
[FloatObject(0), FloatObject(0), FloatObject(target["width"]), FloatObject(target["height"])]
|
|
139
|
+
)
|
|
140
|
+
if resources is not None:
|
|
141
|
+
stream[NameObject("/Resources")] = resources
|
|
142
|
+
reference = writer._add_object(stream)
|
|
143
|
+
page = writer.pages[target["page"] - 1]
|
|
144
|
+
for annotation in page.get("/Annots") or []:
|
|
145
|
+
obj = annotation.get_object()
|
|
146
|
+
if obj.get("/Subtype") != "/Widget":
|
|
147
|
+
continue
|
|
148
|
+
parent = obj.get("/Parent")
|
|
149
|
+
source = parent.get_object() if parent is not None else obj
|
|
150
|
+
tag = obj.get("/T") or source.get("/T")
|
|
151
|
+
if str(tag) != target["name"]:
|
|
152
|
+
continue
|
|
153
|
+
obj[NameObject("/AP")] = DictionaryObject({NameObject("/N"): reference})
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
while len(writer.pages) > original_pages:
|
|
157
|
+
writer.remove_page(len(writer.pages) - 1)
|
|
158
|
+
with open(path, "wb") as handle:
|
|
159
|
+
writer.write(handle)
|
|
160
|
+
return [target["name"] for target in targets]
|
|
161
|
+
|
|
162
|
+
|
|
52
163
|
def ref_id(node):
|
|
53
164
|
if isinstance(node, IndirectObject):
|
|
54
165
|
return node.idnum
|
|
@@ -285,6 +396,85 @@ def values_for_field(field, value):
|
|
|
285
396
|
return str(value)
|
|
286
397
|
|
|
287
398
|
|
|
399
|
+
def flatten_widgets(path):
|
|
400
|
+
"""Paint every widget appearance into page content and drop the form."""
|
|
401
|
+
from pypdf import PdfReader, PdfWriter
|
|
402
|
+
from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, StreamObject
|
|
403
|
+
|
|
404
|
+
reader = PdfReader(str(path))
|
|
405
|
+
writer = PdfWriter()
|
|
406
|
+
writer.clone_document_from_reader(reader)
|
|
407
|
+
|
|
408
|
+
for page in writer.pages:
|
|
409
|
+
annotations = page.get("/Annots")
|
|
410
|
+
if not annotations:
|
|
411
|
+
continue
|
|
412
|
+
resources = page.get("/Resources")
|
|
413
|
+
if resources is None:
|
|
414
|
+
resources = DictionaryObject()
|
|
415
|
+
page[NameObject("/Resources")] = resources
|
|
416
|
+
resources = resources.get_object()
|
|
417
|
+
xobjects = resources.get("/XObject")
|
|
418
|
+
if xobjects is None:
|
|
419
|
+
xobjects = DictionaryObject()
|
|
420
|
+
resources[NameObject("/XObject")] = xobjects
|
|
421
|
+
xobjects = xobjects.get_object()
|
|
422
|
+
|
|
423
|
+
calls = []
|
|
424
|
+
for index, annotation in enumerate(annotations):
|
|
425
|
+
widget = annotation.get_object()
|
|
426
|
+
if widget.get("/Subtype") != "/Widget":
|
|
427
|
+
continue
|
|
428
|
+
normal = widget.get("/AP")
|
|
429
|
+
if normal is None:
|
|
430
|
+
continue
|
|
431
|
+
normal = normal.get_object().get("/N")
|
|
432
|
+
if normal is None:
|
|
433
|
+
continue
|
|
434
|
+
normal = normal.get_object()
|
|
435
|
+
if not hasattr(normal, "get_data"):
|
|
436
|
+
if not hasattr(normal, "keys"):
|
|
437
|
+
continue
|
|
438
|
+
state = widget.get("/AS")
|
|
439
|
+
if state is None or normal.get(state) is None:
|
|
440
|
+
continue
|
|
441
|
+
normal = normal[state].get_object()
|
|
442
|
+
if not hasattr(normal, "get_data"):
|
|
443
|
+
continue
|
|
444
|
+
name = NameObject(f"/DSCJK{index}")
|
|
445
|
+
xobjects[name] = normal.indirect_reference or normal
|
|
446
|
+
rect = [float(value) for value in widget.get("/Rect") or []]
|
|
447
|
+
bbox = [float(value) for value in normal.get("/BBox") or []]
|
|
448
|
+
if len(rect) != 4 or len(bbox) != 4:
|
|
449
|
+
continue
|
|
450
|
+
x, y, x2, y2 = rect[0], rect[1], rect[2], rect[3]
|
|
451
|
+
width = x2 - x
|
|
452
|
+
height = y2 - y
|
|
453
|
+
box_width = bbox[2] - bbox[0] or width
|
|
454
|
+
box_height = bbox[3] - bbox[1] or height
|
|
455
|
+
calls.append(
|
|
456
|
+
f"q {width / box_width:.6f} 0 0 {height / box_height:.6f} {x:.4f} {y:.4f} cm {name} Do Q"
|
|
457
|
+
)
|
|
458
|
+
if not calls:
|
|
459
|
+
continue
|
|
460
|
+
stream = StreamObject()
|
|
461
|
+
stream.set_data("\n".join(calls).encode("latin-1"))
|
|
462
|
+
reference = writer._add_object(stream)
|
|
463
|
+
existing = page.get("/Contents")
|
|
464
|
+
if existing is None:
|
|
465
|
+
page[NameObject("/Contents")] = reference
|
|
466
|
+
elif hasattr(existing, "get_data"):
|
|
467
|
+
page[NameObject("/Contents")] = ArrayObject([existing.indirect_reference or existing, reference])
|
|
468
|
+
else:
|
|
469
|
+
page[NameObject("/Contents")] = ArrayObject([*existing, reference])
|
|
470
|
+
|
|
471
|
+
for page in writer.pages:
|
|
472
|
+
page[NameObject("/Annots")] = ArrayObject()
|
|
473
|
+
writer.root_object.pop(NameObject("/AcroForm"), None)
|
|
474
|
+
with open(path, "wb") as handle:
|
|
475
|
+
writer.write(handle)
|
|
476
|
+
|
|
477
|
+
|
|
288
478
|
def write_fill(args, values):
|
|
289
479
|
from pypdf import PdfReader, PdfWriter
|
|
290
480
|
|
|
@@ -332,16 +522,28 @@ def write_fill(args, values):
|
|
|
332
522
|
payload = current
|
|
333
523
|
payload = {name: values_for_field(fields[name], value) for name, value in payload.items()}
|
|
334
524
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if
|
|
338
|
-
|
|
339
|
-
|
|
525
|
+
cjk_values = {name: value for name, value in expected.items() if has_non_latin(value)}
|
|
526
|
+
flatten = args.flatten
|
|
527
|
+
if flatten and cjk_values:
|
|
528
|
+
# pypdf can only flatten with base-14 fonts; keep the widgets, give them
|
|
529
|
+
# real CJK appearances, then paint every appearance into the page.
|
|
530
|
+
writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=False)
|
|
531
|
+
sync_button_states(writer)
|
|
532
|
+
else:
|
|
533
|
+
writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=flatten)
|
|
534
|
+
sync_button_states(writer)
|
|
535
|
+
if flatten:
|
|
536
|
+
writer.remove_annotations(subtypes="/Widget")
|
|
537
|
+
writer.root_object.pop(NameObject("/AcroForm"), None)
|
|
340
538
|
|
|
341
539
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
342
540
|
with open(args.out, "wb") as handle:
|
|
343
541
|
writer.write(handle)
|
|
344
542
|
|
|
543
|
+
cjk_rendered = rebuild_cjk_appearances(args.out, expected, args.font_file)
|
|
544
|
+
if flatten and cjk_values:
|
|
545
|
+
flatten_widgets(args.out)
|
|
546
|
+
|
|
345
547
|
checks = verify(args.out, expected, flatten=args.flatten)
|
|
346
548
|
rendered = render(args.out, args.render_dir, args.dpi)
|
|
347
549
|
|
|
@@ -351,6 +553,7 @@ def write_fill(args, values):
|
|
|
351
553
|
"output": str(args.out),
|
|
352
554
|
"flatten": args.flatten,
|
|
353
555
|
"reattached_fields": reattached,
|
|
556
|
+
"cjk_appearance_fields": cjk_rendered,
|
|
354
557
|
"written": expected,
|
|
355
558
|
"checks": checks,
|
|
356
559
|
"rendered_pages": rendered,
|
|
@@ -517,6 +720,7 @@ def main():
|
|
|
517
720
|
help='JSON object of {field: value}; accepts a file path, inline JSON, or "-" for stdin',
|
|
518
721
|
)
|
|
519
722
|
fill_parser.add_argument("--flatten", action="store_true", help="Paint values and remove every widget")
|
|
723
|
+
fill_parser.add_argument("--font-file", help="TTF/OTF/TTC file used to paint non-Latin values")
|
|
520
724
|
fill_parser.add_argument("--no-reattach", action="store_true", help="Do not repair widgets missing from /AcroForm/Fields")
|
|
521
725
|
fill_parser.add_argument("--allow-ambiguous", action="store_true", help="Fill even when widget and field views disagree")
|
|
522
726
|
fill_parser.add_argument("--render-dir", help="Rasterise the result here for a visual check")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Font handling shared by the PDF scripts.
|
|
2
|
+
|
|
3
|
+
reportlab's base-14 fonts cover Latin-1 only, so a Chinese, Japanese, or Korean
|
|
4
|
+
stamp or form value needs a real font file. These helpers find one, or report
|
|
5
|
+
that the caller must pass --font-file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
CJK_FONT_CANDIDATES = {
|
|
15
|
+
"win32": [
|
|
16
|
+
"C:/Windows/Fonts/msyh.ttc",
|
|
17
|
+
"C:/Windows/Fonts/msyhbd.ttc",
|
|
18
|
+
"C:/Windows/Fonts/simhei.ttf",
|
|
19
|
+
"C:/Windows/Fonts/simsun.ttc",
|
|
20
|
+
],
|
|
21
|
+
"linux": [
|
|
22
|
+
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
|
23
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
24
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
25
|
+
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
|
26
|
+
],
|
|
27
|
+
"darwin": [
|
|
28
|
+
"/System/Library/Fonts/PingFang.ttc",
|
|
29
|
+
"/System/Library/Fonts/STHeiti Medium.ttc",
|
|
30
|
+
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def has_non_latin(text) -> bool:
|
|
36
|
+
"""True when text needs a font beyond the base-14 Latin sets."""
|
|
37
|
+
return isinstance(text, str) and any(ord(character) > 0xFF for character in text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_cjk_font(explicit=None) -> Path | None:
|
|
41
|
+
"""Return a usable CJK font path, honouring an explicit path first."""
|
|
42
|
+
if explicit:
|
|
43
|
+
path = Path(explicit).expanduser()
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
raise FileNotFoundError(f"font file not found: {path}")
|
|
46
|
+
return path
|
|
47
|
+
override = os.environ.get("DSH_OFFICE_CJK_FONT")
|
|
48
|
+
if override and Path(override).expanduser().is_file():
|
|
49
|
+
return Path(override).expanduser()
|
|
50
|
+
platform_key = "win32" if sys.platform.startswith("win") else "darwin" if sys.platform == "darwin" else "linux"
|
|
51
|
+
for candidate in CJK_FONT_CANDIDATES[platform_key]:
|
|
52
|
+
path = Path(candidate)
|
|
53
|
+
if path.is_file():
|
|
54
|
+
return path
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register_cjk_font(path: Path, name: str) -> str:
|
|
59
|
+
"""Register a TTF/OTF/TTC file with reportlab and return the resource name."""
|
|
60
|
+
from reportlab.pdfbase import pdfmetrics
|
|
61
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
62
|
+
|
|
63
|
+
kwargs = {}
|
|
64
|
+
if path.suffix.lower() in {".ttc", ".otc", ".dfont"}:
|
|
65
|
+
kwargs["subfontIndex"] = 0
|
|
66
|
+
pdfmetrics.registerFont(TTFont(name, str(path), **kwargs))
|
|
67
|
+
return name
|
|
@@ -28,6 +28,8 @@ from pypdf import PdfReader, PdfWriter
|
|
|
28
28
|
from reportlab.lib.colors import HexColor
|
|
29
29
|
from reportlab.pdfgen import canvas
|
|
30
30
|
|
|
31
|
+
from pdf_fonts import find_cjk_font, has_non_latin, register_cjk_font
|
|
32
|
+
|
|
31
33
|
try:
|
|
32
34
|
import pymupdf
|
|
33
35
|
except ImportError: # pragma: no cover - PyMuPDF is a required dependency
|
|
@@ -90,7 +92,8 @@ def main():
|
|
|
90
92
|
default="bottom-center",
|
|
91
93
|
help="Anchor of the stamp (default: bottom-center)",
|
|
92
94
|
)
|
|
93
|
-
parser.add_argument("--font", default="Helvetica", help="reportlab
|
|
95
|
+
parser.add_argument("--font", default="Helvetica", help="reportlab font name (default: Helvetica)")
|
|
96
|
+
parser.add_argument("--font-file", help="TTF/OTF/TTC file used for the stamp; required for CJK unless a system font is found")
|
|
94
97
|
parser.add_argument("--font-size", type=float, default=9.0, help="Font size in points (default: 9)")
|
|
95
98
|
parser.add_argument("--color", default="#666666", help="Hex text colour (default: #666666)")
|
|
96
99
|
parser.add_argument("--margin", type=float, default=28.0, help="Page-edge margin in points (default: 28)")
|
|
@@ -127,6 +130,22 @@ def main():
|
|
|
127
130
|
page_count = len(reader.pages)
|
|
128
131
|
selected = parse_pages(args.pages, page_count)
|
|
129
132
|
|
|
133
|
+
sample = args.text.format(page=1, pages=page_count)
|
|
134
|
+
font_name = args.font
|
|
135
|
+
font_file = None
|
|
136
|
+
if args.font_file or has_non_latin(sample):
|
|
137
|
+
try:
|
|
138
|
+
path = find_cjk_font(args.font_file)
|
|
139
|
+
except FileNotFoundError as exc:
|
|
140
|
+
fail(str(exc), code=2)
|
|
141
|
+
if path is None:
|
|
142
|
+
fail("stamp text needs a font beyond the base-14 Latin sets; pass --font-file with a CJK-capable TTF/TTC", code=2)
|
|
143
|
+
try:
|
|
144
|
+
font_name = register_cjk_font(path, "StampCJK")
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
fail(f"cannot use font file {path}: {exc}", code=2)
|
|
147
|
+
font_file = str(path)
|
|
148
|
+
|
|
130
149
|
writer = PdfWriter()
|
|
131
150
|
stamped = []
|
|
132
151
|
for index, page in enumerate(reader.pages, start=1):
|
|
@@ -138,9 +157,9 @@ def main():
|
|
|
138
157
|
buffer = io.BytesIO()
|
|
139
158
|
stamp = canvas.Canvas(buffer, pagesize=(width, height))
|
|
140
159
|
stamp.setFillColor(colour)
|
|
141
|
-
stamp.setFont(
|
|
160
|
+
stamp.setFont(font_name, args.font_size)
|
|
142
161
|
stamp.setFillAlpha(args.opacity)
|
|
143
|
-
text_width = stamp.stringWidth(label,
|
|
162
|
+
text_width = stamp.stringWidth(label, font_name, args.font_size)
|
|
144
163
|
anchor = POSITIONS[args.position]
|
|
145
164
|
if anchor == "left":
|
|
146
165
|
x = args.margin
|
|
@@ -185,7 +204,8 @@ def main():
|
|
|
185
204
|
"pages": page_count,
|
|
186
205
|
"stamped_pages": stamped,
|
|
187
206
|
"position": args.position,
|
|
188
|
-
"font":
|
|
207
|
+
"font": font_name,
|
|
208
|
+
"font_file": font_file,
|
|
189
209
|
"font_size": args.font_size,
|
|
190
210
|
"color": args.color,
|
|
191
211
|
"rendered_pages": rendered,
|
|
@@ -55,6 +55,15 @@ Radio groups fail most: pypdf updates the group `/V` but leaves the old button's
|
|
|
55
55
|
|
|
56
56
|
Flatten when the file must stay uneditable: fill with `flatten=True` (the value is painted into page content), remove every widget annotation (`subtypes="/Widget"`), and drop `/AcroForm` from the catalog. Then confirm no `/Widget` and no `/AcroForm` remain; otherwise readers still treat the file as editable.
|
|
57
57
|
|
|
58
|
+
## Non-Latin values
|
|
59
|
+
|
|
60
|
+
pypdf's appearance writer encodes WinAnsi, so a Chinese, Japanese, or Korean value lands in `/V` but the widget paints `?` or falls back to Helvetica. The script detects non-Latin values, registers a system CJK font (YaHei, WenQuanYi, PingFang, Noto) or the `--font-file` you pass, draws the value with reportlab, and replaces `/AP /N` with that form; the field names it repainted are listed as `cjk_appearance_fields`. `--flatten` paints those streams into the page content before stripping the widgets. Verify `/V` plus the render: the subset font has no ToUnicode map, so a text dump of the appearance is not reliable evidence.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
"<python>" "<skill>/scripts/forms.py" fill --input "<form.pdf>" --out "<filled.pdf>" \
|
|
64
|
+
--values '{"full_name": "张伟"}' --font-file "C:/Windows/Fonts/msyh.ttc" --render-dir "<render>"
|
|
65
|
+
```
|
|
66
|
+
|
|
58
67
|
## What cannot be filled, and the fix
|
|
59
68
|
|
|
60
69
|
- **No appearance stream and a broken `/DA`**: the reader may not show a new value. Render after filling; if invisible, rebuild that widget's appearance with reportlab and merge it over the page.
|
|
@@ -52,7 +52,8 @@ Use each page's own `mediabox` when sizes may mix; a stamp drawn for A4 lands in
|
|
|
52
52
|
- **Existing footers**: check the bottom margin first; the render is the proof, not the coordinate math. If a page number is already there, skip the stamp or use the opposite side.
|
|
53
53
|
- **Rotated pages**: a `/Rotate 90` page renders sideways, so text drawn unrotated appears rotated. Rotate the stamp (`c.rotate(90)`, adjusting the origin), or rotate the page back first.
|
|
54
54
|
- **Repeated stamping**: merging the same overlay twice doubles the opacity at the same position; re-run from the original input when iterating.
|
|
55
|
-
- **Fonts**: Helvetica handles Latin; a CJK
|
|
55
|
+
- **Fonts**: Helvetica handles Latin; for CJK the script registers a system CJK font (YaHei, WenQuanYi, PingFang, Noto) and reports it as `font_file`. Pass `--font-file` with a specific TTF/OTF/TTC when the host has none; without one it exits 2 instead of drawing blank boxes. Check the render.
|
|
56
|
+
- **Font files**: a `.ttc` collection needs its first face; the script handles that. Keep `--font` at its default when passing `--font-file` — the file overrides the base-14 name.
|
|
56
57
|
- **Opacity**: `setFillAlpha` works only in viewers that honour transparency groups; a stamp that must show on any viewer is safer at full opacity.
|
|
57
58
|
|
|
58
59
|
## Verify
|
|
@@ -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,130 @@ 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
|
+
|
|
317
|
+
def docx_used_fonts(path: Path) -> set[str]:
|
|
318
|
+
"""Font names the document actually applies.
|
|
319
|
+
|
|
320
|
+
The kit reports every font in the package, including latent styles inherited
|
|
321
|
+
from python-docx's template (e.g. Courier in the unused MacroText style), so
|
|
322
|
+
a document with no Courier text still warns about it. Keep only fonts that
|
|
323
|
+
appear on a run, in a referenced style, in the defaults, or in the theme.
|
|
324
|
+
"""
|
|
325
|
+
import zipfile
|
|
326
|
+
|
|
327
|
+
from lxml import etree
|
|
328
|
+
|
|
329
|
+
w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
330
|
+
fonts: set[str] = set()
|
|
331
|
+
styles: dict[str, tuple[set[str], str | None]] = {}
|
|
332
|
+
referenced: set[str] = set()
|
|
333
|
+
theme_fonts: set[str] = set()
|
|
334
|
+
|
|
335
|
+
def rfonts(element) -> set[str]:
|
|
336
|
+
found = set()
|
|
337
|
+
for node in element.iter(f"{w}rFonts"):
|
|
338
|
+
for attribute in ("ascii", "hAnsi", "eastAsia", "cs"):
|
|
339
|
+
value = node.get(f"{w}{attribute}")
|
|
340
|
+
if value:
|
|
341
|
+
found.add(value)
|
|
342
|
+
return found
|
|
343
|
+
|
|
344
|
+
with zipfile.ZipFile(path) as archive:
|
|
345
|
+
names = set(archive.namelist())
|
|
346
|
+
|
|
347
|
+
def load(name: str):
|
|
348
|
+
return etree.fromstring(archive.read(name))
|
|
349
|
+
|
|
350
|
+
if "word/styles.xml" in names:
|
|
351
|
+
root = load("word/styles.xml")
|
|
352
|
+
defaults = root.find(f"{w}docDefaults")
|
|
353
|
+
if defaults is not None:
|
|
354
|
+
fonts.update(rfonts(defaults))
|
|
355
|
+
for style in root.iter(f"{w}style"):
|
|
356
|
+
style_id = style.get(f"{w}styleId")
|
|
357
|
+
based_on = style.find(f"{w}basedOn")
|
|
358
|
+
styles[style_id] = (rfonts(style), based_on.get(f"{w}val") if based_on is not None else None)
|
|
359
|
+
|
|
360
|
+
for name in names:
|
|
361
|
+
if not name.startswith("word/") or not name.endswith(".xml"):
|
|
362
|
+
continue
|
|
363
|
+
if name.startswith(("word/styles", "word/theme/")):
|
|
364
|
+
continue
|
|
365
|
+
root = load(name)
|
|
366
|
+
fonts.update(rfonts(root))
|
|
367
|
+
for tag in ("pStyle", "rStyle", "tblStyle"):
|
|
368
|
+
for element in root.iter(f"{w}{tag}"):
|
|
369
|
+
value = element.get(f"{w}val")
|
|
370
|
+
if value:
|
|
371
|
+
referenced.add(value)
|
|
372
|
+
|
|
373
|
+
if "word/theme/theme1.xml" in names:
|
|
374
|
+
theme = load("word/theme/theme1.xml")
|
|
375
|
+
for tag in ("majorFont", "minorFont"):
|
|
376
|
+
for element in theme.iter(tag):
|
|
377
|
+
latin = element.find("latin")
|
|
378
|
+
if latin is not None and latin.get("typeface"):
|
|
379
|
+
theme_fonts.add(latin.get("typeface"))
|
|
380
|
+
|
|
381
|
+
pending = list(referenced)
|
|
382
|
+
seen = set()
|
|
383
|
+
while pending:
|
|
384
|
+
style_id = pending.pop()
|
|
385
|
+
if style_id in seen:
|
|
386
|
+
continue
|
|
387
|
+
seen.add(style_id)
|
|
388
|
+
entry = styles.get(style_id)
|
|
389
|
+
if entry is None:
|
|
390
|
+
continue
|
|
391
|
+
style_fonts, based_on = entry
|
|
392
|
+
fonts.update(style_fonts)
|
|
393
|
+
if based_on:
|
|
394
|
+
pending.append(based_on)
|
|
395
|
+
return fonts | theme_fonts
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def filter_missing_fonts(input_path: Path, kind: str, missing: list[str]) -> list[str]:
|
|
399
|
+
if kind != "docx" or not missing:
|
|
400
|
+
return missing
|
|
401
|
+
used = {name.lower() for name in docx_used_fonts(input_path)}
|
|
402
|
+
return [name for name in missing if name.lower() in used]
|
|
403
|
+
|
|
404
|
+
|
|
285
405
|
def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
|
|
286
406
|
"""Rasterise an Office file page by page through the kit.
|
|
287
407
|
|
|
@@ -314,7 +434,7 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
|
|
|
314
434
|
shutil.rmtree(staging, ignore_errors=True)
|
|
315
435
|
if not images:
|
|
316
436
|
fail(f"the LibreOffice kit rendered no pages for {input_path.name}")
|
|
317
|
-
missing = [str(name) for name in report.get("missingFonts") or []]
|
|
437
|
+
missing = filter_missing_fonts(input_path, kind, [str(name) for name in report.get("missingFonts") or []])
|
|
318
438
|
warnings = glyph_warnings(missing)
|
|
319
439
|
report_warnings(warnings)
|
|
320
440
|
result: dict[str, Any] = {
|
|
@@ -333,10 +453,11 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
|
|
|
333
453
|
if emit_pdf:
|
|
334
454
|
converted = export_pdf(input_path, out_dir, dpi)
|
|
335
455
|
result["pdf"] = converted["pdf"]
|
|
336
|
-
|
|
337
|
-
|
|
456
|
+
converted_missing = filter_missing_fonts(input_path, kind, converted["missing_fonts"])
|
|
457
|
+
if not missing and converted_missing:
|
|
458
|
+
result["missing_fonts"] = converted_missing
|
|
338
459
|
result["glyph_fallback"] = True
|
|
339
|
-
result["warnings"] = glyph_warnings(
|
|
460
|
+
result["warnings"] = glyph_warnings(converted_missing)
|
|
340
461
|
report_warnings(result["warnings"])
|
|
341
462
|
return result
|
|
342
463
|
|
|
@@ -769,8 +890,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
|
|
|
769
890
|
|
|
770
891
|
styles = getSampleStyleSheet()
|
|
771
892
|
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)
|
|
893
|
+
heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24, spaceBefore=16, spaceAfter=8)
|
|
894
|
+
heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20, spaceBefore=12, spaceAfter=6)
|
|
774
895
|
title = ParagraphStyle("OfficeTitle", parent=styles["Title"], fontName=font, fontSize=22, leading=28)
|
|
775
896
|
flowables = []
|
|
776
897
|
for block in blocks:
|
|
@@ -788,8 +909,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
|
|
|
788
909
|
flowables.append(Paragraph(escape(str(block.get("text", ""))), body))
|
|
789
910
|
counters["paragraphs"] += 1
|
|
790
911
|
elif block_type in {"bullets", "numbered"}:
|
|
791
|
-
for item in block.get("items", []):
|
|
792
|
-
prefix = "• " if block_type == "bullets" else "
|
|
912
|
+
for index, item in enumerate(block.get("items", []), start=1):
|
|
913
|
+
prefix = "• " if block_type == "bullets" else f"{index}. "
|
|
793
914
|
flowables.append(Paragraph(prefix + escape(str(item)), body))
|
|
794
915
|
counters["list_items"] += 1
|
|
795
916
|
elif block_type == "table":
|
|
@@ -1427,6 +1548,8 @@ def main() -> None:
|
|
|
1427
1548
|
out_dir = Path(args.out_dir)
|
|
1428
1549
|
if args.format == "pdf":
|
|
1429
1550
|
result = pdf_render(input_path, out_dir, args.dpi)
|
|
1551
|
+
elif args.format == "xlsx":
|
|
1552
|
+
result = xlsx_render(input_path, out_dir, args.dpi)
|
|
1430
1553
|
else:
|
|
1431
1554
|
result = render_office(input_path, args.format, out_dir, args.dpi, emit_pdf=bool(getattr(args, "pdf", False)))
|
|
1432
1555
|
elif args.command == "recalc":
|