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