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,101 @@
1
+ #!/usr/bin/env python
2
+ """Render two documents and report which pages changed, pixel by pixel.
3
+
4
+ This is how you prove an edit did what you meant without opening Word: render
5
+ both versions, diff the page images, then look at the pages that moved. The diff
6
+ image marks changed pixels in colour so a one-line change is obvious.
7
+
8
+ Exit codes: 0 compared, 1 render failed, 3 no renderer installed, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ import docx_kit as kit
19
+
20
+ THRESHOLD = 8
21
+
22
+
23
+ def render(path: Path, out_dir: Path, dpi: int) -> tuple[int, list[str], str]:
24
+ code, stdout, stderr = kit.run_office_cli(["docx", "render", "--input", str(path), "--out-dir", str(out_dir), "--dpi", str(dpi)])
25
+ payload = kit.cli_json(stdout) or kit.cli_json(stderr) or {}
26
+ detail = payload.get("error") or payload.get("reason") or ""
27
+ if not detail:
28
+ lines = [line.strip() for line in (stderr or stdout).splitlines() if line.strip() not in ("", "{", "}")]
29
+ detail = lines[-1] if lines else "render failed"
30
+ return code, payload.get("pages") or [], detail
31
+
32
+
33
+ def diff_pages(first: Path, second: Path, out_path: Path) -> dict:
34
+ from PIL import Image, ImageChops, ImageOps
35
+
36
+ with Image.open(first) as a, Image.open(second) as b:
37
+ left = a.convert("RGB")
38
+ right = b.convert("RGB")
39
+ if left.size != right.size:
40
+ right = right.resize(left.size)
41
+ difference = ImageChops.difference(left, right)
42
+ grey = difference.convert("L").point(lambda value: 255 if value > THRESHOLD else 0)
43
+ changed = sum(1 for value in grey.getdata() if value)
44
+ box = difference.getbbox()
45
+ ImageOps.autocontrast(difference).save(out_path)
46
+ return {
47
+ "changed_pixels": changed,
48
+ "total_pixels": left.width * left.height,
49
+ "ratio": round(changed / float(left.width * left.height), 6),
50
+ "bbox": list(box) if box else None,
51
+ "size": list(left.size),
52
+ "diff_image": str(out_path),
53
+ }
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = argparse.ArgumentParser(description="Render two .docx files and report per-page pixel differences.")
58
+ parser.add_argument("first", help="Original .docx")
59
+ parser.add_argument("second", help="Changed .docx")
60
+ parser.add_argument("--outdir", required=True, help="Directory for rendered pages and diff images")
61
+ parser.add_argument("--dpi", type=int, default=120)
62
+ parser.add_argument("--threshold", type=int, default=THRESHOLD, help="Per-pixel channel difference that counts as a change")
63
+ args = parser.parse_args(argv)
64
+
65
+ root = Path(args.outdir).expanduser().resolve()
66
+ first = Path(args.first).expanduser().resolve()
67
+ second = Path(args.second).expanduser().resolve()
68
+ for path in (first, second):
69
+ if not path.is_file():
70
+ print("error: %s not found" % path, file=sys.stderr)
71
+ return 2
72
+
73
+ code_a, pages_a, detail_a = render(first, root / "a", args.dpi)
74
+ code_b, pages_b, detail_b = render(second, root / "b", args.dpi)
75
+ if code_a != 0 or code_b != 0 or not pages_a or not pages_b:
76
+ detail = detail_a or detail_b or "render failed"
77
+ reason = "no_renderer" if ("renderer" in detail.lower() or "libreoffice" in detail.lower()) else "render_failed"
78
+ kit.emit({"ok": False, "reason": reason, "detail": detail, "remedy": "Install Microsoft Office, WPS Office, or LibreOffice, then re-run."})
79
+ return 3 if reason == "no_renderer" else 1
80
+
81
+ pairs = list(zip(pages_a, pages_b))
82
+ results = []
83
+ for index, (left, right) in enumerate(pairs, start=1):
84
+ results.append(diff_pages(Path(left), Path(right), root / ("diff-%02d.png" % index)))
85
+
86
+ changed_pages = [index for index, item in enumerate(results, start=1) if item["changed_pixels"]]
87
+ kit.emit(
88
+ {
89
+ "ok": True,
90
+ "pages_first": len(pages_a),
91
+ "pages_second": len(pages_b),
92
+ "changed_pages": changed_pages,
93
+ "page_count_delta": len(pages_b) - len(pages_a),
94
+ "diffs": results,
95
+ }
96
+ )
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Replace text in a Word document without touching the original.
3
+
4
+ Pairs run inside single runs first; when a match straddles runs the paragraph
5
+ text is rewritten into its first run, so ordinary prose survives intact and a
6
+ paragraph that mixes formats collapses. Body, tables, and every section header
7
+ and footer are covered.
8
+
9
+ Usage:
10
+ replace_text.py --input IN.docx [--out OUT.docx] --find OLD --replace NEW [--find ...]
11
+ replace_text.py --input IN.docx [--out OUT.docx] --spec edits.json
12
+
13
+ The spec file is {"replace": [{"find": "18%", "replace": "21%"}]} or a bare list
14
+ of those objects.
15
+
16
+ Exit codes: 0 replaced, 1 nothing replaced (with --require-all) or invalid
17
+ input, 2 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
+
31
+ def paragraph_text_nodes(paragraph) -> list:
32
+ """Every w:t below a paragraph, including runs inside hyperlinks."""
33
+ return paragraph._p.xpath(".//w:t")
34
+
35
+
36
+ def replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
37
+ nodes = paragraph_text_nodes(paragraph)
38
+ if not nodes:
39
+ return 0
40
+ count = 0
41
+ for node in nodes:
42
+ text = node.text or ""
43
+ if find in text:
44
+ hits = text.count(find)
45
+ node.text = text.replace(find, replacement)
46
+ count += hits
47
+ if count:
48
+ return count
49
+ joined = "".join(node.text or "" for node in nodes)
50
+ if find not in joined:
51
+ return 0
52
+ hits = joined.count(find)
53
+ nodes[0].text = joined.replace(find, replacement)
54
+ for node in nodes[1:]:
55
+ node.text = ""
56
+ return hits
57
+
58
+
59
+ def iter_paragraphs(document):
60
+ def walk_tables(tables):
61
+ for table in tables:
62
+ for row in table.rows:
63
+ for cell in row.cells:
64
+ yield from cell.paragraphs
65
+ yield from walk_tables(cell.tables)
66
+
67
+ yield from document.paragraphs
68
+ yield from walk_tables(document.tables)
69
+ for section in document.sections:
70
+ for part in (section.header, section.footer, section.first_page_header,
71
+ section.first_page_footer, section.even_page_header,
72
+ section.even_page_footer):
73
+ try:
74
+ yield from part.paragraphs
75
+ except Exception: # a header/footer that is not linked or not present
76
+ continue
77
+
78
+
79
+ def fail(payload: dict, code: int = 1) -> int:
80
+ kit.emit({"ok": False, **payload})
81
+ return code
82
+
83
+
84
+ def load_spec(path: str) -> list[tuple[str, str]]:
85
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
86
+ items = payload.get("replace", []) if isinstance(payload, dict) else payload
87
+ pairs: list[tuple[str, str]] = []
88
+ for item in items:
89
+ find = str(item.get("find", ""))
90
+ if find:
91
+ pairs.append((find, str(item.get("replace", ""))))
92
+ return pairs
93
+
94
+
95
+ def main() -> int:
96
+ parser = argparse.ArgumentParser(description="Replace text in a .docx copy.")
97
+ parser.add_argument("--input", required=True, help="source .docx (never modified)")
98
+ parser.add_argument("--out", help="output path; default <name>-edited.docx")
99
+ parser.add_argument("--find", action="append", default=[], help="text to find; pair with --replace")
100
+ parser.add_argument("--replace", action="append", default=[], help="replacement for the matching --find")
101
+ parser.add_argument("--spec", help="JSON file with replace pairs")
102
+ parser.add_argument("--require-all", action="store_true", help="exit 1 when a pair matches nothing")
103
+ args = parser.parse_args()
104
+
105
+ if args.find and len(args.find) != len(args.replace):
106
+ return fail({"error": "--find and --replace must be given in pairs"}, 2)
107
+ pairs = list(zip(args.find, args.replace))
108
+ if args.spec:
109
+ try:
110
+ pairs.extend(load_spec(args.spec))
111
+ except (OSError, ValueError) as exc:
112
+ return fail({"error": f"cannot read spec: {exc}"}, 2)
113
+ if not pairs:
114
+ return fail({"error": "no replacements requested"}, 2)
115
+
116
+ source = Path(args.input).expanduser()
117
+ if not source.is_file():
118
+ return fail({"error": f"input not found: {args.input}"}, 1)
119
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
120
+ if out.resolve() == source.resolve():
121
+ return fail({"error": "refusing to overwrite the input; pass a different --out"}, 1)
122
+
123
+ from docx import Document
124
+
125
+ document = Document(str(source))
126
+ per_pair: list[dict] = []
127
+ total = 0
128
+ for find, replacement in pairs:
129
+ hits = 0
130
+ for paragraph in iter_paragraphs(document):
131
+ hits += replace_in_paragraph(paragraph, find, replacement)
132
+ total += hits
133
+ per_pair.append({"find": find, "replace": replacement, "count": hits})
134
+
135
+ out.parent.mkdir(parents=True, exist_ok=True)
136
+ document.save(str(out))
137
+ misses = [item["find"] for item in per_pair if not item["count"]]
138
+ payload = {
139
+ "ok": not (args.require_all and misses),
140
+ "input": str(source),
141
+ "output": str(out),
142
+ "replacements": total,
143
+ "pairs": per_pair,
144
+ "misses": misses,
145
+ }
146
+ if args.require_all and misses:
147
+ payload["error"] = "patterns matched nothing: " + ", ".join(misses)
148
+ return fail(payload, 1)
149
+ kit.emit(payload)
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python
2
+ """Report section geometry in inches so layout problems are visible as numbers.
3
+
4
+ Every ``sectPr`` is one page-layout decision: page size, orientation (portrait is
5
+ a page size where width < height, not a flag), margins, columns, header/footer
6
+ links, and the section start type. Mixed page sizes in one file are the most
7
+ common cause of "why did page 4 shrink" reports, so a summary flag calls it out.
8
+
9
+ Exit codes: 0 clean, 1 suspicious geometry, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import docx_kit as kit
20
+
21
+ TWIPS_PER_INCH = 1440
22
+
23
+
24
+ def inches(value: str | None) -> float | None:
25
+ return round(int(value) / TWIPS_PER_INCH, 3) if value and value.lstrip("-").isdigit() else None
26
+
27
+
28
+ def describe(part: str, sect, index: int, doc: kit.Docx) -> dict:
29
+ page = sect.find(kit.q("pgSz"))
30
+ margins = sect.find(kit.q("pgMar"))
31
+ columns = sect.find(kit.q("cols"))
32
+ kind = sect.find(kit.q("type"))
33
+ info = {
34
+ "index": index,
35
+ "part": part,
36
+ "width_in": inches(page.get(kit.q("w"))) if page is not None else None,
37
+ "height_in": inches(page.get(kit.q("h"))) if page is not None else None,
38
+ "orientation": None,
39
+ "margin_in": {},
40
+ "columns": int(columns.get(kit.q("num"))) if columns is not None and columns.get(kit.q("num")) else 1,
41
+ "start": kind.get(kit.q("val")) if kind is not None else "nextPage",
42
+ "headers": [],
43
+ "footers": [],
44
+ "title_page": sect.find(kit.q("titlePg")) is not None,
45
+ "page_number_start": None,
46
+ }
47
+ if info["width_in"] and info["height_in"]:
48
+ info["orientation"] = "portrait" if info["width_in"] < info["height_in"] else "landscape"
49
+ if margins is not None:
50
+ for side in ("top", "bottom", "left", "right", "header", "footer", "gutter"):
51
+ info["margin_in"][side] = inches(margins.get(kit.q(side)))
52
+ numbering = sect.find(kit.q("pgNumType"))
53
+ if numbering is not None and numbering.get(kit.q("start")):
54
+ info["page_number_start"] = int(numbering.get(kit.q("start")))
55
+ rels = {entry["id"]: entry for entry in doc.relationships(part)}
56
+ for tag, key in (("headerReference", "headers"), ("footerReference", "footers")):
57
+ for ref in sect.findall(kit.q(tag)):
58
+ rid = ref.get(kit.q("id", kit.R))
59
+ info[key].append({"type": ref.get(kit.q("type")), "part": rels[rid]["resolved"] if rid in rels else None})
60
+ return info
61
+
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ parser = argparse.ArgumentParser(description="Report page size, orientation, margins and columns per section.")
65
+ parser.add_argument("docx", help="Input .docx")
66
+ args = parser.parse_args(argv)
67
+
68
+ try:
69
+ doc = kit.Docx(args.docx)
70
+ except (OSError, KeyError) as exc:
71
+ print("error: %s" % exc, file=sys.stderr)
72
+ return 2
73
+
74
+ sections = [describe("word/document.xml", sect, index, doc) for index, sect in enumerate(doc.sections(), start=1)]
75
+ findings = []
76
+ sizes = {(item["width_in"], item["height_in"]) for item in sections}
77
+ if len(sizes) > 1:
78
+ findings.append({"kind": "mixed_page_sizes", "sizes": sorted(sizes, key=lambda pair: (pair[0] or 0, pair[1] or 0))})
79
+ for item in sections:
80
+ margins = item["margin_in"]
81
+ if margins.get("left") is not None and margins.get("right") is not None and margins["left"] + margins["right"] >= (item["width_in"] or 8.5) - 1:
82
+ findings.append({"kind": "narrow_text_column", "section": item["index"], "left": margins["left"], "right": margins["right"]})
83
+ if item["width_in"] and item["width_in"] > 8.5 and item["orientation"] == "portrait":
84
+ findings.append({"kind": "portrait_wider_than_letter", "section": item["index"]})
85
+
86
+ kit.emit({"ok": not findings, "sections": sections, "findings": findings})
87
+ return 1 if findings else 0
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python
2
+ """Set or clear Word's document-protection editing restriction.
3
+
4
+ Protection is a workplace guardrail, not encryption: without a password hash the
5
+ restriction can be removed by anyone who knows the settings part, which is often
6
+ exactly what is wanted for review copies. Supply a precomputed ``--hash`` if the
7
+ file must resist casual removal.
8
+
9
+ Exit codes: 0 success, 2 bad input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from lxml import etree
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+ import docx_kit as kit
22
+
23
+ MODES = {
24
+ "off": None,
25
+ "none": None,
26
+ "readonly": "readOnly",
27
+ "read_only": "readOnly",
28
+ "read-only": "readOnly",
29
+ "comments": "comments",
30
+ "comment": "comments",
31
+ "trackedchanges": "trackedChanges",
32
+ "tracked_changes": "trackedChanges",
33
+ "tracked-changes": "trackedChanges",
34
+ "forms": "forms",
35
+ "form": "forms",
36
+ }
37
+ HASH_ALGORITHM_SID = "4" # SHA-512, the modern Word default
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ parser = argparse.ArgumentParser(description="Apply or remove the editing restriction stored in settings.xml.")
42
+ parser.add_argument("docx", help="Input .docx")
43
+ parser.add_argument("--mode", required=True, help="off, readOnly, comments, trackedChanges, forms")
44
+ parser.add_argument("--out", default=None, help="Output .docx (default: overwrite the input)")
45
+ parser.add_argument("--no_enforce", action="store_true", help="Write the mode but leave enforcement off")
46
+ parser.add_argument("--hash", default=None, help="Precomputed password hash (skip to leave protection password-free)")
47
+ parser.add_argument("--salt", default=None, help="Salt value that belongs with --hash")
48
+ parser.add_argument("--spin_count", default="100000")
49
+ args = parser.parse_args(argv)
50
+
51
+ mode = MODES.get(args.mode.strip().lower())
52
+ if mode is None and args.mode.strip().lower() not in ("off", "none"):
53
+ print("error: unknown --mode %r (use off, readOnly, comments, trackedChanges, forms)" % args.mode, file=sys.stderr)
54
+ return 2
55
+
56
+ try:
57
+ doc = kit.Docx(args.docx)
58
+ except (OSError, KeyError) as exc:
59
+ print("error: %s" % exc, file=sys.stderr)
60
+ return 2
61
+
62
+ settings = doc.xml("word/settings.xml")
63
+ for existing in settings.findall(kit.q("documentProtection")):
64
+ settings.remove(existing)
65
+
66
+ if mode is not None:
67
+ node = etree.Element(kit.q("documentProtection"))
68
+ node.set(kit.q("edit"), mode)
69
+ node.set(kit.q("enforcement"), "0" if args.no_enforce else "1")
70
+ if args.hash:
71
+ node.set(kit.q("cryptProviderType"), "rsaFull")
72
+ node.set(kit.q("cryptAlgorithmClass"), "hash")
73
+ node.set(kit.q("cryptAlgorithmType"), "typeAny")
74
+ node.set(kit.q("cryptAlgorithmSid"), HASH_ALGORITHM_SID)
75
+ node.set(kit.q("cryptSpinCount"), args.spin_count)
76
+ node.set(kit.q("hash"), args.hash)
77
+ if args.salt:
78
+ node.set(kit.q("salt"), args.salt)
79
+ anchor = settings.find(kit.q("writeProtection"))
80
+ if anchor is None:
81
+ settings.insert(0, node)
82
+ else:
83
+ anchor.addnext(node)
84
+
85
+ out = args.out or args.docx
86
+ doc.save(out)
87
+ kit.emit({"ok": True, "output": str(Path(out)), "mode": mode or "off", "enforced": bool(mode) and not args.no_enforce, "password_hash": bool(args.hash)})
88
+ return 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python
2
+ """Lint a document for formatting that fights its own styles.
3
+
4
+ The report answers "can Word restyle this document?" Direct run formatting,
5
+ undefined style ids, hand-typed bullets and one-off font sizes are the usual
6
+ reasons a restyle pass does nothing. Findings are advisory: a few are legitimate.
7
+
8
+ Exit codes: 0 clean, 1 findings, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from collections import Counter
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import docx_kit as kit
21
+
22
+ MAX_FINDINGS = 50
23
+ BULLET_TEXT = ("\u2022", "- ", "* ")
24
+
25
+
26
+ def known_styles(doc: kit.Docx) -> dict[str, str]:
27
+ styles = {}
28
+ root = doc.xml("word/styles.xml")
29
+ for node in root.findall(kit.q("style")):
30
+ ident = node.get(kit.q("styleId"))
31
+ name = node.find(kit.q("name"))
32
+ styles[ident] = name.get(kit.q("val")) if name is not None else ident
33
+ return styles
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ parser = argparse.ArgumentParser(description="Report direct formatting, undefined styles and other style conflicts.")
38
+ parser.add_argument("docx", help="Input .docx")
39
+ parser.add_argument("--json", default=None, help="Also write the report to this path")
40
+ args = parser.parse_args(argv)
41
+
42
+ try:
43
+ doc = kit.Docx(args.docx)
44
+ except (OSError, KeyError) as exc:
45
+ print("error: %s" % exc, file=sys.stderr)
46
+ return 2
47
+
48
+ styles = known_styles(doc)
49
+ findings = []
50
+ sizes = Counter()
51
+ direct = 0
52
+
53
+ for index, paragraph in enumerate(doc.paragraphs(), start=1):
54
+ text = kit.paragraph_text(paragraph).strip()
55
+ style_id = kit.paragraph_style(paragraph)
56
+ level = kit.heading_level(paragraph)
57
+ if style_id and style_id not in styles:
58
+ findings.append({"kind": "undefined_style", "index": index, "style": style_id})
59
+ if level and paragraph.find(kit.q("pPr") + "/" + kit.q("rPr")) is not None:
60
+ findings.append({"kind": "heading_paragraph_formatting", "index": index, "text": text[:80]})
61
+ for run in paragraph.iter(kit.q("r")):
62
+ props = run.find(kit.q("rPr"))
63
+ if props is None or len(props) == 0:
64
+ continue
65
+ direct += 1
66
+ if level and any(props.find(kit.q(tag)) is not None for tag in ("b", "i", "color", "sz")):
67
+ findings.append({"kind": "heading_direct_formatting", "index": index, "text": text[:80]})
68
+ break
69
+ size = props.find(kit.q("sz"))
70
+ if size is not None and not level and (size.get(kit.q("val")) or "").isdigit():
71
+ sizes[int(size.get(kit.q("val")))] += 1
72
+ if not kit.text_of(run):
73
+ findings.append({"kind": "empty_run", "index": index})
74
+ break
75
+ if text.startswith(BULLET_TEXT) and paragraph.find(kit.q("pPr") + "/" + kit.q("numPr")) is None:
76
+ findings.append({"kind": "manual_bullet", "index": index, "text": text[:80]})
77
+ if " " in text:
78
+ findings.append({"kind": "double_space", "index": index, "text": text[:80]})
79
+ if text != text.rstrip():
80
+ findings.append({"kind": "trailing_space", "index": index})
81
+
82
+ if sizes:
83
+ common, _ = sizes.most_common(1)[0]
84
+ for size, count in sizes.items():
85
+ if size != common and count <= 3:
86
+ findings.append({"kind": "body_size_outlier", "half_points": size, "count": count, "common_half_points": common})
87
+
88
+ for table in doc.body().iter(kit.q("tbl")):
89
+ if table.find(kit.q("tblPr") + "/" + kit.q("tblStyle")) is None:
90
+ findings.append({"kind": "table_without_style"})
91
+
92
+ counts = {}
93
+ for finding in findings:
94
+ counts[finding["kind"]] = counts.get(finding["kind"], 0) + 1
95
+
96
+ report = {
97
+ "ok": not findings,
98
+ "styles_defined": len(styles),
99
+ "directly_formatted_runs": direct,
100
+ "counts": counts,
101
+ "findings": findings[:MAX_FINDINGS],
102
+ "truncated": max(0, len(findings) - MAX_FINDINGS),
103
+ }
104
+ if args.json:
105
+ Path(args.json).write_text(json.dumps(report, indent=2), encoding="utf-8")
106
+ kit.emit(report)
107
+ return 1 if findings else 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python
2
+ """Push formatting back onto styles so the document can be restyled again.
3
+
4
+ Two passes, both opt-in:
5
+
6
+ ``--clear_paragraph_format``
7
+ delete paragraph-mark direct formatting and stray run formatting, keeping
8
+ character styles, so the paragraph style governs again.
9
+ ``--enforce_heading_spacing``
10
+ give headings consistent spacing and "keep with next" so a page break cannot
11
+ strand a heading at the bottom of a page.
12
+
13
+ Exit codes: 0 success, 2 bad input.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
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
+ HEADING_SPACING = {1: (320, 120), 2: (240, 80), 3: (200, 60)}
28
+ KEEP_RUN_PROPS = {kit.q("rStyle"), kit.q("rtl"), kit.q("lang")}
29
+
30
+
31
+ def strip_run_formatting(paragraph) -> int:
32
+ removed = 0
33
+ for run in paragraph.iter(kit.q("r")):
34
+ props = run.find(kit.q("rPr"))
35
+ if props is None:
36
+ continue
37
+ for child in list(props):
38
+ if child.tag not in KEEP_RUN_PROPS:
39
+ props.remove(child)
40
+ removed += 1
41
+ if len(props) == 0:
42
+ run.remove(props)
43
+ return removed
44
+
45
+
46
+ def enforce_spacing(paragraph, level: int) -> None:
47
+ props = paragraph.find(kit.q("pPr"))
48
+ if props is None:
49
+ props = etree.Element(kit.q("pPr"))
50
+ paragraph.insert(0, props)
51
+ for tag in ("spacing", "keepNext"):
52
+ for existing in props.findall(kit.q(tag)):
53
+ props.remove(existing)
54
+ before, after = HEADING_SPACING.get(level, (240, 120))
55
+ spacing = etree.SubElement(props, kit.q("spacing"))
56
+ spacing.set(kit.q("before"), str(before))
57
+ spacing.set(kit.q("after"), str(after))
58
+ etree.SubElement(props, kit.q("keepNext"))
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ parser = argparse.ArgumentParser(description="Remove direct formatting and normalize heading spacing.")
63
+ parser.add_argument("input_docx", help="Input .docx")
64
+ parser.add_argument("output_docx", nargs="?", default=None, help="Output .docx")
65
+ parser.add_argument("--out", default=None, help="Output .docx (alternative to the positional argument)")
66
+ parser.add_argument("--clear_paragraph_format", action="store_true", help="Strip direct run and paragraph-mark formatting")
67
+ parser.add_argument("--enforce_heading_spacing", action="store_true", help="Apply consistent heading spacing and keep-with-next")
68
+ args = parser.parse_args(argv)
69
+
70
+ out = args.out or args.output_docx
71
+ if not out:
72
+ parser.error("provide an output path (positional or --out)")
73
+ if not (args.clear_paragraph_format or args.enforce_heading_spacing):
74
+ parser.error("choose --clear_paragraph_format and/or --enforce_heading_spacing")
75
+
76
+ try:
77
+ doc = kit.Docx(args.input_docx)
78
+ except (OSError, KeyError) as exc:
79
+ print("error: %s" % exc, file=sys.stderr)
80
+ return 2
81
+
82
+ runs_cleaned = 0
83
+ headings_spaced = 0
84
+ for paragraph in doc.paragraphs():
85
+ if args.clear_paragraph_format:
86
+ props = paragraph.find(kit.q("pPr"))
87
+ if props is not None:
88
+ for mark in props.findall(kit.q("rPr")):
89
+ props.remove(mark)
90
+ runs_cleaned += strip_run_formatting(paragraph)
91
+ if args.enforce_heading_spacing:
92
+ level = kit.heading_level(paragraph)
93
+ if level:
94
+ enforce_spacing(paragraph, level)
95
+ headings_spaced += 1
96
+
97
+ doc.save(out)
98
+ kit.emit({"ok": True, "output": str(Path(out)), "runs_cleaned": runs_cleaned, "headings_spaced": headings_spaced})
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())