dsh-office-kit 0.2.0 → 0.3.1

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.
Files changed (197) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/README.md +3 -1
  3. package/assets/skills/excel-live-control/SKILL.md +234 -0
  4. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  5. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  6. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  7. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  8. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  9. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  10. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  11. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  12. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  13. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  14. package/assets/skills/office-documents/SKILL.md +90 -77
  15. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  16. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  17. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  18. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  19. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  20. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  21. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  22. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  23. package/assets/skills/office-documents/render_docx.py +99 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +143 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +199 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +131 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +66 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +87 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +133 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +161 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +98 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +99 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +118 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -0
  64. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  65. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  66. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  67. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  68. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  69. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  70. package/assets/skills/office-documents/tasks/equations.md +59 -0
  71. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  72. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  73. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  74. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  75. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  76. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  77. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  78. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  79. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  80. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  81. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  82. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  83. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  84. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  85. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  86. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  87. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  88. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  89. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  90. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  91. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  92. package/assets/skills/office-pdf/SKILL.md +73 -56
  93. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  94. package/assets/skills/office-pdf/references/geometry.md +44 -0
  95. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  96. package/assets/skills/office-pdf/references/limits.md +58 -0
  97. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  98. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  99. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  100. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  101. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  102. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  103. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  104. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  105. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  106. package/assets/skills/office-pdf/tasks/create.md +75 -0
  107. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  108. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  109. package/assets/skills/office-pdf/tasks/read.md +62 -0
  110. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  111. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  112. package/assets/skills/office-presentations/SKILL.md +141 -87
  113. package/assets/skills/office-presentations/container_tools/add_slides.py +143 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -0
  126. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  127. package/assets/skills/office-presentations/recipes/README.md +71 -0
  128. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  129. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  130. package/assets/skills/office-presentations/recipes/images.md +80 -0
  131. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  132. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  133. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  134. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  135. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  136. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  137. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  138. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  139. package/assets/skills/office-presentations/references/finalization.md +63 -0
  140. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  141. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  142. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  143. package/assets/skills/office-presentations/references/templates.md +76 -0
  144. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  145. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  146. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  147. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  148. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  149. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  150. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  151. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +183 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -0
  166. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  167. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  168. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  169. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  170. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  171. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  172. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  173. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  174. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  175. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  176. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  177. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  178. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  179. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  180. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  181. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  182. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  183. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  184. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  185. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  186. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  187. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  188. package/assets/skills/shared/annotation-recipes.md +252 -0
  189. package/assets/skills/shared/check_runtime.py +260 -0
  190. package/assets/skills/shared/common-workflow.md +139 -0
  191. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  192. package/dist/client.js +4 -4
  193. package/dist/client.js.map +1 -1
  194. package/dist/index.js +246 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +213 -254
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python
2
+ """Remove metadata that identifies the author or the machine that edited a file.
3
+
4
+ Word leaves a trail: core properties, the company and manager fields, custom
5
+ properties, revision-save identifiers (``w:rsid*``) and spell-check markers. None
6
+ of it is visible in the document, all of it travels with the file.
7
+
8
+ Exit codes: 0 success, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from lxml import etree
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import docx_kit as kit
21
+
22
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+ DC = "http://purl.org/dc/elements/1.1/"
30
+ CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
31
+ DCTERMS = "http://purl.org/dc/terms/"
32
+ EP = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
33
+ REL_CUSTOM = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
34
+
35
+ CORE_CLEAR = ["{%s}creator" % DC, "{%s}lastModifiedBy" % CP, "{%s}keywords" % CP, "{%s}description" % DC, "{%s}subject" % DC, "{%s}title" % DC, "{%s}category" % CP]
36
+ APP_CLEAR = ["{%s}Company" % EP, "{%s}Manager" % EP, "{%s}Application" % EP]
37
+
38
+
39
+ def clear_core(doc: kit.Docx) -> list[str]:
40
+ cleared = []
41
+ if not doc.exists("docProps/core.xml"):
42
+ return cleared
43
+ root = doc.xml("docProps/core.xml")
44
+ for tag in CORE_CLEAR:
45
+ for node in root.findall(tag):
46
+ node.text = ""
47
+ cleared.append(tag.rsplit("}", 1)[-1])
48
+ for node in root.findall("{%s}revision" % CP):
49
+ node.text = "1"
50
+ return cleared
51
+
52
+
53
+ def clear_app(doc: kit.Docx) -> list[str]:
54
+ cleared = []
55
+ if not doc.exists("docProps/app.xml"):
56
+ return cleared
57
+ root = doc.xml("docProps/app.xml")
58
+ for tag in APP_CLEAR:
59
+ for node in root.findall(tag):
60
+ node.text = ""
61
+ cleared.append(tag.rsplit("}", 1)[-1])
62
+ return cleared
63
+
64
+
65
+ def drop_custom(doc: kit.Docx) -> bool:
66
+ dropped = False
67
+ if doc.exists("docProps/custom.xml"):
68
+ doc.drop("docProps/custom.xml")
69
+ doc.drop_override("docProps/custom.xml")
70
+ dropped = True
71
+ ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
72
+ for name in [part for part in doc.parts if part.endswith(".rels")]:
73
+ root = doc.xml(name)
74
+ changed = False
75
+ for node in list(root.findall(ns + "Relationship")):
76
+ if node.get("Type") == REL_CUSTOM:
77
+ root.remove(node)
78
+ changed = True
79
+ dropped = True
80
+ if changed:
81
+ doc.set_xml(name, root)
82
+ return dropped
83
+
84
+
85
+ def scrub_document(doc: kit.Docx) -> dict:
86
+ stats = {"rsid_attributes": 0, "proof_error_markers": 0, "render_breaks": 0}
87
+ for name in [part for part in doc.parts if part.endswith(".xml") and (part.startswith("word/") or part == "word/document.xml")]:
88
+ root = doc.xml(name)
89
+ changed = False
90
+ for node in root.iter():
91
+ for key in list(node.attrib):
92
+ if key.startswith(kit.q("rsid")) or key.startswith("{%s}rsid" % kit.W):
93
+ del node.attrib[key]
94
+ stats["rsid_attributes"] += 1
95
+ changed = True
96
+ for tag, key in ((kit.q("proofErr"), "proof_error_markers"), (kit.q("lastRenderedPageBreak"), "render_breaks")):
97
+ for node in list(root.iter(tag)):
98
+ node.getparent().remove(node)
99
+ stats[key] += 1
100
+ changed = True
101
+ if changed:
102
+ doc.set_xml(name, root)
103
+ return stats
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ parser = argparse.ArgumentParser(description="Strip author, company, custom properties and revision identifiers.")
108
+ parser.add_argument("input_docx", help="Input .docx")
109
+ parser.add_argument("--out", required=True, help="Output .docx")
110
+ parser.add_argument("--keep_title", action="store_true", help="Keep dc:title and dc:subject")
111
+ args = parser.parse_args(argv)
112
+
113
+ try:
114
+ doc = kit.Docx(args.input_docx)
115
+ except (OSError, KeyError) as exc:
116
+ print("error: %s" % exc, file=sys.stderr)
117
+ return 2
118
+
119
+ cleared = clear_core(doc) + clear_app(doc)
120
+ if args.keep_title:
121
+ cleared = [name for name in cleared if name not in ("title", "subject")]
122
+ root = doc.xml("docProps/core.xml")
123
+ original = kit.Docx(args.input_docx).xml("docProps/core.xml")
124
+ for tag in ("{%s}title" % DC, "{%s}subject" % DC):
125
+ source = original.find(tag)
126
+ target = root.find(tag)
127
+ if source is not None and target is not None:
128
+ target.text = source.text
129
+
130
+ custom_dropped = drop_custom(doc)
131
+ stats = scrub_document(doc)
132
+ doc.save(args.out)
133
+ kit.emit({"ok": True, "output": str(Path(args.out)), "cleared": cleared, "custom_properties_dropped": custom_dropped, **stats})
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main())
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python
2
+ """Mask emails, phone numbers and custom patterns in a document's text.
3
+
4
+ The replacement happens inside individual text runs, so a match split across
5
+ formatted runs is not found; that trade keeps the surrounding formatting (bold,
6
+ italics, links) untouched. Length-preserving masking keeps a redacted table
7
+ aligned, which is usually why the number of characters matters.
8
+
9
+ Exit codes: 0 success, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import docx_kit as kit
21
+
22
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+ EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
30
+ PHONE = re.compile(r"(?<!\d)(?:\+\d{1,3}[\s.\-]?)?(?:\(\d{2,4}\)[\s.\-]?)?\d{2,4}[\s.\-]\d{3,4}(?:[\s.\-]\d{3,4})?(?!\d)")
31
+
32
+
33
+ def redact_text(text: str, patterns: list[re.Pattern], mask: str, preserve: bool) -> tuple[str, int]:
34
+ total = 0
35
+ for pattern in patterns:
36
+ def replace(match: re.Match) -> str:
37
+ nonlocal total
38
+ total += 1
39
+ return (mask * len(match.group(0))) if preserve else mask
40
+
41
+ text = pattern.sub(replace, text)
42
+ return text, total
43
+
44
+
45
+ def collect_parts(doc: kit.Docx, include_comments: bool) -> list[str]:
46
+ parts = ["word/document.xml"] + doc.names("word/header") + doc.names("word/footer")
47
+ for note in ("word/footnotes.xml", "word/endnotes.xml"):
48
+ if doc.exists(note):
49
+ parts.append(note)
50
+ if include_comments and doc.exists("word/comments.xml"):
51
+ parts.append("word/comments.xml")
52
+ return parts
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ parser = argparse.ArgumentParser(description="Mask emails, phone numbers or custom regex matches in text runs.")
57
+ parser.add_argument("input_docx", help="Input .docx")
58
+ parser.add_argument("output_docx", help="Output .docx")
59
+ parser.add_argument("--emails", action="store_true", help="Redact email addresses (default when no pattern is given)")
60
+ parser.add_argument("--phones", action="store_true", help="Redact phone numbers (default when no pattern is given)")
61
+ parser.add_argument("--pattern", action="append", default=[], help="Extra regex to redact (repeatable)")
62
+ parser.add_argument("--mask_char", default="*", help="Masking character")
63
+ parser.add_argument("--replacement", default="[redacted]", help="Replacement text when length is not preserved")
64
+ parser.add_argument("--no_preserve_length", action="store_true", help="Use --replacement instead of a per-character mask")
65
+ parser.add_argument("--include_comments", action="store_true", help="Also redact comment text")
66
+ args = parser.parse_args(argv)
67
+
68
+ patterns: list[re.Pattern] = []
69
+ if args.emails or not (args.emails or args.phones or args.pattern):
70
+ patterns.append(EMAIL)
71
+ if args.phones or not (args.emails or args.phones or args.pattern):
72
+ patterns.append(PHONE)
73
+ for raw in args.pattern:
74
+ try:
75
+ patterns.append(re.compile(raw))
76
+ except re.error as exc:
77
+ print("error: bad --pattern %r: %s" % (raw, exc), file=sys.stderr)
78
+ return 2
79
+
80
+ try:
81
+ doc = kit.Docx(args.input_docx)
82
+ except (OSError, KeyError) as exc:
83
+ print("error: %s" % exc, file=sys.stderr)
84
+ return 2
85
+
86
+ mask = args.replacement if args.no_preserve_length else (args.mask_char or "*")[:1]
87
+ preserve = not args.no_preserve_length
88
+ matches = 0
89
+ touched_parts = []
90
+ for part in collect_parts(doc, args.include_comments):
91
+ if not doc.exists(part):
92
+ continue
93
+ root = doc.xml(part)
94
+ part_matches = 0
95
+ for node in root.iter(kit.q("t")):
96
+ if not node.text:
97
+ continue
98
+ updated, count = redact_text(node.text, patterns, mask, preserve)
99
+ if count:
100
+ node.text = updated
101
+ part_matches += count
102
+ for node in root.iter(kit.q("delText")):
103
+ if not node.text:
104
+ continue
105
+ updated, count = redact_text(node.text, patterns, mask, preserve)
106
+ if count:
107
+ node.text = updated
108
+ part_matches += count
109
+ if part_matches:
110
+ doc.set_xml(part, root)
111
+ matches += part_matches
112
+ touched_parts.append({"part": part, "matches": part_matches})
113
+
114
+ doc.save(args.output_docx)
115
+ kit.emit({"ok": True, "output": str(Path(args.output_docx)), "matches": matches, "patterns": len(patterns), "parts": touched_parts})
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python
2
+ """Render two documents and report which pages changed, pixel by pixel.
3
+
4
+ This is how you prove an edit did what you meant without opening Word: render
5
+ both versions, diff the page images, then look at the pages that moved. The diff
6
+ image marks changed pixels in colour so a one-line change is obvious.
7
+
8
+ Exit codes: 0 compared, 1 render failed, 3 no renderer installed, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ import docx_kit as kit
19
+
20
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
21
+ for _stream in (sys.stdout, sys.stderr):
22
+ try:
23
+ _stream.reconfigure(encoding="utf-8", errors="replace")
24
+ except (AttributeError, ValueError):
25
+ pass
26
+
27
+ THRESHOLD = 8
28
+
29
+
30
+ def render(path: Path, out_dir: Path, dpi: int) -> tuple[int, list[str], str]:
31
+ code, stdout, stderr = kit.run_office_cli(["docx", "render", "--input", str(path), "--out-dir", str(out_dir), "--dpi", str(dpi)])
32
+ payload = kit.cli_json(stdout) or kit.cli_json(stderr) or {}
33
+ detail = payload.get("error") or payload.get("reason") or ""
34
+ if not detail:
35
+ lines = [line.strip() for line in (stderr or stdout).splitlines() if line.strip() not in ("", "{", "}")]
36
+ detail = lines[-1] if lines else "render failed"
37
+ return code, payload.get("pages") or [], detail
38
+
39
+
40
+ def diff_pages(first: Path, second: Path, out_path: Path) -> dict:
41
+ from PIL import Image, ImageChops, ImageOps
42
+
43
+ with Image.open(first) as a, Image.open(second) as b:
44
+ left = a.convert("RGB")
45
+ right = b.convert("RGB")
46
+ if left.size != right.size:
47
+ right = right.resize(left.size)
48
+ difference = ImageChops.difference(left, right)
49
+ grey = difference.convert("L").point(lambda value: 255 if value > THRESHOLD else 0)
50
+ changed = sum(1 for value in grey.getdata() if value)
51
+ box = difference.getbbox()
52
+ ImageOps.autocontrast(difference).save(out_path)
53
+ return {
54
+ "changed_pixels": changed,
55
+ "total_pixels": left.width * left.height,
56
+ "ratio": round(changed / float(left.width * left.height), 6),
57
+ "bbox": list(box) if box else None,
58
+ "size": list(left.size),
59
+ "diff_image": str(out_path),
60
+ }
61
+
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ parser = argparse.ArgumentParser(description="Render two .docx files and report per-page pixel differences.")
65
+ parser.add_argument("first", help="Original .docx")
66
+ parser.add_argument("second", help="Changed .docx")
67
+ parser.add_argument("--outdir", required=True, help="Directory for rendered pages and diff images")
68
+ parser.add_argument("--dpi", type=int, default=120)
69
+ parser.add_argument("--threshold", type=int, default=THRESHOLD, help="Per-pixel channel difference that counts as a change")
70
+ args = parser.parse_args(argv)
71
+
72
+ root = Path(args.outdir).expanduser().resolve()
73
+ first = Path(args.first).expanduser().resolve()
74
+ second = Path(args.second).expanduser().resolve()
75
+ for path in (first, second):
76
+ if not path.is_file():
77
+ print("error: %s not found" % path, file=sys.stderr)
78
+ return 2
79
+
80
+ code_a, pages_a, detail_a = render(first, root / "a", args.dpi)
81
+ code_b, pages_b, detail_b = render(second, root / "b", args.dpi)
82
+ if code_a != 0 or code_b != 0 or not pages_a or not pages_b:
83
+ detail = detail_a or detail_b or "render failed"
84
+ reason = "no_renderer" if ("renderer" in detail.lower() or "libreoffice" in detail.lower()) else "render_failed"
85
+ kit.emit({"ok": False, "reason": reason, "detail": detail, "remedy": "Install Microsoft Office, WPS Office, or LibreOffice, then re-run."})
86
+ return 3 if reason == "no_renderer" else 1
87
+
88
+ pairs = list(zip(pages_a, pages_b))
89
+ results = []
90
+ for index, (left, right) in enumerate(pairs, start=1):
91
+ results.append(diff_pages(Path(left), Path(right), root / ("diff-%02d.png" % index)))
92
+
93
+ changed_pages = [index for index, item in enumerate(results, start=1) if item["changed_pixels"]]
94
+ kit.emit(
95
+ {
96
+ "ok": True,
97
+ "pages_first": len(pages_a),
98
+ "pages_second": len(pages_b),
99
+ "changed_pages": changed_pages,
100
+ "page_count_delta": len(pages_b) - len(pages_a),
101
+ "diffs": results,
102
+ }
103
+ )
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(main())
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """Replace text in a Word document without touching the original.
3
+
4
+ Pairs run inside single runs first; when a match straddles runs the paragraph
5
+ text is rewritten into its first run, so ordinary prose survives intact and a
6
+ paragraph that mixes formats collapses. Body, tables, and every section header
7
+ and footer are covered.
8
+
9
+ Usage:
10
+ replace_text.py --input IN.docx [--out OUT.docx] --find OLD --replace NEW [--find ...]
11
+ replace_text.py --input IN.docx [--out OUT.docx] --spec edits.json
12
+
13
+ The spec file is {"replace": [{"find": "18%", "replace": "21%"}]} or a bare list
14
+ of those objects.
15
+
16
+ Exit codes: 0 replaced, 1 nothing replaced (with --require-all) or invalid
17
+ input, 2 argparse usage error.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+
28
+ import docx_kit as kit # noqa: E402
29
+
30
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
31
+ for _stream in (sys.stdout, sys.stderr):
32
+ try:
33
+ _stream.reconfigure(encoding="utf-8", errors="replace")
34
+ except (AttributeError, ValueError):
35
+ pass
36
+
37
+
38
+ def paragraph_text_nodes(paragraph) -> list:
39
+ """Every w:t below a paragraph, including runs inside hyperlinks."""
40
+ return paragraph._p.xpath(".//w:t")
41
+
42
+
43
+ def replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
44
+ nodes = paragraph_text_nodes(paragraph)
45
+ if not nodes:
46
+ return 0
47
+ count = 0
48
+ for node in nodes:
49
+ text = node.text or ""
50
+ if find in text:
51
+ hits = text.count(find)
52
+ node.text = text.replace(find, replacement)
53
+ count += hits
54
+ if count:
55
+ return count
56
+ joined = "".join(node.text or "" for node in nodes)
57
+ if find not in joined:
58
+ return 0
59
+ hits = joined.count(find)
60
+ nodes[0].text = joined.replace(find, replacement)
61
+ for node in nodes[1:]:
62
+ node.text = ""
63
+ return hits
64
+
65
+
66
+ def iter_paragraphs(document):
67
+ def walk_tables(tables):
68
+ for table in tables:
69
+ for row in table.rows:
70
+ for cell in row.cells:
71
+ yield from cell.paragraphs
72
+ yield from walk_tables(cell.tables)
73
+
74
+ yield from document.paragraphs
75
+ yield from walk_tables(document.tables)
76
+ for section in document.sections:
77
+ for part in (section.header, section.footer, section.first_page_header,
78
+ section.first_page_footer, section.even_page_header,
79
+ section.even_page_footer):
80
+ try:
81
+ yield from part.paragraphs
82
+ except Exception: # a header/footer that is not linked or not present
83
+ continue
84
+
85
+
86
+ def fail(payload: dict, code: int = 1) -> int:
87
+ kit.emit({"ok": False, **payload})
88
+ return code
89
+
90
+
91
+ def load_spec(path: str) -> list[tuple[str, str]]:
92
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
93
+ items = payload.get("replace", []) if isinstance(payload, dict) else payload
94
+ pairs: list[tuple[str, str]] = []
95
+ for item in items:
96
+ find = str(item.get("find", ""))
97
+ if find:
98
+ pairs.append((find, str(item.get("replace", ""))))
99
+ return pairs
100
+
101
+
102
+ def main() -> int:
103
+ parser = argparse.ArgumentParser(description="Replace text in a .docx copy.")
104
+ parser.add_argument("--input", required=True, help="source .docx (never modified)")
105
+ parser.add_argument("--out", help="output path; default <name>-edited.docx")
106
+ parser.add_argument("--find", action="append", default=[], help="text to find; pair with --replace")
107
+ parser.add_argument("--replace", action="append", default=[], help="replacement for the matching --find")
108
+ parser.add_argument("--spec", help="JSON file with replace pairs")
109
+ parser.add_argument("--require-all", action="store_true", help="exit 1 when a pair matches nothing")
110
+ args = parser.parse_args()
111
+
112
+ if args.find and len(args.find) != len(args.replace):
113
+ return fail({"error": "--find and --replace must be given in pairs"}, 2)
114
+ pairs = list(zip(args.find, args.replace))
115
+ if args.spec:
116
+ try:
117
+ pairs.extend(load_spec(args.spec))
118
+ except (OSError, ValueError) as exc:
119
+ return fail({"error": f"cannot read spec: {exc}"}, 2)
120
+ if not pairs:
121
+ return fail({"error": "no replacements requested"}, 2)
122
+
123
+ source = Path(args.input).expanduser()
124
+ if not source.is_file():
125
+ return fail({"error": f"input not found: {args.input}"}, 1)
126
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
127
+ if out.resolve() == source.resolve():
128
+ return fail({"error": "refusing to overwrite the input; pass a different --out"}, 1)
129
+
130
+ from docx import Document
131
+
132
+ document = Document(str(source))
133
+ per_pair: list[dict] = []
134
+ total = 0
135
+ for find, replacement in pairs:
136
+ hits = 0
137
+ for paragraph in iter_paragraphs(document):
138
+ hits += replace_in_paragraph(paragraph, find, replacement)
139
+ total += hits
140
+ per_pair.append({"find": find, "replace": replacement, "count": hits})
141
+
142
+ out.parent.mkdir(parents=True, exist_ok=True)
143
+ document.save(str(out))
144
+ misses = [item["find"] for item in per_pair if not item["count"]]
145
+ payload = {
146
+ "ok": not (args.require_all and misses),
147
+ "input": str(source),
148
+ "output": str(out),
149
+ "replacements": total,
150
+ "pairs": per_pair,
151
+ "misses": misses,
152
+ }
153
+ if args.require_all and misses:
154
+ payload["error"] = "patterns matched nothing: " + ", ".join(misses)
155
+ return fail(payload, 1)
156
+ kit.emit(payload)
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python
2
+ """Report section geometry in inches so layout problems are visible as numbers.
3
+
4
+ Every ``sectPr`` is one page-layout decision: page size, orientation (portrait is
5
+ a page size where width < height, not a flag), margins, columns, header/footer
6
+ links, and the section start type. Mixed page sizes in one file are the most
7
+ common cause of "why did page 4 shrink" reports, so a summary flag calls it out.
8
+
9
+ Exit codes: 0 clean, 1 suspicious geometry, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import docx_kit as kit
20
+
21
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
22
+ for _stream in (sys.stdout, sys.stderr):
23
+ try:
24
+ _stream.reconfigure(encoding="utf-8", errors="replace")
25
+ except (AttributeError, ValueError):
26
+ pass
27
+
28
+ TWIPS_PER_INCH = 1440
29
+
30
+
31
+ def inches(value: str | None) -> float | None:
32
+ return round(int(value) / TWIPS_PER_INCH, 3) if value and value.lstrip("-").isdigit() else None
33
+
34
+
35
+ def describe(part: str, sect, index: int, doc: kit.Docx) -> dict:
36
+ page = sect.find(kit.q("pgSz"))
37
+ margins = sect.find(kit.q("pgMar"))
38
+ columns = sect.find(kit.q("cols"))
39
+ kind = sect.find(kit.q("type"))
40
+ info = {
41
+ "index": index,
42
+ "part": part,
43
+ "width_in": inches(page.get(kit.q("w"))) if page is not None else None,
44
+ "height_in": inches(page.get(kit.q("h"))) if page is not None else None,
45
+ "orientation": None,
46
+ "margin_in": {},
47
+ "columns": int(columns.get(kit.q("num"))) if columns is not None and columns.get(kit.q("num")) else 1,
48
+ "start": kind.get(kit.q("val")) if kind is not None else "nextPage",
49
+ "headers": [],
50
+ "footers": [],
51
+ "title_page": sect.find(kit.q("titlePg")) is not None,
52
+ "page_number_start": None,
53
+ }
54
+ if info["width_in"] and info["height_in"]:
55
+ info["orientation"] = "portrait" if info["width_in"] < info["height_in"] else "landscape"
56
+ if margins is not None:
57
+ for side in ("top", "bottom", "left", "right", "header", "footer", "gutter"):
58
+ info["margin_in"][side] = inches(margins.get(kit.q(side)))
59
+ numbering = sect.find(kit.q("pgNumType"))
60
+ if numbering is not None and numbering.get(kit.q("start")):
61
+ info["page_number_start"] = int(numbering.get(kit.q("start")))
62
+ rels = {entry["id"]: entry for entry in doc.relationships(part)}
63
+ for tag, key in (("headerReference", "headers"), ("footerReference", "footers")):
64
+ for ref in sect.findall(kit.q(tag)):
65
+ rid = ref.get(kit.q("id", kit.R))
66
+ info[key].append({"type": ref.get(kit.q("type")), "part": rels[rid]["resolved"] if rid in rels else None})
67
+ return info
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ parser = argparse.ArgumentParser(description="Report page size, orientation, margins and columns per section.")
72
+ parser.add_argument("docx", help="Input .docx")
73
+ args = parser.parse_args(argv)
74
+
75
+ try:
76
+ doc = kit.Docx(args.docx)
77
+ except (OSError, KeyError) as exc:
78
+ print("error: %s" % exc, file=sys.stderr)
79
+ return 2
80
+
81
+ sections = [describe("word/document.xml", sect, index, doc) for index, sect in enumerate(doc.sections(), start=1)]
82
+ findings = []
83
+ sizes = {(item["width_in"], item["height_in"]) for item in sections}
84
+ if len(sizes) > 1:
85
+ findings.append({"kind": "mixed_page_sizes", "sizes": sorted(sizes, key=lambda pair: (pair[0] or 0, pair[1] or 0))})
86
+ for item in sections:
87
+ margins = item["margin_in"]
88
+ if margins.get("left") is not None and margins.get("right") is not None and margins["left"] + margins["right"] >= (item["width_in"] or 8.5) - 1:
89
+ findings.append({"kind": "narrow_text_column", "section": item["index"], "left": margins["left"], "right": margins["right"]})
90
+ if item["width_in"] and item["width_in"] > 8.5 and item["orientation"] == "portrait":
91
+ findings.append({"kind": "portrait_wider_than_letter", "section": item["index"]})
92
+
93
+ kit.emit({"ok": not findings, "sections": sections, "findings": findings})
94
+ return 1 if findings else 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())