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,99 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
24
+ for _stream in (sys.stdout, sys.stderr):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+ MODES = {
31
+ "off": None,
32
+ "none": None,
33
+ "readonly": "readOnly",
34
+ "read_only": "readOnly",
35
+ "read-only": "readOnly",
36
+ "comments": "comments",
37
+ "comment": "comments",
38
+ "trackedchanges": "trackedChanges",
39
+ "tracked_changes": "trackedChanges",
40
+ "tracked-changes": "trackedChanges",
41
+ "forms": "forms",
42
+ "form": "forms",
43
+ }
44
+ HASH_ALGORITHM_SID = "4" # SHA-512, the modern Word default
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ parser = argparse.ArgumentParser(description="Apply or remove the editing restriction stored in settings.xml.")
49
+ parser.add_argument("docx", help="Input .docx")
50
+ parser.add_argument("--mode", required=True, help="off, readOnly, comments, trackedChanges, forms")
51
+ parser.add_argument("--out", default=None, help="Output .docx (default: overwrite the input)")
52
+ parser.add_argument("--no_enforce", action="store_true", help="Write the mode but leave enforcement off")
53
+ parser.add_argument("--hash", default=None, help="Precomputed password hash (skip to leave protection password-free)")
54
+ parser.add_argument("--salt", default=None, help="Salt value that belongs with --hash")
55
+ parser.add_argument("--spin_count", default="100000")
56
+ args = parser.parse_args(argv)
57
+
58
+ mode = MODES.get(args.mode.strip().lower())
59
+ if mode is None and args.mode.strip().lower() not in ("off", "none"):
60
+ print("error: unknown --mode %r (use off, readOnly, comments, trackedChanges, forms)" % args.mode, file=sys.stderr)
61
+ return 2
62
+
63
+ try:
64
+ doc = kit.Docx(args.docx)
65
+ except (OSError, KeyError) as exc:
66
+ print("error: %s" % exc, file=sys.stderr)
67
+ return 2
68
+
69
+ settings = doc.xml("word/settings.xml")
70
+ for existing in settings.findall(kit.q("documentProtection")):
71
+ settings.remove(existing)
72
+
73
+ if mode is not None:
74
+ node = etree.Element(kit.q("documentProtection"))
75
+ node.set(kit.q("edit"), mode)
76
+ node.set(kit.q("enforcement"), "0" if args.no_enforce else "1")
77
+ if args.hash:
78
+ node.set(kit.q("cryptProviderType"), "rsaFull")
79
+ node.set(kit.q("cryptAlgorithmClass"), "hash")
80
+ node.set(kit.q("cryptAlgorithmType"), "typeAny")
81
+ node.set(kit.q("cryptAlgorithmSid"), HASH_ALGORITHM_SID)
82
+ node.set(kit.q("cryptSpinCount"), args.spin_count)
83
+ node.set(kit.q("hash"), args.hash)
84
+ if args.salt:
85
+ node.set(kit.q("salt"), args.salt)
86
+ anchor = settings.find(kit.q("writeProtection"))
87
+ if anchor is None:
88
+ settings.insert(0, node)
89
+ else:
90
+ anchor.addnext(node)
91
+
92
+ out = args.out or args.docx
93
+ doc.save(out)
94
+ 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)})
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -0,0 +1,118 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+ MAX_FINDINGS = 50
30
+ BULLET_TEXT = ("\u2022", "- ", "* ")
31
+
32
+
33
+ def known_styles(doc: kit.Docx) -> dict[str, str]:
34
+ styles = {}
35
+ root = doc.xml("word/styles.xml")
36
+ for node in root.findall(kit.q("style")):
37
+ ident = node.get(kit.q("styleId"))
38
+ name = node.find(kit.q("name"))
39
+ styles[ident] = name.get(kit.q("val")) if name is not None else ident
40
+ return styles
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ parser = argparse.ArgumentParser(description="Report direct formatting, undefined styles and other style conflicts.")
45
+ parser.add_argument("docx", help="Input .docx")
46
+ parser.add_argument("--json", default=None, help="Also write the report to this path")
47
+ args = parser.parse_args(argv)
48
+
49
+ try:
50
+ doc = kit.Docx(args.docx)
51
+ except (OSError, KeyError) as exc:
52
+ print("error: %s" % exc, file=sys.stderr)
53
+ return 2
54
+
55
+ styles = known_styles(doc)
56
+ findings = []
57
+ sizes = Counter()
58
+ direct = 0
59
+
60
+ for index, paragraph in enumerate(doc.paragraphs(), start=1):
61
+ text = kit.paragraph_text(paragraph).strip()
62
+ style_id = kit.paragraph_style(paragraph)
63
+ level = kit.heading_level(paragraph)
64
+ if style_id and style_id not in styles:
65
+ findings.append({"kind": "undefined_style", "index": index, "style": style_id})
66
+ if level and paragraph.find(kit.q("pPr") + "/" + kit.q("rPr")) is not None:
67
+ findings.append({"kind": "heading_paragraph_formatting", "index": index, "text": text[:80]})
68
+ for run in paragraph.iter(kit.q("r")):
69
+ props = run.find(kit.q("rPr"))
70
+ if props is None or len(props) == 0:
71
+ continue
72
+ direct += 1
73
+ if level and any(props.find(kit.q(tag)) is not None for tag in ("b", "i", "color", "sz")):
74
+ findings.append({"kind": "heading_direct_formatting", "index": index, "text": text[:80]})
75
+ break
76
+ size = props.find(kit.q("sz"))
77
+ if size is not None and not level and (size.get(kit.q("val")) or "").isdigit():
78
+ sizes[int(size.get(kit.q("val")))] += 1
79
+ if not kit.text_of(run):
80
+ findings.append({"kind": "empty_run", "index": index})
81
+ break
82
+ if text.startswith(BULLET_TEXT) and paragraph.find(kit.q("pPr") + "/" + kit.q("numPr")) is None:
83
+ findings.append({"kind": "manual_bullet", "index": index, "text": text[:80]})
84
+ if " " in text:
85
+ findings.append({"kind": "double_space", "index": index, "text": text[:80]})
86
+ if text != text.rstrip():
87
+ findings.append({"kind": "trailing_space", "index": index})
88
+
89
+ if sizes:
90
+ common, _ = sizes.most_common(1)[0]
91
+ for size, count in sizes.items():
92
+ if size != common and count <= 3:
93
+ findings.append({"kind": "body_size_outlier", "half_points": size, "count": count, "common_half_points": common})
94
+
95
+ for table in doc.body().iter(kit.q("tbl")):
96
+ if table.find(kit.q("tblPr") + "/" + kit.q("tblStyle")) is None:
97
+ findings.append({"kind": "table_without_style"})
98
+
99
+ counts = {}
100
+ for finding in findings:
101
+ counts[finding["kind"]] = counts.get(finding["kind"], 0) + 1
102
+
103
+ report = {
104
+ "ok": not findings,
105
+ "styles_defined": len(styles),
106
+ "directly_formatted_runs": direct,
107
+ "counts": counts,
108
+ "findings": findings[:MAX_FINDINGS],
109
+ "truncated": max(0, len(findings) - MAX_FINDINGS),
110
+ }
111
+ if args.json:
112
+ Path(args.json).write_text(json.dumps(report, indent=2), encoding="utf-8")
113
+ kit.emit(report)
114
+ return 1 if findings else 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())
@@ -0,0 +1,110 @@
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
+ # 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
+ HEADING_SPACING = {1: (320, 120), 2: (240, 80), 3: (200, 60)}
35
+ KEEP_RUN_PROPS = {kit.q("rStyle"), kit.q("rtl"), kit.q("lang")}
36
+
37
+
38
+ def strip_run_formatting(paragraph) -> int:
39
+ removed = 0
40
+ for run in paragraph.iter(kit.q("r")):
41
+ props = run.find(kit.q("rPr"))
42
+ if props is None:
43
+ continue
44
+ for child in list(props):
45
+ if child.tag not in KEEP_RUN_PROPS:
46
+ props.remove(child)
47
+ removed += 1
48
+ if len(props) == 0:
49
+ run.remove(props)
50
+ return removed
51
+
52
+
53
+ def enforce_spacing(paragraph, level: int) -> None:
54
+ props = paragraph.find(kit.q("pPr"))
55
+ if props is None:
56
+ props = etree.Element(kit.q("pPr"))
57
+ paragraph.insert(0, props)
58
+ for tag in ("spacing", "keepNext"):
59
+ for existing in props.findall(kit.q(tag)):
60
+ props.remove(existing)
61
+ before, after = HEADING_SPACING.get(level, (240, 120))
62
+ spacing = etree.SubElement(props, kit.q("spacing"))
63
+ spacing.set(kit.q("before"), str(before))
64
+ spacing.set(kit.q("after"), str(after))
65
+ etree.SubElement(props, kit.q("keepNext"))
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ parser = argparse.ArgumentParser(description="Remove direct formatting and normalize heading spacing.")
70
+ parser.add_argument("input_docx", help="Input .docx")
71
+ parser.add_argument("output_docx", nargs="?", default=None, help="Output .docx")
72
+ parser.add_argument("--out", default=None, help="Output .docx (alternative to the positional argument)")
73
+ parser.add_argument("--clear_paragraph_format", action="store_true", help="Strip direct run and paragraph-mark formatting")
74
+ parser.add_argument("--enforce_heading_spacing", action="store_true", help="Apply consistent heading spacing and keep-with-next")
75
+ args = parser.parse_args(argv)
76
+
77
+ out = args.out or args.output_docx
78
+ if not out:
79
+ parser.error("provide an output path (positional or --out)")
80
+ if not (args.clear_paragraph_format or args.enforce_heading_spacing):
81
+ parser.error("choose --clear_paragraph_format and/or --enforce_heading_spacing")
82
+
83
+ try:
84
+ doc = kit.Docx(args.input_docx)
85
+ except (OSError, KeyError) as exc:
86
+ print("error: %s" % exc, file=sys.stderr)
87
+ return 2
88
+
89
+ runs_cleaned = 0
90
+ headings_spaced = 0
91
+ for paragraph in doc.paragraphs():
92
+ if args.clear_paragraph_format:
93
+ props = paragraph.find(kit.q("pPr"))
94
+ if props is not None:
95
+ for mark in props.findall(kit.q("rPr")):
96
+ props.remove(mark)
97
+ runs_cleaned += strip_run_formatting(paragraph)
98
+ if args.enforce_heading_spacing:
99
+ level = kit.heading_level(paragraph)
100
+ if level:
101
+ enforce_spacing(paragraph, level)
102
+ headings_spaced += 1
103
+
104
+ doc.save(out)
105
+ kit.emit({"ok": True, "output": str(Path(out)), "runs_cleaned": runs_cleaned, "headings_spaced": headings_spaced})
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python
2
+ """Audit table widths in twips, and optionally make every table fit the page.
3
+
4
+ Word stores table widths three ways (``tblW``, grid columns, cell widths) and they
5
+ disagree often enough that a table overflows the margin after a page-size change.
6
+ ``--fix`` makes the table fixed-layout and distributes the text width across the
7
+ grid, writing the same number to each cell so the two can no longer disagree.
8
+
9
+ Exit codes: 0 clean, 1 problems found (without --fix), 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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
24
+ for _stream in (sys.stdout, sys.stderr):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+
31
+ def text_width_twips(doc: kit.Docx) -> int:
32
+ sections = doc.sections()
33
+ if not sections:
34
+ return 9360
35
+ sect = sections[0]
36
+ page = sect.find(kit.q("pgSz"))
37
+ margins = sect.find(kit.q("pgMar"))
38
+ width = int(page.get(kit.q("w")) or 12240) if page is not None else 12240
39
+ if margins is None:
40
+ return width - 2880
41
+ return width - sum(int(margins.get(kit.q(side)) or 0) for side in ("left", "right"))
42
+
43
+
44
+ def grid_widths(table) -> list[int]:
45
+ grid = table.find(kit.q("tblGrid"))
46
+ if grid is None:
47
+ return []
48
+ widths = []
49
+ for column in grid.findall(kit.q("gridCol")):
50
+ value = column.get(kit.q("w"))
51
+ widths.append(int(value) if value and value.lstrip("-").isdigit() else 0)
52
+ return widths
53
+
54
+
55
+ def table_info(table) -> dict:
56
+ props = table.find(kit.q("tblPr"))
57
+ width = None
58
+ layout = None
59
+ if props is not None:
60
+ node = props.find(kit.q("tblW"))
61
+ if node is not None:
62
+ width = {"type": node.get(kit.q("type")), "value": node.get(kit.q("w"))}
63
+ node = props.find(kit.q("tblLayout"))
64
+ if node is not None:
65
+ layout = node.get(kit.q("type"))
66
+ widths = grid_widths(table)
67
+ cells = table.findall(".//" + kit.q("tc"))
68
+ cell_widths = []
69
+ for cell in cells:
70
+ node = cell.find(kit.q("tcPr") + "/" + kit.q("tcW"))
71
+ if node is not None and (node.get(kit.q("w")) or "").isdigit():
72
+ cell_widths.append(int(node.get(kit.q("w"))))
73
+ return {
74
+ "tblW": width,
75
+ "layout": layout,
76
+ "grid_twips": widths,
77
+ "grid_total": sum(widths),
78
+ "cells": len(cells),
79
+ "cell_width_min": min(cell_widths) if cell_widths else None,
80
+ "cell_width_max": max(cell_widths) if cell_widths else None,
81
+ }
82
+
83
+
84
+ def fix_table(table, target: int) -> None:
85
+ props = table.find(kit.q("tblPr"))
86
+ if props is None:
87
+ props = etree.Element(kit.q("tblPr"))
88
+ table.insert(0, props)
89
+ for tag in ("tblW", "tblLayout"):
90
+ for existing in props.findall(kit.q(tag)):
91
+ props.remove(existing)
92
+ width = etree.SubElement(props, kit.q("tblW"))
93
+ width.set(kit.q("type"), "dxa")
94
+ width.set(kit.q("w"), str(target))
95
+ etree.SubElement(props, kit.q("tblLayout")).set(kit.q("type"), "fixed")
96
+
97
+ columns = grid_widths(table)
98
+ if not columns:
99
+ columns = [1]
100
+ total = sum(columns) or len(columns)
101
+ scaled = [max(1, round(target * value / total)) for value in columns]
102
+ grid = table.find(kit.q("tblGrid"))
103
+ if grid is None:
104
+ grid = etree.Element(kit.q("tblGrid"))
105
+ table.insert(1, grid)
106
+ for column in list(grid):
107
+ grid.remove(column)
108
+ for value in scaled:
109
+ etree.SubElement(grid, kit.q("gridCol")).set(kit.q("w"), str(value))
110
+
111
+ for row in table.findall(kit.q("tr")):
112
+ position = 0
113
+ for cell in row.findall(kit.q("tc")):
114
+ span = 1
115
+ cell_props = cell.find(kit.q("tcPr"))
116
+ if cell_props is None:
117
+ cell_props = etree.Element(kit.q("tcPr"))
118
+ cell.insert(0, cell_props)
119
+ node = cell_props.find(kit.q("gridSpan"))
120
+ if node is not None and (node.get(kit.q("val")) or "").isdigit():
121
+ span = int(node.get(kit.q("val")))
122
+ own = sum(scaled[position : position + span]) or scaled[0]
123
+ for existing in cell_props.findall(kit.q("tcW")):
124
+ cell_props.remove(existing)
125
+ tcw = etree.SubElement(cell_props, kit.q("tcW"))
126
+ tcw.set(kit.q("type"), "dxa")
127
+ tcw.set(kit.q("w"), str(own))
128
+ position += span
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ parser = argparse.ArgumentParser(description="Audit table geometry in twips and optionally fit tables to the text width.")
133
+ parser.add_argument("docx", help="Input .docx")
134
+ parser.add_argument("--fix", action="store_true", help="Set fixed layout and rewrite grid/cell widths to fit the page")
135
+ parser.add_argument("--out", default=None, help="Output .docx (required with --fix, defaults to overwriting)")
136
+ args = parser.parse_args(argv)
137
+
138
+ try:
139
+ doc = kit.Docx(args.docx)
140
+ except (OSError, KeyError) as exc:
141
+ print("error: %s" % exc, file=sys.stderr)
142
+ return 2
143
+
144
+ target = text_width_twips(doc)
145
+ tables = list(doc.body().iter(kit.q("tbl")))
146
+ rows = [table_info(table) for table in tables]
147
+ findings = []
148
+ for index, info in enumerate(rows, start=1):
149
+ if info["grid_total"] and info["grid_total"] > target:
150
+ findings.append({"kind": "grid_wider_than_text", "table": index, "grid_total": info["grid_total"], "text_width": target})
151
+ if info["layout"] != "fixed":
152
+ findings.append({"kind": "not_fixed_layout", "table": index})
153
+ if info["cell_width_max"] and info["grid_total"] and info["cell_width_max"] > info["grid_total"]:
154
+ findings.append({"kind": "cell_wider_than_grid", "table": index})
155
+ if info["tblW"] and info["tblW"]["type"] == "pct" and not info["grid_total"]:
156
+ findings.append({"kind": "percent_width_without_grid", "table": index})
157
+
158
+ if args.fix:
159
+ if not tables:
160
+ print("error: no tables found", file=sys.stderr)
161
+ return 2
162
+ for table in tables:
163
+ fix_table(table, target)
164
+ out = args.out or args.docx
165
+ doc.save(out)
166
+ kit.emit({"ok": True, "fixed": True, "output": str(Path(out)), "tables": len(tables), "text_width": target, "findings": findings})
167
+ return 0
168
+
169
+ kit.emit({"ok": not findings, "text_width": target, "tables": rows, "findings": findings})
170
+ return 1 if findings else 0
171
+
172
+
173
+ if __name__ == "__main__":
174
+ raise SystemExit(main())
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python
2
+ """Add a diagonal text watermark to the page background.
3
+
4
+ The watermark is a VML text shape in the default header, which is how Word has
5
+ stored them since the 2007 file format: it survives conversion, prints on every
6
+ page, and can be removed again by deleting the header shape.
7
+
8
+ Exit codes: 0 success, 2 bad input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ import docx_kit as kit
19
+
20
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
21
+ for _stream in (sys.stdout, sys.stderr):
22
+ try:
23
+ _stream.reconfigure(encoding="utf-8", errors="replace")
24
+ except (AttributeError, ValueError):
25
+ pass
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ parser = argparse.ArgumentParser(description="Add a VML text watermark to the document header.")
30
+ parser.add_argument("input_docx", help="Input .docx")
31
+ parser.add_argument("--out", required=True, help="Output .docx")
32
+ parser.add_argument("--text", required=True, help="Watermark text, e.g. CONFIDENTIAL")
33
+ parser.add_argument("--color", default="#C0C0C0", help="Shape fill colour (hex)")
34
+ args = parser.parse_args(argv)
35
+
36
+ try:
37
+ doc = kit.Docx(args.input_docx)
38
+ except (OSError, KeyError) as exc:
39
+ print("error: %s" % exc, file=sys.stderr)
40
+ return 2
41
+
42
+ if kit.watermark_texts(doc):
43
+ print("error: this document already has a watermark; remove it first", file=sys.stderr)
44
+ return 2
45
+
46
+ part = doc.ensure_header_footer("header")
47
+ root = doc.xml(part)
48
+ root.append(kit.watermark_paragraph(args.text, doc.next_id(), args.color))
49
+ doc.save(args.out)
50
+ kit.emit({"ok": True, "output": str(Path(args.out)), "text": args.text, "part": part})
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ raise SystemExit(main())
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python
2
+ """Find and remove VML text watermarks from headers.
3
+
4
+ A watermark that was meant for a draft can end up in a signed document, and it is
5
+ invisible in text extraction because it lives in a shape, not in a paragraph run.
6
+
7
+ Exit codes: 0 clean, 1 watermarks found (report mode), 2 bad input.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+ import docx_kit as kit
18
+
19
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
20
+ for _stream in (sys.stdout, sys.stderr):
21
+ try:
22
+ _stream.reconfigure(encoding="utf-8", errors="replace")
23
+ except (AttributeError, ValueError):
24
+ pass
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ parser = argparse.ArgumentParser(description="Report or remove watermarks stored as VML shapes in headers.")
29
+ parser.add_argument("input_docx", help="Input .docx")
30
+ parser.add_argument("--mode", choices=("report", "remove"), default="report")
31
+ parser.add_argument("--contains", default=None, help="Only match watermarks containing this text")
32
+ parser.add_argument("--out", default=None, help="Output .docx for --mode remove")
33
+ args = parser.parse_args(argv)
34
+
35
+ if args.mode == "remove" and not args.out:
36
+ print("error: --mode remove needs --out", file=sys.stderr)
37
+ return 2
38
+
39
+ try:
40
+ doc = kit.Docx(args.input_docx)
41
+ except (OSError, KeyError) as exc:
42
+ print("error: %s" % exc, file=sys.stderr)
43
+ return 2
44
+
45
+ found = kit.watermark_texts(doc)
46
+ if args.contains:
47
+ wanted = args.contains.lower()
48
+ found = [item for item in found if wanted in (item["text"] or "").lower()]
49
+
50
+ removed = 0
51
+ if args.mode == "remove" and found:
52
+ for item in found:
53
+ paragraph = item["paragraph"]
54
+ if paragraph is not None and paragraph.getparent() is not None:
55
+ paragraph.getparent().remove(paragraph)
56
+ removed += 1
57
+ doc.save(args.out)
58
+ elif args.mode == "remove":
59
+ doc.save(args.out)
60
+
61
+ kit.emit(
62
+ {
63
+ "ok": args.mode == "remove" or not found,
64
+ "mode": args.mode,
65
+ "watermarks": [{"part": item["part"], "text": item["text"]} for item in found],
66
+ "removed": removed,
67
+ "output": str(Path(args.out)) if args.out else None,
68
+ }
69
+ )
70
+ return 0 if (args.mode == "remove" or not found) else 1
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())