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,143 @@
1
+ #!/usr/bin/env python
2
+ """Attach Word comments to the paragraphs that contain given text.
3
+
4
+ Each ``--add "needle=comment body"`` anchors a comment over the first paragraph
5
+ whose text contains ``needle``: range start, range end and the reference run are
6
+ inserted inline, and the comment body is appended to comments.xml. Nothing is
7
+ reworded; the body is exactly the text you pass.
8
+
9
+ Exit codes: 0 all comments placed, 1 a pattern matched nothing (with
10
+ --require_all nothing is written), 2 bad input.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import datetime
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from lxml import etree
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ import docx_kit as kit
24
+
25
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
26
+ for _stream in (sys.stdout, sys.stderr):
27
+ try:
28
+ _stream.reconfigure(encoding="utf-8", errors="replace")
29
+ except (AttributeError, ValueError):
30
+ pass
31
+
32
+
33
+ def comments_root(doc: kit.Docx):
34
+ if not doc.exists("word/comments.xml"):
35
+ root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
36
+ doc.set_xml("word/comments.xml", root)
37
+ doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
38
+ doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
39
+ return doc.xml("word/comments.xml")
40
+
41
+
42
+ def initials(name: str) -> str:
43
+ parts = [piece for piece in name.replace(".", " ").split() if piece]
44
+ return "".join(piece[0] for piece in parts)[:3].upper() or "OR"
45
+
46
+
47
+ def append_comment(root, text: str, author: str, stamp: str, ident: int) -> None:
48
+ comment = etree.SubElement(root, kit.q("comment"))
49
+ comment.set(kit.q("id"), str(ident))
50
+ comment.set(kit.q("author"), author)
51
+ comment.set(kit.q("initials"), initials(author))
52
+ comment.set(kit.q("date"), stamp)
53
+ paragraph = etree.SubElement(comment, kit.q("p"))
54
+ run = etree.SubElement(paragraph, kit.q("r"))
55
+ node = etree.SubElement(run, kit.q("t"))
56
+ node.text = text
57
+ node.set(kit.q("space", kit.XML), "preserve")
58
+
59
+
60
+ def anchor(paragraph, ident: int) -> None:
61
+ start = etree.Element(kit.q("commentRangeStart"))
62
+ start.set(kit.q("id"), str(ident))
63
+ position = 1 if paragraph.find(kit.q("pPr")) is not None else 0
64
+ paragraph.insert(position, start)
65
+ end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
66
+ end.set(kit.q("id"), str(ident))
67
+ run = etree.SubElement(paragraph, kit.q("r"))
68
+ ref = etree.SubElement(run, kit.q("commentReference"))
69
+ ref.set(kit.q("id"), str(ident))
70
+
71
+
72
+ def parse_add(raw: str) -> tuple[str, str]:
73
+ if "=" not in raw:
74
+ raise ValueError("--add expects NEEDLE=COMMENT, got %r" % raw)
75
+ needle, body = raw.split("=", 1)
76
+ if not needle or not body:
77
+ raise ValueError("--add needs a non-empty needle and comment")
78
+ return needle, body
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ parser = argparse.ArgumentParser(description="Add Word comments over paragraphs matched by substring.")
83
+ parser.add_argument("in_docx", help="Input .docx")
84
+ parser.add_argument("--out", required=True, help="Output .docx")
85
+ parser.add_argument("--author", default="dsh-office", help="Comment author")
86
+ parser.add_argument("--add", action="append", default=[], help="NEEDLE=COMMENT (repeatable)")
87
+ parser.add_argument("--ignore_case", action="store_true", help="Match needle case-insensitively")
88
+ parser.add_argument("--require_all", action="store_true", help="Fail without writing if any needle is absent")
89
+ parser.add_argument("--date", default=None, help="ISO timestamp for the comments (default: now, UTC)")
90
+ args = parser.parse_args(argv)
91
+
92
+ try:
93
+ doc = kit.Docx(args.in_docx)
94
+ except (OSError, KeyError) as exc:
95
+ print("error: %s" % exc, file=sys.stderr)
96
+ return 2
97
+ if not args.add:
98
+ parser.error("at least one --add NEEDLE=COMMENT is required")
99
+
100
+ paragraphs = doc.paragraphs()
101
+ requests = []
102
+ for raw in args.add:
103
+ try:
104
+ needle, body = parse_add(raw)
105
+ except ValueError as exc:
106
+ print("error: %s" % exc, file=sys.stderr)
107
+ return 2
108
+ target = None
109
+ for paragraph in paragraphs:
110
+ haystack = kit.paragraph_text(paragraph)
111
+ if (needle.lower() in haystack.lower()) if args.ignore_case else (needle in haystack):
112
+ target = paragraph
113
+ break
114
+ requests.append({"needle": needle, "body": body, "paragraph": target})
115
+
116
+ missing = [item["needle"] for item in requests if item["paragraph"] is None]
117
+ if missing and args.require_all:
118
+ kit.emit({"ok": False, "output": None, "matched": 0, "missing": missing})
119
+ return 1
120
+
121
+ stamp = args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
122
+ root = comments_root(doc)
123
+ ident = doc.next_id()
124
+ placed = []
125
+ for item in requests:
126
+ if item["paragraph"] is None:
127
+ continue
128
+ append_comment(root, item["body"], args.author, stamp, ident)
129
+ anchor(item["paragraph"], ident)
130
+ placed.append({"id": ident, "needle": item["needle"], "body": item["body"]})
131
+ ident += 1
132
+
133
+ if not placed:
134
+ kit.emit({"ok": False, "output": None, "matched": 0, "missing": missing})
135
+ return 1
136
+
137
+ doc.save(args.out)
138
+ kit.emit({"ok": True, "output": str(Path(args.out)), "author": args.author, "placed": placed, "missing": missing})
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())
@@ -0,0 +1,121 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
30
+ for _stream in (sys.stdout, sys.stderr):
31
+ try:
32
+ _stream.reconfigure(encoding="utf-8", errors="replace")
33
+ except (AttributeError, ValueError):
34
+ pass
35
+
36
+
37
+ def set_body(comment, text: str) -> None:
38
+ paragraphs = comment.findall(kit.q("p"))
39
+ if not paragraphs:
40
+ paragraph = etree.SubElement(comment, kit.q("p"))
41
+ else:
42
+ paragraph = paragraphs[0]
43
+ for extra in paragraphs[1:]:
44
+ comment.remove(extra)
45
+ ppr = paragraph.find(kit.q("pPr"))
46
+ for child in list(paragraph):
47
+ if child is not ppr:
48
+ paragraph.remove(child)
49
+ run = etree.SubElement(paragraph, kit.q("r"))
50
+ node = etree.SubElement(run, kit.q("t"))
51
+ node.text = text
52
+ node.set(kit.q("space", kit.XML), "preserve")
53
+
54
+
55
+ def append_body(comment, text: str) -> None:
56
+ paragraph = etree.SubElement(comment, kit.q("p"))
57
+ run = etree.SubElement(paragraph, kit.q("r"))
58
+ node = etree.SubElement(run, kit.q("t"))
59
+ node.text = text
60
+ node.set(kit.q("space", kit.XML), "preserve")
61
+
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ parser = argparse.ArgumentParser(description="Apply append/replace/resolve operations to existing Word comments.")
65
+ parser.add_argument("in_docx", help="Input .docx")
66
+ parser.add_argument("patch_json", help="JSON patch file")
67
+ parser.add_argument("--out", required=True, help="Output .docx")
68
+ args = parser.parse_args(argv)
69
+
70
+ try:
71
+ patch = json.loads(Path(args.patch_json).read_text(encoding="utf-8"))
72
+ except (OSError, json.JSONDecodeError) as exc:
73
+ print("error: cannot read patch: %s" % exc, file=sys.stderr)
74
+ return 2
75
+ ops = patch.get("ops") if isinstance(patch, dict) else patch
76
+ if not isinstance(ops, list):
77
+ print("error: patch must be a list or an object with an 'ops' list", file=sys.stderr)
78
+ return 2
79
+
80
+ try:
81
+ doc = kit.Docx(args.in_docx)
82
+ except (OSError, KeyError) as exc:
83
+ print("error: %s" % exc, file=sys.stderr)
84
+ return 2
85
+ if not doc.exists("word/comments.xml"):
86
+ print("error: document has no comments", file=sys.stderr)
87
+ return 1
88
+
89
+ by_id = {node.get(kit.q("id")): node for node in doc.xml("word/comments.xml").findall(kit.q("comment"))}
90
+ applied, missing = [], []
91
+ for op in ops:
92
+ if not isinstance(op, dict):
93
+ print("error: each operation must be an object", file=sys.stderr)
94
+ return 2
95
+ ident = str(op.get("id"))
96
+ comment = by_id.get(ident)
97
+ if comment is None:
98
+ missing.append(ident)
99
+ continue
100
+ changed = []
101
+ if "replace" in op:
102
+ set_body(comment, str(op["replace"]))
103
+ changed.append("replace")
104
+ if "append" in op:
105
+ append_body(comment, str(op["append"]))
106
+ changed.append("append")
107
+ if "resolved" in op:
108
+ if op["resolved"]:
109
+ comment.set(kit.q("done", kit.W15), "1")
110
+ else:
111
+ comment.attrib.pop(kit.q("done", kit.W15), None)
112
+ changed.append("resolved")
113
+ applied.append({"id": ident, "changes": changed})
114
+
115
+ doc.save(args.out)
116
+ kit.emit({"ok": not missing, "output": str(Path(args.out)), "applied": applied, "missing_ids": missing})
117
+ return 1 if missing else 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
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
+ # 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
+
28
+ def anchor_map(doc: kit.Docx) -> dict[str, dict]:
29
+ found: dict[str, dict] = {}
30
+ hf = [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
31
+ parts = ["word/document.xml"] + hf + [part for part in doc.text_parts() if part != "word/document.xml"]
32
+ for part in parts:
33
+ active = None
34
+ for node in doc.xml(part).iter():
35
+ if not isinstance(node.tag, str):
36
+ continue
37
+ local = node.tag.split("}", 1)[1] if node.tag.startswith("{%s}" % kit.W) else ""
38
+ if local == "commentRangeStart":
39
+ ident = node.get(kit.q("id")) or ""
40
+ active = ident
41
+ found.setdefault(ident, {"part": part, "anchored_text": ""})
42
+ elif local == "commentRangeEnd":
43
+ if active is not None and (node.get(kit.q("id")) or "") == active:
44
+ active = None
45
+ elif local == "commentReference" and active is None:
46
+ ident = node.get(kit.q("id")) or ""
47
+ found.setdefault(ident, {"part": part, "anchored_text": ""})
48
+ elif active is not None and local in ("t", "delText"):
49
+ found.setdefault(active, {"part": part, "anchored_text": ""})["anchored_text"] += node.text or ""
50
+ return found
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ parser = argparse.ArgumentParser(description="Extract comments (author, date, body, anchored text) from a .docx.")
55
+ parser.add_argument("in_docx", help="Input .docx")
56
+ parser.add_argument("--out", required=True, help="Write the JSON report here")
57
+ args = parser.parse_args(argv)
58
+
59
+ try:
60
+ doc = kit.Docx(args.in_docx)
61
+ except (OSError, KeyError) as exc:
62
+ print("error: %s" % exc, file=sys.stderr)
63
+ return 2
64
+ if not doc.exists("word/comments.xml"):
65
+ kit.emit({"ok": True, "input": str(Path(args.in_docx)), "count": 0, "comments": []}, args.out)
66
+ return 0
67
+
68
+ anchors = anchor_map(doc)
69
+ comments = []
70
+ for node in doc.xml("word/comments.xml").findall(kit.q("comment")):
71
+ ident = node.get(kit.q("id")) or ""
72
+ paragraphs = [kit.text_of(paragraph) for paragraph in node.iter(kit.q("p"))]
73
+ info = anchors.get(ident, {})
74
+ comments.append(
75
+ {
76
+ "id": int(ident) if ident.lstrip("-").isdigit() else ident,
77
+ "author": node.get(kit.q("author")) or "",
78
+ "initials": node.get(kit.q("initials")) or "",
79
+ "date": node.get(kit.q("date")) or "",
80
+ "text": "\n".join(text for text in paragraphs if text),
81
+ "anchored_text": info.get("anchored_text", "").strip(),
82
+ "part": info.get("part"),
83
+ "resolved": node.get(kit.q("done", kit.W15)) in ("1", "true"),
84
+ }
85
+ )
86
+ comments.sort(key=lambda item: item["id"] if isinstance(item["id"], int) else 0)
87
+ kit.emit({"ok": True, "input": str(Path(args.in_docx)), "count": len(comments), "comments": comments}, args.out)
88
+ return 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -0,0 +1,81 @@
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
+ # 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
+ MARKERS = ("commentRangeStart", "commentRangeEnd", "commentReference")
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ parser = argparse.ArgumentParser(description="Strip all comments and comment markers from a .docx.")
32
+ parser.add_argument("in_docx", help="Input .docx")
33
+ parser.add_argument("--out", required=True, help="Output .docx")
34
+ args = parser.parse_args(argv)
35
+
36
+ try:
37
+ doc = kit.Docx(args.in_docx)
38
+ except (OSError, KeyError) as exc:
39
+ print("error: %s" % exc, file=sys.stderr)
40
+ return 2
41
+
42
+ hf = [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
43
+ removed_markers = 0
44
+ for part in ["word/document.xml"] + hf + doc.text_parts():
45
+ if not doc.exists(part):
46
+ continue
47
+ root = doc.xml(part)
48
+ for local in MARKERS:
49
+ for node in list(root.iter(kit.q(local))):
50
+ parent = node.getparent()
51
+ if parent is None:
52
+ continue
53
+ if local == "commentReference" and parent.tag == kit.q("r") and len(parent) <= 1:
54
+ grand = parent.getparent()
55
+ if grand is not None:
56
+ grand.remove(parent)
57
+ else:
58
+ parent.remove(node)
59
+ removed_markers += 1
60
+
61
+ had_part = doc.exists("word/comments.xml")
62
+ if had_part:
63
+ doc.drop("word/comments.xml")
64
+ doc.drop_rel_target("word/document.xml", "word/comments.xml")
65
+ doc.drop_override("word/comments.xml")
66
+
67
+ doc.save(args.out)
68
+ kit.emit(
69
+ {
70
+ "ok": True,
71
+ "input": str(Path(args.in_docx)),
72
+ "output": str(Path(args.out)),
73
+ "comments_part_removed": had_part,
74
+ "markers_removed": removed_markers,
75
+ }
76
+ )
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(main())
@@ -0,0 +1,199 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
24
+ for _stream in (sys.stdout, sys.stderr):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+ TYPE_TAGS = ("text", "richText", "date", "dropDownList", "comboBox", "picture", "checkbox")
31
+ CONTAINER_TAGS = ("docPartObj", "group", "citation", "bibliography")
32
+
33
+
34
+ def part_list(doc: kit.Docx, include_headers: bool) -> list[str]:
35
+ parts = ["word/document.xml"]
36
+ if include_headers:
37
+ parts += [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
38
+ return [part for part in parts if doc.exists(part)]
39
+
40
+
41
+ def controls(doc: kit.Docx, part: str):
42
+ return [node for node in doc.xml(part).iter(kit.q("sdt")) if node.find(kit.q("sdtPr")) is not None]
43
+
44
+
45
+ def describe(doc: kit.Docx, part: str, sdt) -> dict:
46
+ props = sdt.find(kit.q("sdtPr"))
47
+ tag = props.find(kit.q("tag")) if props is not None else None
48
+ alias = props.find(kit.q("alias")) if props is not None else None
49
+ ident = props.find(kit.q("id")) if props is not None else None
50
+ kind = "unknown"
51
+ if props is not None:
52
+ for name in TYPE_TAGS:
53
+ if props.find(kit.q(name)) is not None:
54
+ kind = name
55
+ break
56
+ if kind == "unknown" and any(props.find(kit.q(name)) is not None for name in CONTAINER_TAGS):
57
+ kind = "container"
58
+ content = sdt.find(kit.q("sdtContent"))
59
+ text = kit.text_of(content) if content is not None else ""
60
+ return {
61
+ "part": part,
62
+ "tag": tag.get(kit.q("val")) if tag is not None else "",
63
+ "alias": alias.get(kit.q("val")) if alias is not None else "",
64
+ "id": ident.get(kit.q("val")) if ident is not None else "",
65
+ "type": kind,
66
+ "text": text,
67
+ }
68
+
69
+
70
+ def build_control(tag: str, alias: str, text: str, ident: int, content) -> etree._Element:
71
+ sdt = etree.Element(kit.q("sdt"))
72
+ props = etree.SubElement(sdt, kit.q("sdtPr"))
73
+ if tag:
74
+ etree.SubElement(props, kit.q("tag")).set(kit.q("val"), tag)
75
+ if alias:
76
+ etree.SubElement(props, kit.q("alias")).set(kit.q("val"), alias)
77
+ etree.SubElement(props, kit.q("id")).set(kit.q("val"), str(ident))
78
+ etree.SubElement(props, kit.q("text"))
79
+ body = etree.SubElement(sdt, kit.q("sdtContent"))
80
+ if content is not None:
81
+ body.append(content)
82
+ else:
83
+ paragraph = etree.SubElement(body, kit.q("p"))
84
+ run = etree.SubElement(paragraph, kit.q("r"))
85
+ node = etree.SubElement(run, kit.q("t"))
86
+ node.text = text
87
+ return sdt
88
+
89
+
90
+ def replace_text(sdt, value: str) -> bool:
91
+ content = sdt.find(kit.q("sdtContent"))
92
+ if content is None:
93
+ return False
94
+ paragraphs = list(content.iter(kit.q("p")))
95
+ if not paragraphs:
96
+ return False
97
+ paragraph = paragraphs[0]
98
+ for extra in paragraphs[1:]:
99
+ parent = extra.getparent()
100
+ if parent is not None:
101
+ parent.remove(extra)
102
+ ppr = paragraph.find(kit.q("pPr"))
103
+ for child in list(paragraph):
104
+ if child is not ppr:
105
+ paragraph.remove(child)
106
+ run = etree.SubElement(paragraph, kit.q("r"))
107
+ node = etree.SubElement(run, kit.q("t"))
108
+ node.text = value
109
+ node.set(kit.q("space"), kit.XML)
110
+ return True
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(description="List, create, or fill Word content controls (SDTs).")
115
+ sub = parser.add_subparsers(dest="command", required=True)
116
+
117
+ listing = sub.add_parser("list", help="list content controls")
118
+ listing.add_argument("docx")
119
+ listing.add_argument("--json", action="store_true", help="emit JSON (always on; kept for scripts)")
120
+ listing.add_argument("--no_headers_footers", action="store_true")
121
+
122
+ create = sub.add_parser("create", help="wrap a paragraph or add a new control")
123
+ create.add_argument("docx")
124
+ create.add_argument("--tag", default="", help="Stable tag used by later edits")
125
+ create.add_argument("--alias", default="", help="Display name shown in Word")
126
+ create.add_argument("--text", default="", help="Initial value for a new control")
127
+ create.add_argument("--placeholder", default="", help="Wrap the paragraph containing this text instead of adding a new control")
128
+ create.add_argument("--output", required=True)
129
+ create.add_argument("--no_headers_footers", action="store_true")
130
+
131
+ update = sub.add_parser("set", help="fill controls by tag")
132
+ update.add_argument("docx")
133
+ update.add_argument("--set", action="append", default=[], help="TAG=VALUE (repeatable)")
134
+ update.add_argument("--output", required=True)
135
+ update.add_argument("--no_headers_footers", action="store_true")
136
+
137
+ args = parser.parse_args(argv)
138
+
139
+ try:
140
+ doc = kit.Docx(args.docx)
141
+ except (OSError, KeyError) as exc:
142
+ print("error: %s" % exc, file=sys.stderr)
143
+ return 2
144
+
145
+ if args.command == "list":
146
+ rows = []
147
+ for part in part_list(doc, not args.no_headers_footers):
148
+ for sdt in controls(doc, part):
149
+ rows.append(describe(doc, part, sdt))
150
+ kit.emit({"ok": True, "input": str(Path(args.docx)), "count": len(rows), "controls": rows})
151
+ return 0
152
+
153
+ if args.command == "set":
154
+ if not args.set:
155
+ parser.error("at least one --set TAG=VALUE is required")
156
+ wanted = {}
157
+ for raw in args.set:
158
+ if "=" not in raw:
159
+ parser.error("--set expects TAG=VALUE, got %r" % raw)
160
+ tag, value = raw.split("=", 1)
161
+ wanted[tag] = value
162
+ filled, missing = [], []
163
+ for part in part_list(doc, not args.no_headers_footers):
164
+ for sdt in controls(doc, part):
165
+ info = describe(doc, part, sdt)
166
+ if info["tag"] in wanted and replace_text(sdt, wanted[info["tag"]]):
167
+ filled.append({"tag": info["tag"], "value": wanted[info["tag"]], "part": part})
168
+ wanted.pop(info["tag"], None)
169
+ missing = sorted(wanted)
170
+ doc.save(args.output)
171
+ kit.emit({"ok": not missing, "output": str(Path(args.output)), "filled": filled, "missing_tags": missing})
172
+ return 1 if missing else 0
173
+
174
+ ident = doc.next_id()
175
+ if args.placeholder:
176
+ target = None
177
+ for paragraph in doc.paragraphs():
178
+ if args.placeholder in kit.paragraph_text(paragraph):
179
+ target = paragraph
180
+ break
181
+ if target is None:
182
+ print("error: placeholder %r not found" % args.placeholder, file=sys.stderr)
183
+ return 1
184
+ parent = target.getparent()
185
+ index = list(parent).index(target)
186
+ sdt = build_control(args.tag, args.alias, args.text, ident, target)
187
+ parent.insert(index, sdt)
188
+ where = "wrapped"
189
+ else:
190
+ sdt = build_control(args.tag, args.alias, args.text, ident, None)
191
+ doc.body().append(sdt)
192
+ where = "appended"
193
+ doc.save(args.output)
194
+ kit.emit({"ok": True, "output": str(Path(args.output)), "mode": where, "tag": args.tag, "id": ident})
195
+ return 0
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())