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,232 @@
1
+ #!/usr/bin/env python
2
+ """Escape hatch for WordprocessingML features python-docx does not expose.
3
+
4
+ One invocation may combine several patches, which is what makes it useful for
5
+ finishing a document in one pass:
6
+
7
+ --enable-track switch on revisions
8
+ --tracked-replace-ins-id ID turn an insertion into delete + insert
9
+ --add-comment --comment-text attach a comment to the first matching paragraph
10
+ --header-date TEXT set a right-aligned first-header line
11
+ --add-page-numbers centered PAGE field in the footer
12
+ --hyperlink-first URL wrap the first text paragraph in a hyperlink
13
+
14
+ Exit codes: 0 success, 1 requested target not found, 2 bad input.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import datetime
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
+ PKG_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
37
+ REL_HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
38
+
39
+
40
+ def stamp(args) -> str:
41
+ return args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
42
+
43
+
44
+ def enable_track(doc: kit.Docx) -> str:
45
+ settings = doc.xml("word/settings.xml")
46
+ if settings.find(kit.q("trackRevisions")) is None:
47
+ settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
48
+ return "trackRevisions enabled"
49
+
50
+
51
+ def tracked_replace(doc: kit.Docx, ins_id: str, new_text: str, author: str, date: str) -> str:
52
+ for part in doc.text_parts():
53
+ for node in doc.xml(part).iter(kit.q("ins")):
54
+ if (node.get(kit.q("id")) or "") != str(ins_id):
55
+ continue
56
+ parent = node.getparent()
57
+ ident = doc.next_id()
58
+ deletion = etree.Element(kit.q("del"))
59
+ deletion.set(kit.q("id"), str(ident))
60
+ deletion.set(kit.q("author"), author)
61
+ deletion.set(kit.q("date"), date)
62
+ for child in list(node):
63
+ clone = etree.fromstring(etree.tostring(child))
64
+ for text in clone.iter(kit.q("t")):
65
+ text.tag = kit.q("delText")
66
+ deletion.append(clone)
67
+ insertion = etree.Element(kit.q("ins"))
68
+ insertion.set(kit.q("id"), str(ident + 1))
69
+ insertion.set(kit.q("author"), author)
70
+ insertion.set(kit.q("date"), date)
71
+ insertion.append(kit.make_run(new_text))
72
+ position = parent.index(node)
73
+ parent.remove(node)
74
+ parent.insert(position, deletion)
75
+ parent.insert(position + 1, insertion)
76
+ return "converted ins %s to del+ins in %s" % (ins_id, part)
77
+ return ""
78
+
79
+
80
+ def find_paragraph(doc: kit.Docx, contains: str | None, indent: int | None):
81
+ for paragraph in doc.paragraphs():
82
+ props = paragraph.find(kit.q("pPr"))
83
+ if contains is not None and contains not in kit.paragraph_text(paragraph):
84
+ continue
85
+ if indent is not None:
86
+ ind = props.find(kit.q("ind")) if props is not None else None
87
+ if ind is None or (ind.get(kit.q("left")) or "") != str(indent):
88
+ continue
89
+ return paragraph
90
+ return None
91
+
92
+
93
+ def add_comment(doc: kit.Docx, paragraph, text: str, author: str, date: str, explicit_id: str | None) -> str:
94
+ if not doc.exists("word/comments.xml"):
95
+ root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
96
+ doc.set_xml("word/comments.xml", root)
97
+ doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
98
+ doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
99
+ root = doc.xml("word/comments.xml")
100
+ ident = int(explicit_id) if explicit_id and explicit_id.isdigit() else doc.next_id()
101
+ comment = etree.SubElement(root, kit.q("comment"))
102
+ comment.set(kit.q("id"), str(ident))
103
+ comment.set(kit.q("author"), author)
104
+ comment.set(kit.q("initials"), author[:2].upper())
105
+ comment.set(kit.q("date"), date)
106
+ body = etree.SubElement(comment, kit.q("p"))
107
+ run = etree.SubElement(body, kit.q("r"))
108
+ node = etree.SubElement(run, kit.q("t"))
109
+ node.text = text
110
+ node.set(kit.q("space"), kit.XML)
111
+
112
+ start = etree.Element(kit.q("commentRangeStart"))
113
+ start.set(kit.q("id"), str(ident))
114
+ position = 1 if paragraph.find(kit.q("pPr")) is not None else 0
115
+ paragraph.insert(position, start)
116
+ end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
117
+ end.set(kit.q("id"), str(ident))
118
+ ref_run = etree.SubElement(paragraph, kit.q("r"))
119
+ etree.SubElement(ref_run, kit.q("commentReference")).set(kit.q("id"), str(ident))
120
+ return "comment %d anchored" % ident
121
+
122
+
123
+ def header_date(doc: kit.Docx, text: str) -> str:
124
+ part = doc.ensure_header_footer("header")
125
+ paragraph = doc.xml(part).find(".//" + kit.q("p"))
126
+ if paragraph is None:
127
+ paragraph = etree.SubElement(doc.xml(part), kit.q("p"))
128
+ props = paragraph.find(kit.q("pPr"))
129
+ if props is None:
130
+ props = etree.Element(kit.q("pPr"))
131
+ paragraph.insert(0, props)
132
+ for existing in props.findall(kit.q("jc")):
133
+ props.remove(existing)
134
+ etree.SubElement(props, kit.q("jc")).set(kit.q("val"), "right")
135
+ kit.set_paragraph_text(paragraph, text)
136
+ return "header set in %s" % part
137
+
138
+
139
+ def page_numbers(doc: kit.Docx) -> str:
140
+ part = doc.ensure_header_footer("footer")
141
+ paragraph = doc.xml(part).find(".//" + kit.q("p"))
142
+ if paragraph is None:
143
+ paragraph = etree.SubElement(doc.xml(part), kit.q("p"))
144
+ props = paragraph.find(kit.q("pPr"))
145
+ if props is None:
146
+ props = etree.Element(kit.q("pPr"))
147
+ paragraph.insert(0, props)
148
+ etree.SubElement(props, kit.q("jc")).set(kit.q("val"), "center")
149
+ for run in kit.field_runs("PAGE", "1"):
150
+ paragraph.append(run)
151
+ return "PAGE field added to %s" % part
152
+
153
+
154
+ def hyperlink_first(doc: kit.Docx, url: str) -> str:
155
+ for paragraph in doc.paragraphs():
156
+ runs = [child for child in paragraph if child.tag == kit.q("r") and kit.text_of(child).strip()]
157
+ if not runs:
158
+ continue
159
+ rid = doc.ensure_rel("word/document.xml", REL_HYPERLINK, url, external=True)
160
+ link = etree.Element(kit.q("hyperlink"))
161
+ link.set(kit.q("id", kit.R), rid)
162
+ position = paragraph.index(runs[0])
163
+ for run in runs:
164
+ paragraph.remove(run)
165
+ link.append(run)
166
+ paragraph.insert(position, link)
167
+ return "wrapped %d run(s) in hyperlink %s" % (len(runs), url)
168
+ return ""
169
+
170
+
171
+ def main(argv: list[str] | None = None) -> int:
172
+ parser = argparse.ArgumentParser(description="Apply targeted OOXML patches that python-docx cannot express.")
173
+ parser.add_argument("docx", help="Input .docx")
174
+ parser.add_argument("--out", default=None, help="Output .docx (default: overwrite the input)")
175
+ parser.add_argument("--enable-track", action="store_true")
176
+ parser.add_argument("--author", default="dsh-office")
177
+ parser.add_argument("--date", default=None, help="ISO timestamp for revisions and comments")
178
+ parser.add_argument("--tracked-replace-ins-id", default=None)
179
+ parser.add_argument("--new-text", default="")
180
+ parser.add_argument("--add-comment", action="store_true")
181
+ parser.add_argument("--comment-text", default="")
182
+ parser.add_argument("--contains", default=None, help="Target paragraph containing this text")
183
+ parser.add_argument("--indent-left-twips", type=int, default=None, help="Target paragraph with this left indent")
184
+ parser.add_argument("--comment-id", default=None)
185
+ parser.add_argument("--header-date", nargs="?", const="auto", default=None, help="Right-aligned header line; omit the value to use today's date")
186
+ parser.add_argument("--add-page-numbers", action="store_true")
187
+ parser.add_argument("--hyperlink-first", default=None)
188
+ args = parser.parse_args(argv)
189
+
190
+ try:
191
+ doc = kit.Docx(args.docx)
192
+ except (OSError, KeyError) as exc:
193
+ print("error: %s" % exc, file=sys.stderr)
194
+ return 2
195
+
196
+ actions = []
197
+ missing = []
198
+ when = stamp(args)
199
+
200
+ if args.enable_track:
201
+ actions.append(enable_track(doc))
202
+ if args.tracked_replace_ins_id is not None:
203
+ result = tracked_replace(doc, args.tracked_replace_ins_id, args.new_text, args.author, when)
204
+ actions.append(result) if result else missing.append("ins id %s" % args.tracked_replace_ins_id)
205
+ if args.add_comment:
206
+ paragraph = find_paragraph(doc, args.contains, args.indent_left_twips)
207
+ if paragraph is None:
208
+ missing.append("paragraph for --add-comment")
209
+ else:
210
+ actions.append(add_comment(doc, paragraph, args.comment_text, args.author, when, args.comment_id))
211
+ if args.header_date is not None:
212
+ from datetime import date
213
+
214
+ stamp_text = date.today().isoformat() if args.header_date == "auto" else args.header_date
215
+ actions.append(header_date(doc, stamp_text))
216
+ if args.add_page_numbers:
217
+ actions.append(page_numbers(doc))
218
+ if args.hyperlink_first:
219
+ result = hyperlink_first(doc, args.hyperlink_first)
220
+ actions.append(result) if result else missing.append("paragraph for --hyperlink-first")
221
+
222
+ if not actions and not missing:
223
+ parser.error("no patch requested")
224
+
225
+ out = args.out or args.docx
226
+ doc.save(out)
227
+ kit.emit({"ok": not missing, "output": str(Path(out)), "actions": actions, "missing": missing})
228
+ return 1 if missing else 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python
2
+ """Export one table from a .docx to CSV.
3
+
4
+ Merged cells are expanded so the CSV has a rectangular shape: a horizontally
5
+ merged cell repeats its text across the spanned columns and a vertically merged
6
+ cell inherits the value from the row above. That keeps downstream spreadsheets
7
+ aligned instead of silently shifting columns.
8
+
9
+ Exit codes: 0 success, 1 table index out of range, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import csv
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import docx_kit as kit
21
+
22
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+
30
+ def cell_span(cell) -> int:
31
+ props = cell.find(kit.q("tcPr"))
32
+ if props is None:
33
+ return 1
34
+ span = props.find(kit.q("gridSpan"))
35
+ if span is None:
36
+ return 1
37
+ try:
38
+ return max(1, int(span.get(kit.q("val")) or 1))
39
+ except ValueError:
40
+ return 1
41
+
42
+
43
+ def vertical_merge(cell) -> str:
44
+ props = cell.find(kit.q("tcPr"))
45
+ if props is None:
46
+ return ""
47
+ merge = props.find(kit.q("vMerge"))
48
+ if merge is None:
49
+ return ""
50
+ return merge.get(kit.q("val")) or "continue"
51
+
52
+
53
+ def table_rows(table) -> list[list[str]]:
54
+ rows: list[list[str]] = []
55
+ grid_width = 0
56
+ for row in table.findall(kit.q("tr")):
57
+ values: list[str] = []
58
+ for cell in row.findall(kit.q("tc")):
59
+ text = " ".join(" ".join(kit.text_of(paragraph).split()) for paragraph in cell.findall(kit.q("p"))).strip()
60
+ span = cell_span(cell)
61
+ merge = vertical_merge(cell)
62
+ if merge == "continue":
63
+ column = len(values)
64
+ inherited = ""
65
+ if rows and column < len(rows[-1]):
66
+ inherited = rows[-1][column]
67
+ text = inherited
68
+ values.extend([text] * span)
69
+ grid_width = max(grid_width, len(values))
70
+ rows.append(values)
71
+ for row in rows:
72
+ while len(row) < grid_width:
73
+ row.append("")
74
+ return rows
75
+
76
+
77
+ def main(argv: list[str] | None = None) -> int:
78
+ parser = argparse.ArgumentParser(description="Export a table from a .docx to CSV, expanding merged cells.")
79
+ parser.add_argument("docx", help="Input .docx")
80
+ parser.add_argument("--table_index", type=int, default=0, help="Zero-based table index")
81
+ parser.add_argument("--out", required=True, help="Output .csv path")
82
+ args = parser.parse_args(argv)
83
+
84
+ try:
85
+ doc = kit.Docx(args.docx)
86
+ except (OSError, KeyError) as exc:
87
+ print("error: %s" % exc, file=sys.stderr)
88
+ return 2
89
+
90
+ tables = list(doc.xml("word/document.xml").iter(kit.q("tbl")))
91
+ if args.table_index < 0 or args.table_index >= len(tables):
92
+ print("error: table_index %d out of range (document has %d)" % (args.table_index, len(tables)), file=sys.stderr)
93
+ return 1
94
+
95
+ rows = table_rows(tables[args.table_index])
96
+ out = Path(args.out)
97
+ out.parent.mkdir(parents=True, exist_ok=True)
98
+ with out.open("w", encoding="utf-8", newline="") as handle:
99
+ csv.writer(handle).writerows(rows)
100
+
101
+ kit.emit({"ok": True, "output": str(out), "tables": len(tables), "table_index": args.table_index, "rows": len(rows), "columns": len(rows[0]) if rows else 0})
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ raise SystemExit(main())
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python
2
+ """Freeze SEQ/REF field results into plain text so renders match Word.
3
+
4
+ Headless renderers do not always evaluate fields, so a caption may show "Table"
5
+ with no number. The cached result is already inside the document; this script
6
+ throws away the field machinery and keeps that result as ordinary runs.
7
+
8
+ Exit codes: 0 success, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from lxml import etree
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import docx_kit as kit
21
+
22
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+
30
+ def freeze(doc: kit.Docx, allowed: set[str]) -> int:
31
+ flattened = 0
32
+ for part in doc.text_parts():
33
+ for paragraph in doc.paragraphs(part):
34
+ for field in reversed(kit.paragraph_fields(paragraph)):
35
+ if field["type"] not in allowed:
36
+ continue
37
+ span = field["span"]
38
+ if not span:
39
+ continue
40
+ first = span[0]
41
+ rpr = first.find(kit.q("rPr")) if first.tag == kit.q("r") else None
42
+ position = paragraph.index(first)
43
+ for node in span:
44
+ if node.getparent() is paragraph:
45
+ paragraph.remove(node)
46
+ if field["result"]:
47
+ run = kit.make_run(field["result"], etree.fromstring(etree.tostring(rpr)) if rpr is not None else None)
48
+ paragraph.insert(position, run)
49
+ flattened += 1
50
+ return flattened
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ parser = argparse.ArgumentParser(description="Replace SEQ/REF fields with their cached result text.")
55
+ parser.add_argument("input_docx", help="Input .docx")
56
+ parser.add_argument("--out", required=True, help="Output .docx")
57
+ parser.add_argument("--only", nargs="+", default=["SEQ", "REF"], choices=["SEQ", "REF"], help="Field types to freeze")
58
+ args = parser.parse_args(argv)
59
+
60
+ try:
61
+ doc = kit.Docx(args.input_docx)
62
+ except (OSError, KeyError) as exc:
63
+ print("error: %s" % exc, file=sys.stderr)
64
+ return 2
65
+
66
+ flattened = freeze(doc, set(args.only))
67
+ doc.save(args.out)
68
+ kit.emit({"ok": True, "output": str(Path(args.out)), "only": args.only, "fields_frozen": flattened})
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python
2
+ """List the Word fields in a .docx and show what each one asks for.
3
+
4
+ Fields are the dynamic bits Word fills at layout time: ``TOC``, ``SEQ``, ``REF``,
5
+ ``PAGEREF``, ``HYPERLINK``, ``DATE`` and friends. Each is reported once with its
6
+ instruction, cached result, and the part it lives in, so a headless workflow can
7
+ tell whether a document depends on a refresh before rendering.
8
+
9
+ Exit codes: 0 success (also when there are no fields), 2 bad input or missing part.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import docx_kit as kit
20
+
21
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
22
+ for _stream in (sys.stdout, sys.stderr):
23
+ try:
24
+ _stream.reconfigure(encoding="utf-8", errors="replace")
25
+ except (AttributeError, ValueError):
26
+ pass
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ parser = argparse.ArgumentParser(description="Report Word fields (TOC, SEQ, REF, PAGEREF, HYPERLINK, ...) in a .docx.")
31
+ parser.add_argument("docx", help="Input .docx")
32
+ parser.add_argument("--max_examples", type=int, default=8, help="How many sample instructions to keep per field type")
33
+ args = parser.parse_args(argv)
34
+
35
+ try:
36
+ doc = kit.Docx(args.docx)
37
+ except (OSError, KeyError) as exc:
38
+ print("error: %s" % exc, file=sys.stderr)
39
+ return 2
40
+
41
+ counts: dict[str, int] = {}
42
+ examples: dict[str, list[dict]] = {}
43
+ kinds: dict[str, int] = {}
44
+ for part, paragraph, field in kit.iter_fields(doc):
45
+ name = field["type"] or "UNKNOWN"
46
+ counts[name] = counts.get(name, 0) + 1
47
+ kinds[field["kind"]] = kinds.get(field["kind"], 0) + 1
48
+ bucket = examples.setdefault(name, [])
49
+ if len(bucket) < args.max_examples:
50
+ bucket.append({"part": part, "instr": field["instr"], "result": field["result"][:80]})
51
+
52
+ kit.emit(
53
+ {
54
+ "ok": True,
55
+ "input": str(Path(args.docx)),
56
+ "total": sum(counts.values()),
57
+ "counts": counts,
58
+ "kinds": kinds,
59
+ "examples": examples,
60
+ }
61
+ )
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python
2
+ """Replace REF and PAGEREF fields with the text they resolve to.
3
+
4
+ A focused subset of field freezing: cross-reference fields are the ones that
5
+ most often render blank outside Word, so this script rewrites only those and
6
+ leaves TOC, SEQ and date fields untouched.
7
+
8
+ Exit codes: 0 success, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from lxml import etree
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import docx_kit as kit
21
+
22
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+ TARGETS = {"REF", "PAGEREF"}
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ parser = argparse.ArgumentParser(description="Flatten REF/PAGEREF cross-reference fields to literal text.")
34
+ parser.add_argument("in_docx", help="Input .docx")
35
+ parser.add_argument("--out", required=True, help="Output .docx")
36
+ args = parser.parse_args(argv)
37
+
38
+ try:
39
+ doc = kit.Docx(args.in_docx)
40
+ except (OSError, KeyError) as exc:
41
+ print("error: %s" % exc, file=sys.stderr)
42
+ return 2
43
+
44
+ flattened = 0
45
+ for part in doc.text_parts():
46
+ for paragraph in doc.paragraphs(part):
47
+ for field in reversed(kit.paragraph_fields(paragraph)):
48
+ if field["type"] not in TARGETS or not field["span"]:
49
+ continue
50
+ first = field["span"][0]
51
+ rpr = first.find(kit.q("rPr")) if first.tag == kit.q("r") else None
52
+ position = paragraph.index(first)
53
+ for node in field["span"]:
54
+ if node.getparent() is paragraph:
55
+ paragraph.remove(node)
56
+ if field["result"]:
57
+ paragraph.insert(
58
+ position,
59
+ kit.make_run(field["result"], etree.fromstring(etree.tostring(rpr)) if rpr is not None else None),
60
+ )
61
+ flattened += 1
62
+
63
+ doc.save(args.out)
64
+ kit.emit({"ok": True, "output": str(Path(args.out)), "flattened": flattened})
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ raise SystemExit(main())
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python
2
+ """Report footnotes and endnotes stored in a .docx.
3
+
4
+ Real notes are not footer text: they live in word/footnotes.xml or
5
+ word/endnotes.xml, and the body only carries a reference run. This script
6
+ prints every note with its id, kind and text, plus how many references the body
7
+ makes, which is how you catch notes that were orphaned by an edit.
8
+
9
+ Exit codes: 0 success, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import docx_kit as kit
20
+
21
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
22
+ for _stream in (sys.stdout, sys.stderr):
23
+ try:
24
+ _stream.reconfigure(encoding="utf-8", errors="replace")
25
+ except (AttributeError, ValueError):
26
+ pass
27
+
28
+ PARTS = {"footnote": "word/footnotes.xml", "endnote": "word/endnotes.xml"}
29
+ SEPARATOR_TYPES = {"separator", "continuationSeparator", "continuationNotice"}
30
+
31
+
32
+ def collect(doc: kit.Docx, kind: str) -> list[dict]:
33
+ part = PARTS[kind]
34
+ if not doc.exists(part):
35
+ return []
36
+ notes = []
37
+ for node in doc.xml(part).iter(kit.q(kind)):
38
+ note_type = node.get(kit.q("type")) or "normal"
39
+ if note_type in SEPARATOR_TYPES:
40
+ continue
41
+ paragraphs = [kit.text_of(paragraph).strip() for paragraph in node.iter(kit.q("p"))]
42
+ notes.append(
43
+ {
44
+ "kind": kind,
45
+ "id": node.get(kit.q("id")),
46
+ "text": " ".join(text for text in paragraphs if text),
47
+ "empty": not any(text for text in paragraphs),
48
+ }
49
+ )
50
+ return notes
51
+
52
+
53
+ def references(doc: kit.Docx, kind: str) -> list[str]:
54
+ return [node.get(kit.q("id")) for node in doc.xml("word/document.xml").iter(kit.q(kind + "Reference"))]
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ parser = argparse.ArgumentParser(description="Report footnote/endnote content and reference counts in a .docx.")
59
+ parser.add_argument("docx", help="Input .docx")
60
+ args = parser.parse_args(argv)
61
+
62
+ try:
63
+ doc = kit.Docx(args.docx)
64
+ except (OSError, KeyError) as exc:
65
+ print("error: %s" % exc, file=sys.stderr)
66
+ return 2
67
+
68
+ payload = {"ok": True, "input": str(Path(args.docx)), "notes": {}, "references": {}, "orphans": {}}
69
+ for kind in ("footnote", "endnote"):
70
+ notes = collect(doc, kind)
71
+ refs = references(doc, kind)
72
+ ids = {note["id"] for note in notes}
73
+ payload["notes"][kind] = notes
74
+ payload["references"][kind] = {"count": len(refs), "ids": refs}
75
+ payload["orphans"][kind] = {
76
+ "unreferenced_notes": sorted(ids - set(refs), key=lambda value: int(value) if (value or "").lstrip("-").isdigit() else 0),
77
+ "dangling_references": [value for value in refs if value not in ids],
78
+ }
79
+ kit.emit(payload)
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())