dsh-office-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -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 +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -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 +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -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 +253 -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 +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Decide whether every native chart carries a title the author meant to ship.
|
|
2
|
+
|
|
3
|
+
Purpose: a chart with the literal "Chart Title" placeholder is an unfinished
|
|
4
|
+
chart. PowerPoint will happily render it, so no render check catches it. This
|
|
5
|
+
reads the chart parts and classifies each title, then fails only on the two
|
|
6
|
+
states that mean "nobody decided here": a default placeholder and an empty
|
|
7
|
+
title element.
|
|
8
|
+
|
|
9
|
+
Statuses: absent, present, default_placeholder, linked_title_unresolved,
|
|
10
|
+
explicitly_suppressed, empty_title.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 pass, 1 fail, 2 unreadable input.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import zipfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from xml.etree import ElementTree
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
24
|
+
|
|
25
|
+
C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
|
|
26
|
+
A_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
|
|
27
|
+
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
|
28
|
+
|
|
29
|
+
DEFAULT_TITLE_STRINGS = ("chart title", "chart 1 title")
|
|
30
|
+
BLOCKING = {"default_placeholder", "empty_title"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _chart_title_status(chart_xml: bytes) -> tuple[str, str]:
|
|
34
|
+
root = ElementTree.fromstring(chart_xml)
|
|
35
|
+
chart = root.find(f"{C_NS}chart")
|
|
36
|
+
if chart is None:
|
|
37
|
+
return "absent", ""
|
|
38
|
+
deleted = chart.find(f"{C_NS}autoTitleDeleted")
|
|
39
|
+
suppressed = deleted is not None and deleted.get("val", "1") in {"1", "true"}
|
|
40
|
+
title = chart.find(f"{C_NS}title")
|
|
41
|
+
if title is None:
|
|
42
|
+
return ("explicitly_suppressed", "") if suppressed else ("absent", "")
|
|
43
|
+
|
|
44
|
+
if title.find(f"{C_NS}tx/{C_NS}strRef") is not None:
|
|
45
|
+
return "linked_title_unresolved", ""
|
|
46
|
+
rich = title.find(f"{C_NS}tx/{C_NS}rich")
|
|
47
|
+
if rich is None:
|
|
48
|
+
return "empty_title", ""
|
|
49
|
+
pieces = [node.text or "" for node in rich.iter(f"{A_NS}t")]
|
|
50
|
+
text = "".join(pieces).strip()
|
|
51
|
+
if not text:
|
|
52
|
+
return "empty_title", ""
|
|
53
|
+
if " ".join(text.split()).lower() in DEFAULT_TITLE_STRINGS:
|
|
54
|
+
return "default_placeholder", text
|
|
55
|
+
return "present", text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def inspect_chart_titles(pptx_path: str | Path) -> dict:
|
|
59
|
+
path = Path(pptx_path)
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
return {"gate": "native-chart-title", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
|
|
62
|
+
|
|
63
|
+
from pptx import Presentation
|
|
64
|
+
|
|
65
|
+
presentation = Presentation(str(path))
|
|
66
|
+
slides: list[dict] = []
|
|
67
|
+
checks: list[dict] = []
|
|
68
|
+
chart_parts: dict[str, bytes] = {}
|
|
69
|
+
with zipfile.ZipFile(path) as archive:
|
|
70
|
+
for name in archive.namelist():
|
|
71
|
+
if name.startswith("ppt/charts/") and name.endswith(".xml"):
|
|
72
|
+
chart_parts[name] = archive.read(name)
|
|
73
|
+
|
|
74
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
75
|
+
chart_count = 0
|
|
76
|
+
for shape in slide.shapes:
|
|
77
|
+
if not getattr(shape, "has_chart", False):
|
|
78
|
+
continue
|
|
79
|
+
chart_count += 1
|
|
80
|
+
part = shape.chart.part
|
|
81
|
+
part_name = str(part.partname).lstrip("/")
|
|
82
|
+
payload = chart_parts.get(part_name)
|
|
83
|
+
if payload is None:
|
|
84
|
+
checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": "part_missing", "title": ""})
|
|
85
|
+
continue
|
|
86
|
+
status, text = _chart_title_status(payload)
|
|
87
|
+
checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": status, "title": text})
|
|
88
|
+
slides.append({"slide": slide_no, "charts": chart_count})
|
|
89
|
+
|
|
90
|
+
unused = sorted(set(chart_parts) - {check["part"] for check in checks})
|
|
91
|
+
findings = [
|
|
92
|
+
{
|
|
93
|
+
"kind": "chart_title",
|
|
94
|
+
"severity": "fail",
|
|
95
|
+
"slide": check["slide"],
|
|
96
|
+
"chart": check["chart"],
|
|
97
|
+
"status": check["status"],
|
|
98
|
+
"message": (
|
|
99
|
+
"chart still shows the placeholder title"
|
|
100
|
+
if check["status"] == "default_placeholder"
|
|
101
|
+
else "chart has an empty title element"
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
for check in checks
|
|
105
|
+
if check["status"] in BLOCKING
|
|
106
|
+
]
|
|
107
|
+
for part in unused:
|
|
108
|
+
findings.append({"kind": "orphan_chart_part", "severity": "review", "part": part, "message": "chart part is not placed on any slide"})
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"gate": "native-chart-title",
|
|
112
|
+
"presentation": str(path.resolve()),
|
|
113
|
+
"slide_count": len(slides),
|
|
114
|
+
"native_chart_count": sum(item["charts"] for item in slides),
|
|
115
|
+
"checks": checks,
|
|
116
|
+
"findings": findings,
|
|
117
|
+
"native_powerpoint_verified": False,
|
|
118
|
+
"source_text_emitted": False,
|
|
119
|
+
"passed": not [item for item in findings if item["severity"] == "fail"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
import argparse
|
|
125
|
+
|
|
126
|
+
parser = argparse.ArgumentParser(description="Fail when a native chart keeps a placeholder or empty title.")
|
|
127
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
128
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
129
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
130
|
+
args = parser.parse_args(argv)
|
|
131
|
+
path = args.input_path or args.presentation
|
|
132
|
+
if not path:
|
|
133
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
134
|
+
|
|
135
|
+
report = inspect_chart_titles(path)
|
|
136
|
+
if args.json_out:
|
|
137
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
138
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
139
|
+
sys.stdout.write("\n")
|
|
140
|
+
return 0 if report["passed"] else 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Check that a quantitative chart is backed by real data, not just shapes.
|
|
2
|
+
|
|
3
|
+
Purpose: a chart can look right and carry nothing editable — an empty cache, a
|
|
4
|
+
series with no categories, points that exist as a count but not as values. This
|
|
5
|
+
gate only governs slides the caller declares as quantitative (`--slide`), so a
|
|
6
|
+
decorative series is not nagged.
|
|
7
|
+
|
|
8
|
+
Two modes, ported from the original behaviour:
|
|
9
|
+
portable (default) a durable embedded workbook must ship with the chart
|
|
10
|
+
powerpoint also accept a complete native literal cache (`c:numCache` with a
|
|
11
|
+
point count that matches the category count)
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 pass, 1 fail, 2 unreadable input.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import zipfile
|
|
21
|
+
from decimal import Decimal, InvalidOperation
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from xml.etree import ElementTree
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
26
|
+
|
|
27
|
+
C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
|
|
28
|
+
R_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _embedded_workbooks(archive: zipfile.ZipFile, chart_part: str) -> list[str]:
|
|
32
|
+
folder, _, name = chart_part.rpartition("/")
|
|
33
|
+
rels = f"{folder}/_rels/{name}.rels"
|
|
34
|
+
if rels not in archive.namelist():
|
|
35
|
+
return []
|
|
36
|
+
try:
|
|
37
|
+
root = ElementTree.fromstring(archive.read(rels))
|
|
38
|
+
except ElementTree.ParseError:
|
|
39
|
+
return []
|
|
40
|
+
found: list[str] = []
|
|
41
|
+
for relationship in root.findall(f"{R_NS}Relationship"):
|
|
42
|
+
target = relationship.get("Target", "")
|
|
43
|
+
if target.endswith(".xlsx") or target.endswith(".xlsb"):
|
|
44
|
+
resolved = target.lstrip("/") if target.startswith("/") else f"{folder}/{target}"
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
for segment in resolved.split("/"):
|
|
47
|
+
if segment in {"", "."}:
|
|
48
|
+
continue
|
|
49
|
+
if segment == "..":
|
|
50
|
+
if parts:
|
|
51
|
+
parts.pop()
|
|
52
|
+
continue
|
|
53
|
+
parts.append(segment)
|
|
54
|
+
found.append("/".join(parts))
|
|
55
|
+
return found
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _series_report(chart_xml: bytes) -> list[dict]:
|
|
59
|
+
root = ElementTree.fromstring(chart_xml)
|
|
60
|
+
report: list[dict] = []
|
|
61
|
+
for series in root.iter(f"{C_NS}ser"):
|
|
62
|
+
name_node = series.find(f"{C_NS}tx/{C_NS}strRef/{C_NS}strCache/{C_NS}pt/{C_NS}v")
|
|
63
|
+
if name_node is None:
|
|
64
|
+
name_node = series.find(f"{C_NS}tx/{C_NS}v")
|
|
65
|
+
categories = [node.text for node in series.findall(f"{C_NS}cat//{C_NS}pt/{C_NS}v")]
|
|
66
|
+
points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}pt/{C_NS}v")]
|
|
67
|
+
if not points:
|
|
68
|
+
# some producers store values as c:v children of c:numCache without pt wrappers
|
|
69
|
+
points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}v")]
|
|
70
|
+
numeric = 0
|
|
71
|
+
for value in points:
|
|
72
|
+
if value is None:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
Decimal(str(value))
|
|
76
|
+
numeric += 1
|
|
77
|
+
except InvalidOperation:
|
|
78
|
+
continue
|
|
79
|
+
report.append(
|
|
80
|
+
{
|
|
81
|
+
"name": (name_node.text if name_node is not None else "") or "",
|
|
82
|
+
"categories": len(categories),
|
|
83
|
+
"points": len(points),
|
|
84
|
+
"numeric_points": numeric,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return report
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def inspect_charts(pptx_path: str | Path, declared_slides: list[int], mode: str = "portable") -> dict:
|
|
91
|
+
path = Path(pptx_path)
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
return {"gate": "native-quantitative-chart", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
|
|
94
|
+
|
|
95
|
+
from pptx import Presentation
|
|
96
|
+
|
|
97
|
+
presentation = Presentation(str(path))
|
|
98
|
+
checks: list[dict] = []
|
|
99
|
+
findings: list[dict] = []
|
|
100
|
+
with zipfile.ZipFile(path) as archive:
|
|
101
|
+
names = set(archive.namelist())
|
|
102
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
103
|
+
if slide_no not in declared_slides:
|
|
104
|
+
continue
|
|
105
|
+
for shape in slide.shapes:
|
|
106
|
+
if not getattr(shape, "has_chart", False):
|
|
107
|
+
continue
|
|
108
|
+
chart_part = str(shape.chart.part.partname).lstrip("/")
|
|
109
|
+
if chart_part not in names:
|
|
110
|
+
findings.append({"kind": "chart_part_missing", "severity": "fail", "slide": slide_no, "part": chart_part})
|
|
111
|
+
continue
|
|
112
|
+
payload = archive.read(chart_part)
|
|
113
|
+
workbooks = [item for item in _embedded_workbooks(archive, chart_part) if item in names]
|
|
114
|
+
series = _series_report(payload)
|
|
115
|
+
problems: list[str] = []
|
|
116
|
+
if not series:
|
|
117
|
+
problems.append("the chart declares no series")
|
|
118
|
+
for entry in series:
|
|
119
|
+
if entry["categories"] == 0:
|
|
120
|
+
problems.append(f"series '{entry['name'] or '(unnamed)'}' has no categories")
|
|
121
|
+
if entry["points"] == 0:
|
|
122
|
+
problems.append(f"series '{entry['name'] or '(unnamed)'}' has no values")
|
|
123
|
+
elif entry["numeric_points"] != entry["points"]:
|
|
124
|
+
problems.append(
|
|
125
|
+
f"series '{entry['name'] or '(unnamed)'}' has {entry['points'] - entry['numeric_points']} non-numeric value(s)"
|
|
126
|
+
)
|
|
127
|
+
elif entry["categories"] and entry["points"] and entry["categories"] != entry["points"]:
|
|
128
|
+
problems.append(
|
|
129
|
+
f"series '{entry['name'] or '(unnamed)'}' has {entry['categories']} categories but {entry['points']} values"
|
|
130
|
+
)
|
|
131
|
+
has_cache = bool(series) and all(entry["points"] > 0 for entry in series)
|
|
132
|
+
if mode == "portable" and not workbooks:
|
|
133
|
+
problems.append("no embedded workbook: the chart would not be editable after export")
|
|
134
|
+
if mode == "powerpoint" and not has_cache and not workbooks:
|
|
135
|
+
problems.append("neither an embedded workbook nor a complete literal cache")
|
|
136
|
+
checks.append(
|
|
137
|
+
{
|
|
138
|
+
"slide": slide_no,
|
|
139
|
+
"chart": shape.name,
|
|
140
|
+
"part": chart_part,
|
|
141
|
+
"mode": mode,
|
|
142
|
+
"embedded_workbooks": workbooks,
|
|
143
|
+
"series": series,
|
|
144
|
+
"problems": problems,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
for problem in problems:
|
|
148
|
+
findings.append(
|
|
149
|
+
{
|
|
150
|
+
"kind": "quantitative_chart",
|
|
151
|
+
"severity": "fail",
|
|
152
|
+
"slide": slide_no,
|
|
153
|
+
"chart": shape.name,
|
|
154
|
+
"message": problem,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"gate": "native-quantitative-chart",
|
|
160
|
+
"presentation": str(path.resolve()),
|
|
161
|
+
"mode": mode,
|
|
162
|
+
"declared_slides": declared_slides,
|
|
163
|
+
"checks": checks,
|
|
164
|
+
"findings": findings,
|
|
165
|
+
"passed": not [item for item in findings if item["severity"] == "fail"],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
import argparse
|
|
171
|
+
|
|
172
|
+
parser = argparse.ArgumentParser(description="Require real, editable data behind charts on declared slides.")
|
|
173
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
174
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
175
|
+
parser.add_argument("--slide", type=int, action="append", default=[], help="1-based slide to govern (repeatable)")
|
|
176
|
+
parser.add_argument("--all-slides", action="store_true", help="govern every slide that holds a chart")
|
|
177
|
+
parser.add_argument("--mode", choices=("portable", "powerpoint"), default="portable", help="acceptance mode (default portable)")
|
|
178
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
179
|
+
args = parser.parse_args(argv)
|
|
180
|
+
path = args.input_path or args.presentation
|
|
181
|
+
if not path:
|
|
182
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
183
|
+
|
|
184
|
+
declared = list(args.slide)
|
|
185
|
+
if args.all_slides or not declared:
|
|
186
|
+
from pptx import Presentation
|
|
187
|
+
|
|
188
|
+
presentation = Presentation(path)
|
|
189
|
+
declared = [index for index, slide in enumerate(presentation.slides, start=1) if any(getattr(shape, "has_chart", False) for shape in slide.shapes)]
|
|
190
|
+
report = inspect_charts(path, declared, args.mode)
|
|
191
|
+
if args.json_out:
|
|
192
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
193
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
194
|
+
sys.stdout.write("\n")
|
|
195
|
+
return 0 if report["passed"] else 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
|
|
29
|
+
TOTAL_PATTERN = re.compile(r"\btotal\b", re.IGNORECASE)
|
|
30
|
+
SUBTOTAL_PATTERN = re.compile(r"\bsub[\s-]?total\b", re.IGNORECASE)
|
|
31
|
+
NON_ADDITIVE = re.compile(r"(unit\s*price|per\s*share|ratio|rate|%|percent|margin|average|avg|median)", re.IGNORECASE)
|
|
32
|
+
NUMBER = re.compile(r"^[+-]?[0-9][0-9,]*(\.[0-9]+)?$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_decimal(text: str) -> Decimal | None:
|
|
36
|
+
cleaned = text.strip().replace(",", "").replace("\u2212", "-")
|
|
37
|
+
if cleaned.startswith("(") and cleaned.endswith(")"):
|
|
38
|
+
cleaned = "-" + cleaned[1:-1]
|
|
39
|
+
cleaned = cleaned.replace("$", "").replace("\u20ac", "").replace("\u00a3", "").replace("%", "").strip()
|
|
40
|
+
if not NUMBER.match(cleaned):
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return Decimal(cleaned)
|
|
44
|
+
except InvalidOperation:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _decimals(text: str) -> int:
|
|
49
|
+
cleaned = text.strip().replace(",", "")
|
|
50
|
+
return len(cleaned.split(".")[1]) if "." in cleaned else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tables(slide) -> list:
|
|
54
|
+
return [shape for shape in slide.shapes if getattr(shape, "has_table", False)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check(presentation_path: str | Path, contracts: list[dict], row_base: int = 0) -> dict:
|
|
58
|
+
path = Path(presentation_path)
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return {"gate": "table-arithmetic", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
|
|
61
|
+
|
|
62
|
+
from pptx import Presentation
|
|
63
|
+
|
|
64
|
+
presentation = Presentation(str(path))
|
|
65
|
+
slides = list(presentation.slides)
|
|
66
|
+
findings: list[dict] = []
|
|
67
|
+
checks: list[dict] = []
|
|
68
|
+
declared: set[tuple[int, int]] = set()
|
|
69
|
+
|
|
70
|
+
for contract in contracts:
|
|
71
|
+
slide_no = int(contract["slide"])
|
|
72
|
+
table_index = int(contract.get("table", 1)) - 1
|
|
73
|
+
base = int(contract.get("row_base", row_base))
|
|
74
|
+
if not 1 <= slide_no <= len(slides):
|
|
75
|
+
findings.append({"kind": "bad_contract", "severity": "fail", "message": f"slide {slide_no} is out of range"})
|
|
76
|
+
continue
|
|
77
|
+
tables = _tables(slides[slide_no - 1])
|
|
78
|
+
if not 1 <= table_index + 1 <= len(tables):
|
|
79
|
+
findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"table {table_index + 1} not found"})
|
|
80
|
+
continue
|
|
81
|
+
table = tables[table_index].table
|
|
82
|
+
declared.add((slide_no, table_index))
|
|
83
|
+
total_row = int(contract["total_row"]) - 1 if base == 1 else int(contract["total_row"])
|
|
84
|
+
rows = list(table.rows)
|
|
85
|
+
if not 0 <= total_row < len(rows):
|
|
86
|
+
findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"total row {total_row} is out of range"})
|
|
87
|
+
continue
|
|
88
|
+
tolerance = Decimal(str(contract.get("tolerance", "0.01")))
|
|
89
|
+
columns = [int(item) for item in contract.get("columns", range(1, len(table.columns)))]
|
|
90
|
+
header = [cell.text for cell in rows[0].cells] if rows else []
|
|
91
|
+
|
|
92
|
+
for column in columns:
|
|
93
|
+
if not 0 <= column < len(table.columns):
|
|
94
|
+
findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"column {column} is out of range"})
|
|
95
|
+
continue
|
|
96
|
+
header_text = header[column] if column < len(header) else ""
|
|
97
|
+
if NON_ADDITIVE.search(header_text):
|
|
98
|
+
checks.append({"slide": slide_no, "column": column, "header": header_text, "status": "skipped_non_additive"})
|
|
99
|
+
continue
|
|
100
|
+
displayed_text = rows[total_row].cells[column].text
|
|
101
|
+
displayed = _to_decimal(displayed_text)
|
|
102
|
+
if displayed is None:
|
|
103
|
+
findings.append(
|
|
104
|
+
{
|
|
105
|
+
"kind": "unparseable_total",
|
|
106
|
+
"severity": "fail",
|
|
107
|
+
"slide": slide_no,
|
|
108
|
+
"column": column,
|
|
109
|
+
"header": header_text,
|
|
110
|
+
"displayed": displayed_text,
|
|
111
|
+
"message": "the declared total cell is not a number this gate can read",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
continue
|
|
115
|
+
addends: list[Decimal] = []
|
|
116
|
+
for row in rows[1:total_row]:
|
|
117
|
+
value = _to_decimal(row.cells[column].text)
|
|
118
|
+
if value is not None:
|
|
119
|
+
addends.append(value)
|
|
120
|
+
computed = sum(addends, Decimal(0))
|
|
121
|
+
unit = Decimal(1).scaleb(-max([_decimals(displayed_text)] + [_decimals(rows[index].cells[column].text) for index in range(1, total_row)] or [0]))
|
|
122
|
+
delta = abs(computed - displayed)
|
|
123
|
+
rounding_slack = max(tolerance, unit / 2)
|
|
124
|
+
passed = delta <= rounding_slack
|
|
125
|
+
checks.append(
|
|
126
|
+
{
|
|
127
|
+
"slide": slide_no,
|
|
128
|
+
"column": column,
|
|
129
|
+
"header": header_text,
|
|
130
|
+
"status": "pass" if passed else "mismatch",
|
|
131
|
+
"displayed": str(displayed),
|
|
132
|
+
"computed": str(computed),
|
|
133
|
+
"delta": str(delta),
|
|
134
|
+
"rounding_slack": str(rounding_slack),
|
|
135
|
+
"addends": [str(item) for item in addends],
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
if not passed:
|
|
139
|
+
findings.append(
|
|
140
|
+
{
|
|
141
|
+
"kind": "total_mismatch",
|
|
142
|
+
"severity": "fail",
|
|
143
|
+
"slide": slide_no,
|
|
144
|
+
"column": column,
|
|
145
|
+
"header": header_text,
|
|
146
|
+
"displayed": str(displayed),
|
|
147
|
+
"computed": str(computed),
|
|
148
|
+
"message": f"column '{header_text or column}' totals {computed} but displays {displayed}",
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# diagnostics only: never fail on a row we were not told to enforce
|
|
153
|
+
for slide_no, slide in enumerate(slides, start=1):
|
|
154
|
+
for table_index, shape in enumerate(_tables(slide)):
|
|
155
|
+
if (slide_no, table_index) in declared:
|
|
156
|
+
continue
|
|
157
|
+
rows = list(shape.table.rows)
|
|
158
|
+
for row_index, row in enumerate(rows):
|
|
159
|
+
first = row.cells[0].text.strip()
|
|
160
|
+
if TOTAL_PATTERN.search(first) or SUBTOTAL_PATTERN.search(first):
|
|
161
|
+
findings.append(
|
|
162
|
+
{
|
|
163
|
+
"kind": "auto_detected_total",
|
|
164
|
+
"severity": "diagnostic",
|
|
165
|
+
"slide": slide_no,
|
|
166
|
+
"row": row_index,
|
|
167
|
+
"label": first,
|
|
168
|
+
"message": "total-looking row is not declared in a contract, so it was not enforced",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"gate": "table-arithmetic",
|
|
174
|
+
"presentation": str(path.resolve()),
|
|
175
|
+
"contracts": len(contracts),
|
|
176
|
+
"checks": checks,
|
|
177
|
+
"findings": findings,
|
|
178
|
+
"passed": not [item for item in findings if item["severity"] == "fail"],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> int:
|
|
183
|
+
import argparse
|
|
184
|
+
|
|
185
|
+
parser = argparse.ArgumentParser(description="Verify declared table totals against their columns.")
|
|
186
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
187
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
188
|
+
parser.add_argument("--contracts", required=True, help="JSON file listing the totals to enforce")
|
|
189
|
+
parser.add_argument("--row-base", type=int, choices=(0, 1), default=0, help="0-based row indices (default) or 1-based")
|
|
190
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
191
|
+
args = parser.parse_args(argv)
|
|
192
|
+
path = args.input_path or args.presentation
|
|
193
|
+
if not path:
|
|
194
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
payload = json.loads(Path(args.contracts).read_text(encoding="utf-8"))
|
|
198
|
+
except Exception as exc: # noqa: BLE001 - any parse problem is the caller's contract
|
|
199
|
+
json.dump({"gate": "table-arithmetic", "passed": False, "error": {"code": "bad_contracts", "message": str(exc)}}, sys.stdout)
|
|
200
|
+
sys.stdout.write("\n")
|
|
201
|
+
return 2
|
|
202
|
+
contracts = payload.get("contracts", payload if isinstance(payload, list) else [])
|
|
203
|
+
report = check(path, contracts, args.row_base)
|
|
204
|
+
if args.json_out:
|
|
205
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
206
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
207
|
+
sys.stdout.write("\n")
|
|
208
|
+
return 0 if report["passed"] else 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|