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