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,153 @@
1
+ #!/usr/bin/env python
2
+ """Build deterministic internal navigation: a linked contents list and anchors.
3
+
4
+ Word's TOC field needs a refresh before its links work. This script instead
5
+ writes static hyperlinks that any renderer follows: bookmarks on every heading,
6
+ a contents list at the top (with quick links and back links), and optional
7
+ Top/Bottom anchors. Because the entries are literal text there is nothing to
8
+ update before the file is read.
9
+
10
+ Exit codes: 0 success, 1 no headings, 2 bad input.
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
+ TOP_BOOKMARK = "nav_top"
32
+ BOTTOM_BOOKMARK = "nav_bottom"
33
+ TOC_BOOKMARK = "nav_toc"
34
+
35
+
36
+ def link_run(text: str, anchor: str):
37
+ link = etree.Element(kit.q("hyperlink"))
38
+ link.set(kit.q("anchor"), anchor)
39
+ link.append(kit.make_run(text))
40
+ return link
41
+
42
+
43
+ def add_bookmark(paragraph, name: str, ident: int) -> None:
44
+ start = etree.Element(kit.q("bookmarkStart"))
45
+ start.set(kit.q("id"), str(ident))
46
+ start.set(kit.q("name"), name)
47
+ end = etree.Element(kit.q("bookmarkEnd"))
48
+ end.set(kit.q("id"), str(ident))
49
+ position = 1 if paragraph.find(kit.q("pPr")) is not None else 0
50
+ paragraph.insert(position, start)
51
+ paragraph.append(end)
52
+
53
+
54
+ def paragraph(style: str | None = None):
55
+ node = etree.Element(kit.q("p"))
56
+ if style:
57
+ props = etree.SubElement(node, kit.q("pPr"))
58
+ etree.SubElement(props, kit.q("pStyle")).set(kit.q("val"), style)
59
+ return node
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ parser = argparse.ArgumentParser(description="Add bookmark anchors and static internal links to headings.")
64
+ parser.add_argument("input_docx", help="Input .docx")
65
+ parser.add_argument("--out", required=True, help="Output .docx")
66
+ parser.add_argument("--toc_title", default="Table of Contents")
67
+ parser.add_argument("--levels", nargs="+", type=int, default=[1, 2, 3], help="Heading levels to include")
68
+ parser.add_argument("--no_quicklinks", action="store_true", help="Skip the quick links bar")
69
+ parser.add_argument("--no_back_to_toc", action="store_true", help="Skip per-heading back links")
70
+ parser.add_argument("--no_top_bottom", action="store_true", help="Skip Top/Bottom anchors")
71
+ args = parser.parse_args(argv)
72
+
73
+ try:
74
+ doc = kit.Docx(args.input_docx)
75
+ except (OSError, KeyError) as exc:
76
+ print("error: %s" % exc, file=sys.stderr)
77
+ return 2
78
+
79
+ body = doc.body()
80
+ headings = []
81
+ ident = doc.next_id()
82
+ for paragraph_node in list(body.iter(kit.q("p"))):
83
+ level = kit.heading_level(paragraph_node)
84
+ text = kit.paragraph_text(paragraph_node).strip()
85
+ if level in args.levels and text:
86
+ name = "nav_h%d" % ident
87
+ add_bookmark(paragraph_node, name, ident)
88
+ headings.append({"level": level, "text": text, "anchor": name})
89
+ ident += 1
90
+
91
+ if not headings:
92
+ print("error: document has no headings at levels %s" % args.levels, file=sys.stderr)
93
+ return 1
94
+
95
+ first = body.find(kit.q("p"))
96
+ if first is None:
97
+ first = etree.SubElement(body, kit.q("p"))
98
+ position = body.index(first)
99
+
100
+ block = []
101
+ if not args.no_top_bottom:
102
+ top = paragraph()
103
+ add_bookmark(top, TOP_BOOKMARK, ident)
104
+ ident += 1
105
+ block.append(top)
106
+
107
+ title = paragraph("Heading1")
108
+ add_bookmark(title, TOC_BOOKMARK, ident)
109
+ ident += 1
110
+ title.append(kit.make_run(args.toc_title))
111
+ block.append(title)
112
+
113
+ if not args.no_quicklinks:
114
+ quick = paragraph()
115
+ quick.append(kit.make_run("Quick links: "))
116
+ for index, item in enumerate([entry for entry in headings if entry["level"] == 1] or headings):
117
+ if index:
118
+ quick.append(kit.make_run(" | "))
119
+ quick.append(link_run(item["text"], item["anchor"]))
120
+ block.append(quick)
121
+
122
+ for item in headings:
123
+ entry = paragraph("TOC%d" % item["level"] if item["level"] <= 3 else "TOC3")
124
+ props = entry.find(kit.q("pPr"))
125
+ ind = etree.SubElement(props, kit.q("ind"))
126
+ ind.set(kit.q("left"), str((item["level"] - 1) * 360))
127
+ entry.append(link_run(item["text"], item["anchor"]))
128
+ block.append(entry)
129
+
130
+ for offset, node in enumerate(block):
131
+ body.insert(position + offset, node)
132
+
133
+ if not args.no_back_to_toc:
134
+ for paragraph_node in list(body.iter(kit.q("p"))):
135
+ if any(entry["anchor"] == name for entry in headings for name in (paragraph_node.find(kit.q("bookmarkStart")).get(kit.q("name")),) if paragraph_node.find(kit.q("bookmarkStart")) is not None):
136
+ back = paragraph("Caption")
137
+ back.append(link_run("Back to contents", TOC_BOOKMARK))
138
+ paragraph_node.addnext(back)
139
+
140
+ if not args.no_top_bottom:
141
+ last = paragraph()
142
+ add_bookmark(last, BOTTOM_BOOKMARK, ident)
143
+ ident += 1
144
+ last.append(link_run("Back to top", TOP_BOOKMARK))
145
+ body.append(last)
146
+
147
+ doc.save(args.out)
148
+ kit.emit({"ok": True, "output": str(Path(args.out)), "headings": headings, "entries": len(block)})
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python
2
+ """Generate small documents that exercise the awkward parts of the format.
3
+
4
+ Reviewing comments, tracked changes or watermarks is impossible without files
5
+ that contain them, and hand-building those by clicking in Word is not repeatable.
6
+ Each fixture targets one feature so a script's report can be judged against a
7
+ known-clean or known-broken input.
8
+
9
+ Exit codes: 0 success, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ import tempfile
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
+ from docx import Document
25
+ from docx.shared import Inches
26
+
27
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
28
+ for _stream in (sys.stdout, sys.stderr):
29
+ try:
30
+ _stream.reconfigure(encoding="utf-8", errors="replace")
31
+ except (AttributeError, ValueError):
32
+ pass
33
+
34
+
35
+ def base_document(title: str, paragraphs: list[str]) -> Document:
36
+ document = Document()
37
+ document.add_heading(title, level=1)
38
+ for text in paragraphs:
39
+ document.add_paragraph(text)
40
+ return document
41
+
42
+
43
+ def mark_paragraph(paragraph, before: str, deleted: str, inserted: str, after: str, author: str, date: str) -> None:
44
+ rpr = kit.first_rpr(paragraph)
45
+ kit.clear_paragraph_content(paragraph)
46
+ paragraph.append(kit.make_run(before, etree.fromstring(etree.tostring(rpr)) if rpr is not None else None))
47
+ deletion = etree.SubElement(paragraph, kit.q("del"))
48
+ deletion.set(kit.q("id"), "101")
49
+ deletion.set(kit.q("author"), author)
50
+ deletion.set(kit.q("date"), date)
51
+ deletion.append(kit.make_run(deleted, tag="delText"))
52
+ insertion = etree.SubElement(paragraph, kit.q("ins"))
53
+ insertion.set(kit.q("id"), "102")
54
+ insertion.set(kit.q("author"), author)
55
+ insertion.set(kit.q("date"), date)
56
+ insertion.append(kit.make_run(inserted))
57
+ paragraph.append(kit.make_run(after))
58
+
59
+
60
+ def make_tracked(target: Path) -> dict:
61
+ document = base_document("Revisions demo", ["Regional revenue grew by 12% in the quarter.", "Hardware sales stayed flat."])
62
+ document.save(target)
63
+ doc = kit.Docx(target)
64
+ paragraphs = doc.paragraphs()
65
+ mark_paragraph(paragraphs[1], "Regional revenue grew by ", "12%", "14%", " in the quarter.", "Reviewer", "2024-05-01T09:00:00Z")
66
+ mark_paragraph(paragraphs[2], "Hardware sales ", "stayed", "remained", " flat.", "Reviewer", "2024-05-01T09:01:00Z")
67
+ settings = doc.xml("word/settings.xml")
68
+ if settings.find(kit.q("trackRevisions")) is None:
69
+ settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
70
+ doc.save(target)
71
+ return {"path": str(target), "tracked_changes": 4}
72
+
73
+
74
+ def make_watermark(target: Path) -> dict:
75
+ document = base_document("Watermark demo", ["This copy is for internal review."])
76
+ document.save(target)
77
+ doc = kit.Docx(target)
78
+ part = doc.ensure_header_footer("header")
79
+ doc.xml(part).append(kit.watermark_paragraph("DRAFT", doc.next_id()))
80
+ doc.save(target)
81
+ return {"path": str(target), "watermark": "DRAFT"}
82
+
83
+
84
+ def make_comments(target: Path) -> dict:
85
+ document = base_document("Comments demo", ["The first paragraph carries a question.", "The second paragraph is clean."])
86
+ document.save(target)
87
+ doc = kit.Docx(target)
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
+ comment = etree.SubElement(root, kit.q("comment"))
93
+ comment.set(kit.q("id"), "1")
94
+ comment.set(kit.q("author"), "Reviewer")
95
+ comment.set(kit.q("initials"), "RV")
96
+ comment.set(kit.q("date"), "2024-05-02T10:00:00Z")
97
+ body = etree.SubElement(comment, kit.q("p"))
98
+ etree.SubElement(etree.SubElement(body, kit.q("r")), kit.q("t")).text = "Is this number current?"
99
+
100
+ paragraph = doc.paragraphs()[1]
101
+ start = etree.Element(kit.q("commentRangeStart"))
102
+ start.set(kit.q("id"), "1")
103
+ paragraph.insert(1, start)
104
+ end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
105
+ end.set(kit.q("id"), "1")
106
+ run = etree.SubElement(paragraph, kit.q("r"))
107
+ etree.SubElement(run, kit.q("commentReference")).set(kit.q("id"), "1")
108
+ doc.save(target)
109
+ return {"path": str(target), "comments": 1}
110
+
111
+
112
+ def make_edge_cases(target: Path, image_dir: Path) -> dict:
113
+ image_path = image_dir / "sample.png"
114
+ try:
115
+ from PIL import Image
116
+
117
+ Image.new("RGB", (320, 200), "#4472c4").save(image_path)
118
+ except ImportError:
119
+ image_path = None
120
+
121
+ document = Document()
122
+ document.add_heading("Edge cases", level=1)
123
+ document.add_paragraph("")
124
+ document.add_heading("A level three heading after a level one", level=3)
125
+ document.add_paragraph("A very long paragraph " * 40)
126
+ document.add_paragraph("Contact: writer@example.org or +1 555 010 9999.")
127
+ table = document.add_table(rows=2, cols=10)
128
+ for row_index in range(2):
129
+ for column_index in range(10):
130
+ table.cell(row_index, column_index).text = "R%dC%d" % (row_index + 1, column_index + 1)
131
+ if image_path is not None:
132
+ document.add_picture(str(image_path), width=Inches(4))
133
+ document.save(target)
134
+ return {"path": str(target), "fixtures": ["empty_paragraph", "skipped_heading_level", "wide_table", "image_without_alt", "contact_details"]}
135
+
136
+
137
+ BUILDERS = {
138
+ "tracked": make_tracked,
139
+ "watermark": make_watermark,
140
+ "comments": make_comments,
141
+ }
142
+
143
+
144
+ def main(argv: list[str] | None = None) -> int:
145
+ parser = argparse.ArgumentParser(description="Create sample .docx files for tracked changes, watermarks, comments and edge cases.")
146
+ parser.add_argument("--outdir", required=True, help="Directory for the generated files")
147
+ parser.add_argument("--only", default="all", help="tracked, watermark, comments, edge, or all")
148
+ args = parser.parse_args(argv)
149
+
150
+ out_dir = Path(args.outdir).expanduser().resolve()
151
+ out_dir.mkdir(parents=True, exist_ok=True)
152
+ wanted = [name.strip() for name in args.only.split(",") if name.strip()]
153
+ if "all" in wanted:
154
+ wanted = ["tracked", "watermark", "comments", "edge"]
155
+ unknown = [name for name in wanted if name not in BUILDERS and name != "edge"]
156
+ if unknown:
157
+ print("error: unknown fixture %s" % ", ".join(unknown), file=sys.stderr)
158
+ return 2
159
+
160
+ results = []
161
+ try:
162
+ for name in wanted:
163
+ if name == "edge":
164
+ results.append(make_edge_cases(out_dir / "edge_cases.docx", out_dir))
165
+ else:
166
+ results.append(BUILDERS[name](out_dir / ("%s.docx" % name)))
167
+ except (OSError, KeyError) as exc:
168
+ print("error: %s" % exc, file=sys.stderr)
169
+ return 2
170
+
171
+ kit.emit({"ok": True, "outdir": str(out_dir), "fixtures": results})
172
+ return 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ raise SystemExit(main())
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python
2
+ """Append one document's body to another, carrying its parts across.
3
+
4
+ Merging by pasting XML is the easy half; the hard half is that the second file's
5
+ relationships point at its own media, numbering and styles. This script copies
6
+ those parts into the base under free names, remaps relationship ids and
7
+ numbering ids, and brings over any style the appended content needs.
8
+
9
+ Drawings are refused by default: a nested drawing can carry its own rel chain and
10
+ silently break a merge, so it has to be asked for explicitly.
11
+
12
+ Exit codes: 0 success, 1 refused (drawings present, incompatible input), 2 bad input.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import posixpath
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from lxml import etree
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ import docx_kit as kit
26
+
27
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
28
+ for _stream in (sys.stdout, sys.stderr):
29
+ try:
30
+ _stream.reconfigure(encoding="utf-8", errors="replace")
31
+ except (AttributeError, ValueError):
32
+ pass
33
+
34
+ CT = "{http://schemas.openxmlformats.org/package/2006/content-types}"
35
+ W_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
36
+ REL_NUMBERING = kit.REL_NUMBERING
37
+
38
+
39
+ def free_part_name(base: kit.Docx, source: str) -> str:
40
+ folder = posixpath.dirname(source) or "word/media"
41
+ stem, extension = posixpath.splitext(posixpath.basename(source))
42
+ candidate = posixpath.join(folder, stem + extension)
43
+ counter = 1
44
+ while base.exists(candidate):
45
+ candidate = posixpath.join(folder, "%s_copy%d%s" % (stem, counter, extension))
46
+ counter += 1
47
+ return candidate
48
+
49
+
50
+ def copy_part(base: kit.Docx, append: kit.Docx, source: str) -> str:
51
+ target = free_part_name(base, source)
52
+ base.parts[target] = append.parts[source]
53
+ base.order.append(target)
54
+ base._cache.pop(target, None)
55
+ for node in append.content_types().findall(CT + "Override"):
56
+ if node.get("PartName") == "/" + source:
57
+ base.ensure_override(target, node.get("ContentType"))
58
+ break
59
+ return target
60
+
61
+
62
+ def copy_defaults(base: kit.Docx, append: kit.Docx) -> None:
63
+ existing = {(node.get("Extension") or "").lower() for node in base.content_types().findall(CT + "Default")}
64
+ for node in append.content_types().findall(CT + "Default"):
65
+ extension = (node.get("Extension") or "").lower()
66
+ if extension and extension not in existing:
67
+ clone = etree.SubElement(base.content_types(), CT + "Default")
68
+ clone.set("Extension", node.get("Extension"))
69
+ clone.set("ContentType", node.get("ContentType"))
70
+ existing.add(extension)
71
+
72
+
73
+ def ensure_style(base: kit.Docx, append: kit.Docx, wanted: str) -> bool:
74
+ if not append.exists("word/styles.xml") or not wanted:
75
+ return False
76
+ base_styles = base.xml("word/styles.xml")
77
+ present = {node.get(kit.q("styleId")) for node in base_styles.findall(kit.q("style"))}
78
+ if wanted in present:
79
+ return False
80
+ source_styles = {node.get(kit.q("styleId")): node for node in append.xml("word/styles.xml").findall(kit.q("style"))}
81
+ pending = [wanted]
82
+ copied = False
83
+ while pending:
84
+ style_id = pending.pop()
85
+ node = source_styles.get(style_id)
86
+ if node is None or style_id in present:
87
+ continue
88
+ clone = etree.fromstring(etree.tostring(node))
89
+ base_styles.append(clone)
90
+ present.add(style_id)
91
+ copied = True
92
+ for link_tag in ("basedOn", "next", "link"):
93
+ ref = node.find(kit.q(link_tag))
94
+ if ref is not None and ref.get(kit.q("val")):
95
+ pending.append(ref.get(kit.q("val")))
96
+ return copied
97
+
98
+
99
+ def remap_numbering(base: kit.Docx, append: kit.Docx, nodes: list) -> dict[str, str]:
100
+ used = set()
101
+ for node in nodes:
102
+ for num_pr in node.iter(kit.q("numPr")):
103
+ num_id = num_pr.find(kit.q("numId"))
104
+ if num_id is not None and num_id.get(kit.q("val")):
105
+ used.add(num_id.get(kit.q("val")))
106
+ if not used or not append.exists("word/numbering.xml"):
107
+ return {}
108
+ if not base.exists("word/numbering.xml"):
109
+ base.parts["word/numbering.xml"] = append.parts["word/numbering.xml"]
110
+ base.order.append("word/numbering.xml")
111
+ base.ensure_override("word/numbering.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml")
112
+ base.ensure_rel("word/document.xml", kit.REL_NUMBERING, "numbering.xml")
113
+ return {}
114
+
115
+ base_root = base.xml("word/numbering.xml")
116
+ source_root = append.xml("word/numbering.xml")
117
+ base_nums = {node.get(kit.q("numId")): node for node in base_root.findall(kit.q("num"))}
118
+ base_abstract = {node.get(kit.q("abstractNumId")): node for node in base_root.findall(kit.q("abstractNum"))}
119
+ next_num = max([int(value) for value in base_nums if (value or "").isdigit()], default=0) + 1
120
+ next_abstract = max([int(value) for value in base_abstract if (value or "").isdigit()], default=0) + 1
121
+ source_nums = {node.get(kit.q("numId")): node for node in source_root.findall(kit.q("num"))}
122
+ source_abstract = {node.get(kit.q("abstractNumId")): node for node in source_root.findall(kit.q("abstractNum"))}
123
+ mapping = {}
124
+ for old in used:
125
+ node = source_nums.get(old)
126
+ if node is None:
127
+ continue
128
+ clone = etree.fromstring(etree.tostring(node))
129
+ reference = clone.find(kit.q("abstractNumId"))
130
+ abstract_old = reference.get(kit.q("val")) if reference is not None else None
131
+ if abstract_old in source_abstract and abstract_old not in base_abstract:
132
+ abstract_clone = etree.fromstring(etree.tostring(source_abstract[abstract_old]))
133
+ abstract_clone.set(kit.q("abstractNumId"), str(next_abstract))
134
+ base_root.insert(0, abstract_clone)
135
+ base_abstract[str(next_abstract)] = abstract_clone
136
+ next_abstract += 1
137
+ if reference is not None:
138
+ reference.set(kit.q("val"), abstract_clone.get(kit.q("abstractNumId")))
139
+ clone.set(kit.q("numId"), str(next_num))
140
+ mapping[old] = str(next_num)
141
+ base_root.append(clone)
142
+ base_nums[str(next_num)] = clone
143
+ next_num += 1
144
+ return mapping
145
+
146
+
147
+ def remap_relationships(base: kit.Docx, append: kit.Docx, nodes: list) -> int:
148
+ mapping = {}
149
+ copied = 0
150
+ source_rels = {entry["id"]: entry for entry in append.relationships("word/document.xml")}
151
+ attributes = (kit.q("embed", kit.R), kit.q("id", kit.R), kit.q("link", kit.R))
152
+ for node in nodes:
153
+ for element in node.iter():
154
+ for attribute in attributes:
155
+ rid = element.get(attribute)
156
+ if not rid:
157
+ continue
158
+ if rid not in mapping:
159
+ rel = source_rels.get(rid)
160
+ if rel is None:
161
+ mapping[rid] = rid
162
+ continue
163
+ if rel["mode"] == "External":
164
+ mapping[rid] = base.ensure_rel("word/document.xml", rel["type"], rel["target"], external=True)
165
+ elif append.exists(rel["resolved"]):
166
+ target = copy_part(base, append, rel["resolved"])
167
+ relative = posixpath.relpath(target, "word")
168
+ mapping[rid] = base.ensure_rel("word/document.xml", rel["type"], relative)
169
+ copied += 1
170
+ else:
171
+ mapping[rid] = rid
172
+ element.set(attribute, mapping[rid])
173
+ return copied
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ parser = argparse.ArgumentParser(description="Append one .docx body to another, copying media, numbering and styles.")
178
+ parser.add_argument("base", help="Document to append to")
179
+ parser.add_argument("append", help="Document whose body is copied in")
180
+ parser.add_argument("--out", required=True, help="Output .docx")
181
+ parser.add_argument("--allow_drawings", action="store_true", help="Copy drawings and their relationships")
182
+ args = parser.parse_args(argv)
183
+
184
+ try:
185
+ base = kit.Docx(args.base)
186
+ append = kit.Docx(args.append)
187
+ except (OSError, KeyError) as exc:
188
+ print("error: %s" % exc, file=sys.stderr)
189
+ return 2
190
+
191
+ append_body = append.body()
192
+ incoming = [child for child in append_body if child.tag != kit.q("sectPr")]
193
+ if not incoming:
194
+ print("error: %s has an empty body" % args.append, file=sys.stderr)
195
+ return 2
196
+
197
+ has_drawings = any(child.find(".//" + tag) is not None for child in incoming for tag in (kit.q("drawing"), kit.q("pict")))
198
+ if has_drawings and not args.allow_drawings:
199
+ print("error: appended body contains drawings; re-run with --allow_drawings", file=sys.stderr)
200
+ return 1
201
+
202
+ style_ids = set()
203
+ numbering_nodes = []
204
+ for child in incoming:
205
+ for node in child.iter():
206
+ for tag in ("pStyle", "rStyle", "tblStyle"):
207
+ if node.tag == kit.q(tag) and node.get(kit.q("val")):
208
+ style_ids.add(node.get(kit.q("val")))
209
+ if child.find(".//" + kit.q("numPr")) is not None:
210
+ numbering_nodes.append(child)
211
+
212
+ styles_copied = sum(1 for style_id in sorted(style_ids) if ensure_style(base, append, style_id))
213
+ number_map = remap_numbering(base, append, numbering_nodes)
214
+
215
+ clones = [etree.fromstring(etree.tostring(child)) for child in incoming]
216
+ if number_map:
217
+ for clone in clones:
218
+ for num_pr in clone.iter(kit.q("numPr")):
219
+ num_id = num_pr.find(kit.q("numId"))
220
+ if num_id is not None and num_id.get(kit.q("val")) in number_map:
221
+ num_id.set(kit.q("val"), number_map[num_id.get(kit.q("val"))])
222
+ media_copied = remap_relationships(base, append, clones)
223
+ copy_defaults(base, append)
224
+
225
+ body = base.body()
226
+ sect = body.find(kit.q("sectPr"))
227
+ anchor = body.index(sect) if sect is not None else len(body)
228
+ for offset, clone in enumerate(clones):
229
+ body.insert(anchor + offset, clone)
230
+
231
+ base.save(args.out)
232
+ kit.emit(
233
+ {
234
+ "ok": True,
235
+ "output": str(Path(args.out)),
236
+ "appended_elements": len(clones),
237
+ "styles_copied": styles_copied,
238
+ "media_copied": media_copied,
239
+ "numbering_remapped": len(number_map),
240
+ }
241
+ )
242
+ return 0
243
+
244
+
245
+ if __name__ == "__main__":
246
+ raise SystemExit(main())