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,158 @@
1
+ #!/usr/bin/env python
2
+ """Report, accept, or reject tracked revisions in a .docx.
3
+
4
+ Revision marks live inline in the WordprocessingML: ``w:ins`` wraps content that
5
+ was added, ``w:del`` wraps content that was removed, and paragraph-mark changes
6
+ sit in ``w:rPrChange``/``w:pPrChange``. Accepting keeps inserted content and
7
+ drops deleted content; rejecting does the reverse. Format-only revisions are
8
+ reported but always dropped because they carry no text.
9
+
10
+ Exit codes: 0 success, 1 nothing to report while --mode report, 2 bad input.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
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
+ REVISION_TAGS = {
30
+ "ins": "insertion",
31
+ "del": "deletion",
32
+ "moveFrom": "move_from",
33
+ "moveTo": "move_to",
34
+ "rPrChange": "format_change",
35
+ "pPrChange": "format_change",
36
+ "sectPrChange": "format_change",
37
+ "tblPrChange": "format_change",
38
+ "trPrChange": "format_change",
39
+ "tcPrChange": "format_change",
40
+ }
41
+ REMOVABLE_TEXT_TAGS = ("ins", "del", "moveFrom", "moveTo")
42
+
43
+
44
+ def scan(doc: kit.Docx) -> list[dict]:
45
+ rows = []
46
+ for part in doc.text_parts():
47
+ for node in doc.xml(part).iter():
48
+ if not isinstance(node.tag, str) or not node.tag.startswith("{%s}" % kit.W):
49
+ continue
50
+ local = node.tag.split("}", 1)[1]
51
+ if local not in REVISION_TAGS:
52
+ continue
53
+ rows.append(
54
+ {
55
+ "part": part,
56
+ "type": REVISION_TAGS[local],
57
+ "author": node.get(kit.q("author")) or "",
58
+ "date": node.get(kit.q("date")) or "",
59
+ "id": node.get(kit.q("id")) or "",
60
+ "text": kit.text_of(node)[:120],
61
+ }
62
+ )
63
+ return rows
64
+
65
+
66
+ def unwrap(node) -> None:
67
+ parent = node.getparent()
68
+ if parent is None:
69
+ return
70
+ index = parent.index(node)
71
+ for child in list(node):
72
+ parent.insert(index, child)
73
+ index += 1
74
+ parent.remove(node)
75
+
76
+
77
+ def convert_deleted_text(node) -> None:
78
+ for del_text in list(node.iter(kit.q("delText"))):
79
+ del_text.tag = kit.q("t")
80
+
81
+
82
+ def apply(doc: kit.Docx, mode: str) -> int:
83
+ """Apply accept or reject across every text-bearing part."""
84
+ touched = 0
85
+ for part in doc.text_parts():
86
+ root = doc.xml(part)
87
+ nodes = [node for node in root.iter() if isinstance(node.tag, str) and node.tag.startswith("{%s}" % kit.W) and node.tag.split("}", 1)[1] in REMOVABLE_TEXT_TAGS]
88
+ for node in reversed(nodes):
89
+ local = node.tag.split("}", 1)[1]
90
+ keep = (local in ("ins", "moveTo")) == (mode == "accept")
91
+ if keep:
92
+ if local == "del" or local == "moveFrom":
93
+ convert_deleted_text(node)
94
+ unwrap(node)
95
+ else:
96
+ parent = node.getparent()
97
+ if parent is not None:
98
+ parent.remove(node)
99
+ touched += 1
100
+ # Paragraph-mark revisions have no text: drop them either way so a
101
+ # reopened document no longer claims to be dirty.
102
+ for local in ("rPrChange", "pPrChange", "sectPrChange", "tblPrChange", "trPrChange", "tcPrChange"):
103
+ for node in list(root.iter(kit.q(local))):
104
+ parent = node.getparent()
105
+ if parent is not None:
106
+ parent.remove(node)
107
+ touched += 1
108
+ return touched
109
+
110
+
111
+ def main(argv: list[str] | None = None) -> int:
112
+ parser = argparse.ArgumentParser(description="Report, accept, or reject tracked changes in a .docx.")
113
+ parser.add_argument("in_docx", help="Input .docx")
114
+ parser.add_argument("--mode", required=True, choices=["report", "accept", "reject"])
115
+ parser.add_argument("--out", help="Output .docx (required for accept/reject)")
116
+ parser.add_argument("--keep_tracking_on", action="store_true", help="Leave the track-revisions flag enabled in settings.xml")
117
+ args = parser.parse_args(argv)
118
+
119
+ try:
120
+ doc = kit.Docx(args.in_docx)
121
+ except (OSError, KeyError) as exc:
122
+ print("error: %s" % exc, file=sys.stderr)
123
+ return 2
124
+
125
+ revisions = scan(doc)
126
+ counts: dict[str, int] = {}
127
+ for row in revisions:
128
+ counts[row["type"]] = counts.get(row["type"], 0) + 1
129
+
130
+ if args.mode == "report":
131
+ kit.emit({"ok": True, "input": str(Path(args.in_docx)), "total": len(revisions), "counts": counts, "revisions": revisions})
132
+ return 0 if revisions else 1
133
+
134
+ if not args.out:
135
+ parser.error("--out is required for accept/reject")
136
+ touched = apply(doc, args.mode)
137
+
138
+ settings = doc.xml("word/settings.xml")
139
+ if not args.keep_tracking_on:
140
+ for node in list(settings.iter(kit.q("trackRevisions"))):
141
+ settings.remove(node)
142
+
143
+ doc.save(args.out)
144
+ kit.emit(
145
+ {
146
+ "ok": True,
147
+ "mode": args.mode,
148
+ "output": str(Path(args.out)),
149
+ "revisions_seen": len(revisions),
150
+ "nodes_rewritten": touched,
151
+ "counts": counts,
152
+ }
153
+ )
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python
2
+ """Turn find/replace edits into tracked revisions (delete + insert).
3
+
4
+ Each ``--replace OLD=NEW`` finds the literal text inside a paragraph and
5
+ rewrites just the runs it covers: the old characters become a ``w:del`` and the
6
+ new characters a ``w:ins``, both attributed to ``--author``. Word then shows
7
+ the edit as a suggestion instead of an opaque rewrite. Tracking is switched on
8
+ in settings.xml so follow-up edits are recorded too.
9
+
10
+ Exit codes: 0 success (replacements may still be zero), 1 no replacement matched,
11
+ 2 bad input or malformed --replace.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import datetime
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from lxml import etree
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+ import docx_kit as kit
25
+
26
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
27
+ for _stream in (sys.stdout, sys.stderr):
28
+ try:
29
+ _stream.reconfigure(encoding="utf-8", errors="replace")
30
+ except (AttributeError, ValueError):
31
+ pass
32
+
33
+
34
+ def split_pair(raw: str) -> tuple[str, str]:
35
+ if "=" not in raw:
36
+ raise argparse.ArgumentTypeError("--replace expects OLD=NEW, got %r" % raw)
37
+ old, new = raw.split("=", 1)
38
+ if not old:
39
+ raise argparse.ArgumentTypeError("--replace needs a non-empty OLD value")
40
+ return old, new
41
+
42
+
43
+ def run_text(run) -> str:
44
+ return kit.text_of(run)
45
+
46
+
47
+ def tracked_replace(paragraph, old: str, new: str, author: str, stamp: str, counter: list[int]) -> bool:
48
+ full = kit.paragraph_text(paragraph)
49
+ start = full.find(old)
50
+ if start < 0:
51
+ return False
52
+ end = start + len(old)
53
+ runs = [child for child in paragraph if child.tag == kit.q("r")]
54
+ if not runs:
55
+ return False
56
+
57
+ out: list = []
58
+ deleted: list = []
59
+ insert_at: int | None = None
60
+ cursor = 0
61
+ for run in runs:
62
+ text = run_text(run)
63
+ rpr = run.find(kit.q("rPr"))
64
+ left_end = start
65
+ right_start = end
66
+ run_start, run_end = cursor, cursor + len(text)
67
+ cursor = run_end
68
+ if run_end <= left_end or run_start >= right_start:
69
+ out.append(run)
70
+ continue
71
+ left = text[: max(0, left_end - run_start)]
72
+ middle = text[max(0, left_end - run_start) : min(len(text), right_start - run_start)]
73
+ right = text[min(len(text), right_start - run_start) :]
74
+ if left:
75
+ out.append(kit.make_run(left, rpr))
76
+ if middle:
77
+ if insert_at is None:
78
+ insert_at = len(out)
79
+ deleted.append(kit.make_run(middle, rpr, tag="delText"))
80
+ if right:
81
+ out.append(kit.make_run(right, rpr))
82
+
83
+ if not deleted:
84
+ return False
85
+
86
+ ident = counter[0]
87
+ counter[0] += 2
88
+ deletion = etree.Element(kit.q("del"))
89
+ deletion.set(kit.q("id"), str(ident))
90
+ deletion.set(kit.q("author"), author)
91
+ deletion.set(kit.q("date"), stamp)
92
+ for node in deleted:
93
+ deletion.append(node)
94
+ insertion = etree.Element(kit.q("ins"))
95
+ insertion.set(kit.q("id"), str(ident + 1))
96
+ insertion.set(kit.q("author"), author)
97
+ insertion.set(kit.q("date"), stamp)
98
+ insertion.append(kit.make_run(new))
99
+
100
+ position = insert_at if insert_at is not None else len(out)
101
+ out.insert(position, deletion)
102
+ out.insert(position + 1, insertion)
103
+
104
+ ppr = paragraph.find(kit.q("pPr"))
105
+ for child in list(paragraph):
106
+ if child is not ppr:
107
+ paragraph.remove(child)
108
+ for child in out:
109
+ paragraph.append(child)
110
+ return True
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(description="Record find/replace edits as tracked deletions and insertions.")
115
+ parser.add_argument("in_docx", help="Input .docx")
116
+ parser.add_argument("--out", required=True, help="Output .docx")
117
+ parser.add_argument("--author", default="dsh-office", help="Revision author shown in Word")
118
+ parser.add_argument("--replace", action="append", default=[], help="Replacement formatted as OLD=NEW (repeatable)")
119
+ parser.add_argument("--date", default=None, help="ISO timestamp for the revisions (default: now, UTC)")
120
+ args = parser.parse_args(argv)
121
+
122
+ try:
123
+ doc = kit.Docx(args.in_docx)
124
+ except (OSError, KeyError) as exc:
125
+ print("error: %s" % exc, file=sys.stderr)
126
+ return 2
127
+ if not args.replace:
128
+ parser.error("at least one --replace OLD=NEW is required")
129
+
130
+ stamp = args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
131
+ counter = [doc.next_id()]
132
+ applied = []
133
+ for raw in args.replace:
134
+ try:
135
+ old, new = split_pair(raw)
136
+ except argparse.ArgumentTypeError as exc:
137
+ print("error: %s" % exc, file=sys.stderr)
138
+ return 2
139
+ hits = 0
140
+ for paragraph in doc.paragraphs():
141
+ if tracked_replace(paragraph, old, new, args.author, stamp, counter):
142
+ hits += 1
143
+ applied.append({"old": old, "new": new, "paragraphs": hits})
144
+
145
+ settings = doc.xml("word/settings.xml")
146
+ if settings.find(kit.q("trackRevisions")) is None:
147
+ settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
148
+
149
+ doc.save(args.out)
150
+ kit.emit({"ok": True, "output": str(Path(args.out)), "author": args.author, "replacements": applied})
151
+ return 0 if any(item["paragraphs"] for item in applied) else 1
152
+
153
+
154
+ if __name__ == "__main__":
155
+ raise SystemExit(main())
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ """Append blocks to the end of an existing Word document.
3
+
4
+ The block builder (headings, paragraphs, bullets, numbered lists, tables,
5
+ images, equations, quotes, page breaks, spacers) is the helper CLI's own
6
+ `_docx_add_blocks`, imported rather than re-implemented so a spec that works
7
+ with `docx create` works here too.
8
+
9
+ Usage:
10
+ append_blocks.py --input IN.docx [--out OUT.docx] --blocks blocks.json
11
+ append_blocks.py --input IN.docx [--out OUT.docx] --spec spec.json
12
+
13
+ `--blocks` takes a JSON list of blocks or {"blocks": [...]}; `--spec` takes a
14
+ document spec whose `blocks` (or `sections`) array is appended.
15
+
16
+ Exit codes: 0 appended, 1 the document or a block could not be handled, 2
17
+ argparse usage error.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+
28
+ import docx_kit as kit # noqa: E402
29
+
30
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
31
+ for _stream in (sys.stdout, sys.stderr):
32
+ try:
33
+ _stream.reconfigure(encoding="utf-8", errors="replace")
34
+ except (AttributeError, ValueError):
35
+ pass
36
+
37
+ COUNTER_KEYS = ("headings", "paragraphs", "list_items", "tables", "images", "equations", "equation_fallbacks")
38
+
39
+
40
+ def fail(payload: dict, code: int = 1) -> int:
41
+ kit.emit({"ok": False, **payload})
42
+ return code
43
+
44
+
45
+ def load_builder():
46
+ cli = kit.office_cli_path()
47
+ if cli is None:
48
+ raise FileNotFoundError("helper CLI not found; set DSH_OFFICE_CLI to python/office_cli.py")
49
+ sys.path.insert(0, str(cli.parent))
50
+ import office_cli
51
+
52
+ return office_cli
53
+
54
+
55
+ def load_blocks(args) -> list[dict]:
56
+ raw = args.blocks or args.spec
57
+ text = raw.strip()
58
+ if text.startswith(("[", "{")):
59
+ payload = json.loads(text)
60
+ else:
61
+ payload = json.loads(Path(text).expanduser().read_text(encoding="utf-8"))
62
+ if isinstance(payload, list):
63
+ blocks = payload
64
+ else:
65
+ blocks = payload.get("blocks", payload.get("sections", []))
66
+ if not isinstance(blocks, list):
67
+ raise ValueError("blocks must be a JSON array")
68
+ return [block for block in blocks if isinstance(block, dict)]
69
+
70
+
71
+ def main() -> int:
72
+ parser = argparse.ArgumentParser(description="Append content blocks to a .docx copy.")
73
+ parser.add_argument("--input", required=True, help="source .docx (never modified)")
74
+ parser.add_argument("--out", help="output path; default <name>-edited.docx")
75
+ parser.add_argument("--blocks", help="JSON file holding an array of blocks")
76
+ parser.add_argument("--spec", help="JSON document spec; its blocks array is appended")
77
+ args = parser.parse_args()
78
+
79
+ if not (args.blocks or args.spec):
80
+ return fail({"error": "pass --blocks or --spec"}, 2)
81
+
82
+ source = Path(args.input).expanduser()
83
+ if not source.is_file():
84
+ return fail({"error": f"input not found: {args.input}"})
85
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
86
+ if out.resolve() == source.resolve():
87
+ return fail({"error": "refusing to overwrite the input; pass a different --out"})
88
+
89
+ try:
90
+ blocks = load_blocks(args)
91
+ except (OSError, ValueError) as exc:
92
+ return fail({"error": f"cannot read blocks: {exc}"}, 2)
93
+
94
+ try:
95
+ office_cli = load_builder()
96
+ except FileNotFoundError as exc:
97
+ return fail({"error": str(exc)})
98
+
99
+ from docx import Document
100
+
101
+ document = Document(str(source))
102
+ counters = {key: 0 for key in COUNTER_KEYS}
103
+ try:
104
+ office_cli._docx_add_blocks(document, blocks, counters)
105
+ except SystemExit as exc: # the builder reports bad blocks through the CLI
106
+ return fail({"error": f"a block was rejected: {exc}", "counts": counters})
107
+
108
+ out.parent.mkdir(parents=True, exist_ok=True)
109
+ document.save(str(out))
110
+ kit.emit({"ok": True, "input": str(source), "output": str(out), "appended": len(blocks), "counts": counters})
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python
2
+ """Merge a style pack (another .docx/.dotx) into a target .docx.
3
+
4
+ Only the formatting layer is transplanted: styles, and the numbering
5
+ definitions the styles point at. Body content, media, headers and section
6
+ geometry stay exactly as they are in the target. Styles that share an id are
7
+ replaced, target-only styles survive, and template-only styles are appended so
8
+ named styles referenced by the pack resolve.
9
+
10
+ Exit codes: 0 success, 1 template has no styles part, 2 bad input.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ from lxml import etree
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ import docx_kit as kit
24
+
25
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
26
+ for _stream in (sys.stdout, sys.stderr):
27
+ try:
28
+ _stream.reconfigure(encoding="utf-8", errors="replace")
29
+ except (AttributeError, ValueError):
30
+ pass
31
+
32
+
33
+ def load_xml(path: Path, part: str):
34
+ with zipfile.ZipFile(path) as zf:
35
+ if part not in zf.namelist():
36
+ return None
37
+ return etree.fromstring(zf.read(part))
38
+
39
+
40
+ def merge_styles(doc: kit.Docx, template_styles) -> dict:
41
+ target = doc.xml("word/styles.xml")
42
+ incoming = {node.get(kit.q("styleId")): node for node in template_styles.findall(kit.q("style")) if node.get(kit.q("styleId"))}
43
+ existing = {node.get(kit.q("styleId")): node for node in target.findall(kit.q("style")) if node.get(kit.q("styleId"))}
44
+ replaced = added = 0
45
+ for style_id, node in incoming.items():
46
+ clone = etree.fromstring(etree.tostring(node))
47
+ if style_id in existing:
48
+ target.replace(existing[style_id], clone)
49
+ existing[style_id] = clone
50
+ replaced += 1
51
+ else:
52
+ target.append(clone)
53
+ existing[style_id] = clone
54
+ added += 1
55
+ template_defaults = template_styles.find(kit.q("docDefaults"))
56
+ if template_defaults is not None:
57
+ clone = etree.fromstring(etree.tostring(template_defaults))
58
+ current = target.find(kit.q("docDefaults"))
59
+ if current is not None:
60
+ target.replace(current, clone)
61
+ else:
62
+ target.insert(0, clone)
63
+ return {"replaced": replaced, "added": added, "total": len(existing)}
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ parser = argparse.ArgumentParser(description="Apply the styles and numbering of a template .docx/.dotx to a target .docx.")
68
+ parser.add_argument("--template", required=True, help="Template or style-pack .docx/.dotx")
69
+ parser.add_argument("--target", required=True, help="Document whose styles should be updated")
70
+ parser.add_argument("--out", required=True, help="Output .docx (never overwrite the target)")
71
+ args = parser.parse_args(argv)
72
+
73
+ template_path = Path(args.template)
74
+ if template_path.suffix.lower() == ".dotx":
75
+ # A .dotx is a .docx package with a different content type; reading the
76
+ # zip directly works the same, so no conversion is needed.
77
+ pass
78
+ try:
79
+ template_styles = load_xml(template_path, "word/styles.xml")
80
+ except OSError as exc:
81
+ print("error: %s" % exc, file=sys.stderr)
82
+ return 2
83
+ if template_styles is None:
84
+ print("error: template has no word/styles.xml", file=sys.stderr)
85
+ return 1
86
+
87
+ try:
88
+ doc = kit.Docx(args.target)
89
+ except (OSError, KeyError) as exc:
90
+ print("error: %s" % exc, file=sys.stderr)
91
+ return 2
92
+
93
+ stats = merge_styles(doc, template_styles)
94
+ template_numbering = None
95
+ try:
96
+ template_numbering = load_xml(template_path, "word/numbering.xml")
97
+ except OSError:
98
+ template_numbering = None
99
+ if template_numbering is not None:
100
+ doc.set_xml("word/numbering.xml", etree.fromstring(etree.tostring(template_numbering)))
101
+ doc.ensure_override("word/numbering.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml")
102
+ doc.ensure_rel("word/document.xml", kit.REL_NUMBERING, "numbering.xml")
103
+ stats["numbering"] = "copied"
104
+
105
+ doc.save(args.out)
106
+ kit.emit({"ok": True, "template": str(template_path), "target": str(Path(args.target)), "output": str(Path(args.out)), "styles": stats})
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python
2
+ """Add numbered captions under tables and figures, with optional bookmarks.
3
+
4
+ The number is written as a ``SEQ`` field by default so Word renumbers when
5
+ captions move, with the current value cached as the field result so a renderer
6
+ that skips fields still shows a number. ``--bookmarks`` adds stable bookmark
7
+ names (tbl1, fig1, ...) which ``insert_ref_fields.py`` can then reference.
8
+
9
+ Exit codes: 0 success, 2 bad input, 3 --materialize requested alongside a field
10
+ mode conflict (never raised: materialize simply writes literal numbers).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from lxml import etree
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ import docx_kit as kit
23
+
24
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
25
+ for _stream in (sys.stdout, sys.stderr):
26
+ try:
27
+ _stream.reconfigure(encoding="utf-8", errors="replace")
28
+ except (AttributeError, ValueError):
29
+ pass
30
+
31
+ CAPTION_LABEL = {"table": "Table", "figure": "Figure"}
32
+
33
+
34
+ def caption_paragraph(label: str, number: int, text: str, bookmark: str | None, ident: int, materialize: bool):
35
+ paragraph = etree.Element(kit.q("p"))
36
+ props = etree.SubElement(paragraph, kit.q("pPr"))
37
+ etree.SubElement(props, kit.q("pStyle")).set(kit.q("val"), "Caption")
38
+ paragraph.append(kit.make_run("%s " % label))
39
+ if bookmark:
40
+ start = etree.SubElement(paragraph, kit.q("bookmarkStart"))
41
+ start.set(kit.q("id"), str(ident))
42
+ start.set(kit.q("name"), bookmark)
43
+ if materialize:
44
+ paragraph.append(kit.make_run(str(number)))
45
+ else:
46
+ for run in kit.field_runs("SEQ %s \\* ARABIC" % label, str(number)):
47
+ paragraph.append(run)
48
+ if bookmark:
49
+ end = etree.SubElement(paragraph, kit.q("bookmarkEnd"))
50
+ end.set(kit.q("id"), str(ident))
51
+ if text:
52
+ paragraph.append(kit.make_run(". " + text))
53
+ return paragraph
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = argparse.ArgumentParser(description="Insert numbered table/figure captions, optionally bookmarking them.")
58
+ parser.add_argument("input_docx", help="Input .docx")
59
+ parser.add_argument("output_docx", help="Output .docx")
60
+ parser.add_argument("--tables", action="store_true", help="Caption tables")
61
+ parser.add_argument("--figures", action="store_true", help="Caption figures (paragraphs containing a drawing)")
62
+ parser.add_argument("--caption_text", default="", help="Text placed after the number")
63
+ parser.add_argument("--bookmarks", action="store_true", help="Add tblN/figN bookmarks around the number")
64
+ parser.add_argument("--materialize", action="store_true", help="Write literal numbers instead of SEQ fields")
65
+ args = parser.parse_args(argv)
66
+
67
+ try:
68
+ doc = kit.Docx(args.input_docx)
69
+ except (OSError, KeyError) as exc:
70
+ print("error: %s" % exc, file=sys.stderr)
71
+ return 2
72
+
73
+ do_tables = args.tables or args.figures
74
+ want_tables = args.tables or not do_tables
75
+ want_figures = args.figures or not do_tables
76
+
77
+ body = doc.body()
78
+ tables = list(body.iter(kit.q("tbl"))) if want_tables else []
79
+ figures = []
80
+ if want_figures:
81
+ for paragraph in body.iter(kit.q("p")):
82
+ if paragraph.find(".//" + kit.q("drawing")) is not None or paragraph.find(".//" + kit.q("pict", kit.V)) is not None:
83
+ figures.append(paragraph)
84
+
85
+ ident = doc.next_id()
86
+ added = []
87
+ for index, table in enumerate(tables, start=1):
88
+ bookmark = "tbl%d" % index if args.bookmarks else None
89
+ paragraph = caption_paragraph(CAPTION_LABEL["table"], index, args.caption_text, bookmark, ident, args.materialize)
90
+ table.addnext(paragraph)
91
+ added.append({"kind": "table", "number": index, "bookmark": bookmark})
92
+ ident += 1
93
+ for index, figure in enumerate(figures, start=1):
94
+ bookmark = "fig%d" % index if args.bookmarks else None
95
+ paragraph = caption_paragraph(CAPTION_LABEL["figure"], index, args.caption_text, bookmark, ident, args.materialize)
96
+ figure.addnext(paragraph)
97
+ added.append({"kind": "figure", "number": index, "bookmark": bookmark})
98
+ ident += 1
99
+
100
+ if not added:
101
+ print("error: no tables or figures found to caption", file=sys.stderr)
102
+ return 2
103
+ doc.save(args.output_docx)
104
+ kit.emit({"ok": True, "output": str(Path(args.output_docx)), "captions": added})
105
+ return 0
106
+
107
+
108
+ if __name__ == "__main__":
109
+ raise SystemExit(main())