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,148 @@
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
+
27
+ def split_pair(raw: str) -> tuple[str, str]:
28
+ if "=" not in raw:
29
+ raise argparse.ArgumentTypeError("--replace expects OLD=NEW, got %r" % raw)
30
+ old, new = raw.split("=", 1)
31
+ if not old:
32
+ raise argparse.ArgumentTypeError("--replace needs a non-empty OLD value")
33
+ return old, new
34
+
35
+
36
+ def run_text(run) -> str:
37
+ return kit.text_of(run)
38
+
39
+
40
+ def tracked_replace(paragraph, old: str, new: str, author: str, stamp: str, counter: list[int]) -> bool:
41
+ full = kit.paragraph_text(paragraph)
42
+ start = full.find(old)
43
+ if start < 0:
44
+ return False
45
+ end = start + len(old)
46
+ runs = [child for child in paragraph if child.tag == kit.q("r")]
47
+ if not runs:
48
+ return False
49
+
50
+ out: list = []
51
+ deleted: list = []
52
+ insert_at: int | None = None
53
+ cursor = 0
54
+ for run in runs:
55
+ text = run_text(run)
56
+ rpr = run.find(kit.q("rPr"))
57
+ left_end = start
58
+ right_start = end
59
+ run_start, run_end = cursor, cursor + len(text)
60
+ cursor = run_end
61
+ if run_end <= left_end or run_start >= right_start:
62
+ out.append(run)
63
+ continue
64
+ left = text[: max(0, left_end - run_start)]
65
+ middle = text[max(0, left_end - run_start) : min(len(text), right_start - run_start)]
66
+ right = text[min(len(text), right_start - run_start) :]
67
+ if left:
68
+ out.append(kit.make_run(left, rpr))
69
+ if middle:
70
+ if insert_at is None:
71
+ insert_at = len(out)
72
+ deleted.append(kit.make_run(middle, rpr, tag="delText"))
73
+ if right:
74
+ out.append(kit.make_run(right, rpr))
75
+
76
+ if not deleted:
77
+ return False
78
+
79
+ ident = counter[0]
80
+ counter[0] += 2
81
+ deletion = etree.Element(kit.q("del"))
82
+ deletion.set(kit.q("id"), str(ident))
83
+ deletion.set(kit.q("author"), author)
84
+ deletion.set(kit.q("date"), stamp)
85
+ for node in deleted:
86
+ deletion.append(node)
87
+ insertion = etree.Element(kit.q("ins"))
88
+ insertion.set(kit.q("id"), str(ident + 1))
89
+ insertion.set(kit.q("author"), author)
90
+ insertion.set(kit.q("date"), stamp)
91
+ insertion.append(kit.make_run(new))
92
+
93
+ position = insert_at if insert_at is not None else len(out)
94
+ out.insert(position, deletion)
95
+ out.insert(position + 1, insertion)
96
+
97
+ ppr = paragraph.find(kit.q("pPr"))
98
+ for child in list(paragraph):
99
+ if child is not ppr:
100
+ paragraph.remove(child)
101
+ for child in out:
102
+ paragraph.append(child)
103
+ return True
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ parser = argparse.ArgumentParser(description="Record find/replace edits as tracked deletions and insertions.")
108
+ parser.add_argument("in_docx", help="Input .docx")
109
+ parser.add_argument("--out", required=True, help="Output .docx")
110
+ parser.add_argument("--author", default="dsh-office", help="Revision author shown in Word")
111
+ parser.add_argument("--replace", action="append", default=[], help="Replacement formatted as OLD=NEW (repeatable)")
112
+ parser.add_argument("--date", default=None, help="ISO timestamp for the revisions (default: now, UTC)")
113
+ args = parser.parse_args(argv)
114
+
115
+ try:
116
+ doc = kit.Docx(args.in_docx)
117
+ except (OSError, KeyError) as exc:
118
+ print("error: %s" % exc, file=sys.stderr)
119
+ return 2
120
+ if not args.replace:
121
+ parser.error("at least one --replace OLD=NEW is required")
122
+
123
+ stamp = args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
124
+ counter = [doc.next_id()]
125
+ applied = []
126
+ for raw in args.replace:
127
+ try:
128
+ old, new = split_pair(raw)
129
+ except argparse.ArgumentTypeError as exc:
130
+ print("error: %s" % exc, file=sys.stderr)
131
+ return 2
132
+ hits = 0
133
+ for paragraph in doc.paragraphs():
134
+ if tracked_replace(paragraph, old, new, args.author, stamp, counter):
135
+ hits += 1
136
+ applied.append({"old": old, "new": new, "paragraphs": hits})
137
+
138
+ settings = doc.xml("word/settings.xml")
139
+ if settings.find(kit.q("trackRevisions")) is None:
140
+ settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
141
+
142
+ doc.save(args.out)
143
+ kit.emit({"ok": True, "output": str(Path(args.out)), "author": args.author, "replacements": applied})
144
+ return 0 if any(item["paragraphs"] for item in applied) else 1
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
@@ -0,0 +1,108 @@
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
+ COUNTER_KEYS = ("headings", "paragraphs", "list_items", "tables", "images", "equations", "equation_fallbacks")
31
+
32
+
33
+ def fail(payload: dict, code: int = 1) -> int:
34
+ kit.emit({"ok": False, **payload})
35
+ return code
36
+
37
+
38
+ def load_builder():
39
+ cli = kit.office_cli_path()
40
+ if cli is None:
41
+ raise FileNotFoundError("helper CLI not found; set DSH_OFFICE_CLI to python/office_cli.py")
42
+ sys.path.insert(0, str(cli.parent))
43
+ import office_cli
44
+
45
+ return office_cli
46
+
47
+
48
+ def load_blocks(args) -> list[dict]:
49
+ raw = args.blocks or args.spec
50
+ text = raw.strip()
51
+ if text.startswith(("[", "{")):
52
+ payload = json.loads(text)
53
+ else:
54
+ payload = json.loads(Path(text).expanduser().read_text(encoding="utf-8"))
55
+ if isinstance(payload, list):
56
+ blocks = payload
57
+ else:
58
+ blocks = payload.get("blocks", payload.get("sections", []))
59
+ if not isinstance(blocks, list):
60
+ raise ValueError("blocks must be a JSON array")
61
+ return [block for block in blocks if isinstance(block, dict)]
62
+
63
+
64
+ def main() -> int:
65
+ parser = argparse.ArgumentParser(description="Append content blocks to a .docx copy.")
66
+ parser.add_argument("--input", required=True, help="source .docx (never modified)")
67
+ parser.add_argument("--out", help="output path; default <name>-edited.docx")
68
+ parser.add_argument("--blocks", help="JSON file holding an array of blocks")
69
+ parser.add_argument("--spec", help="JSON document spec; its blocks array is appended")
70
+ args = parser.parse_args()
71
+
72
+ if not (args.blocks or args.spec):
73
+ return fail({"error": "pass --blocks or --spec"}, 2)
74
+
75
+ source = Path(args.input).expanduser()
76
+ if not source.is_file():
77
+ return fail({"error": f"input not found: {args.input}"})
78
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
79
+ if out.resolve() == source.resolve():
80
+ return fail({"error": "refusing to overwrite the input; pass a different --out"})
81
+
82
+ try:
83
+ blocks = load_blocks(args)
84
+ except (OSError, ValueError) as exc:
85
+ return fail({"error": f"cannot read blocks: {exc}"}, 2)
86
+
87
+ try:
88
+ office_cli = load_builder()
89
+ except FileNotFoundError as exc:
90
+ return fail({"error": str(exc)})
91
+
92
+ from docx import Document
93
+
94
+ document = Document(str(source))
95
+ counters = {key: 0 for key in COUNTER_KEYS}
96
+ try:
97
+ office_cli._docx_add_blocks(document, blocks, counters)
98
+ except SystemExit as exc: # the builder reports bad blocks through the CLI
99
+ return fail({"error": f"a block was rejected: {exc}", "counts": counters})
100
+
101
+ out.parent.mkdir(parents=True, exist_ok=True)
102
+ document.save(str(out))
103
+ kit.emit({"ok": True, "input": str(source), "output": str(out), "appended": len(blocks), "counts": counters})
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(main())
@@ -0,0 +1,104 @@
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
+
26
+ def load_xml(path: Path, part: str):
27
+ with zipfile.ZipFile(path) as zf:
28
+ if part not in zf.namelist():
29
+ return None
30
+ return etree.fromstring(zf.read(part))
31
+
32
+
33
+ def merge_styles(doc: kit.Docx, template_styles) -> dict:
34
+ target = doc.xml("word/styles.xml")
35
+ incoming = {node.get(kit.q("styleId")): node for node in template_styles.findall(kit.q("style")) if node.get(kit.q("styleId"))}
36
+ existing = {node.get(kit.q("styleId")): node for node in target.findall(kit.q("style")) if node.get(kit.q("styleId"))}
37
+ replaced = added = 0
38
+ for style_id, node in incoming.items():
39
+ clone = etree.fromstring(etree.tostring(node))
40
+ if style_id in existing:
41
+ target.replace(existing[style_id], clone)
42
+ existing[style_id] = clone
43
+ replaced += 1
44
+ else:
45
+ target.append(clone)
46
+ existing[style_id] = clone
47
+ added += 1
48
+ template_defaults = template_styles.find(kit.q("docDefaults"))
49
+ if template_defaults is not None:
50
+ clone = etree.fromstring(etree.tostring(template_defaults))
51
+ current = target.find(kit.q("docDefaults"))
52
+ if current is not None:
53
+ target.replace(current, clone)
54
+ else:
55
+ target.insert(0, clone)
56
+ return {"replaced": replaced, "added": added, "total": len(existing)}
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ parser = argparse.ArgumentParser(description="Apply the styles and numbering of a template .docx/.dotx to a target .docx.")
61
+ parser.add_argument("--template", required=True, help="Template or style-pack .docx/.dotx")
62
+ parser.add_argument("--target", required=True, help="Document whose styles should be updated")
63
+ parser.add_argument("--out", required=True, help="Output .docx (never overwrite the target)")
64
+ args = parser.parse_args(argv)
65
+
66
+ template_path = Path(args.template)
67
+ if template_path.suffix.lower() == ".dotx":
68
+ # A .dotx is a .docx package with a different content type; reading the
69
+ # zip directly works the same, so no conversion is needed.
70
+ pass
71
+ try:
72
+ template_styles = load_xml(template_path, "word/styles.xml")
73
+ except OSError as exc:
74
+ print("error: %s" % exc, file=sys.stderr)
75
+ return 2
76
+ if template_styles is None:
77
+ print("error: template has no word/styles.xml", file=sys.stderr)
78
+ return 1
79
+
80
+ try:
81
+ doc = kit.Docx(args.target)
82
+ except (OSError, KeyError) as exc:
83
+ print("error: %s" % exc, file=sys.stderr)
84
+ return 2
85
+
86
+ stats = merge_styles(doc, template_styles)
87
+ template_numbering = None
88
+ try:
89
+ template_numbering = load_xml(template_path, "word/numbering.xml")
90
+ except OSError:
91
+ template_numbering = None
92
+ if template_numbering is not None:
93
+ doc.set_xml("word/numbering.xml", etree.fromstring(etree.tostring(template_numbering)))
94
+ doc.ensure_override("word/numbering.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml")
95
+ doc.ensure_rel("word/document.xml", kit.REL_NUMBERING, "numbering.xml")
96
+ stats["numbering"] = "copied"
97
+
98
+ doc.save(args.out)
99
+ kit.emit({"ok": True, "template": str(template_path), "target": str(Path(args.target)), "output": str(Path(args.out)), "styles": stats})
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
@@ -0,0 +1,102 @@
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
+ CAPTION_LABEL = {"table": "Table", "figure": "Figure"}
25
+
26
+
27
+ def caption_paragraph(label: str, number: int, text: str, bookmark: str | None, ident: int, materialize: bool):
28
+ paragraph = etree.Element(kit.q("p"))
29
+ props = etree.SubElement(paragraph, kit.q("pPr"))
30
+ etree.SubElement(props, kit.q("pStyle")).set(kit.q("val"), "Caption")
31
+ paragraph.append(kit.make_run("%s " % label))
32
+ if bookmark:
33
+ start = etree.SubElement(paragraph, kit.q("bookmarkStart"))
34
+ start.set(kit.q("id"), str(ident))
35
+ start.set(kit.q("name"), bookmark)
36
+ if materialize:
37
+ paragraph.append(kit.make_run(str(number)))
38
+ else:
39
+ for run in kit.field_runs("SEQ %s \\* ARABIC" % label, str(number)):
40
+ paragraph.append(run)
41
+ if bookmark:
42
+ end = etree.SubElement(paragraph, kit.q("bookmarkEnd"))
43
+ end.set(kit.q("id"), str(ident))
44
+ if text:
45
+ paragraph.append(kit.make_run(". " + text))
46
+ return paragraph
47
+
48
+
49
+ def main(argv: list[str] | None = None) -> int:
50
+ parser = argparse.ArgumentParser(description="Insert numbered table/figure captions, optionally bookmarking them.")
51
+ parser.add_argument("input_docx", help="Input .docx")
52
+ parser.add_argument("output_docx", help="Output .docx")
53
+ parser.add_argument("--tables", action="store_true", help="Caption tables")
54
+ parser.add_argument("--figures", action="store_true", help="Caption figures (paragraphs containing a drawing)")
55
+ parser.add_argument("--caption_text", default="", help="Text placed after the number")
56
+ parser.add_argument("--bookmarks", action="store_true", help="Add tblN/figN bookmarks around the number")
57
+ parser.add_argument("--materialize", action="store_true", help="Write literal numbers instead of SEQ fields")
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
+ do_tables = args.tables or args.figures
67
+ want_tables = args.tables or not do_tables
68
+ want_figures = args.figures or not do_tables
69
+
70
+ body = doc.body()
71
+ tables = list(body.iter(kit.q("tbl"))) if want_tables else []
72
+ figures = []
73
+ if want_figures:
74
+ for paragraph in body.iter(kit.q("p")):
75
+ if paragraph.find(".//" + kit.q("drawing")) is not None or paragraph.find(".//" + kit.q("pict", kit.V)) is not None:
76
+ figures.append(paragraph)
77
+
78
+ ident = doc.next_id()
79
+ added = []
80
+ for index, table in enumerate(tables, start=1):
81
+ bookmark = "tbl%d" % index if args.bookmarks else None
82
+ paragraph = caption_paragraph(CAPTION_LABEL["table"], index, args.caption_text, bookmark, ident, args.materialize)
83
+ table.addnext(paragraph)
84
+ added.append({"kind": "table", "number": index, "bookmark": bookmark})
85
+ ident += 1
86
+ for index, figure in enumerate(figures, start=1):
87
+ bookmark = "fig%d" % index if args.bookmarks else None
88
+ paragraph = caption_paragraph(CAPTION_LABEL["figure"], index, args.caption_text, bookmark, ident, args.materialize)
89
+ figure.addnext(paragraph)
90
+ added.append({"kind": "figure", "number": index, "bookmark": bookmark})
91
+ ident += 1
92
+
93
+ if not added:
94
+ print("error: no tables or figures found to caption", file=sys.stderr)
95
+ return 2
96
+ doc.save(args.output_docx)
97
+ kit.emit({"ok": True, "output": str(Path(args.output_docx)), "captions": added})
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python
2
+ """Attach Word comments to the paragraphs that contain given text.
3
+
4
+ Each ``--add "needle=comment body"`` anchors a comment over the first paragraph
5
+ whose text contains ``needle``: range start, range end and the reference run are
6
+ inserted inline, and the comment body is appended to comments.xml. Nothing is
7
+ reworded; the body is exactly the text you pass.
8
+
9
+ Exit codes: 0 all comments placed, 1 a pattern matched nothing (with
10
+ --require_all nothing is written), 2 bad input.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import datetime
17
+ import sys
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
+
26
+ def comments_root(doc: kit.Docx):
27
+ if not doc.exists("word/comments.xml"):
28
+ root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
29
+ doc.set_xml("word/comments.xml", root)
30
+ doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
31
+ doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
32
+ return doc.xml("word/comments.xml")
33
+
34
+
35
+ def initials(name: str) -> str:
36
+ parts = [piece for piece in name.replace(".", " ").split() if piece]
37
+ return "".join(piece[0] for piece in parts)[:3].upper() or "OR"
38
+
39
+
40
+ def append_comment(root, text: str, author: str, stamp: str, ident: int) -> None:
41
+ comment = etree.SubElement(root, kit.q("comment"))
42
+ comment.set(kit.q("id"), str(ident))
43
+ comment.set(kit.q("author"), author)
44
+ comment.set(kit.q("initials"), initials(author))
45
+ comment.set(kit.q("date"), stamp)
46
+ paragraph = etree.SubElement(comment, kit.q("p"))
47
+ run = etree.SubElement(paragraph, kit.q("r"))
48
+ node = etree.SubElement(run, kit.q("t"))
49
+ node.text = text
50
+ node.set(kit.q("space", kit.XML), "preserve")
51
+
52
+
53
+ def anchor(paragraph, ident: int) -> None:
54
+ start = etree.Element(kit.q("commentRangeStart"))
55
+ start.set(kit.q("id"), str(ident))
56
+ position = 1 if paragraph.find(kit.q("pPr")) is not None else 0
57
+ paragraph.insert(position, start)
58
+ end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
59
+ end.set(kit.q("id"), str(ident))
60
+ run = etree.SubElement(paragraph, kit.q("r"))
61
+ ref = etree.SubElement(run, kit.q("commentReference"))
62
+ ref.set(kit.q("id"), str(ident))
63
+
64
+
65
+ def parse_add(raw: str) -> tuple[str, str]:
66
+ if "=" not in raw:
67
+ raise ValueError("--add expects NEEDLE=COMMENT, got %r" % raw)
68
+ needle, body = raw.split("=", 1)
69
+ if not needle or not body:
70
+ raise ValueError("--add needs a non-empty needle and comment")
71
+ return needle, body
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = argparse.ArgumentParser(description="Add Word comments over paragraphs matched by substring.")
76
+ parser.add_argument("in_docx", help="Input .docx")
77
+ parser.add_argument("--out", required=True, help="Output .docx")
78
+ parser.add_argument("--author", default="dsh-office", help="Comment author")
79
+ parser.add_argument("--add", action="append", default=[], help="NEEDLE=COMMENT (repeatable)")
80
+ parser.add_argument("--ignore_case", action="store_true", help="Match needle case-insensitively")
81
+ parser.add_argument("--require_all", action="store_true", help="Fail without writing if any needle is absent")
82
+ parser.add_argument("--date", default=None, help="ISO timestamp for the comments (default: now, UTC)")
83
+ args = parser.parse_args(argv)
84
+
85
+ try:
86
+ doc = kit.Docx(args.in_docx)
87
+ except (OSError, KeyError) as exc:
88
+ print("error: %s" % exc, file=sys.stderr)
89
+ return 2
90
+ if not args.add:
91
+ parser.error("at least one --add NEEDLE=COMMENT is required")
92
+
93
+ paragraphs = doc.paragraphs()
94
+ requests = []
95
+ for raw in args.add:
96
+ try:
97
+ needle, body = parse_add(raw)
98
+ except ValueError as exc:
99
+ print("error: %s" % exc, file=sys.stderr)
100
+ return 2
101
+ target = None
102
+ for paragraph in paragraphs:
103
+ haystack = kit.paragraph_text(paragraph)
104
+ if (needle.lower() in haystack.lower()) if args.ignore_case else (needle in haystack):
105
+ target = paragraph
106
+ break
107
+ requests.append({"needle": needle, "body": body, "paragraph": target})
108
+
109
+ missing = [item["needle"] for item in requests if item["paragraph"] is None]
110
+ if missing and args.require_all:
111
+ kit.emit({"ok": False, "output": None, "matched": 0, "missing": missing})
112
+ return 1
113
+
114
+ stamp = args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
115
+ root = comments_root(doc)
116
+ ident = doc.next_id()
117
+ placed = []
118
+ for item in requests:
119
+ if item["paragraph"] is None:
120
+ continue
121
+ append_comment(root, item["body"], args.author, stamp, ident)
122
+ anchor(item["paragraph"], ident)
123
+ placed.append({"id": ident, "needle": item["needle"], "body": item["body"]})
124
+ ident += 1
125
+
126
+ if not placed:
127
+ kit.emit({"ok": False, "output": None, "matched": 0, "missing": missing})
128
+ return 1
129
+
130
+ doc.save(args.out)
131
+ kit.emit({"ok": True, "output": str(Path(args.out)), "author": args.author, "placed": placed, "missing": missing})
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())