dsh-office-kit 0.1.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 (198) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/LICENSE.md +21 -21
  3. package/README.md +108 -106
  4. package/assets/skills/excel-live-control/SKILL.md +234 -0
  5. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  6. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  7. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  8. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  9. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  10. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  11. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  12. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  13. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  14. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  15. package/assets/skills/office-documents/SKILL.md +90 -77
  16. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  17. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  18. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  19. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  20. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  21. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  22. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  23. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  24. package/assets/skills/office-documents/render_docx.py +92 -0
  25. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  26. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  27. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  28. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  29. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  30. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  31. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  32. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  33. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  34. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  35. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  36. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  37. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  38. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  39. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  40. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  41. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  42. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  43. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  44. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  45. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  46. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  47. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  48. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  49. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  50. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  51. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  52. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  53. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  54. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  55. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  56. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  57. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  58. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  59. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  60. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  61. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  62. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  63. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  64. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  65. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  66. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  67. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  68. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  69. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  70. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  71. package/assets/skills/office-documents/tasks/equations.md +59 -0
  72. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  73. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  74. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  75. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  76. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  77. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  78. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  79. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  80. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  81. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  82. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  83. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  84. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  85. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  86. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  87. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  88. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  89. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  90. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  91. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  92. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  93. package/assets/skills/office-pdf/SKILL.md +73 -56
  94. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  95. package/assets/skills/office-pdf/references/geometry.md +44 -0
  96. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  97. package/assets/skills/office-pdf/references/limits.md +58 -0
  98. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  99. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  100. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  101. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  102. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  103. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  104. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  105. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  106. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  107. package/assets/skills/office-pdf/tasks/create.md +75 -0
  108. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  109. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  110. package/assets/skills/office-pdf/tasks/read.md +62 -0
  111. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  112. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  113. package/assets/skills/office-presentations/SKILL.md +141 -87
  114. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  115. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  116. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  118. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  119. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  120. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  121. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  122. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  123. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  124. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  125. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  126. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  127. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  128. package/assets/skills/office-presentations/recipes/README.md +71 -0
  129. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  130. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  131. package/assets/skills/office-presentations/recipes/images.md +80 -0
  132. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  133. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  134. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  135. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  136. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  137. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  138. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  139. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  140. package/assets/skills/office-presentations/references/finalization.md +63 -0
  141. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  142. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  143. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  144. package/assets/skills/office-presentations/references/templates.md +76 -0
  145. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  146. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  147. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  148. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  149. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  150. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  151. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  152. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  153. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  154. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  155. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  156. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  157. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  158. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  159. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  160. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  161. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  162. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  163. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  164. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  165. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  166. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  167. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  168. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  169. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  170. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  171. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  172. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  173. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  174. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  175. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  176. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  177. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  178. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  179. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  180. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  181. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  182. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  183. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  184. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  185. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  186. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  187. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  188. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  189. package/assets/skills/shared/annotation-recipes.md +252 -0
  190. package/assets/skills/shared/check_runtime.py +253 -0
  191. package/assets/skills/shared/common-workflow.md +139 -0
  192. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  193. package/dist/client.js +4 -4
  194. package/dist/client.js.map +1 -1
  195. package/dist/index.js +239 -89
  196. package/package.json +146 -149
  197. package/postinstall.mjs +92 -82
  198. package/python/office_cli.py +171 -246
