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
package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Check that a .pptx is a self-consistent package, not just a readable file.
|
|
2
|
+
|
|
3
|
+
Purpose: python-pptx opens many broken decks happily. A half-written edit can
|
|
4
|
+
leave a relationship pointing at a part that is not in the archive, a chart
|
|
5
|
+
whose embedded workbook vanished, or a slide whose layout was never shipped.
|
|
6
|
+
This walks the archive and reports what is actually there.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 healthy, 1 problems found, 2 unreadable input.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from xml.etree import ElementTree
|
|
18
|
+
|
|
19
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
20
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
21
|
+
try:
|
|
22
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
23
|
+
except (AttributeError, ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
27
|
+
CT_NS = "{http://schemas.openxmlformats.org/package/2006/content-types}"
|
|
28
|
+
P_NS = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
|
|
29
|
+
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rels_targets(archive: zipfile.ZipFile, part: str) -> list[str]:
|
|
33
|
+
folder, _, name = part.rpartition("/")
|
|
34
|
+
rels_name = f"{folder}/_rels/{name}.rels" if folder else f"_rels/{name}.rels"
|
|
35
|
+
if rels_name not in archive.namelist():
|
|
36
|
+
return []
|
|
37
|
+
try:
|
|
38
|
+
root = ElementTree.fromstring(archive.read(rels_name))
|
|
39
|
+
except ElementTree.ParseError:
|
|
40
|
+
return []
|
|
41
|
+
targets: list[str] = []
|
|
42
|
+
for relationship in root.findall(f"{REL_NS}Relationship"):
|
|
43
|
+
if relationship.get("TargetMode") == "External":
|
|
44
|
+
continue
|
|
45
|
+
target = relationship.get("Target", "")
|
|
46
|
+
if target.startswith("/"):
|
|
47
|
+
resolved = target.lstrip("/")
|
|
48
|
+
else:
|
|
49
|
+
resolved = f"{folder}/{target}" if folder else target
|
|
50
|
+
parts: list[str] = []
|
|
51
|
+
for segment in resolved.split("/"):
|
|
52
|
+
if segment in {"", "."}:
|
|
53
|
+
continue
|
|
54
|
+
if segment == "..":
|
|
55
|
+
if parts:
|
|
56
|
+
parts.pop()
|
|
57
|
+
continue
|
|
58
|
+
parts.append(segment)
|
|
59
|
+
targets.append("/".join(parts))
|
|
60
|
+
return targets
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def inspect_package(pptx_path: str | Path) -> dict:
|
|
64
|
+
path = Path(pptx_path)
|
|
65
|
+
problems: list[dict] = []
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "missing_file", "detail": str(path)}]}
|
|
68
|
+
if not zipfile.is_zipfile(path):
|
|
69
|
+
return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "not_a_zip", "detail": "not a readable OOXML archive"}]}
|
|
70
|
+
|
|
71
|
+
with zipfile.ZipFile(path) as archive:
|
|
72
|
+
names = set(archive.namelist())
|
|
73
|
+
for required in ("[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"):
|
|
74
|
+
if required not in names:
|
|
75
|
+
problems.append({"kind": "missing_part", "part": required})
|
|
76
|
+
|
|
77
|
+
slides = sorted(name for name in names if name.startswith("ppt/slides/slide") and name.endswith(".xml"))
|
|
78
|
+
layouts = sorted(name for name in names if name.startswith("ppt/slideLayouts/slideLayout") and name.endswith(".xml"))
|
|
79
|
+
masters = sorted(name for name in names if name.startswith("ppt/slideMasters/slideMaster") and name.endswith(".xml"))
|
|
80
|
+
charts = sorted(name for name in names if name.startswith("ppt/charts/chart") and name.endswith(".xml"))
|
|
81
|
+
media = sorted(name for name in names if name.startswith("ppt/media/"))
|
|
82
|
+
embeddings = sorted(name for name in names if name.startswith("ppt/embeddings/"))
|
|
83
|
+
|
|
84
|
+
if not slides:
|
|
85
|
+
problems.append({"kind": "no_slides", "detail": "the package has no ppt/slides parts"})
|
|
86
|
+
if not masters:
|
|
87
|
+
problems.append({"kind": "no_master", "detail": "the package has no slide master"})
|
|
88
|
+
|
|
89
|
+
for part in slides + charts + layouts + masters:
|
|
90
|
+
for target in _rels_targets(archive, part):
|
|
91
|
+
if target and target not in names:
|
|
92
|
+
problems.append({"kind": "dangling_relationship", "part": part, "target": target})
|
|
93
|
+
|
|
94
|
+
for part in slides:
|
|
95
|
+
try:
|
|
96
|
+
root = ElementTree.fromstring(archive.read(part))
|
|
97
|
+
except ElementTree.ParseError as exc:
|
|
98
|
+
problems.append({"kind": "unparsable_xml", "part": part, "detail": str(exc)})
|
|
99
|
+
continue
|
|
100
|
+
if root.tag != f"{P_NS}sld":
|
|
101
|
+
problems.append({"kind": "wrong_root_element", "part": part, "detail": root.tag})
|
|
102
|
+
|
|
103
|
+
presentation_xml = archive.read("ppt/presentation.xml") if "ppt/presentation.xml" in names else b""
|
|
104
|
+
declared_slides = 0
|
|
105
|
+
if presentation_xml:
|
|
106
|
+
try:
|
|
107
|
+
root = ElementTree.fromstring(presentation_xml)
|
|
108
|
+
slide_ids = root.find(f"{P_NS}sldIdLst")
|
|
109
|
+
declared_slides = len(list(slide_ids)) if slide_ids is not None else 0
|
|
110
|
+
except ElementTree.ParseError:
|
|
111
|
+
problems.append({"kind": "unparsable_xml", "part": "ppt/presentation.xml"})
|
|
112
|
+
if declared_slides and declared_slides != len(slides):
|
|
113
|
+
problems.append(
|
|
114
|
+
{
|
|
115
|
+
"kind": "slide_count_mismatch",
|
|
116
|
+
"detail": f"presentation.xml lists {declared_slides} slides but the package carries {len(slides)} parts",
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
duplicates = len(slides) - len({name.lower() for name in slides})
|
|
121
|
+
if duplicates:
|
|
122
|
+
problems.append({"kind": "duplicate_parts", "count": duplicates})
|
|
123
|
+
|
|
124
|
+
notes = [name for name in names if name.startswith("ppt/notesSlides/notesSlide") and name.endswith(".xml")]
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"gate": "package-integrity",
|
|
128
|
+
"presentation": str(path.resolve()),
|
|
129
|
+
"parts": {
|
|
130
|
+
"slides": len(slides),
|
|
131
|
+
"layouts": len(layouts),
|
|
132
|
+
"masters": len(masters),
|
|
133
|
+
"charts": len(charts),
|
|
134
|
+
"notesSlides": len(notes),
|
|
135
|
+
"media": len(media),
|
|
136
|
+
"embeddings": len(embeddings),
|
|
137
|
+
},
|
|
138
|
+
"problems": problems,
|
|
139
|
+
"passed": not problems,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> int:
|
|
144
|
+
import argparse
|
|
145
|
+
|
|
146
|
+
parser = argparse.ArgumentParser(description="Verify OOXML part/relationship integrity of a .pptx.")
|
|
147
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
148
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
149
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
path = args.input_path or args.presentation
|
|
152
|
+
if not path:
|
|
153
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
154
|
+
|
|
155
|
+
report = inspect_package(path)
|
|
156
|
+
if args.json_out:
|
|
157
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
158
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
159
|
+
sys.stdout.write("\n")
|
|
160
|
+
return 0 if report["passed"] else 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
24
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
25
|
+
try:
|
|
26
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
27
|
+
except (AttributeError, ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
|
+
|
|
32
|
+
C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
|
|
33
|
+
A_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
|
|
34
|
+
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
|
35
|
+
|
|
36
|
+
DEFAULT_TITLE_STRINGS = ("chart title", "chart 1 title")
|
|
37
|
+
BLOCKING = {"default_placeholder", "empty_title"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _chart_title_status(chart_xml: bytes) -> tuple[str, str]:
|
|
41
|
+
root = ElementTree.fromstring(chart_xml)
|
|
42
|
+
chart = root.find(f"{C_NS}chart")
|
|
43
|
+
if chart is None:
|
|
44
|
+
return "absent", ""
|
|
45
|
+
deleted = chart.find(f"{C_NS}autoTitleDeleted")
|
|
46
|
+
suppressed = deleted is not None and deleted.get("val", "1") in {"1", "true"}
|
|
47
|
+
title = chart.find(f"{C_NS}title")
|
|
48
|
+
if title is None:
|
|
49
|
+
return ("explicitly_suppressed", "") if suppressed else ("absent", "")
|
|
50
|
+
|
|
51
|
+
if title.find(f"{C_NS}tx/{C_NS}strRef") is not None:
|
|
52
|
+
return "linked_title_unresolved", ""
|
|
53
|
+
rich = title.find(f"{C_NS}tx/{C_NS}rich")
|
|
54
|
+
if rich is None:
|
|
55
|
+
return "empty_title", ""
|
|
56
|
+
pieces = [node.text or "" for node in rich.iter(f"{A_NS}t")]
|
|
57
|
+
text = "".join(pieces).strip()
|
|
58
|
+
if not text:
|
|
59
|
+
return "empty_title", ""
|
|
60
|
+
if " ".join(text.split()).lower() in DEFAULT_TITLE_STRINGS:
|
|
61
|
+
return "default_placeholder", text
|
|
62
|
+
return "present", text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def inspect_chart_titles(pptx_path: str | Path) -> dict:
|
|
66
|
+
path = Path(pptx_path)
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
return {"gate": "native-chart-title", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
|
|
69
|
+
|
|
70
|
+
from pptx import Presentation
|
|
71
|
+
|
|
72
|
+
presentation = Presentation(str(path))
|
|
73
|
+
slides: list[dict] = []
|
|
74
|
+
checks: list[dict] = []
|
|
75
|
+
chart_parts: dict[str, bytes] = {}
|
|
76
|
+
with zipfile.ZipFile(path) as archive:
|
|
77
|
+
for name in archive.namelist():
|
|
78
|
+
if name.startswith("ppt/charts/") and name.endswith(".xml"):
|
|
79
|
+
chart_parts[name] = archive.read(name)
|
|
80
|
+
|
|
81
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
82
|
+
chart_count = 0
|
|
83
|
+
for shape in slide.shapes:
|
|
84
|
+
if not getattr(shape, "has_chart", False):
|
|
85
|
+
continue
|
|
86
|
+
chart_count += 1
|
|
87
|
+
part = shape.chart.part
|
|
88
|
+
part_name = str(part.partname).lstrip("/")
|
|
89
|
+
payload = chart_parts.get(part_name)
|
|
90
|
+
if payload is None:
|
|
91
|
+
checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": "part_missing", "title": ""})
|
|
92
|
+
continue
|
|
93
|
+
status, text = _chart_title_status(payload)
|
|
94
|
+
checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": status, "title": text})
|
|
95
|
+
slides.append({"slide": slide_no, "charts": chart_count})
|
|
96
|
+
|
|
97
|
+
unused = sorted(set(chart_parts) - {check["part"] for check in checks})
|
|
98
|
+
findings = [
|
|
99
|
+
{
|
|
100
|
+
"kind": "chart_title",
|
|
101
|
+
"severity": "fail",
|
|
102
|
+
"slide": check["slide"],
|
|
103
|
+
"chart": check["chart"],
|
|
104
|
+
"status": check["status"],
|
|
105
|
+
"message": (
|
|
106
|
+
"chart still shows the placeholder title"
|
|
107
|
+
if check["status"] == "default_placeholder"
|
|
108
|
+
else "chart has an empty title element"
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
for check in checks
|
|
112
|
+
if check["status"] in BLOCKING
|
|
113
|
+
]
|
|
114
|
+
for part in unused:
|
|
115
|
+
findings.append({"kind": "orphan_chart_part", "severity": "review", "part": part, "message": "chart part is not placed on any slide"})
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"gate": "native-chart-title",
|
|
119
|
+
"presentation": str(path.resolve()),
|
|
120
|
+
"slide_count": len(slides),
|
|
121
|
+
"native_chart_count": sum(item["charts"] for item in slides),
|
|
122
|
+
"checks": checks,
|
|
123
|
+
"findings": findings,
|
|
124
|
+
"native_powerpoint_verified": False,
|
|
125
|
+
"source_text_emitted": False,
|
|
126
|
+
"passed": not [item for item in findings if item["severity"] == "fail"],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: list[str] | None = None) -> int:
|
|
131
|
+
import argparse
|
|
132
|
+
|
|
133
|
+
parser = argparse.ArgumentParser(description="Fail when a native chart keeps a placeholder or empty title.")
|
|
134
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
135
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
136
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
137
|
+
args = parser.parse_args(argv)
|
|
138
|
+
path = args.input_path or args.presentation
|
|
139
|
+
if not path:
|
|
140
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
141
|
+
|
|
142
|
+
report = inspect_chart_titles(path)
|
|
143
|
+
if args.json_out:
|
|
144
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
145
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
146
|
+
sys.stdout.write("\n")
|
|
147
|
+
return 0 if report["passed"] else 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
26
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
27
|
+
try:
|
|
28
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
29
|
+
except (AttributeError, ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
33
|
+
|
|
34
|
+
C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
|
|
35
|
+
R_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _embedded_workbooks(archive: zipfile.ZipFile, chart_part: str) -> list[str]:
|
|
39
|
+
folder, _, name = chart_part.rpartition("/")
|
|
40
|
+
rels = f"{folder}/_rels/{name}.rels"
|
|
41
|
+
if rels not in archive.namelist():
|
|
42
|
+
return []
|
|
43
|
+
try:
|
|
44
|
+
root = ElementTree.fromstring(archive.read(rels))
|
|
45
|
+
except ElementTree.ParseError:
|
|
46
|
+
return []
|
|
47
|
+
found: list[str] = []
|
|
48
|
+
for relationship in root.findall(f"{R_NS}Relationship"):
|
|
49
|
+
target = relationship.get("Target", "")
|
|
50
|
+
if target.endswith(".xlsx") or target.endswith(".xlsb"):
|
|
51
|
+
resolved = target.lstrip("/") if target.startswith("/") else f"{folder}/{target}"
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
for segment in resolved.split("/"):
|
|
54
|
+
if segment in {"", "."}:
|
|
55
|
+
continue
|
|
56
|
+
if segment == "..":
|
|
57
|
+
if parts:
|
|
58
|
+
parts.pop()
|
|
59
|
+
continue
|
|
60
|
+
parts.append(segment)
|
|
61
|
+
found.append("/".join(parts))
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _series_report(chart_xml: bytes) -> list[dict]:
|
|
66
|
+
root = ElementTree.fromstring(chart_xml)
|
|
67
|
+
report: list[dict] = []
|
|
68
|
+
for series in root.iter(f"{C_NS}ser"):
|
|
69
|
+
name_node = series.find(f"{C_NS}tx/{C_NS}strRef/{C_NS}strCache/{C_NS}pt/{C_NS}v")
|
|
70
|
+
if name_node is None:
|
|
71
|
+
name_node = series.find(f"{C_NS}tx/{C_NS}v")
|
|
72
|
+
categories = [node.text for node in series.findall(f"{C_NS}cat//{C_NS}pt/{C_NS}v")]
|
|
73
|
+
points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}pt/{C_NS}v")]
|
|
74
|
+
if not points:
|
|
75
|
+
# some producers store values as c:v children of c:numCache without pt wrappers
|
|
76
|
+
points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}v")]
|
|
77
|
+
numeric = 0
|
|
78
|
+
for value in points:
|
|
79
|
+
if value is None:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
Decimal(str(value))
|
|
83
|
+
numeric += 1
|
|
84
|
+
except InvalidOperation:
|
|
85
|
+
continue
|
|
86
|
+
report.append(
|
|
87
|
+
{
|
|
88
|
+
"name": (name_node.text if name_node is not None else "") or "",
|
|
89
|
+
"categories": len(categories),
|
|
90
|
+
"points": len(points),
|
|
91
|
+
"numeric_points": numeric,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
return report
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def inspect_charts(pptx_path: str | Path, declared_slides: list[int], mode: str = "portable") -> dict:
|
|
98
|
+
path = Path(pptx_path)
|
|
99
|
+
if not path.is_file():
|
|
100
|
+
return {"gate": "native-quantitative-chart", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
|
|
101
|
+
|
|
102
|
+
from pptx import Presentation
|
|
103
|
+
|
|
104
|
+
presentation = Presentation(str(path))
|
|
105
|
+
checks: list[dict] = []
|
|
106
|
+
findings: list[dict] = []
|
|
107
|
+
with zipfile.ZipFile(path) as archive:
|
|
108
|
+
names = set(archive.namelist())
|
|
109
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
110
|
+
if slide_no not in declared_slides:
|
|
111
|
+
continue
|
|
112
|
+
for shape in slide.shapes:
|
|
113
|
+
if not getattr(shape, "has_chart", False):
|
|
114
|
+
continue
|
|
115
|
+
chart_part = str(shape.chart.part.partname).lstrip("/")
|
|
116
|
+
if chart_part not in names:
|
|
117
|
+
findings.append({"kind": "chart_part_missing", "severity": "fail", "slide": slide_no, "part": chart_part})
|
|
118
|
+
continue
|
|
119
|
+
payload = archive.read(chart_part)
|
|
120
|
+
workbooks = [item for item in _embedded_workbooks(archive, chart_part) if item in names]
|
|
121
|
+
series = _series_report(payload)
|
|
122
|
+
problems: list[str] = []
|
|
123
|
+
if not series:
|
|
124
|
+
problems.append("the chart declares no series")
|
|
125
|
+
for entry in series:
|
|
126
|
+
if entry["categories"] == 0:
|
|
127
|
+
problems.append(f"series '{entry['name'] or '(unnamed)'}' has no categories")
|
|
128
|
+
if entry["points"] == 0:
|
|
129
|
+
problems.append(f"series '{entry['name'] or '(unnamed)'}' has no values")
|
|
130
|
+
elif entry["numeric_points"] != entry["points"]:
|
|
131
|
+
problems.append(
|
|
132
|
+
f"series '{entry['name'] or '(unnamed)'}' has {entry['points'] - entry['numeric_points']} non-numeric value(s)"
|
|
133
|
+
)
|
|
134
|
+
elif entry["categories"] and entry["points"] and entry["categories"] != entry["points"]:
|
|
135
|
+
problems.append(
|
|
136
|
+
f"series '{entry['name'] or '(unnamed)'}' has {entry['categories']} categories but {entry['points']} values"
|
|
137
|
+
)
|
|
138
|
+
has_cache = bool(series) and all(entry["points"] > 0 for entry in series)
|
|
139
|
+
if mode == "portable" and not workbooks:
|
|
140
|
+
problems.append("no embedded workbook: the chart would not be editable after export")
|
|
141
|
+
if mode == "powerpoint" and not has_cache and not workbooks:
|
|
142
|
+
problems.append("neither an embedded workbook nor a complete literal cache")
|
|
143
|
+
checks.append(
|
|
144
|
+
{
|
|
145
|
+
"slide": slide_no,
|
|
146
|
+
"chart": shape.name,
|
|
147
|
+
"part": chart_part,
|
|
148
|
+
"mode": mode,
|
|
149
|
+
"embedded_workbooks": workbooks,
|
|
150
|
+
"series": series,
|
|
151
|
+
"problems": problems,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
for problem in problems:
|
|
155
|
+
findings.append(
|
|
156
|
+
{
|
|
157
|
+
"kind": "quantitative_chart",
|
|
158
|
+
"severity": "fail",
|
|
159
|
+
"slide": slide_no,
|
|
160
|
+
"chart": shape.name,
|
|
161
|
+
"message": problem,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"gate": "native-quantitative-chart",
|
|
167
|
+
"presentation": str(path.resolve()),
|
|
168
|
+
"mode": mode,
|
|
169
|
+
"declared_slides": declared_slides,
|
|
170
|
+
"checks": checks,
|
|
171
|
+
"findings": findings,
|
|
172
|
+
"passed": not [item for item in findings if item["severity"] == "fail"],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv: list[str] | None = None) -> int:
|
|
177
|
+
import argparse
|
|
178
|
+
|
|
179
|
+
parser = argparse.ArgumentParser(description="Require real, editable data behind charts on declared slides.")
|
|
180
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
181
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
182
|
+
parser.add_argument("--slide", type=int, action="append", default=[], help="1-based slide to govern (repeatable)")
|
|
183
|
+
parser.add_argument("--all-slides", action="store_true", help="govern every slide that holds a chart")
|
|
184
|
+
parser.add_argument("--mode", choices=("portable", "powerpoint"), default="portable", help="acceptance mode (default portable)")
|
|
185
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
186
|
+
args = parser.parse_args(argv)
|
|
187
|
+
path = args.input_path or args.presentation
|
|
188
|
+
if not path:
|
|
189
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
190
|
+
|
|
191
|
+
declared = list(args.slide)
|
|
192
|
+
if args.all_slides or not declared:
|
|
193
|
+
from pptx import Presentation
|
|
194
|
+
|
|
195
|
+
presentation = Presentation(path)
|
|
196
|
+
declared = [index for index, slide in enumerate(presentation.slides, start=1) if any(getattr(shape, "has_chart", False) for shape in slide.shapes)]
|
|
197
|
+
report = inspect_charts(path, declared, args.mode)
|
|
198
|
+
if args.json_out:
|
|
199
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
200
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
201
|
+
sys.stdout.write("\n")
|
|
202
|
+
return 0 if report["passed"] else 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|