dsh-office-kit 0.2.0 → 0.3.0

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 +92 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -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 +136 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -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 +176 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -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 +253 -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 +239 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +171 -246
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python
2
+ """Generate small documents that exercise the awkward parts of the format.
3
+
4
+ Reviewing comments, tracked changes or watermarks is impossible without files
5
+ that contain them, and hand-building those by clicking in Word is not repeatable.
6
+ Each fixture targets one feature so a script's report can be judged against a
7
+ known-clean or known-broken input.
8
+
9
+ Exit codes: 0 success, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ from lxml import etree
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ import docx_kit as kit
23
+
24
+ from docx import Document
25
+ from docx.shared import Inches
26
+
27
+
28
+ def base_document(title: str, paragraphs: list[str]) -> Document:
29
+ document = Document()
30
+ document.add_heading(title, level=1)
31
+ for text in paragraphs:
32
+ document.add_paragraph(text)
33
+ return document
34
+
35
+
36
+ def mark_paragraph(paragraph, before: str, deleted: str, inserted: str, after: str, author: str, date: str) -> None:
37
+ rpr = kit.first_rpr(paragraph)
38
+ kit.clear_paragraph_content(paragraph)
39
+ paragraph.append(kit.make_run(before, etree.fromstring(etree.tostring(rpr)) if rpr is not None else None))
40
+ deletion = etree.SubElement(paragraph, kit.q("del"))
41
+ deletion.set(kit.q("id"), "101")
42
+ deletion.set(kit.q("author"), author)
43
+ deletion.set(kit.q("date"), date)
44
+ deletion.append(kit.make_run(deleted, tag="delText"))
45
+ insertion = etree.SubElement(paragraph, kit.q("ins"))
46
+ insertion.set(kit.q("id"), "102")
47
+ insertion.set(kit.q("author"), author)
48
+ insertion.set(kit.q("date"), date)
49
+ insertion.append(kit.make_run(inserted))
50
+ paragraph.append(kit.make_run(after))
51
+
52
+
53
+ def make_tracked(target: Path) -> dict:
54
+ document = base_document("Revisions demo", ["Regional revenue grew by 12% in the quarter.", "Hardware sales stayed flat."])
55
+ document.save(target)
56
+ doc = kit.Docx(target)
57
+ paragraphs = doc.paragraphs()
58
+ mark_paragraph(paragraphs[1], "Regional revenue grew by ", "12%", "14%", " in the quarter.", "Reviewer", "2024-05-01T09:00:00Z")
59
+ mark_paragraph(paragraphs[2], "Hardware sales ", "stayed", "remained", " flat.", "Reviewer", "2024-05-01T09:01:00Z")
60
+ settings = doc.xml("word/settings.xml")
61
+ if settings.find(kit.q("trackRevisions")) is None:
62
+ settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
63
+ doc.save(target)
64
+ return {"path": str(target), "tracked_changes": 4}
65
+
66
+
67
+ def make_watermark(target: Path) -> dict:
68
+ document = base_document("Watermark demo", ["This copy is for internal review."])
69
+ document.save(target)
70
+ doc = kit.Docx(target)
71
+ part = doc.ensure_header_footer("header")
72
+ doc.xml(part).append(kit.watermark_paragraph("DRAFT", doc.next_id()))
73
+ doc.save(target)
74
+ return {"path": str(target), "watermark": "DRAFT"}
75
+
76
+
77
+ def make_comments(target: Path) -> dict:
78
+ document = base_document("Comments demo", ["The first paragraph carries a question.", "The second paragraph is clean."])
79
+ document.save(target)
80
+ doc = kit.Docx(target)
81
+ root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
82
+ doc.set_xml("word/comments.xml", root)
83
+ doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
84
+ doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
85
+ comment = etree.SubElement(root, kit.q("comment"))
86
+ comment.set(kit.q("id"), "1")
87
+ comment.set(kit.q("author"), "Reviewer")
88
+ comment.set(kit.q("initials"), "RV")
89
+ comment.set(kit.q("date"), "2024-05-02T10:00:00Z")
90
+ body = etree.SubElement(comment, kit.q("p"))
91
+ etree.SubElement(etree.SubElement(body, kit.q("r")), kit.q("t")).text = "Is this number current?"
92
+
93
+ paragraph = doc.paragraphs()[1]
94
+ start = etree.Element(kit.q("commentRangeStart"))
95
+ start.set(kit.q("id"), "1")
96
+ paragraph.insert(1, start)
97
+ end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
98
+ end.set(kit.q("id"), "1")
99
+ run = etree.SubElement(paragraph, kit.q("r"))
100
+ etree.SubElement(run, kit.q("commentReference")).set(kit.q("id"), "1")
101
+ doc.save(target)
102
+ return {"path": str(target), "comments": 1}
103
+
104
+
105
+ def make_edge_cases(target: Path, image_dir: Path) -> dict:
106
+ image_path = image_dir / "sample.png"
107
+ try:
108
+ from PIL import Image
109
+
110
+ Image.new("RGB", (320, 200), "#4472c4").save(image_path)
111
+ except ImportError:
112
+ image_path = None
113
+
114
+ document = Document()
115
+ document.add_heading("Edge cases", level=1)
116
+ document.add_paragraph("")
117
+ document.add_heading("A level three heading after a level one", level=3)
118
+ document.add_paragraph("A very long paragraph " * 40)
119
+ document.add_paragraph("Contact: writer@example.org or +1 555 010 9999.")
120
+ table = document.add_table(rows=2, cols=10)
121
+ for row_index in range(2):
122
+ for column_index in range(10):
123
+ table.cell(row_index, column_index).text = "R%dC%d" % (row_index + 1, column_index + 1)
124
+ if image_path is not None:
125
+ document.add_picture(str(image_path), width=Inches(4))
126
+ document.save(target)
127
+ return {"path": str(target), "fixtures": ["empty_paragraph", "skipped_heading_level", "wide_table", "image_without_alt", "contact_details"]}
128
+
129
+
130
+ BUILDERS = {
131
+ "tracked": make_tracked,
132
+ "watermark": make_watermark,
133
+ "comments": make_comments,
134
+ }
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ parser = argparse.ArgumentParser(description="Create sample .docx files for tracked changes, watermarks, comments and edge cases.")
139
+ parser.add_argument("--outdir", required=True, help="Directory for the generated files")
140
+ parser.add_argument("--only", default="all", help="tracked, watermark, comments, edge, or all")
141
+ args = parser.parse_args(argv)
142
+
143
+ out_dir = Path(args.outdir).expanduser().resolve()
144
+ out_dir.mkdir(parents=True, exist_ok=True)
145
+ wanted = [name.strip() for name in args.only.split(",") if name.strip()]
146
+ if "all" in wanted:
147
+ wanted = ["tracked", "watermark", "comments", "edge"]
148
+ unknown = [name for name in wanted if name not in BUILDERS and name != "edge"]
149
+ if unknown:
150
+ print("error: unknown fixture %s" % ", ".join(unknown), file=sys.stderr)
151
+ return 2
152
+
153
+ results = []
154
+ try:
155
+ for name in wanted:
156
+ if name == "edge":
157
+ results.append(make_edge_cases(out_dir / "edge_cases.docx", out_dir))
158
+ else:
159
+ results.append(BUILDERS[name](out_dir / ("%s.docx" % name)))
160
+ except (OSError, KeyError) as exc:
161
+ print("error: %s" % exc, file=sys.stderr)
162
+ return 2
163
+
164
+ kit.emit({"ok": True, "outdir": str(out_dir), "fixtures": results})
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ raise SystemExit(main())
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python
2
+ """Append one document's body to another, carrying its parts across.
3
+
4
+ Merging by pasting XML is the easy half; the hard half is that the second file's
5
+ relationships point at its own media, numbering and styles. This script copies
6
+ those parts into the base under free names, remaps relationship ids and
7
+ numbering ids, and brings over any style the appended content needs.
8
+
9
+ Drawings are refused by default: a nested drawing can carry its own rel chain and
10
+ silently break a merge, so it has to be asked for explicitly.
11
+
12
+ Exit codes: 0 success, 1 refused (drawings present, incompatible input), 2 bad input.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import posixpath
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from lxml import etree
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ import docx_kit as kit
26
+
27
+ CT = "{http://schemas.openxmlformats.org/package/2006/content-types}"
28
+ W_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
29
+ REL_NUMBERING = kit.REL_NUMBERING
30
+
31
+
32
+ def free_part_name(base: kit.Docx, source: str) -> str:
33
+ folder = posixpath.dirname(source) or "word/media"
34
+ stem, extension = posixpath.splitext(posixpath.basename(source))
35
+ candidate = posixpath.join(folder, stem + extension)
36
+ counter = 1
37
+ while base.exists(candidate):
38
+ candidate = posixpath.join(folder, "%s_copy%d%s" % (stem, counter, extension))
39
+ counter += 1
40
+ return candidate
41
+
42
+
43
+ def copy_part(base: kit.Docx, append: kit.Docx, source: str) -> str:
44
+ target = free_part_name(base, source)
45
+ base.parts[target] = append.parts[source]
46
+ base.order.append(target)
47
+ base._cache.pop(target, None)
48
+ for node in append.content_types().findall(CT + "Override"):
49
+ if node.get("PartName") == "/" + source:
50
+ base.ensure_override(target, node.get("ContentType"))
51
+ break
52
+ return target
53
+
54
+
55
+ def copy_defaults(base: kit.Docx, append: kit.Docx) -> None:
56
+ existing = {(node.get("Extension") or "").lower() for node in base.content_types().findall(CT + "Default")}
57
+ for node in append.content_types().findall(CT + "Default"):
58
+ extension = (node.get("Extension") or "").lower()
59
+ if extension and extension not in existing:
60
+ clone = etree.SubElement(base.content_types(), CT + "Default")
61
+ clone.set("Extension", node.get("Extension"))
62
+ clone.set("ContentType", node.get("ContentType"))
63
+ existing.add(extension)
64
+
65
+
66
+ def ensure_style(base: kit.Docx, append: kit.Docx, wanted: str) -> bool:
67
+ if not append.exists("word/styles.xml") or not wanted:
68
+ return False
69
+ base_styles = base.xml("word/styles.xml")
70
+ present = {node.get(kit.q("styleId")) for node in base_styles.findall(kit.q("style"))}
71
+ if wanted in present:
72
+ return False
73
+ source_styles = {node.get(kit.q("styleId")): node for node in append.xml("word/styles.xml").findall(kit.q("style"))}
74
+ pending = [wanted]
75
+ copied = False
76
+ while pending:
77
+ style_id = pending.pop()
78
+ node = source_styles.get(style_id)
79
+ if node is None or style_id in present:
80
+ continue
81
+ clone = etree.fromstring(etree.tostring(node))
82
+ base_styles.append(clone)
83
+ present.add(style_id)
84
+ copied = True
85
+ for link_tag in ("basedOn", "next", "link"):
86
+ ref = node.find(kit.q(link_tag))
87
+ if ref is not None and ref.get(kit.q("val")):
88
+ pending.append(ref.get(kit.q("val")))
89
+ return copied
90
+
91
+
92
+ def remap_numbering(base: kit.Docx, append: kit.Docx, nodes: list) -> dict[str, str]:
93
+ used = set()
94
+ for node in nodes:
95
+ for num_pr in node.iter(kit.q("numPr")):
96
+ num_id = num_pr.find(kit.q("numId"))
97
+ if num_id is not None and num_id.get(kit.q("val")):
98
+ used.add(num_id.get(kit.q("val")))
99
+ if not used or not append.exists("word/numbering.xml"):
100
+ return {}
101
+ if not base.exists("word/numbering.xml"):
102
+ base.parts["word/numbering.xml"] = append.parts["word/numbering.xml"]
103
+ base.order.append("word/numbering.xml")
104
+ base.ensure_override("word/numbering.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml")
105
+ base.ensure_rel("word/document.xml", kit.REL_NUMBERING, "numbering.xml")
106
+ return {}
107
+
108
+ base_root = base.xml("word/numbering.xml")
109
+ source_root = append.xml("word/numbering.xml")
110
+ base_nums = {node.get(kit.q("numId")): node for node in base_root.findall(kit.q("num"))}
111
+ base_abstract = {node.get(kit.q("abstractNumId")): node for node in base_root.findall(kit.q("abstractNum"))}
112
+ next_num = max([int(value) for value in base_nums if (value or "").isdigit()], default=0) + 1
113
+ next_abstract = max([int(value) for value in base_abstract if (value or "").isdigit()], default=0) + 1
114
+ source_nums = {node.get(kit.q("numId")): node for node in source_root.findall(kit.q("num"))}
115
+ source_abstract = {node.get(kit.q("abstractNumId")): node for node in source_root.findall(kit.q("abstractNum"))}
116
+ mapping = {}
117
+ for old in used:
118
+ node = source_nums.get(old)
119
+ if node is None:
120
+ continue
121
+ clone = etree.fromstring(etree.tostring(node))
122
+ reference = clone.find(kit.q("abstractNumId"))
123
+ abstract_old = reference.get(kit.q("val")) if reference is not None else None
124
+ if abstract_old in source_abstract and abstract_old not in base_abstract:
125
+ abstract_clone = etree.fromstring(etree.tostring(source_abstract[abstract_old]))
126
+ abstract_clone.set(kit.q("abstractNumId"), str(next_abstract))
127
+ base_root.insert(0, abstract_clone)
128
+ base_abstract[str(next_abstract)] = abstract_clone
129
+ next_abstract += 1
130
+ if reference is not None:
131
+ reference.set(kit.q("val"), abstract_clone.get(kit.q("abstractNumId")))
132
+ clone.set(kit.q("numId"), str(next_num))
133
+ mapping[old] = str(next_num)
134
+ base_root.append(clone)
135
+ base_nums[str(next_num)] = clone
136
+ next_num += 1
137
+ return mapping
138
+
139
+
140
+ def remap_relationships(base: kit.Docx, append: kit.Docx, nodes: list) -> int:
141
+ mapping = {}
142
+ copied = 0
143
+ source_rels = {entry["id"]: entry for entry in append.relationships("word/document.xml")}
144
+ attributes = (kit.q("embed", kit.R), kit.q("id", kit.R), kit.q("link", kit.R))
145
+ for node in nodes:
146
+ for element in node.iter():
147
+ for attribute in attributes:
148
+ rid = element.get(attribute)
149
+ if not rid:
150
+ continue
151
+ if rid not in mapping:
152
+ rel = source_rels.get(rid)
153
+ if rel is None:
154
+ mapping[rid] = rid
155
+ continue
156
+ if rel["mode"] == "External":
157
+ mapping[rid] = base.ensure_rel("word/document.xml", rel["type"], rel["target"], external=True)
158
+ elif append.exists(rel["resolved"]):
159
+ target = copy_part(base, append, rel["resolved"])
160
+ relative = posixpath.relpath(target, "word")
161
+ mapping[rid] = base.ensure_rel("word/document.xml", rel["type"], relative)
162
+ copied += 1
163
+ else:
164
+ mapping[rid] = rid
165
+ element.set(attribute, mapping[rid])
166
+ return copied
167
+
168
+
169
+ def main(argv: list[str] | None = None) -> int:
170
+ parser = argparse.ArgumentParser(description="Append one .docx body to another, copying media, numbering and styles.")
171
+ parser.add_argument("base", help="Document to append to")
172
+ parser.add_argument("append", help="Document whose body is copied in")
173
+ parser.add_argument("--out", required=True, help="Output .docx")
174
+ parser.add_argument("--allow_drawings", action="store_true", help="Copy drawings and their relationships")
175
+ args = parser.parse_args(argv)
176
+
177
+ try:
178
+ base = kit.Docx(args.base)
179
+ append = kit.Docx(args.append)
180
+ except (OSError, KeyError) as exc:
181
+ print("error: %s" % exc, file=sys.stderr)
182
+ return 2
183
+
184
+ append_body = append.body()
185
+ incoming = [child for child in append_body if child.tag != kit.q("sectPr")]
186
+ if not incoming:
187
+ print("error: %s has an empty body" % args.append, file=sys.stderr)
188
+ return 2
189
+
190
+ has_drawings = any(child.find(".//" + tag) is not None for child in incoming for tag in (kit.q("drawing"), kit.q("pict")))
191
+ if has_drawings and not args.allow_drawings:
192
+ print("error: appended body contains drawings; re-run with --allow_drawings", file=sys.stderr)
193
+ return 1
194
+
195
+ style_ids = set()
196
+ numbering_nodes = []
197
+ for child in incoming:
198
+ for node in child.iter():
199
+ for tag in ("pStyle", "rStyle", "tblStyle"):
200
+ if node.tag == kit.q(tag) and node.get(kit.q("val")):
201
+ style_ids.add(node.get(kit.q("val")))
202
+ if child.find(".//" + kit.q("numPr")) is not None:
203
+ numbering_nodes.append(child)
204
+
205
+ styles_copied = sum(1 for style_id in sorted(style_ids) if ensure_style(base, append, style_id))
206
+ number_map = remap_numbering(base, append, numbering_nodes)
207
+
208
+ clones = [etree.fromstring(etree.tostring(child)) for child in incoming]
209
+ if number_map:
210
+ for clone in clones:
211
+ for num_pr in clone.iter(kit.q("numPr")):
212
+ num_id = num_pr.find(kit.q("numId"))
213
+ if num_id is not None and num_id.get(kit.q("val")) in number_map:
214
+ num_id.set(kit.q("val"), number_map[num_id.get(kit.q("val"))])
215
+ media_copied = remap_relationships(base, append, clones)
216
+ copy_defaults(base, append)
217
+
218
+ body = base.body()
219
+ sect = body.find(kit.q("sectPr"))
220
+ anchor = body.index(sect) if sect is not None else len(body)
221
+ for offset, clone in enumerate(clones):
222
+ body.insert(anchor + offset, clone)
223
+
224
+ base.save(args.out)
225
+ kit.emit(
226
+ {
227
+ "ok": True,
228
+ "output": str(Path(args.out)),
229
+ "appended_elements": len(clones),
230
+ "styles_copied": styles_copied,
231
+ "media_copied": media_copied,
232
+ "numbering_remapped": len(number_map),
233
+ }
234
+ )
235
+ return 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
@@ -0,0 +1,131 @@
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
+ DC = "http://purl.org/dc/elements/1.1/"
23
+ CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
24
+ DCTERMS = "http://purl.org/dc/terms/"
25
+ EP = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
26
+ REL_CUSTOM = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
27
+
28
+ CORE_CLEAR = ["{%s}creator" % DC, "{%s}lastModifiedBy" % CP, "{%s}keywords" % CP, "{%s}description" % DC, "{%s}subject" % DC, "{%s}title" % DC, "{%s}category" % CP]
29
+ APP_CLEAR = ["{%s}Company" % EP, "{%s}Manager" % EP, "{%s}Application" % EP]
30
+
31
+
32
+ def clear_core(doc: kit.Docx) -> list[str]:
33
+ cleared = []
34
+ if not doc.exists("docProps/core.xml"):
35
+ return cleared
36
+ root = doc.xml("docProps/core.xml")
37
+ for tag in CORE_CLEAR:
38
+ for node in root.findall(tag):
39
+ node.text = ""
40
+ cleared.append(tag.rsplit("}", 1)[-1])
41
+ for node in root.findall("{%s}revision" % CP):
42
+ node.text = "1"
43
+ return cleared
44
+
45
+
46
+ def clear_app(doc: kit.Docx) -> list[str]:
47
+ cleared = []
48
+ if not doc.exists("docProps/app.xml"):
49
+ return cleared
50
+ root = doc.xml("docProps/app.xml")
51
+ for tag in APP_CLEAR:
52
+ for node in root.findall(tag):
53
+ node.text = ""
54
+ cleared.append(tag.rsplit("}", 1)[-1])
55
+ return cleared
56
+
57
+
58
+ def drop_custom(doc: kit.Docx) -> bool:
59
+ dropped = False
60
+ if doc.exists("docProps/custom.xml"):
61
+ doc.drop("docProps/custom.xml")
62
+ doc.drop_override("docProps/custom.xml")
63
+ dropped = True
64
+ ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
65
+ for name in [part for part in doc.parts if part.endswith(".rels")]:
66
+ root = doc.xml(name)
67
+ changed = False
68
+ for node in list(root.findall(ns + "Relationship")):
69
+ if node.get("Type") == REL_CUSTOM:
70
+ root.remove(node)
71
+ changed = True
72
+ dropped = True
73
+ if changed:
74
+ doc.set_xml(name, root)
75
+ return dropped
76
+
77
+
78
+ def scrub_document(doc: kit.Docx) -> dict:
79
+ stats = {"rsid_attributes": 0, "proof_error_markers": 0, "render_breaks": 0}
80
+ for name in [part for part in doc.parts if part.endswith(".xml") and (part.startswith("word/") or part == "word/document.xml")]:
81
+ root = doc.xml(name)
82
+ changed = False
83
+ for node in root.iter():
84
+ for key in list(node.attrib):
85
+ if key.startswith(kit.q("rsid")) or key.startswith("{%s}rsid" % kit.W):
86
+ del node.attrib[key]
87
+ stats["rsid_attributes"] += 1
88
+ changed = True
89
+ for tag, key in ((kit.q("proofErr"), "proof_error_markers"), (kit.q("lastRenderedPageBreak"), "render_breaks")):
90
+ for node in list(root.iter(tag)):
91
+ node.getparent().remove(node)
92
+ stats[key] += 1
93
+ changed = True
94
+ if changed:
95
+ doc.set_xml(name, root)
96
+ return stats
97
+
98
+
99
+ def main(argv: list[str] | None = None) -> int:
100
+ parser = argparse.ArgumentParser(description="Strip author, company, custom properties and revision identifiers.")
101
+ parser.add_argument("input_docx", help="Input .docx")
102
+ parser.add_argument("--out", required=True, help="Output .docx")
103
+ parser.add_argument("--keep_title", action="store_true", help="Keep dc:title and dc:subject")
104
+ args = parser.parse_args(argv)
105
+
106
+ try:
107
+ doc = kit.Docx(args.input_docx)
108
+ except (OSError, KeyError) as exc:
109
+ print("error: %s" % exc, file=sys.stderr)
110
+ return 2
111
+
112
+ cleared = clear_core(doc) + clear_app(doc)
113
+ if args.keep_title:
114
+ cleared = [name for name in cleared if name not in ("title", "subject")]
115
+ root = doc.xml("docProps/core.xml")
116
+ original = kit.Docx(args.input_docx).xml("docProps/core.xml")
117
+ for tag in ("{%s}title" % DC, "{%s}subject" % DC):
118
+ source = original.find(tag)
119
+ target = root.find(tag)
120
+ if source is not None and target is not None:
121
+ target.text = source.text
122
+
123
+ custom_dropped = drop_custom(doc)
124
+ stats = scrub_document(doc)
125
+ doc.save(args.out)
126
+ kit.emit({"ok": True, "output": str(Path(args.out)), "cleared": cleared, "custom_properties_dropped": custom_dropped, **stats})
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ raise SystemExit(main())
@@ -0,0 +1,113 @@
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
+ EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
23
+ 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)")
24
+
25
+
26
+ def redact_text(text: str, patterns: list[re.Pattern], mask: str, preserve: bool) -> tuple[str, int]:
27
+ total = 0
28
+ for pattern in patterns:
29
+ def replace(match: re.Match) -> str:
30
+ nonlocal total
31
+ total += 1
32
+ return (mask * len(match.group(0))) if preserve else mask
33
+
34
+ text = pattern.sub(replace, text)
35
+ return text, total
36
+
37
+
38
+ def collect_parts(doc: kit.Docx, include_comments: bool) -> list[str]:
39
+ parts = ["word/document.xml"] + doc.names("word/header") + doc.names("word/footer")
40
+ for note in ("word/footnotes.xml", "word/endnotes.xml"):
41
+ if doc.exists(note):
42
+ parts.append(note)
43
+ if include_comments and doc.exists("word/comments.xml"):
44
+ parts.append("word/comments.xml")
45
+ return parts
46
+
47
+
48
+ def main(argv: list[str] | None = None) -> int:
49
+ parser = argparse.ArgumentParser(description="Mask emails, phone numbers or custom regex matches in text runs.")
50
+ parser.add_argument("input_docx", help="Input .docx")
51
+ parser.add_argument("output_docx", help="Output .docx")
52
+ parser.add_argument("--emails", action="store_true", help="Redact email addresses (default when no pattern is given)")
53
+ parser.add_argument("--phones", action="store_true", help="Redact phone numbers (default when no pattern is given)")
54
+ parser.add_argument("--pattern", action="append", default=[], help="Extra regex to redact (repeatable)")
55
+ parser.add_argument("--mask_char", default="*", help="Masking character")
56
+ parser.add_argument("--replacement", default="[redacted]", help="Replacement text when length is not preserved")
57
+ parser.add_argument("--no_preserve_length", action="store_true", help="Use --replacement instead of a per-character mask")
58
+ parser.add_argument("--include_comments", action="store_true", help="Also redact comment text")
59
+ args = parser.parse_args(argv)
60
+
61
+ patterns: list[re.Pattern] = []
62
+ if args.emails or not (args.emails or args.phones or args.pattern):
63
+ patterns.append(EMAIL)
64
+ if args.phones or not (args.emails or args.phones or args.pattern):
65
+ patterns.append(PHONE)
66
+ for raw in args.pattern:
67
+ try:
68
+ patterns.append(re.compile(raw))
69
+ except re.error as exc:
70
+ print("error: bad --pattern %r: %s" % (raw, exc), file=sys.stderr)
71
+ return 2
72
+
73
+ try:
74
+ doc = kit.Docx(args.input_docx)
75
+ except (OSError, KeyError) as exc:
76
+ print("error: %s" % exc, file=sys.stderr)
77
+ return 2
78
+
79
+ mask = args.replacement if args.no_preserve_length else (args.mask_char or "*")[:1]
80
+ preserve = not args.no_preserve_length
81
+ matches = 0
82
+ touched_parts = []
83
+ for part in collect_parts(doc, args.include_comments):
84
+ if not doc.exists(part):
85
+ continue
86
+ root = doc.xml(part)
87
+ part_matches = 0
88
+ for node in root.iter(kit.q("t")):
89
+ if not node.text:
90
+ continue
91
+ updated, count = redact_text(node.text, patterns, mask, preserve)
92
+ if count:
93
+ node.text = updated
94
+ part_matches += count
95
+ for node in root.iter(kit.q("delText")):
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
+ if part_matches:
103
+ doc.set_xml(part, root)
104
+ matches += part_matches
105
+ touched_parts.append({"part": part, "matches": part_matches})
106
+
107
+ doc.save(args.output_docx)
108
+ kit.emit({"ok": True, "output": str(Path(args.output_docx)), "matches": matches, "patterns": len(patterns), "parts": touched_parts})
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ raise SystemExit(main())