@@ -0,0 +1,345 @@
1
+ """Shared helpers for the slide-deck container tools.
2
+
3
+ Purpose: one import surface for the small, boring things every checker needs —
4
+ opening a deck, walking shapes (including groups), reading a bounding box in
5
+ inches, guessing how much room a line of text needs, and printing a JSON
6
+ verdict. Nothing here decides anything; the scripts do.
7
+
8
+ Import from a sibling script with:
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ from ooxml_helpers import ...
12
+
13
+ Exit codes are documented per script; this module only exits when a caller
14
+ passes a bad path to `open_presentation`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Any, Iterable, Iterator
25
+
26
+ EMU_PER_INCH = 914400
27
+
28
+ # Anything that renders about a full em wide and wraps mid-token.
29
+ WIDE_CHAR = re.compile(r"[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]")
30
+ WORD = re.compile(r"[A-Za-z0-9][A-Za-z0-9'’.,%+\-]*")
31
+
32
+ TOTAL_WORDS = re.compile(
33
+ r"\b(total|totals|sum|sums|subtotal|sub-total|grand\s+total|overall|all\s+regions?|combined)\b",
34
+ re.IGNORECASE,
35
+ )
36
+ NOT_ADDITIVE = re.compile(
37
+ r"(rate|ratio|percent|percentage|%|share|average|avg|mean|median|margin|margin\s+%|uplift|"
38
+ r"growth|index|price|unit|cpu|latency|duration|per\s+unit|headcount\s+ratio)",
39
+ re.IGNORECASE,
40
+ )
41
+ PLACEHOLDER_TITLE = re.compile(r"^(chart\s*title|title|click\s+to\s+add\s+(a\s+)?title)\s*$", re.IGNORECASE)
42
+
43
+
44
+ def open_presentation(path: str | Path):
45
+ """Open a .pptx; exits 2 with a JSON error if it cannot be read."""
46
+ from pptx import Presentation
47
+
48
+ target = Path(path)
49
+ if not target.is_file():
50
+ emit_error("input_missing", f"no such file: {target}")
51
+ raise SystemExit(2)
52
+ try:
53
+ return Presentation(str(target))
54
+ except Exception as exc: # noqa: BLE001 - report, never traceback at the user
55
+ emit_error("unreadable_presentation", f"{type(exc).__name__}: {exc}")
56
+ raise SystemExit(2)
57
+
58
+
59
+ def slide_size_in(prs) -> tuple[float, float]:
60
+ return prs.slide_width / EMU_PER_INCH, prs.slide_height / EMU_PER_INCH
61
+
62
+
63
+ def iter_shapes(shapes) -> Iterator[Any]:
64
+ """Yield every shape, descending into groups."""
65
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
66
+
67
+ for shape in shapes:
68
+ yield shape
69
+ if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
70
+ yield from iter_shapes(shape.shapes)
71
+
72
+
73
+ def box(shape) -> tuple[float, float, float, float] | None:
74
+ """(left, top, width, height) in inches, or None when the shape has no frame."""
75
+ if shape.left is None or shape.top is None:
76
+ return None
77
+ width = shape.width or 0
78
+ height = shape.height or 0
79
+ return (
80
+ shape.left / EMU_PER_INCH,
81
+ shape.top / EMU_PER_INCH,
82
+ width / EMU_PER_INCH,
83
+ height / EMU_PER_INCH,
84
+ )
85
+
86
+
87
+ def label(shape) -> str:
88
+ name = getattr(shape, "name", "") or ""
89
+ if name:
90
+ return name
91
+ return f"{shape.shape_type}"
92
+
93
+
94
+ def placeholder_kind(shape) -> str | None:
95
+ """Placeholder role as a bare token: TITLE, BODY, SUBTITLE, DATE, ...
96
+
97
+ python-pptx stringifies the enum differently across versions ("TITLE (13)",
98
+ "<PP_PLACEHOLDER.TITLE: 13>"), so the number and decorations are stripped
99
+ here and every caller can compare against one spelling.
100
+ """
101
+ try:
102
+ fmt = shape.placeholder_format
103
+ except (AttributeError, ValueError, KeyError):
104
+ return None
105
+ if fmt is None:
106
+ return None
107
+ kind = getattr(fmt, "type", None)
108
+ if kind is None:
109
+ return "UNKNOWN"
110
+ raw = str(kind).upper().replace("PP_PLACEHOLDER.", " ")
111
+ for token in ("<", ">", "(", ")", ":", "."):
112
+ raw = raw.replace(token, " ")
113
+ tokens = [token for token in raw.split() if token and not token.isdigit()]
114
+ return tokens[-1] if tokens else "UNKNOWN"
115
+
116
+
117
+ def has_text_frame(shape) -> bool:
118
+ return getattr(shape, "has_text_frame", False) and shape.has_text_frame
119
+
120
+
121
+ def text_of(shape) -> str:
122
+ if not has_text_frame(shape):
123
+ return ""
124
+ return "\n".join(p.text for p in shape.text_frame.paragraphs).strip()
125
+
126
+
127
+ def has_visible_text(shape) -> bool:
128
+ return bool(text_of(shape))
129
+
130
+
131
+ def run_size_pt(shape) -> float | None:
132
+ """Largest explicit size in the shape, or None when fully inherited.
133
+
134
+ Size can sit on the run or on the paragraph's default run properties; a
135
+ paragraph-level size still resolves in PowerPoint, so both count.
136
+ """
137
+ if not has_text_frame(shape):
138
+ return None
139
+ sizes: list[float] = []
140
+ for paragraph in shape.text_frame.paragraphs:
141
+ try:
142
+ if paragraph.font.size is not None:
143
+ sizes.append(paragraph.font.size.pt)
144
+ except AttributeError: # older python-pptx without paragraph.font
145
+ pass
146
+ sizes.extend(run.font.size.pt for run in paragraph.runs if run.font.size is not None)
147
+ return max(sizes) if sizes else None
148
+
149
+
150
+ def paragraph_size_pt(paragraph, fallback: float) -> float:
151
+ try:
152
+ if paragraph.font.size is not None:
153
+ return paragraph.font.size.pt
154
+ except AttributeError:
155
+ pass
156
+ for run in paragraph.runs:
157
+ if run.font.size is not None:
158
+ return run.font.size.pt
159
+ return fallback
160
+
161
+
162
+ def default_size_pt(shape) -> float:
163
+ """Approximate the size a viewer resolves for an under-specified text box."""
164
+ kind = (placeholder_kind(shape) or "").upper()
165
+ if "CENTER_TITLE" in kind:
166
+ return 44.0
167
+ if "TITLE" in kind:
168
+ return 32.0
169
+ if "SUBTITLE" in kind:
170
+ return 20.0
171
+ if "BODY" in kind:
172
+ return 18.0
173
+ return 18.0
174
+
175
+
176
+ def line_height_in(font_pt: float, spacing: float = 1.2) -> float:
177
+ return font_pt * spacing / 72.0
178
+
179
+
180
+ def wrap_lines(text: str, font_pt: float, width_in: float) -> int:
181
+ """Crude but stable line count: wide chars cost one em, latin words wrap."""
182
+ if not text.strip():
183
+ return 1
184
+ per_line = max(1.0, width_in * 72.0 / max(font_pt, 1.0))
185
+ used = 0.0
186
+ lines = 1
187
+ for token in re.findall(r"\S+\s*", text):
188
+ cost = 0.0
189
+ for char in token:
190
+ cost += 1.0 if WIDE_CHAR.match(char) else 0.55
191
+ if used + cost > per_line and used > 0:
192
+ lines += 1
193
+ used = cost
194
+ else:
195
+ used += cost
196
+ return lines
197
+
198
+
199
+ def estimated_text_height_in(shape) -> float | None:
200
+ """Rough height the text needs at its declared size, or None if no text."""
201
+ if not has_text_frame(shape):
202
+ return None
203
+ geometry = box(shape)
204
+ if geometry is None:
205
+ return None
206
+ frame = shape.text_frame
207
+ inner_w = max(0.2, geometry[2] - (frame.margin_left + frame.margin_right) / EMU_PER_INCH)
208
+ total = 0.0
209
+ any_text = False
210
+ for paragraph in frame.paragraphs:
211
+ text = paragraph.text
212
+ if not text.strip():
213
+ total += line_height_in(default_size_pt(shape)) * 0.5
214
+ continue
215
+ any_text = True
216
+ size = default_size_pt(shape)
217
+ for run in paragraph.runs:
218
+ if run.font.size is not None:
219
+ size = run.font.size.pt
220
+ break
221
+ else:
222
+ size = paragraph_size_pt(paragraph, size)
223
+ total += wrap_lines(text, size, inner_w) * line_height_in(size)
224
+ return total if any_text else None
225
+
226
+
227
+ def is_picture(shape) -> bool:
228
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
229
+
230
+ return shape.shape_type == MSO_SHAPE_TYPE.PICTURE
231
+
232
+
233
+ def is_table(shape) -> bool:
234
+ return getattr(shape, "has_table", False) and shape.has_table
235
+
236
+
237
+ def is_chart(shape) -> bool:
238
+ return getattr(shape, "has_chart", False) and shape.has_chart
239
+
240
+
241
+ def is_connector(shape) -> bool:
242
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
243
+
244
+ return shape.shape_type in {MSO_SHAPE_TYPE.LINE, MSO_SHAPE_TYPE.FREEFORM} or shape.__class__.__name__ == "Connector"
245
+
246
+
247
+ def is_decorative(shape) -> bool:
248
+ """Shapes a template owner would call furniture: no text, or a picture/line."""
249
+ return is_picture(shape) or is_connector(shape) or not has_text_frame(shape) or not has_visible_text(shape)
250
+
251
+
252
+ def cell_number(value: str) -> float | None:
253
+ """Parse a numeric table cell; None when it is not a number."""
254
+ text = (value or "").strip()
255
+ if not text:
256
+ return None
257
+ negative = text.startswith("(") and text.endswith(")")
258
+ cleaned = re.sub(r"[,\s\u00a0]", "", text.strip("()"))
259
+ cleaned = re.sub(r"^[^\d+\-.]*", "", cleaned)
260
+ match = re.match(r"^[+-]?\d*\.?\d+", cleaned)
261
+ if not match:
262
+ return None
263
+ try:
264
+ number = float(match.group(0))
265
+ except ValueError:
266
+ return None
267
+ return -number if negative else number
268
+
269
+
270
+ def decimals(value: str) -> int:
271
+ match = re.search(r"\.(\d+)", (value or "").replace(",", ""))
272
+ return len(match.group(1)) if match else 0
273
+
274
+
275
+ def rects_intersect(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
276
+ ax, ay, aw, ah = a
277
+ bx, by, bw, bh = b
278
+ return not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay)
279
+
280
+
281
+ def intersection_area(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
282
+ ax, ay, aw, ah = a
283
+ bx, by, bw, bh = b
284
+ dx = min(ax + aw, bx + bw) - max(ax, bx)
285
+ dy = min(ay + ah, by + bh) - max(ay, by)
286
+ return max(0.0, dx) * max(0.0, dy)
287
+
288
+
289
+ def overlap_ratio(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
290
+ smaller = min(a[2] * a[3], b[2] * b[3])
291
+ if smaller <= 0:
292
+ return 0.0
293
+ return intersection_area(a, b) / smaller
294
+
295
+
296
+ def emit(payload: dict[str, Any]) -> None:
297
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
298
+ sys.stdout.write("\n")
299
+
300
+
301
+ def emit_error(code: str, message: str) -> None:
302
+ emit({"ok": False, "error": {"code": code, "message": message}})
303
+
304
+
305
+ def finding(kind: str, severity: str, message: str, **extra: Any) -> dict[str, Any]:
306
+ payload = {"kind": kind, "severity": severity, "message": message}
307
+ payload.update({key: value for key, value in extra.items() if value is not None})
308
+ return payload
309
+
310
+
311
+ def number_or_none(value: Any) -> float | None:
312
+ try:
313
+ out = float(value)
314
+ except (TypeError, ValueError):
315
+ return None
316
+ return out if math.isfinite(out) else None
317
+
318
+
319
+ def iter_media_parts(prs) -> Iterable[Any]:
320
+ for part in prs.part.package.iter_parts():
321
+ if "/media/" in str(part.partname):
322
+ yield part
323
+
324
+
325
+ def demo() -> int:
326
+ """Self-check: the three estimates every gate leans on. Run with --help too."""
327
+ # A 40-character latin line at 18pt needs ~ (40*0.55*18)/72 = 5.5in.
328
+ assert wrap_lines("x" * 40, 18, 5.5) == 1
329
+ # Words wrap: 20 tokens cost 20 * 1.1em, so a 2in frame splits them.
330
+ assert wrap_lines("ab " * 20, 18, 2.0) > 1
331
+ # Wide characters cost a full em, so the same word count needs more lines.
332
+ assert wrap_lines("物 " * 20, 18, 2.0) > wrap_lines("x " * 20, 18, 2.0)
333
+ assert cell_number("(1,234.50)") == -1234.5
334
+ assert cell_number("n/a") is None
335
+ assert decimals("1.50") == 2
336
+ # Coplanar 2x2 boxes offset by 1in share half the smaller area: ratio 0.5.
337
+ assert abs(overlap_ratio((0, 0, 2, 2), (1, 0, 2, 2)) - 0.5) < 1e-9
338
+ # A box swallowed whole counts as fully covered.
339
+ assert overlap_ratio((0, 0, 2, 2), (0, 0, 2, 1)) == 1.0
340
+ emit({"ok": True, "module": "ooxml_helpers", "helpers": 24, "checks": "wrap_lines, cell_number, decimals, overlap_ratio"})
341
+ return 0
342
+
343
+
344
+ if __name__ == "__main__":
345
+ raise SystemExit(demo())