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,587 @@
1
+ """Shared OOXML plumbing for the office-documents scripts.
2
+
3
+ Every other script in this directory imports this module after adding its own
4
+ folder to ``sys.path``. It packages the small set of zip + lxml operations the
5
+ WordprocessingML helpers need (part access, relationship and content-type
6
+ bookkeeping, run/paragraph utilities, field scanning) so each script stays a
7
+ thin, single-purpose CLI.
8
+
9
+ Also usable on its own:
10
+
11
+ python docx_kit.py parts input.docx
12
+ python docx_kit.py text input.docx
13
+ python docx_kit.py xml input.docx word/document.xml
14
+
15
+ Exit codes: 0 success, 1 not found / unreadable, 2 usage error.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import posixpath
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import zipfile
28
+ from pathlib import Path
29
+
30
+ from lxml import etree
31
+
32
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
33
+ R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
34
+ A = "http://schemas.openxmlformats.org/drawingml/2006/main"
35
+ WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
36
+ V = "urn:schemas-microsoft-com:vml"
37
+ W15 = "http://schemas.microsoft.com/office/word/2012/wordml"
38
+ XML = "http://www.w3.org/XML/1998/namespace"
39
+
40
+ CT_RELS = "application/vnd.openxmlformats-package.relationships+xml"
41
+ REL_HEADER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
42
+ REL_FOOTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
43
+ REL_COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
44
+ REL_FOOTNOTES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"
45
+ REL_ENDNOTES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"
46
+ REL_STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
47
+ REL_NUMBERING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"
48
+ REL_SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings"
49
+
50
+ CT_BY_REL = {
51
+ REL_COMMENTS: "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
52
+ REL_FOOTNOTES: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
53
+ REL_ENDNOTES: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
54
+ REL_HEADER: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
55
+ REL_FOOTER: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
56
+ }
57
+
58
+ # Sections of a document whose text and revisions the scripts touch.
59
+ TEXT_PARTS = (
60
+ "word/document.xml",
61
+ "word/footnotes.xml",
62
+ "word/endnotes.xml",
63
+ "word/comments.xml",
64
+ )
65
+
66
+
67
+ def q(tag: str, ns: str = W) -> str:
68
+ return "{%s}%s" % (ns, tag)
69
+
70
+
71
+ def rels_name(part: str) -> str:
72
+ folder, base = posixpath.split(part)
73
+ return posixpath.join(folder, "_rels", base + ".rels")
74
+
75
+
76
+ def resolve_target(part: str, target: str) -> str:
77
+ if target.startswith("/"):
78
+ return target.lstrip("/")
79
+ return posixpath.normpath(posixpath.join(posixpath.dirname(part), target))
80
+
81
+
82
+ def text_of(element) -> str:
83
+ """Concatenated w:t text below an element (deleted text included)."""
84
+ chunks = []
85
+ for node in element.iter():
86
+ if node.tag in (q("t"), q("delText")):
87
+ chunks.append(node.text or "")
88
+ return "".join(chunks)
89
+
90
+
91
+ def paragraph_text(paragraph) -> str:
92
+ return text_of(paragraph)
93
+
94
+
95
+ class Docx:
96
+ """In-memory copy of a .docx package with parse/serialize helpers."""
97
+
98
+ def __init__(self, path: str | Path):
99
+ self.path = Path(path)
100
+ if not self.path.is_file():
101
+ raise FileNotFoundError(str(self.path))
102
+ with zipfile.ZipFile(self.path) as zf:
103
+ self.order = zf.namelist()
104
+ self.parts = {name: zf.read(name) for name in self.order}
105
+ self._cache: dict[str, etree._Element] = {}
106
+
107
+ # -- parts -------------------------------------------------------------
108
+ def exists(self, part: str) -> bool:
109
+ return part in self.parts
110
+
111
+ def names(self, prefix: str = "") -> list[str]:
112
+ return [name for name in self.parts if name.startswith(prefix)]
113
+
114
+ def xml(self, part: str) -> etree._Element:
115
+ if part not in self.parts:
116
+ raise KeyError(part)
117
+ if part not in self._cache:
118
+ parser = etree.XMLParser(remove_blank_text=False, huge_tree=True)
119
+ self._cache[part] = etree.fromstring(self.parts[part], parser)
120
+ return self._cache[part]
121
+
122
+ def set_xml(self, part: str, root) -> None:
123
+ self._cache[part] = root
124
+ if part not in self.parts:
125
+ self.parts[part] = b""
126
+ self.order.append(part)
127
+
128
+ def drop(self, part: str) -> None:
129
+ self.parts.pop(part, None)
130
+ self._cache.pop(part, None)
131
+ if part in self.order:
132
+ self.order.remove(part)
133
+
134
+ # -- relationships and content types -----------------------------------
135
+ def rels(self, part: str) -> etree._Element:
136
+ name = rels_name(part)
137
+ if name not in self.parts:
138
+ root = etree.Element("{http://schemas.openxmlformats.org/package/2006/relationships}Relationships", nsmap={None: "http://schemas.openxmlformats.org/package/2006/relationships"})
139
+ self.parts[name] = b"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>"
140
+ self.order.append(name)
141
+ self._cache.pop(name, None)
142
+ return self.xml(name)
143
+ return self.xml(name)
144
+
145
+ def relationships(self, part: str) -> list[dict]:
146
+ if rels_name(part) not in self.parts:
147
+ return []
148
+ ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
149
+ out = []
150
+ for node in self.xml(rels_name(part)).findall(ns + "Relationship"):
151
+ out.append(
152
+ {
153
+ "id": node.get("Id"),
154
+ "type": node.get("Type"),
155
+ "target": node.get("Target"),
156
+ "mode": node.get("TargetMode"),
157
+ "resolved": resolve_target(part, node.get("Target") or ""),
158
+ }
159
+ )
160
+ return out
161
+
162
+ def ensure_rel(self, part: str, rel_type: str, target: str, external: bool = False) -> str:
163
+ """Return the rId for target, adding the relationship when absent."""
164
+ for rel in self.relationships(part):
165
+ if rel["type"] != rel_type:
166
+ continue
167
+ if external and rel["mode"] == "External" and rel["target"] == target:
168
+ return rel["id"]
169
+ if not external and rel["resolved"] == resolve_target(part, target):
170
+ return rel["id"]
171
+ root = self.rels(part)
172
+ ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
173
+ ids = [int(n.get("Id")[3:]) for n in root.findall(ns + "Relationship") if (n.get("Id") or "").startswith("rId") and (n.get("Id") or "")[3:].isdigit()]
174
+ rid = "rId%d" % (max(ids, default=0) + 1)
175
+ node = etree.SubElement(root, ns + "Relationship")
176
+ node.set("Id", rid)
177
+ node.set("Type", rel_type)
178
+ node.set("Target", target)
179
+ if external:
180
+ node.set("TargetMode", "External")
181
+ return rid
182
+
183
+ def drop_rel_target(self, part: str, resolved: str) -> None:
184
+ if rels_name(part) not in self.parts:
185
+ return
186
+ root = self.rels(part)
187
+ ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
188
+ for node in list(root.findall(ns + "Relationship")):
189
+ if resolve_target(part, node.get("Target") or "") == resolved:
190
+ root.remove(node)
191
+
192
+ def content_types(self) -> etree._Element:
193
+ return self.xml("[Content_Types].xml")
194
+
195
+ def ensure_override(self, part: str, content_type: str) -> None:
196
+ root = self.content_types()
197
+ ct = "{http://schemas.openxmlformats.org/package/2006/content-types}"
198
+ part_name = "/" + part
199
+ for node in root.findall(ct + "Override"):
200
+ if node.get("PartName") == part_name:
201
+ node.set("ContentType", content_type)
202
+ return
203
+ node = etree.SubElement(root, ct + "Override")
204
+ node.set("PartName", part_name)
205
+ node.set("ContentType", content_type)
206
+
207
+ def drop_override(self, part: str) -> None:
208
+ root = self.content_types()
209
+ ct = "{http://schemas.openxmlformats.org/package/2006/content-types}"
210
+ for node in list(root.findall(ct + "Override")):
211
+ if node.get("PartName") == "/" + part:
212
+ root.remove(node)
213
+
214
+ # -- document structure ------------------------------------------------
215
+ def body(self):
216
+ return self.xml("word/document.xml").find(q("body"))
217
+
218
+ def paragraphs(self, part: str = "word/document.xml"):
219
+ return list(self.xml(part).iter(q("p")))
220
+
221
+ def text_parts(self) -> list[str]:
222
+ return [name for name in TEXT_PARTS if self.exists(name)]
223
+
224
+ def sections(self):
225
+ return [node for node in self.xml("word/document.xml").iter(q("sectPr"))]
226
+
227
+ def ensure_header_footer(self, kind: str) -> str:
228
+ """Return the part name for this section's header/footer, creating it."""
229
+ body = self.body()
230
+ sect = body.find(q("sectPr")) if body is not None else None
231
+ if sect is None:
232
+ sect = etree.SubElement(body, q("sectPr"))
233
+ tag = q("headerReference") if kind == "header" else q("footerReference")
234
+ rel_type = REL_HEADER if kind == "header" else REL_FOOTER
235
+ refs = sect.findall(tag)
236
+ for ref in refs:
237
+ rid = ref.get(q("id", R))
238
+ for rel in self.relationships("word/document.xml"):
239
+ if rel["id"] == rid and rel["resolved"]:
240
+ if self.exists(rel["resolved"]):
241
+ return rel["resolved"]
242
+ index = 1
243
+ while self.exists("word/%s%d.xml" % (kind, index)):
244
+ index += 1
245
+ part = "word/%s%d.xml" % (kind, index)
246
+ root = etree.Element(q(kind), nsmap={"w": W, "r": R, "wp": WP, "v": V})
247
+ etree.SubElement(etree.SubElement(root, q("p")), q("r"))
248
+ self.set_xml(part, root)
249
+ self.ensure_override(part, CT_BY_REL[rel_type])
250
+ rid = self.ensure_rel("word/document.xml", rel_type, posixpath.basename(part))
251
+ ref = etree.Element(tag)
252
+ ref.set(q("type"), "default")
253
+ ref.set(q("id", R), rid)
254
+ if kind == "header":
255
+ sect.insert(0, ref)
256
+ else:
257
+ position = 0
258
+ for child in sect:
259
+ if child.tag in (q("headerReference"), q("footerReference")):
260
+ position += 1
261
+ else:
262
+ break
263
+ sect.insert(position, ref)
264
+ return part
265
+
266
+ # -- ids ---------------------------------------------------------------
267
+ def next_id(self) -> int:
268
+ best = 0
269
+ for name in self.text_parts() + ["word/document.xml"]:
270
+ for node in self.xml(name).iter():
271
+ for key, value in node.attrib.items():
272
+ if key.endswith("}id") and value.isdigit():
273
+ best = max(best, int(value))
274
+ return best + 1
275
+
276
+ # -- save --------------------------------------------------------------
277
+ def save(self, out: str | Path) -> Path:
278
+ out = Path(out)
279
+ out.parent.mkdir(parents=True, exist_ok=True)
280
+ for name, root in list(self._cache.items()):
281
+ self.parts[name] = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
282
+ ordered = [name for name in self.order if name in self.parts]
283
+ ordered = ["[Content_Types].xml"] + [n for n in ordered if n != "[Content_Types].xml"]
284
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
285
+ for name in ordered:
286
+ zf.writestr(name, self.parts[name])
287
+ return out
288
+
289
+
290
+ # -- run / paragraph helpers ------------------------------------------------
291
+
292
+ def make_run(text: str, rpr=None, tag: str = "t"):
293
+ run = etree.Element(q("r"))
294
+ if rpr is not None:
295
+ run.append(rpr)
296
+ node = etree.SubElement(run, q(tag))
297
+ node.text = text
298
+ node.set(q("space", XML), "preserve")
299
+ return run
300
+
301
+
302
+ def first_rpr(paragraph):
303
+ for run in paragraph.iter(q("r")):
304
+ rpr = run.find(q("rPr"))
305
+ if rpr is not None:
306
+ return rpr
307
+ return None
308
+
309
+
310
+ def clear_paragraph_content(paragraph, keep=("pPr", "bookmarkStart", "bookmarkEnd")) -> None:
311
+ for child in list(paragraph):
312
+ if child.tag in {q(k) for k in keep}:
313
+ continue
314
+ paragraph.remove(child)
315
+
316
+
317
+ def set_paragraph_text(paragraph, text: str) -> None:
318
+ rpr = first_rpr(paragraph)
319
+ clear_paragraph_content(paragraph)
320
+ paragraph.append(make_run(text, rpr))
321
+
322
+
323
+ def paragraph_style(paragraph) -> str:
324
+ ppr = paragraph.find(q("pPr"))
325
+ if ppr is None:
326
+ return ""
327
+ style = ppr.find(q("pStyle"))
328
+ return style.get(q("val")) if style is not None else ""
329
+
330
+
331
+ def heading_level(paragraph) -> int:
332
+ style = paragraph_style(paragraph).lower()
333
+ if style == "title":
334
+ return 0
335
+ for prefix in ("heading", "heading"):
336
+ if style.startswith(prefix):
337
+ tail = style[len(prefix):].strip()
338
+ if tail.isdigit():
339
+ return int(tail)
340
+ ppr = paragraph.find(q("pPr"))
341
+ if ppr is not None:
342
+ outline = ppr.find(q("outlineLvl"))
343
+ if outline is not None and (outline.get(q("val")) or "").isdigit():
344
+ return int(outline.get(q("val"))) + 1
345
+ return 0
346
+
347
+
348
+ # -- fields -----------------------------------------------------------------
349
+
350
+ def _field_type(instr: str) -> str:
351
+ token = (instr or "").strip().split(" ")[0] if (instr or "").strip() else ""
352
+ return token.upper()
353
+
354
+
355
+ def paragraph_fields(paragraph) -> list[dict]:
356
+ """Return fields in a paragraph as {kind, instr, result, span}."""
357
+ out: list[dict] = []
358
+ start = None
359
+ buffer_instr: list[str] = []
360
+ buffer_result: list[str] = []
361
+ span: list = []
362
+ for child in paragraph:
363
+ if child.tag == q("fldSimple"):
364
+ instr = (child.get(q("instr")) or "").strip()
365
+ out.append({"kind": "simple", "instr": instr, "type": _field_type(instr), "result": text_of(child), "span": [child]})
366
+ continue
367
+ if child.tag != q("r"):
368
+ if start is not None:
369
+ span.append(child)
370
+ buffer_result.append(text_of(child))
371
+ continue
372
+ fld = child.find(q("fldChar"))
373
+ if fld is not None:
374
+ kind = fld.get(q("fldCharType"))
375
+ if kind == "begin":
376
+ start = child
377
+ span = [child]
378
+ buffer_instr = []
379
+ buffer_result = []
380
+ continue
381
+ if kind == "separate":
382
+ if start is not None:
383
+ span.append(child)
384
+ continue
385
+ if kind == "end" and start is not None:
386
+ span.append(child)
387
+ instr = " ".join(part.strip() for part in buffer_instr if part.strip())
388
+ out.append({"kind": "complex", "instr": instr, "type": _field_type(instr), "result": "".join(buffer_result), "span": list(span)})
389
+ start = None
390
+ continue
391
+ if start is not None:
392
+ span.append(child)
393
+ for node in child.findall(q("instrText")):
394
+ buffer_instr.append(node.text or "")
395
+ buffer_result.append(text_of(child))
396
+ return out
397
+
398
+
399
+ def iter_fields(doc: Docx):
400
+ for part in doc.text_parts():
401
+ for paragraph in doc.paragraphs(part):
402
+ for field in paragraph_fields(paragraph):
403
+ yield part, paragraph, field
404
+
405
+
406
+ def field_runs(instr: str, result: str = "", rpr=None) -> list:
407
+ begin = etree.Element(q("r"))
408
+ if rpr is not None:
409
+ begin.append(_copy(rpr))
410
+ fld = etree.SubElement(begin, q("fldChar"))
411
+ fld.set(q("fldCharType"), "begin")
412
+ instr_run = etree.Element(q("r"))
413
+ if rpr is not None:
414
+ instr_run.append(_copy(rpr))
415
+ node = etree.SubElement(instr_run, q("instrText"))
416
+ node.text = " %s " % instr
417
+ node.set(q("space", XML), "preserve")
418
+ sep = etree.Element(q("r"))
419
+ if rpr is not None:
420
+ sep.append(_copy(rpr))
421
+ etree.SubElement(sep, q("fldChar")).set(q("fldCharType"), "separate")
422
+ out = [begin, instr_run, sep]
423
+ if result:
424
+ out.append(make_run(result, _copy(rpr) if rpr is not None else None))
425
+ end = etree.Element(q("r"))
426
+ if rpr is not None:
427
+ end.append(_copy(rpr))
428
+ etree.SubElement(end, q("fldChar")).set(q("fldCharType"), "end")
429
+ out.append(end)
430
+ return out
431
+
432
+
433
+ def _copy(node):
434
+ return etree.fromstring(etree.tostring(node))
435
+
436
+
437
+ def field_simple(instr: str, result: str = "") -> etree._Element:
438
+ node = etree.Element(q("fldSimple"))
439
+ node.set(q("instr"), " %s " % instr)
440
+ if result:
441
+ node.append(make_run(result))
442
+ return node
443
+
444
+
445
+ def add_bookmark(parent, name: str, ident: int) -> None:
446
+ start = etree.SubElement(parent, q("bookmarkStart"))
447
+ start.set(q("id"), str(ident))
448
+ start.set(q("name"), name)
449
+ end = etree.SubElement(parent, q("bookmarkEnd"))
450
+ end.set(q("id"), str(ident))
451
+
452
+
453
+ # -- watermarks -------------------------------------------------------------
454
+
455
+ _WATERMARK_SHAPETYPE = """<v:shapetype xmlns:v="urn:schemas-microsoft-com:vml" id="_x0000_t136" coordsize="21600,21600" adj="10800" path="m@7,l@8,m@5,21600l@11,21600e"><v:formulas><v:f eqn="sum #0 0 10800"/><v:f eqn="prod #0 2 1"/><v:f eqn="sum 21600 0 @1"/><v:f eqn="sum 0 0 @2"/><v:f eqn="sum 21600 0 @3"/><v:f eqn="if @0 @3 0"/><v:f eqn="if @0 21600 @1"/><v:f eqn="if @0 0 @2"/><v:f eqn="if @0 @4 21600"/><v:f eqn="mid @5 @6"/><v:f eqn="mid @8 @5"/><v:f eqn="mid @7 @8"/><v:f eqn="mid @6 @7"/><v:f eqn="sum @6 0 @5"/></v:formulas><v:path textpathok="t"/><v:textpath on="t" fitshape="t"/><v:handles><v:h position="#0,bottomRight" xrange="6629,14971"/></v:handles></v:shapetype>"""
456
+
457
+ _WATERMARK_STYLE = (
458
+ "position:absolute;margin-left:0;margin-top:0;width:468pt;height:117pt;"
459
+ "rotation:-45;z-index:-251658752;mso-position-horizontal:center;"
460
+ "mso-position-horizontal-relative:margin;mso-position-vertical:center;"
461
+ "mso-position-vertical-relative:margin"
462
+ )
463
+
464
+
465
+ def watermark_paragraph(text: str, ident: int = 1, color: str = "#C0C0C0"):
466
+ """A header paragraph holding one VML text watermark."""
467
+ paragraph = etree.Element(q("p"))
468
+ run = etree.SubElement(paragraph, q("r"))
469
+ run_props = etree.SubElement(run, q("rPr"))
470
+ etree.SubElement(run_props, q("noProof"))
471
+ pict = etree.SubElement(run, q("pict"))
472
+ pict.append(etree.fromstring(_WATERMARK_SHAPETYPE))
473
+ shape = etree.SubElement(pict, q("shape", V))
474
+ shape.set("id", "WaterMarkObject%d" % ident)
475
+ shape.set("type", "#_x0000_t136")
476
+ shape.set("style", _WATERMARK_STYLE)
477
+ shape.set("fillcolor", color)
478
+ shape.set("stroked", "f")
479
+ textpath = etree.SubElement(shape, q("textpath", V))
480
+ textpath.set("style", 'font-family:"Calibri";font-size:1pt')
481
+ textpath.set("string", text)
482
+ return paragraph
483
+
484
+
485
+ def watermark_texts(doc: Docx) -> list[dict]:
486
+ """Every VML text watermark in the headers, with the paragraph that holds it."""
487
+ found = []
488
+ for part in doc.names("word/header"):
489
+ for textpath in doc.xml(part).iter(q("textpath", V)):
490
+ paragraph = textpath
491
+ while paragraph is not None and paragraph.tag != q("p"):
492
+ paragraph = paragraph.getparent()
493
+ found.append({"part": part, "text": textpath.get("string"), "paragraph": paragraph})
494
+ return found
495
+
496
+
497
+ # -- helper CLI -------------------------------------------------------------
498
+
499
+ def office_cli_path() -> Path | None:
500
+ """Locate python/office_cli.py: DSH_OFFICE_CLI, then the repo around this skill."""
501
+ override = os.environ.get("DSH_OFFICE_CLI")
502
+ candidates = [Path(override)] if override else []
503
+ here = Path(__file__).resolve()
504
+ for depth in range(1, 7):
505
+ try:
506
+ candidates.append(here.parents[depth] / "python" / "office_cli.py")
507
+ except IndexError:
508
+ break
509
+ for candidate in candidates:
510
+ if candidate.is_file():
511
+ return candidate
512
+ return None
513
+
514
+
515
+ def run_office_cli(argv: list[str], timeout: int = 600) -> tuple[int, str, str]:
516
+ """Run the helper CLI and return (returncode, stdout, stderr)."""
517
+ cli = office_cli_path()
518
+ if cli is None:
519
+ return 127, "", "helper CLI not found; set DSH_OFFICE_CLI to python/office_cli.py"
520
+ try:
521
+ finished = subprocess.run([sys.executable, str(cli), *argv], capture_output=True, text=True, timeout=timeout)
522
+ except subprocess.TimeoutExpired:
523
+ return 124, "", "helper CLI timed out after %ds" % timeout
524
+ return finished.returncode, finished.stdout, finished.stderr
525
+
526
+
527
+ def cli_json(text: str) -> dict | None:
528
+ """Parse the last JSON object printed by the helper CLI (pretty-printed or one line)."""
529
+ if not text:
530
+ return None
531
+ position = text.find("{")
532
+ while position != -1:
533
+ try:
534
+ return json.loads(text[position:])
535
+ except json.JSONDecodeError:
536
+ position = text.find("{", position + 1)
537
+ return None
538
+
539
+
540
+ def emit(payload, out: str | None = None) -> None:
541
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
542
+ if out:
543
+ Path(out).write_text(text + "\n", encoding="utf-8")
544
+ else:
545
+ print(text)
546
+
547
+
548
+ # -- CLI --------------------------------------------------------------------
549
+
550
+ def main(argv: list[str] | None = None) -> int:
551
+ parser = argparse.ArgumentParser(description="Inspect or dump parts of a .docx package.")
552
+ sub = parser.add_subparsers(dest="command", required=True)
553
+ parts = sub.add_parser("parts", help="list package parts with sizes")
554
+ parts.add_argument("docx")
555
+ text = sub.add_parser("text", help="dump paragraph text with style and index")
556
+ text.add_argument("docx")
557
+ text.add_argument("--part", default="word/document.xml")
558
+ xml = sub.add_parser("xml", help="print one part as XML")
559
+ xml.add_argument("docx")
560
+ xml.add_argument("part")
561
+ args = parser.parse_args(argv)
562
+
563
+ try:
564
+ doc = Docx(args.docx)
565
+ except (OSError, zipfile.BadZipFile) as exc:
566
+ print("error: %s" % exc, file=sys.stderr)
567
+ return 1
568
+ if args.command == "parts":
569
+ emit({"ok": True, "parts": [{"name": name, "bytes": len(doc.parts[name])} for name in doc.order]})
570
+ elif args.command == "text":
571
+ if not doc.exists(args.part):
572
+ print("error: no part %s" % args.part, file=sys.stderr)
573
+ return 1
574
+ rows = []
575
+ for index, paragraph in enumerate(doc.paragraphs(args.part)):
576
+ rows.append({"index": index, "style": paragraph_style(paragraph), "level": heading_level(paragraph), "text": paragraph_text(paragraph)})
577
+ emit({"ok": True, "part": args.part, "paragraphs": rows})
578
+ else:
579
+ if not doc.exists(args.part):
580
+ print("error: no part %s" % args.part, file=sys.stderr)
581
+ return 1
582
+ print(etree.tostring(doc.xml(args.part), pretty_print=True, encoding="unicode"))
583
+ return 0
584
+
585
+
586
+ if __name__ == "__main__":
587
+ raise SystemExit(main())