dsh-office-kit 0.2.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 (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 +92 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -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 +136 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -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 +176 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -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 +253 -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 +239 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +171 -246
@@ -0,0 +1,225 @@
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
+ PKG_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
30
+ REL_HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
31
+
32
+
33
+ def stamp(args) -> str:
34
+ return args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
35
+
36
+
37
+ def enable_track(doc: kit.Docx) -> str:
38
+ settings = doc.xml("word/settings.xml")
39
+ if settings.find(kit.q("trackRevisions")) is None:
40
+ settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
41
+ return "trackRevisions enabled"
42
+
43
+
44
+ def tracked_replace(doc: kit.Docx, ins_id: str, new_text: str, author: str, date: str) -> str:
45
+ for part in doc.text_parts():
46
+ for node in doc.xml(part).iter(kit.q("ins")):
47
+ if (node.get(kit.q("id")) or "") != str(ins_id):
48
+ continue
49
+ parent = node.getparent()
50
+ ident = doc.next_id()
51
+ deletion = etree.Element(kit.q("del"))
52
+ deletion.set(kit.q("id"), str(ident))
53
+ deletion.set(kit.q("author"), author)
54
+ deletion.set(kit.q("date"), date)
55
+ for child in list(node):
56
+ clone = etree.fromstring(etree.tostring(child))
57
+ for text in clone.iter(kit.q("t")):
58
+ text.tag = kit.q("delText")
59
+ deletion.append(clone)
60
+ insertion = etree.Element(kit.q("ins"))
61
+ insertion.set(kit.q("id"), str(ident + 1))
62
+ insertion.set(kit.q("author"), author)
63
+ insertion.set(kit.q("date"), date)
64
+ insertion.append(kit.make_run(new_text))
65
+ position = parent.index(node)
66
+ parent.remove(node)
67
+ parent.insert(position, deletion)
68
+ parent.insert(position + 1, insertion)
69
+ return "converted ins %s to del+ins in %s" % (ins_id, part)
70
+ return ""
71
+
72
+
73
+ def find_paragraph(doc: kit.Docx, contains: str | None, indent: int | None):
74
+ for paragraph in doc.paragraphs():
75
+ props = paragraph.find(kit.q("pPr"))
76
+ if contains is not None and contains not in kit.paragraph_text(paragraph):
77
+ continue
78
+ if indent is not None:
79
+ ind = props.find(kit.q("ind")) if props is not None else None
80
+ if ind is None or (ind.get(kit.q("left")) or "") != str(indent):
81
+ continue
82
+ return paragraph
83
+ return None
84
+
85
+
86
+ def add_comment(doc: kit.Docx, paragraph, text: str, author: str, date: str, explicit_id: str | None) -> str:
87
+ if not doc.exists("word/comments.xml"):
88
+ root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
89
+ doc.set_xml("word/comments.xml", root)
90
+ doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
91
+ doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
92
+ root = doc.xml("word/comments.xml")
93
+ ident = int(explicit_id) if explicit_id and explicit_id.isdigit() else doc.next_id()
94
+ comment = etree.SubElement(root, kit.q("comment"))
95
+ comment.set(kit.q("id"), str(ident))
96
+ comment.set(kit.q("author"), author)
97
+ comment.set(kit.q("initials"), author[:2].upper())
98
+ comment.set(kit.q("date"), date)
99
+ body = etree.SubElement(comment, kit.q("p"))
100
+ run = etree.SubElement(body, kit.q("r"))
101
+ node = etree.SubElement(run, kit.q("t"))
102
+ node.text = text
103
+ node.set(kit.q("space"), kit.XML)
104
+
105
+ start = etree.Element(kit.q("commentRangeStart"))
106
+ start.set(kit.q("id"), str(ident))
107
+ position = 1 if paragraph.find(kit.q("pPr")) is not None else 0
108
+ paragraph.insert(position, start)
109
+ end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
110
+ end.set(kit.q("id"), str(ident))
111
+ ref_run = etree.SubElement(paragraph, kit.q("r"))
112
+ etree.SubElement(ref_run, kit.q("commentReference")).set(kit.q("id"), str(ident))
113
+ return "comment %d anchored" % ident
114
+
115
+
116
+ def header_date(doc: kit.Docx, text: str) -> str:
117
+ part = doc.ensure_header_footer("header")
118
+ paragraph = doc.xml(part).find(".//" + kit.q("p"))
119
+ if paragraph is None:
120
+ paragraph = etree.SubElement(doc.xml(part), kit.q("p"))
121
+ props = paragraph.find(kit.q("pPr"))
122
+ if props is None:
123
+ props = etree.Element(kit.q("pPr"))
124
+ paragraph.insert(0, props)
125
+ for existing in props.findall(kit.q("jc")):
126
+ props.remove(existing)
127
+ etree.SubElement(props, kit.q("jc")).set(kit.q("val"), "right")
128
+ kit.set_paragraph_text(paragraph, text)
129
+ return "header set in %s" % part
130
+
131
+
132
+ def page_numbers(doc: kit.Docx) -> str:
133
+ part = doc.ensure_header_footer("footer")
134
+ paragraph = doc.xml(part).find(".//" + kit.q("p"))
135
+ if paragraph is None:
136
+ paragraph = etree.SubElement(doc.xml(part), kit.q("p"))
137
+ props = paragraph.find(kit.q("pPr"))
138
+ if props is None:
139
+ props = etree.Element(kit.q("pPr"))
140
+ paragraph.insert(0, props)
141
+ etree.SubElement(props, kit.q("jc")).set(kit.q("val"), "center")
142
+ for run in kit.field_runs("PAGE", "1"):
143
+ paragraph.append(run)
144
+ return "PAGE field added to %s" % part
145
+
146
+
147
+ def hyperlink_first(doc: kit.Docx, url: str) -> str:
148
+ for paragraph in doc.paragraphs():
149
+ runs = [child for child in paragraph if child.tag == kit.q("r") and kit.text_of(child).strip()]
150
+ if not runs:
151
+ continue
152
+ rid = doc.ensure_rel("word/document.xml", REL_HYPERLINK, url, external=True)
153
+ link = etree.Element(kit.q("hyperlink"))
154
+ link.set(kit.q("id", kit.R), rid)
155
+ position = paragraph.index(runs[0])
156
+ for run in runs:
157
+ paragraph.remove(run)
158
+ link.append(run)
159
+ paragraph.insert(position, link)
160
+ return "wrapped %d run(s) in hyperlink %s" % (len(runs), url)
161
+ return ""
162
+
163
+
164
+ def main(argv: list[str] | None = None) -> int:
165
+ parser = argparse.ArgumentParser(description="Apply targeted OOXML patches that python-docx cannot express.")
166
+ parser.add_argument("docx", help="Input .docx")
167
+ parser.add_argument("--out", default=None, help="Output .docx (default: overwrite the input)")
168
+ parser.add_argument("--enable-track", action="store_true")
169
+ parser.add_argument("--author", default="dsh-office")
170
+ parser.add_argument("--date", default=None, help="ISO timestamp for revisions and comments")
171
+ parser.add_argument("--tracked-replace-ins-id", default=None)
172
+ parser.add_argument("--new-text", default="")
173
+ parser.add_argument("--add-comment", action="store_true")
174
+ parser.add_argument("--comment-text", default="")
175
+ parser.add_argument("--contains", default=None, help="Target paragraph containing this text")
176
+ parser.add_argument("--indent-left-twips", type=int, default=None, help="Target paragraph with this left indent")
177
+ parser.add_argument("--comment-id", default=None)
178
+ parser.add_argument("--header-date", nargs="?", const="auto", default=None, help="Right-aligned header line; omit the value to use today's date")
179
+ parser.add_argument("--add-page-numbers", action="store_true")
180
+ parser.add_argument("--hyperlink-first", default=None)
181
+ args = parser.parse_args(argv)
182
+
183
+ try:
184
+ doc = kit.Docx(args.docx)
185
+ except (OSError, KeyError) as exc:
186
+ print("error: %s" % exc, file=sys.stderr)
187
+ return 2
188
+
189
+ actions = []
190
+ missing = []
191
+ when = stamp(args)
192
+
193
+ if args.enable_track:
194
+ actions.append(enable_track(doc))
195
+ if args.tracked_replace_ins_id is not None:
196
+ result = tracked_replace(doc, args.tracked_replace_ins_id, args.new_text, args.author, when)
197
+ actions.append(result) if result else missing.append("ins id %s" % args.tracked_replace_ins_id)
198
+ if args.add_comment:
199
+ paragraph = find_paragraph(doc, args.contains, args.indent_left_twips)
200
+ if paragraph is None:
201
+ missing.append("paragraph for --add-comment")
202
+ else:
203
+ actions.append(add_comment(doc, paragraph, args.comment_text, args.author, when, args.comment_id))
204
+ if args.header_date is not None:
205
+ from datetime import date
206
+
207
+ stamp_text = date.today().isoformat() if args.header_date == "auto" else args.header_date
208
+ actions.append(header_date(doc, stamp_text))
209
+ if args.add_page_numbers:
210
+ actions.append(page_numbers(doc))
211
+ if args.hyperlink_first:
212
+ result = hyperlink_first(doc, args.hyperlink_first)
213
+ actions.append(result) if result else missing.append("paragraph for --hyperlink-first")
214
+
215
+ if not actions and not missing:
216
+ parser.error("no patch requested")
217
+
218
+ out = args.out or args.docx
219
+ doc.save(out)
220
+ kit.emit({"ok": not missing, "output": str(Path(out)), "actions": actions, "missing": missing})
221
+ return 1 if missing else 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
@@ -0,0 +1,99 @@
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
+
23
+ def cell_span(cell) -> int:
24
+ props = cell.find(kit.q("tcPr"))
25
+ if props is None:
26
+ return 1
27
+ span = props.find(kit.q("gridSpan"))
28
+ if span is None:
29
+ return 1
30
+ try:
31
+ return max(1, int(span.get(kit.q("val")) or 1))
32
+ except ValueError:
33
+ return 1
34
+
35
+
36
+ def vertical_merge(cell) -> str:
37
+ props = cell.find(kit.q("tcPr"))
38
+ if props is None:
39
+ return ""
40
+ merge = props.find(kit.q("vMerge"))
41
+ if merge is None:
42
+ return ""
43
+ return merge.get(kit.q("val")) or "continue"
44
+
45
+
46
+ def table_rows(table) -> list[list[str]]:
47
+ rows: list[list[str]] = []
48
+ grid_width = 0
49
+ for row in table.findall(kit.q("tr")):
50
+ values: list[str] = []
51
+ for cell in row.findall(kit.q("tc")):
52
+ text = " ".join(" ".join(kit.text_of(paragraph).split()) for paragraph in cell.findall(kit.q("p"))).strip()
53
+ span = cell_span(cell)
54
+ merge = vertical_merge(cell)
55
+ if merge == "continue":
56
+ column = len(values)
57
+ inherited = ""
58
+ if rows and column < len(rows[-1]):
59
+ inherited = rows[-1][column]
60
+ text = inherited
61
+ values.extend([text] * span)
62
+ grid_width = max(grid_width, len(values))
63
+ rows.append(values)
64
+ for row in rows:
65
+ while len(row) < grid_width:
66
+ row.append("")
67
+ return rows
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ parser = argparse.ArgumentParser(description="Export a table from a .docx to CSV, expanding merged cells.")
72
+ parser.add_argument("docx", help="Input .docx")
73
+ parser.add_argument("--table_index", type=int, default=0, help="Zero-based table index")
74
+ parser.add_argument("--out", required=True, help="Output .csv path")
75
+ args = parser.parse_args(argv)
76
+
77
+ try:
78
+ doc = kit.Docx(args.docx)
79
+ except (OSError, KeyError) as exc:
80
+ print("error: %s" % exc, file=sys.stderr)
81
+ return 2
82
+
83
+ tables = list(doc.xml("word/document.xml").iter(kit.q("tbl")))
84
+ if args.table_index < 0 or args.table_index >= len(tables):
85
+ print("error: table_index %d out of range (document has %d)" % (args.table_index, len(tables)), file=sys.stderr)
86
+ return 1
87
+
88
+ rows = table_rows(tables[args.table_index])
89
+ out = Path(args.out)
90
+ out.parent.mkdir(parents=True, exist_ok=True)
91
+ with out.open("w", encoding="utf-8", newline="") as handle:
92
+ csv.writer(handle).writerows(rows)
93
+
94
+ 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})
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
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
+
23
+ def freeze(doc: kit.Docx, allowed: set[str]) -> int:
24
+ flattened = 0
25
+ for part in doc.text_parts():
26
+ for paragraph in doc.paragraphs(part):
27
+ for field in reversed(kit.paragraph_fields(paragraph)):
28
+ if field["type"] not in allowed:
29
+ continue
30
+ span = field["span"]
31
+ if not span:
32
+ continue
33
+ first = span[0]
34
+ rpr = first.find(kit.q("rPr")) if first.tag == kit.q("r") else None
35
+ position = paragraph.index(first)
36
+ for node in span:
37
+ if node.getparent() is paragraph:
38
+ paragraph.remove(node)
39
+ if field["result"]:
40
+ run = kit.make_run(field["result"], etree.fromstring(etree.tostring(rpr)) if rpr is not None else None)
41
+ paragraph.insert(position, run)
42
+ flattened += 1
43
+ return flattened
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ parser = argparse.ArgumentParser(description="Replace SEQ/REF fields with their cached result text.")
48
+ parser.add_argument("input_docx", help="Input .docx")
49
+ parser.add_argument("--out", required=True, help="Output .docx")
50
+ parser.add_argument("--only", nargs="+", default=["SEQ", "REF"], choices=["SEQ", "REF"], help="Field types to freeze")
51
+ args = parser.parse_args(argv)
52
+
53
+ try:
54
+ doc = kit.Docx(args.input_docx)
55
+ except (OSError, KeyError) as exc:
56
+ print("error: %s" % exc, file=sys.stderr)
57
+ return 2
58
+
59
+ flattened = freeze(doc, set(args.only))
60
+ doc.save(args.out)
61
+ kit.emit({"ok": True, "output": str(Path(args.out)), "only": args.only, "fields_frozen": flattened})
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
@@ -0,0 +1,59 @@
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
+
22
+ def main(argv: list[str] | None = None) -> int:
23
+ parser = argparse.ArgumentParser(description="Report Word fields (TOC, SEQ, REF, PAGEREF, HYPERLINK, ...) in a .docx.")
24
+ parser.add_argument("docx", help="Input .docx")
25
+ parser.add_argument("--max_examples", type=int, default=8, help="How many sample instructions to keep per field type")
26
+ args = parser.parse_args(argv)
27
+
28
+ try:
29
+ doc = kit.Docx(args.docx)
30
+ except (OSError, KeyError) as exc:
31
+ print("error: %s" % exc, file=sys.stderr)
32
+ return 2
33
+
34
+ counts: dict[str, int] = {}
35
+ examples: dict[str, list[dict]] = {}
36
+ kinds: dict[str, int] = {}
37
+ for part, paragraph, field in kit.iter_fields(doc):
38
+ name = field["type"] or "UNKNOWN"
39
+ counts[name] = counts.get(name, 0) + 1
40
+ kinds[field["kind"]] = kinds.get(field["kind"], 0) + 1
41
+ bucket = examples.setdefault(name, [])
42
+ if len(bucket) < args.max_examples:
43
+ bucket.append({"part": part, "instr": field["instr"], "result": field["result"][:80]})
44
+
45
+ kit.emit(
46
+ {
47
+ "ok": True,
48
+ "input": str(Path(args.docx)),
49
+ "total": sum(counts.values()),
50
+ "counts": counts,
51
+ "kinds": kinds,
52
+ "examples": examples,
53
+ }
54
+ )
55
+ return 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
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
+ TARGETS = {"REF", "PAGEREF"}
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ parser = argparse.ArgumentParser(description="Flatten REF/PAGEREF cross-reference fields to literal text.")
27
+ parser.add_argument("in_docx", help="Input .docx")
28
+ parser.add_argument("--out", required=True, help="Output .docx")
29
+ args = parser.parse_args(argv)
30
+
31
+ try:
32
+ doc = kit.Docx(args.in_docx)
33
+ except (OSError, KeyError) as exc:
34
+ print("error: %s" % exc, file=sys.stderr)
35
+ return 2
36
+
37
+ flattened = 0
38
+ for part in doc.text_parts():
39
+ for paragraph in doc.paragraphs(part):
40
+ for field in reversed(kit.paragraph_fields(paragraph)):
41
+ if field["type"] not in TARGETS or not field["span"]:
42
+ continue
43
+ first = field["span"][0]
44
+ rpr = first.find(kit.q("rPr")) if first.tag == kit.q("r") else None
45
+ position = paragraph.index(first)
46
+ for node in field["span"]:
47
+ if node.getparent() is paragraph:
48
+ paragraph.remove(node)
49
+ if field["result"]:
50
+ paragraph.insert(
51
+ position,
52
+ kit.make_run(field["result"], etree.fromstring(etree.tostring(rpr)) if rpr is not None else None),
53
+ )
54
+ flattened += 1
55
+
56
+ doc.save(args.out)
57
+ kit.emit({"ok": True, "output": str(Path(args.out)), "flattened": flattened})
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())
@@ -0,0 +1,77 @@
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
+ PARTS = {"footnote": "word/footnotes.xml", "endnote": "word/endnotes.xml"}
22
+ SEPARATOR_TYPES = {"separator", "continuationSeparator", "continuationNotice"}
23
+
24
+
25
+ def collect(doc: kit.Docx, kind: str) -> list[dict]:
26
+ part = PARTS[kind]
27
+ if not doc.exists(part):
28
+ return []
29
+ notes = []
30
+ for node in doc.xml(part).iter(kit.q(kind)):
31
+ note_type = node.get(kit.q("type")) or "normal"
32
+ if note_type in SEPARATOR_TYPES:
33
+ continue
34
+ paragraphs = [kit.text_of(paragraph).strip() for paragraph in node.iter(kit.q("p"))]
35
+ notes.append(
36
+ {
37
+ "kind": kind,
38
+ "id": node.get(kit.q("id")),
39
+ "text": " ".join(text for text in paragraphs if text),
40
+ "empty": not any(text for text in paragraphs),
41
+ }
42
+ )
43
+ return notes
44
+
45
+
46
+ def references(doc: kit.Docx, kind: str) -> list[str]:
47
+ return [node.get(kit.q("id")) for node in doc.xml("word/document.xml").iter(kit.q(kind + "Reference"))]
48
+
49
+
50
+ def main(argv: list[str] | None = None) -> int:
51
+ parser = argparse.ArgumentParser(description="Report footnote/endnote content and reference counts in a .docx.")
52
+ parser.add_argument("docx", help="Input .docx")
53
+ args = parser.parse_args(argv)
54
+
55
+ try:
56
+ doc = kit.Docx(args.docx)
57
+ except (OSError, KeyError) as exc:
58
+ print("error: %s" % exc, file=sys.stderr)
59
+ return 2
60
+
61
+ payload = {"ok": True, "input": str(Path(args.docx)), "notes": {}, "references": {}, "orphans": {}}
62
+ for kind in ("footnote", "endnote"):
63
+ notes = collect(doc, kind)
64
+ refs = references(doc, kind)
65
+ ids = {note["id"] for note in notes}
66
+ payload["notes"][kind] = notes
67
+ payload["references"][kind] = {"count": len(refs), "ids": refs}
68
+ payload["orphans"][kind] = {
69
+ "unreferenced_notes": sorted(ids - set(refs), key=lambda value: int(value) if (value or "").lstrip("-").isdigit() else 0),
70
+ "dangling_references": [value for value in refs if value not in ids],
71
+ }
72
+ kit.emit(payload)
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python
2
+ """Undo the heading damage left by importing a document from a web editor.
3
+
4
+ Imports commonly arrive with a run of empty paragraphs before the body starts and
5
+ with the document title duplicated as a level-1 heading, which makes the outline
6
+ read "My Doc" twice and pushes the real first section down. The cleanup only
7
+ touches the leading region, so a deliberate title page further in is untouched.
8
+
9
+ Exit codes: 0 changed or nothing to change, 1 --check found problems, 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
+
22
+ def leading_region(body, limit: int) -> list:
23
+ seen = 0
24
+ region = []
25
+ for child in body:
26
+ if child.tag == kit.q("p") and kit.paragraph_text(child).strip():
27
+ seen += 1
28
+ if seen >= limit:
29
+ region.append(child)
30
+ break
31
+ region.append(child)
32
+ return region
33
+
34
+
35
+ def sanitize(body, limit: int) -> dict:
36
+ removed_empty = 0
37
+ removed_duplicate = 0
38
+ region = leading_region(body, limit)
39
+ for paragraph in list(region):
40
+ if paragraph.tag != kit.q("p") or kit.paragraph_text(paragraph).strip():
41
+ continue
42
+ parent = paragraph.getparent()
43
+ if parent is not None:
44
+ parent.remove(paragraph)
45
+ removed_empty += 1
46
+
47
+ region = leading_region(body, limit)
48
+ for index, paragraph in enumerate(region):
49
+ if paragraph.tag != kit.q("p") or kit.paragraph_style(paragraph) != "Title":
50
+ continue
51
+ title = kit.paragraph_text(paragraph).strip()
52
+ for follower in region[index + 1 :]:
53
+ if follower.tag != kit.q("p"):
54
+ continue
55
+ text = kit.paragraph_text(follower).strip()
56
+ if not text:
57
+ continue
58
+ if text == title and kit.heading_level(follower) == 1:
59
+ body.remove(follower)
60
+ removed_duplicate += 1
61
+ break
62
+ return {"removed_empty": removed_empty, "removed_duplicate_heading": removed_duplicate}
63
+
64
+
65
+ def main(argv: list[str] | None = None) -> int:
66
+ parser = argparse.ArgumentParser(description="Remove leading empty paragraphs and a duplicated title heading after import.")
67
+ parser.add_argument("input_docx", help="Input .docx")
68
+ parser.add_argument("--out", default=None, help="Output .docx")
69
+ parser.add_argument("--in-place", action="store_true", help="Rewrite the input file")
70
+ parser.add_argument("--check", action="store_true", help="Report only; exit 1 when cleanup is needed")
71
+ parser.add_argument("--leading-nonempty-paragraphs", type=int, default=3, help="How far into the document to look")
72
+ args = parser.parse_args(argv)
73
+
74
+ if not (args.out or args.in_place or args.check):
75
+ parser.error("choose --out, --in-place or --check")
76
+
77
+ try:
78
+ doc = kit.Docx(args.input_docx)
79
+ except (OSError, KeyError) as exc:
80
+ print("error: %s" % exc, file=sys.stderr)
81
+ return 2
82
+
83
+ result = sanitize(doc.body(), max(1, args.leading_nonempty_paragraphs))
84
+ changed = bool(result["removed_empty"] or result["removed_duplicate_heading"])
85
+
86
+ output = None
87
+ if args.in_place or args.out:
88
+ output = args.out or args.input_docx
89
+ doc.save(output)
90
+
91
+ kit.emit({"ok": not (args.check and changed), "changed": changed, "check": args.check, "output": str(Path(output)) if output else None, **result})
92
+ return 1 if (args.check and changed) else 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())