dsh-office-kit 0.1.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 (198) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/LICENSE.md +21 -21
  3. package/README.md +108 -106
  4. package/assets/skills/excel-live-control/SKILL.md +234 -0
  5. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  6. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  7. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  8. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  9. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  10. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  11. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  12. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  13. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  14. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  15. package/assets/skills/office-documents/SKILL.md +90 -77
  16. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  17. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  18. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  19. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  20. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  21. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  22. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  23. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  24. package/assets/skills/office-documents/render_docx.py +92 -0
  25. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  26. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  27. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  28. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  29. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  30. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  31. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  32. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  33. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  34. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  35. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  36. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  37. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  38. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  39. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  40. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  41. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  42. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  43. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  44. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  45. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  46. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  47. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  48. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  49. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  50. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  51. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  52. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  53. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  54. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  55. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  56. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  57. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  58. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  59. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  60. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  61. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  62. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  63. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  64. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  65. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  66. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  67. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  68. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  69. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  70. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  71. package/assets/skills/office-documents/tasks/equations.md +59 -0
  72. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  73. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  74. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  75. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  76. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  77. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  78. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  79. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  80. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  81. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  82. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  83. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  84. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  85. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  86. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  87. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  88. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  89. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  90. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  91. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  92. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  93. package/assets/skills/office-pdf/SKILL.md +73 -56
  94. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  95. package/assets/skills/office-pdf/references/geometry.md +44 -0
  96. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  97. package/assets/skills/office-pdf/references/limits.md +58 -0
  98. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  99. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  100. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  101. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  102. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  103. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  104. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  105. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  106. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  107. package/assets/skills/office-pdf/tasks/create.md +75 -0
  108. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  109. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  110. package/assets/skills/office-pdf/tasks/read.md +62 -0
  111. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  112. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  113. package/assets/skills/office-presentations/SKILL.md +141 -87
  114. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  115. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  116. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  118. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  119. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  120. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  121. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  122. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  123. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  124. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  125. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  126. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  127. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  128. package/assets/skills/office-presentations/recipes/README.md +71 -0
  129. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  130. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  131. package/assets/skills/office-presentations/recipes/images.md +80 -0
  132. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  133. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  134. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  135. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  136. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  137. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  138. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  139. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  140. package/assets/skills/office-presentations/references/finalization.md +63 -0
  141. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  142. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  143. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  144. package/assets/skills/office-presentations/references/templates.md +76 -0
  145. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  146. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  147. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  148. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  149. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  150. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  151. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  152. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  153. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  154. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  155. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  156. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  157. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  158. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  159. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  160. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  161. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  162. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  163. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  164. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  165. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  166. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  167. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  168. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  169. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  170. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  171. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  172. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  173. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  174. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  175. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  176. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  177. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  178. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  179. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  180. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  181. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  182. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  183. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  184. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  185. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  186. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  187. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  188. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  189. package/assets/skills/shared/annotation-recipes.md +252 -0
  190. package/assets/skills/shared/check_runtime.py +253 -0
  191. package/assets/skills/shared/common-workflow.md +139 -0
  192. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  193. package/dist/client.js +4 -4
  194. package/dist/client.js.map +1 -1
  195. package/dist/index.js +239 -89
  196. package/package.json +146 -149
  197. package/postinstall.mjs +92 -82
  198. package/python/office_cli.py +171 -246
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python
2
+ """Edit existing comments in place from a JSON patch file.
3
+
4
+ The patch is a JSON list (or an object with an ``ops`` list) of operations keyed
5
+ by comment id:
6
+
7
+ [{"id": 1, "append": "Also check 2024."},
8
+ {"id": 2, "replace": "Rewritten note."},
9
+ {"id": 3, "resolved": true}]
10
+
11
+ ``append`` adds a paragraph, ``replace`` rewrites the body, and ``resolved``
12
+ marks the comment done (the threading metadata Word uses for "Resolve").
13
+
14
+ Exit codes: 0 all operations applied, 1 some ids were missing, 2 bad input.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from lxml import etree
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+ import docx_kit as kit
28
+
29
+
30
+ def set_body(comment, text: str) -> None:
31
+ paragraphs = comment.findall(kit.q("p"))
32
+ if not paragraphs:
33
+ paragraph = etree.SubElement(comment, kit.q("p"))
34
+ else:
35
+ paragraph = paragraphs[0]
36
+ for extra in paragraphs[1:]:
37
+ comment.remove(extra)
38
+ ppr = paragraph.find(kit.q("pPr"))
39
+ for child in list(paragraph):
40
+ if child is not ppr:
41
+ paragraph.remove(child)
42
+ run = etree.SubElement(paragraph, kit.q("r"))
43
+ node = etree.SubElement(run, kit.q("t"))
44
+ node.text = text
45
+ node.set(kit.q("space", kit.XML), "preserve")
46
+
47
+
48
+ def append_body(comment, text: str) -> None:
49
+ paragraph = etree.SubElement(comment, kit.q("p"))
50
+ run = etree.SubElement(paragraph, kit.q("r"))
51
+ node = etree.SubElement(run, kit.q("t"))
52
+ node.text = text
53
+ node.set(kit.q("space", kit.XML), "preserve")
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = argparse.ArgumentParser(description="Apply append/replace/resolve operations to existing Word comments.")
58
+ parser.add_argument("in_docx", help="Input .docx")
59
+ parser.add_argument("patch_json", help="JSON patch file")
60
+ parser.add_argument("--out", required=True, help="Output .docx")
61
+ args = parser.parse_args(argv)
62
+
63
+ try:
64
+ patch = json.loads(Path(args.patch_json).read_text(encoding="utf-8"))
65
+ except (OSError, json.JSONDecodeError) as exc:
66
+ print("error: cannot read patch: %s" % exc, file=sys.stderr)
67
+ return 2
68
+ ops = patch.get("ops") if isinstance(patch, dict) else patch
69
+ if not isinstance(ops, list):
70
+ print("error: patch must be a list or an object with an 'ops' list", file=sys.stderr)
71
+ return 2
72
+
73
+ try:
74
+ doc = kit.Docx(args.in_docx)
75
+ except (OSError, KeyError) as exc:
76
+ print("error: %s" % exc, file=sys.stderr)
77
+ return 2
78
+ if not doc.exists("word/comments.xml"):
79
+ print("error: document has no comments", file=sys.stderr)
80
+ return 1
81
+
82
+ by_id = {node.get(kit.q("id")): node for node in doc.xml("word/comments.xml").findall(kit.q("comment"))}
83
+ applied, missing = [], []
84
+ for op in ops:
85
+ if not isinstance(op, dict):
86
+ print("error: each operation must be an object", file=sys.stderr)
87
+ return 2
88
+ ident = str(op.get("id"))
89
+ comment = by_id.get(ident)
90
+ if comment is None:
91
+ missing.append(ident)
92
+ continue
93
+ changed = []
94
+ if "replace" in op:
95
+ set_body(comment, str(op["replace"]))
96
+ changed.append("replace")
97
+ if "append" in op:
98
+ append_body(comment, str(op["append"]))
99
+ changed.append("append")
100
+ if "resolved" in op:
101
+ if op["resolved"]:
102
+ comment.set(kit.q("done", kit.W15), "1")
103
+ else:
104
+ comment.attrib.pop(kit.q("done", kit.W15), None)
105
+ changed.append("resolved")
106
+ applied.append({"id": ident, "changes": changed})
107
+
108
+ doc.save(args.out)
109
+ kit.emit({"ok": not missing, "output": str(Path(args.out)), "applied": applied, "missing_ids": missing})
110
+ return 1 if missing else 0
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python
2
+ """Extract Word comments to JSON, including the text each comment is anchored to.
3
+
4
+ The anchor is recovered by walking the marked-up parts in document order and
5
+ collecting the text between ``commentRangeStart`` and ``commentRangeEnd`` for
6
+ the same id. Comments whose range was lost still appear, with an empty anchor.
7
+
8
+ Exit codes: 0 success (an empty list when the file has no comments), 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
+
21
+ def anchor_map(doc: kit.Docx) -> dict[str, dict]:
22
+ found: dict[str, dict] = {}
23
+ hf = [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
24
+ parts = ["word/document.xml"] + hf + [part for part in doc.text_parts() if part != "word/document.xml"]
25
+ for part in parts:
26
+ active = None
27
+ for node in doc.xml(part).iter():
28
+ if not isinstance(node.tag, str):
29
+ continue
30
+ local = node.tag.split("}", 1)[1] if node.tag.startswith("{%s}" % kit.W) else ""
31
+ if local == "commentRangeStart":
32
+ ident = node.get(kit.q("id")) or ""
33
+ active = ident
34
+ found.setdefault(ident, {"part": part, "anchored_text": ""})
35
+ elif local == "commentRangeEnd":
36
+ if active is not None and (node.get(kit.q("id")) or "") == active:
37
+ active = None
38
+ elif local == "commentReference" and active is None:
39
+ ident = node.get(kit.q("id")) or ""
40
+ found.setdefault(ident, {"part": part, "anchored_text": ""})
41
+ elif active is not None and local in ("t", "delText"):
42
+ found.setdefault(active, {"part": part, "anchored_text": ""})["anchored_text"] += node.text or ""
43
+ return found
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ parser = argparse.ArgumentParser(description="Extract comments (author, date, body, anchored text) from a .docx.")
48
+ parser.add_argument("in_docx", help="Input .docx")
49
+ parser.add_argument("--out", required=True, help="Write the JSON report here")
50
+ args = parser.parse_args(argv)
51
+
52
+ try:
53
+ doc = kit.Docx(args.in_docx)
54
+ except (OSError, KeyError) as exc:
55
+ print("error: %s" % exc, file=sys.stderr)
56
+ return 2
57
+ if not doc.exists("word/comments.xml"):
58
+ kit.emit({"ok": True, "input": str(Path(args.in_docx)), "count": 0, "comments": []}, args.out)
59
+ return 0
60
+
61
+ anchors = anchor_map(doc)
62
+ comments = []
63
+ for node in doc.xml("word/comments.xml").findall(kit.q("comment")):
64
+ ident = node.get(kit.q("id")) or ""
65
+ paragraphs = [kit.text_of(paragraph) for paragraph in node.iter(kit.q("p"))]
66
+ info = anchors.get(ident, {})
67
+ comments.append(
68
+ {
69
+ "id": int(ident) if ident.lstrip("-").isdigit() else ident,
70
+ "author": node.get(kit.q("author")) or "",
71
+ "initials": node.get(kit.q("initials")) or "",
72
+ "date": node.get(kit.q("date")) or "",
73
+ "text": "\n".join(text for text in paragraphs if text),
74
+ "anchored_text": info.get("anchored_text", "").strip(),
75
+ "part": info.get("part"),
76
+ "resolved": node.get(kit.q("done", kit.W15)) in ("1", "true"),
77
+ }
78
+ )
79
+ comments.sort(key=lambda item: item["id"] if isinstance(item["id"], int) else 0)
80
+ kit.emit({"ok": True, "input": str(Path(args.in_docx)), "count": len(comments), "comments": comments}, args.out)
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python
2
+ """Remove every comment from a .docx: markers, bodies, relationships, part.
3
+
4
+ Word needs all four removed together. Leaving a stray ``commentReference``
5
+ behind makes Word report a corrupt package, and leaving comments.xml in the zip
6
+ re-adds metadata you thought you deleted, so each is handled here.
7
+
8
+ Exit codes: 0 success (also when there was nothing to strip), 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
+ MARKERS = ("commentRangeStart", "commentRangeEnd", "commentReference")
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ parser = argparse.ArgumentParser(description="Strip all comments and comment markers from a .docx.")
25
+ parser.add_argument("in_docx", help="Input .docx")
26
+ parser.add_argument("--out", required=True, help="Output .docx")
27
+ args = parser.parse_args(argv)
28
+
29
+ try:
30
+ doc = kit.Docx(args.in_docx)
31
+ except (OSError, KeyError) as exc:
32
+ print("error: %s" % exc, file=sys.stderr)
33
+ return 2
34
+
35
+ hf = [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
36
+ removed_markers = 0
37
+ for part in ["word/document.xml"] + hf + doc.text_parts():
38
+ if not doc.exists(part):
39
+ continue
40
+ root = doc.xml(part)
41
+ for local in MARKERS:
42
+ for node in list(root.iter(kit.q(local))):
43
+ parent = node.getparent()
44
+ if parent is None:
45
+ continue
46
+ if local == "commentReference" and parent.tag == kit.q("r") and len(parent) <= 1:
47
+ grand = parent.getparent()
48
+ if grand is not None:
49
+ grand.remove(parent)
50
+ else:
51
+ parent.remove(node)
52
+ removed_markers += 1
53
+
54
+ had_part = doc.exists("word/comments.xml")
55
+ if had_part:
56
+ doc.drop("word/comments.xml")
57
+ doc.drop_rel_target("word/document.xml", "word/comments.xml")
58
+ doc.drop_override("word/comments.xml")
59
+
60
+ doc.save(args.out)
61
+ kit.emit(
62
+ {
63
+ "ok": True,
64
+ "input": str(Path(args.in_docx)),
65
+ "output": str(Path(args.out)),
66
+ "comments_part_removed": had_part,
67
+ "markers_removed": removed_markers,
68
+ }
69
+ )
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python
2
+ """Read or edit Word content controls (structured document tags / SDT).
3
+
4
+ python-docx has no API for content controls, so this works directly on the
5
+ ``w:sdt`` elements: list them, wrap a paragraph into a typed control, or replace
6
+ the text of controls matched by tag. Tags are the stable key — they survive
7
+ copying between documents better than the numeric ids.
8
+
9
+ Exit codes: 0 success, 1 a tag was not found, 2 bad input or bad subcommand.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from lxml import etree
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+ import docx_kit as kit
22
+
23
+ TYPE_TAGS = ("text", "richText", "date", "dropDownList", "comboBox", "picture", "checkbox")
24
+ CONTAINER_TAGS = ("docPartObj", "group", "citation", "bibliography")
25
+
26
+
27
+ def part_list(doc: kit.Docx, include_headers: bool) -> list[str]:
28
+ parts = ["word/document.xml"]
29
+ if include_headers:
30
+ parts += [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
31
+ return [part for part in parts if doc.exists(part)]
32
+
33
+
34
+ def controls(doc: kit.Docx, part: str):
35
+ return [node for node in doc.xml(part).iter(kit.q("sdt")) if node.find(kit.q("sdtPr")) is not None]
36
+
37
+
38
+ def describe(doc: kit.Docx, part: str, sdt) -> dict:
39
+ props = sdt.find(kit.q("sdtPr"))
40
+ tag = props.find(kit.q("tag")) if props is not None else None
41
+ alias = props.find(kit.q("alias")) if props is not None else None
42
+ ident = props.find(kit.q("id")) if props is not None else None
43
+ kind = "unknown"
44
+ if props is not None:
45
+ for name in TYPE_TAGS:
46
+ if props.find(kit.q(name)) is not None:
47
+ kind = name
48
+ break
49
+ if kind == "unknown" and any(props.find(kit.q(name)) is not None for name in CONTAINER_TAGS):
50
+ kind = "container"
51
+ content = sdt.find(kit.q("sdtContent"))
52
+ text = kit.text_of(content) if content is not None else ""
53
+ return {
54
+ "part": part,
55
+ "tag": tag.get(kit.q("val")) if tag is not None else "",
56
+ "alias": alias.get(kit.q("val")) if alias is not None else "",
57
+ "id": ident.get(kit.q("val")) if ident is not None else "",
58
+ "type": kind,
59
+ "text": text,
60
+ }
61
+
62
+
63
+ def build_control(tag: str, alias: str, text: str, ident: int, content) -> etree._Element:
64
+ sdt = etree.Element(kit.q("sdt"))
65
+ props = etree.SubElement(sdt, kit.q("sdtPr"))
66
+ if tag:
67
+ etree.SubElement(props, kit.q("tag")).set(kit.q("val"), tag)
68
+ if alias:
69
+ etree.SubElement(props, kit.q("alias")).set(kit.q("val"), alias)
70
+ etree.SubElement(props, kit.q("id")).set(kit.q("val"), str(ident))
71
+ etree.SubElement(props, kit.q("text"))
72
+ body = etree.SubElement(sdt, kit.q("sdtContent"))
73
+ if content is not None:
74
+ body.append(content)
75
+ else:
76
+ paragraph = etree.SubElement(body, kit.q("p"))
77
+ run = etree.SubElement(paragraph, kit.q("r"))
78
+ node = etree.SubElement(run, kit.q("t"))
79
+ node.text = text
80
+ return sdt
81
+
82
+
83
+ def replace_text(sdt, value: str) -> bool:
84
+ content = sdt.find(kit.q("sdtContent"))
85
+ if content is None:
86
+ return False
87
+ paragraphs = list(content.iter(kit.q("p")))
88
+ if not paragraphs:
89
+ return False
90
+ paragraph = paragraphs[0]
91
+ for extra in paragraphs[1:]:
92
+ parent = extra.getparent()
93
+ if parent is not None:
94
+ parent.remove(extra)
95
+ ppr = paragraph.find(kit.q("pPr"))
96
+ for child in list(paragraph):
97
+ if child is not ppr:
98
+ paragraph.remove(child)
99
+ run = etree.SubElement(paragraph, kit.q("r"))
100
+ node = etree.SubElement(run, kit.q("t"))
101
+ node.text = value
102
+ node.set(kit.q("space"), kit.XML)
103
+ return True
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ parser = argparse.ArgumentParser(description="List, create, or fill Word content controls (SDTs).")
108
+ sub = parser.add_subparsers(dest="command", required=True)
109
+
110
+ listing = sub.add_parser("list", help="list content controls")
111
+ listing.add_argument("docx")
112
+ listing.add_argument("--json", action="store_true", help="emit JSON (always on; kept for scripts)")
113
+ listing.add_argument("--no_headers_footers", action="store_true")
114
+
115
+ create = sub.add_parser("create", help="wrap a paragraph or add a new control")
116
+ create.add_argument("docx")
117
+ create.add_argument("--tag", default="", help="Stable tag used by later edits")
118
+ create.add_argument("--alias", default="", help="Display name shown in Word")
119
+ create.add_argument("--text", default="", help="Initial value for a new control")
120
+ create.add_argument("--placeholder", default="", help="Wrap the paragraph containing this text instead of adding a new control")
121
+ create.add_argument("--output", required=True)
122
+ create.add_argument("--no_headers_footers", action="store_true")
123
+
124
+ update = sub.add_parser("set", help="fill controls by tag")
125
+ update.add_argument("docx")
126
+ update.add_argument("--set", action="append", default=[], help="TAG=VALUE (repeatable)")
127
+ update.add_argument("--output", required=True)
128
+ update.add_argument("--no_headers_footers", action="store_true")
129
+
130
+ args = parser.parse_args(argv)
131
+
132
+ try:
133
+ doc = kit.Docx(args.docx)
134
+ except (OSError, KeyError) as exc:
135
+ print("error: %s" % exc, file=sys.stderr)
136
+ return 2
137
+
138
+ if args.command == "list":
139
+ rows = []
140
+ for part in part_list(doc, not args.no_headers_footers):
141
+ for sdt in controls(doc, part):
142
+ rows.append(describe(doc, part, sdt))
143
+ kit.emit({"ok": True, "input": str(Path(args.docx)), "count": len(rows), "controls": rows})
144
+ return 0
145
+
146
+ if args.command == "set":
147
+ if not args.set:
148
+ parser.error("at least one --set TAG=VALUE is required")
149
+ wanted = {}
150
+ for raw in args.set:
151
+ if "=" not in raw:
152
+ parser.error("--set expects TAG=VALUE, got %r" % raw)
153
+ tag, value = raw.split("=", 1)
154
+ wanted[tag] = value
155
+ filled, missing = [], []
156
+ for part in part_list(doc, not args.no_headers_footers):
157
+ for sdt in controls(doc, part):
158
+ info = describe(doc, part, sdt)
159
+ if info["tag"] in wanted and replace_text(sdt, wanted[info["tag"]]):
160
+ filled.append({"tag": info["tag"], "value": wanted[info["tag"]], "part": part})
161
+ wanted.pop(info["tag"], None)
162
+ missing = sorted(wanted)
163
+ doc.save(args.output)
164
+ kit.emit({"ok": not missing, "output": str(Path(args.output)), "filled": filled, "missing_tags": missing})
165
+ return 1 if missing else 0
166
+
167
+ ident = doc.next_id()
168
+ if args.placeholder:
169
+ target = None
170
+ for paragraph in doc.paragraphs():
171
+ if args.placeholder in kit.paragraph_text(paragraph):
172
+ target = paragraph
173
+ break
174
+ if target is None:
175
+ print("error: placeholder %r not found" % args.placeholder, file=sys.stderr)
176
+ return 1
177
+ parent = target.getparent()
178
+ index = list(parent).index(target)
179
+ sdt = build_control(args.tag, args.alias, args.text, ident, target)
180
+ parent.insert(index, sdt)
181
+ where = "wrapped"
182
+ else:
183
+ sdt = build_control(args.tag, args.alias, args.text, ident, None)
184
+ doc.body().append(sdt)
185
+ where = "appended"
186
+ doc.save(args.output)
187
+ kit.emit({"ok": True, "output": str(Path(args.output)), "mode": where, "tag": args.tag, "id": ident})
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main())
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """Read or set the core properties of a Word document.
3
+
4
+ Core properties are the metadata a reader sees in File > Info: title, author,
5
+ subject, keywords, comments, category and the last modifier. They live outside
6
+ the body in docProps/core.xml, so changing them never touches the content.
7
+
8
+ Usage:
9
+ core_properties.py --input IN.docx [--out OUT.docx] --title "..." --author "..."
10
+ core_properties.py --input IN.docx [--out OUT.docx] --spec core.json
11
+ core_properties.py --input IN.docx --show
12
+
13
+ The spec file is {"core": {"title": "...", "author": "..."}} or a bare object of
14
+ the same names. With no property arguments the command only reports.
15
+
16
+ Exit codes: 0 success, 1 the document or spec could not be read, 2 argparse
17
+ 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
+ TEXT_FIELDS = (
31
+ "title",
32
+ "author",
33
+ "subject",
34
+ "keywords",
35
+ "comments",
36
+ "category",
37
+ "last_modified_by",
38
+ "content_status",
39
+ "identifier",
40
+ "language",
41
+ "version",
42
+ )
43
+ INT_FIELDS = ("revision",)
44
+
45
+
46
+ def fail(payload: dict, code: int = 1) -> int:
47
+ kit.emit({"ok": False, **payload})
48
+ return code
49
+
50
+
51
+ def snapshot(document) -> dict:
52
+ props = document.core_properties
53
+ data: dict = {}
54
+ for name in TEXT_FIELDS:
55
+ value = getattr(props, name, None)
56
+ data[name] = "" if value is None else str(value)
57
+ for name in INT_FIELDS:
58
+ data[name] = getattr(props, name, None)
59
+ return data
60
+
61
+
62
+ def main() -> int:
63
+ parser = argparse.ArgumentParser(description="Read or set .docx core properties.")
64
+ parser.add_argument("--input", required=True, help="source .docx (never modified)")
65
+ parser.add_argument("--out", help="output path; default <name>-edited.docx")
66
+ parser.add_argument("--spec", help="JSON file with a core properties object")
67
+ parser.add_argument("--show", action="store_true", help="report properties and change nothing")
68
+ for name in TEXT_FIELDS:
69
+ parser.add_argument("--" + name.replace("_", "-"), dest=name, help=f"set {name}")
70
+ parser.add_argument("--revision", type=int, help="set the revision counter")
71
+ args = parser.parse_args()
72
+
73
+ source = Path(args.input).expanduser()
74
+ if not source.is_file():
75
+ return fail({"error": f"input not found: {args.input}"})
76
+
77
+ updates: dict = {}
78
+ if args.spec:
79
+ try:
80
+ payload = json.loads(Path(args.spec).expanduser().read_text(encoding="utf-8"))
81
+ except (OSError, ValueError) as exc:
82
+ return fail({"error": f"cannot read spec: {exc}"}, 2)
83
+ updates.update(payload.get("core", payload) if isinstance(payload, dict) else {})
84
+ for name in TEXT_FIELDS:
85
+ value = getattr(args, name)
86
+ if value is not None:
87
+ updates[name] = value
88
+ if args.revision is not None:
89
+ updates["revision"] = args.revision
90
+
91
+ unknown = [key for key in updates if key not in TEXT_FIELDS and key not in INT_FIELDS]
92
+ if unknown:
93
+ return fail({"error": "unknown properties: " + ", ".join(sorted(unknown)), "known": list(TEXT_FIELDS)}, 2)
94
+
95
+ from docx import Document
96
+
97
+ document = Document(str(source))
98
+ before = snapshot(document)
99
+ if args.show or not updates:
100
+ kit.emit({"ok": True, "input": str(source), "properties": before})
101
+ return 0
102
+
103
+ for key, value in updates.items():
104
+ setattr(document.core_properties, key, value)
105
+ after = snapshot(document)
106
+
107
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
108
+ if out.resolve() == source.resolve():
109
+ return fail({"error": "refusing to overwrite the input; pass a different --out"})
110
+ out.parent.mkdir(parents=True, exist_ok=True)
111
+ document.save(str(out))
112
+ kit.emit({
113
+ "ok": True,
114
+ "input": str(source),
115
+ "output": str(out),
116
+ "changed": sorted(updates),
117
+ "before": {key: before[key] for key in updates},
118
+ "after": {key: after[key] for key in updates},
119
+ })
120
+ return 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ raise SystemExit(main())