dsh-office-kit 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/LICENSE.md +21 -21
- package/README.md +108 -106
- 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 +146 -149
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Read or set the document properties of a deck.
|
|
2
|
+
|
|
3
|
+
Title, author, subject, keywords and comments live in `docProps/core.xml`. They
|
|
4
|
+
show up in file listings, in the presenter's title bar and in search, which is
|
|
5
|
+
why a deck titled "PowerPoint Presentation" is a small embarrassment.
|
|
6
|
+
|
|
7
|
+
Passing no property flag prints the current values and changes nothing.
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
<python> core_properties.py --input deck.pptx
|
|
12
|
+
<python> core_properties.py --input deck.pptx --title "Automation Review" --author "Ops"
|
|
13
|
+
<python> core_properties.py --input deck.pptx --keywords "warehouse, automation" --out tagged.pptx
|
|
14
|
+
|
|
15
|
+
Exit codes
|
|
16
|
+
----------
|
|
17
|
+
0 properties read or written
|
|
18
|
+
2 bad arguments or unreadable input
|
|
19
|
+
3 the output file could not be written
|
|
20
|
+
|
|
21
|
+
The JSON report on stdout is `{ok, input, output, before{}, after{}, changed[]}`.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
FIELDS = ("title", "author", "subject", "keywords", "comments", "category", "last_modified_by")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _emit(payload: dict, code: int = 0) -> int:
|
|
35
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
36
|
+
return code
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _snapshot(properties) -> dict:
|
|
40
|
+
return {field: getattr(properties, field, None) for field in FIELDS}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> int:
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
description="Read or set .pptx core properties (title, author, subject, keywords, comments).",
|
|
46
|
+
epilog="Exit codes: 0 ok, 2 bad input, 3 write failure.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("--input", required=True, help="source deck")
|
|
49
|
+
parser.add_argument("--title")
|
|
50
|
+
parser.add_argument("--author")
|
|
51
|
+
parser.add_argument("--subject")
|
|
52
|
+
parser.add_argument("--keywords")
|
|
53
|
+
parser.add_argument("--comments")
|
|
54
|
+
parser.add_argument("--category")
|
|
55
|
+
parser.add_argument("--last-modified-by")
|
|
56
|
+
parser.add_argument("--out", help="output deck (default: <input>-edited.pptx when writing)")
|
|
57
|
+
args = parser.parse_args()
|
|
58
|
+
|
|
59
|
+
source = Path(args.input)
|
|
60
|
+
if not source.is_file():
|
|
61
|
+
return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
|
|
62
|
+
|
|
63
|
+
from pptx import Presentation # noqa: PLC0415
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
prs = Presentation(str(source))
|
|
67
|
+
except Exception as error: # noqa: BLE001
|
|
68
|
+
return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
|
|
69
|
+
|
|
70
|
+
before = _snapshot(prs.core_properties)
|
|
71
|
+
updates = {
|
|
72
|
+
field: (getattr(args, field) if getattr(args, field) is not None else None)
|
|
73
|
+
for field in FIELDS
|
|
74
|
+
if field != "last_modified_by"
|
|
75
|
+
}
|
|
76
|
+
if args.last_modified_by is not None:
|
|
77
|
+
updates["last_modified_by"] = args.last_modified_by
|
|
78
|
+
requested = {key: value for key, value in updates.items() if value is not None}
|
|
79
|
+
|
|
80
|
+
if not requested:
|
|
81
|
+
return _emit(
|
|
82
|
+
{
|
|
83
|
+
"schema": "core_properties/1",
|
|
84
|
+
"ok": True,
|
|
85
|
+
"input": str(source),
|
|
86
|
+
"output": None,
|
|
87
|
+
"before": before,
|
|
88
|
+
"after": before,
|
|
89
|
+
"changed": [],
|
|
90
|
+
"note": "read-only: no property flag was passed",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for field, value in requested.items():
|
|
95
|
+
setattr(prs.core_properties, field, value)
|
|
96
|
+
prs.core_properties.modified = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
97
|
+
after = _snapshot(prs.core_properties)
|
|
98
|
+
|
|
99
|
+
output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
|
|
100
|
+
try:
|
|
101
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
prs.save(str(output))
|
|
103
|
+
except Exception as error: # noqa: BLE001
|
|
104
|
+
return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
|
|
105
|
+
|
|
106
|
+
return _emit(
|
|
107
|
+
{
|
|
108
|
+
"schema": "core_properties/1",
|
|
109
|
+
"ok": True,
|
|
110
|
+
"input": str(source),
|
|
111
|
+
"output": str(output),
|
|
112
|
+
"before": before,
|
|
113
|
+
"after": after,
|
|
114
|
+
"changed": [key for key, value in requested.items() if before.get(key) != value],
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Assemble slide PNGs into one labelled contact sheet.
|
|
2
|
+
|
|
3
|
+
Purpose: eyeballing twenty renders one file at a time wastes context; a single
|
|
4
|
+
grid image lets you spot collisions, empty slides and stray template text at a
|
|
5
|
+
glance, then zoom into the slide that looks wrong.
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 sheet written, 2 no readable input, 3 Pillow unavailable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
18
|
+
except ImportError: # pragma: no cover - whitelist dependency, but fail clearly
|
|
19
|
+
print(json.dumps({"ok": False, "error": {"code": "pillow_missing", "message": "Pillow is required"}}))
|
|
20
|
+
raise SystemExit(3)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _collect(paths: list[str], directory: str | None) -> list[Path]:
|
|
24
|
+
found: list[Path] = [Path(item) for item in paths]
|
|
25
|
+
if directory:
|
|
26
|
+
found.extend(sorted(Path(directory).glob("*.png")))
|
|
27
|
+
ordered: list[Path] = []
|
|
28
|
+
seen: set[str] = set()
|
|
29
|
+
for path in found:
|
|
30
|
+
key = str(path.resolve())
|
|
31
|
+
if key in seen or not path.is_file() or path.name in {"contact-sheet.png", "montage.png"}:
|
|
32
|
+
continue
|
|
33
|
+
seen.add(key)
|
|
34
|
+
ordered.append(path)
|
|
35
|
+
return ordered
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _label(draw, box, text: str, size: int, colour) -> None:
|
|
39
|
+
try:
|
|
40
|
+
font = ImageFont.truetype("arial.ttf", size)
|
|
41
|
+
except OSError:
|
|
42
|
+
try:
|
|
43
|
+
font = ImageFont.load_default(size=size)
|
|
44
|
+
except TypeError: # very old Pillow
|
|
45
|
+
font = ImageFont.load_default()
|
|
46
|
+
draw.text((box[0], box[1]), text, fill=colour, font=font)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_montage(
|
|
50
|
+
paths: list[str],
|
|
51
|
+
output: str | Path,
|
|
52
|
+
directory: str | None = None,
|
|
53
|
+
columns: int = 3,
|
|
54
|
+
cell_width: int = 480,
|
|
55
|
+
padding: int = 16,
|
|
56
|
+
background: tuple[int, int, int] = (245, 246, 248),
|
|
57
|
+
) -> dict:
|
|
58
|
+
images = _collect(paths, directory)
|
|
59
|
+
if not images:
|
|
60
|
+
return {"ok": False, "error": {"code": "no_images", "message": "no PNG inputs found"}}
|
|
61
|
+
|
|
62
|
+
loaded: list[tuple[Path, Image.Image]] = []
|
|
63
|
+
for path in images:
|
|
64
|
+
try:
|
|
65
|
+
with Image.open(path) as handle:
|
|
66
|
+
loaded.append((path, handle.convert("RGB")))
|
|
67
|
+
except Exception as exc: # noqa: BLE001 - one bad file must not kill the sheet
|
|
68
|
+
loaded.append((path, Image.new("RGB", (cell_width, int(cell_width * 0.5625)), (220, 60, 60))))
|
|
69
|
+
columns = max(1, min(columns, len(loaded)))
|
|
70
|
+
cell_height = max(1, int(round(cell_width * loaded[0][1].height / max(loaded[0][1].width, 1))))
|
|
71
|
+
rows = (len(loaded) + columns - 1) // columns
|
|
72
|
+
label_height = 22
|
|
73
|
+
sheet = Image.new(
|
|
74
|
+
"RGB",
|
|
75
|
+
(
|
|
76
|
+
columns * cell_width + (columns + 1) * padding,
|
|
77
|
+
rows * (cell_height + label_height) + (rows + 1) * padding,
|
|
78
|
+
),
|
|
79
|
+
background,
|
|
80
|
+
)
|
|
81
|
+
draw = ImageDraw.Draw(sheet)
|
|
82
|
+
for index, (path, image) in enumerate(loaded):
|
|
83
|
+
column = index % columns
|
|
84
|
+
row = index // columns
|
|
85
|
+
x = padding + column * (cell_width + padding)
|
|
86
|
+
y = padding + row * (cell_height + label_height + padding)
|
|
87
|
+
draw.rectangle([x - 1, y - 1, x + cell_width, y + cell_height], outline=(205, 210, 218))
|
|
88
|
+
thumb = image.copy()
|
|
89
|
+
thumb.thumbnail((cell_width, cell_height), Image.LANCZOS)
|
|
90
|
+
sheet.paste(thumb, (x + (cell_width - thumb.width) // 2, y + (cell_height - thumb.height) // 2))
|
|
91
|
+
_label(draw, (x, y + cell_height + 4), path.stem, 13, (70, 76, 86))
|
|
92
|
+
|
|
93
|
+
target = Path(output)
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
sheet.save(target)
|
|
96
|
+
return {
|
|
97
|
+
"ok": True,
|
|
98
|
+
"output": str(target.resolve()),
|
|
99
|
+
"cells": len(loaded),
|
|
100
|
+
"columns": columns,
|
|
101
|
+
"size": list(sheet.size),
|
|
102
|
+
"cells_in_order": [path.name for path, _ in loaded],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: list[str] | None = None) -> int:
|
|
107
|
+
import argparse
|
|
108
|
+
|
|
109
|
+
parser = argparse.ArgumentParser(description="Combine slide PNGs into one labelled grid image.")
|
|
110
|
+
parser.add_argument("images", nargs="*", help="PNG files (order is preserved)")
|
|
111
|
+
parser.add_argument("--dir", help="also include every .png in this directory, sorted")
|
|
112
|
+
parser.add_argument("--out", required=True, help="path of the contact sheet to write")
|
|
113
|
+
parser.add_argument("--columns", type=int, default=3, help="grid columns (default 3)")
|
|
114
|
+
parser.add_argument("--cell-width", type=int, default=480, help="width of one cell in pixels (default 480)")
|
|
115
|
+
args = parser.parse_args(argv)
|
|
116
|
+
|
|
117
|
+
report = make_montage(args.images, args.out, args.dir, args.columns, args.cell_width)
|
|
118
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
119
|
+
sys.stdout.write("\n")
|
|
120
|
+
if report.get("ok"):
|
|
121
|
+
return 0
|
|
122
|
+
return 2
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
raise SystemExit(main())
|
package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Inspect slide geometry for the mistakes a render catches late.
|
|
2
|
+
|
|
3
|
+
Purpose: before spending a round trip on a render, read the shapes and answer
|
|
4
|
+
the questions that actually break a deck — does anything leave the slide, is
|
|
5
|
+
text dropped out of its own box, do two blocks of text sit on top of each
|
|
6
|
+
other, is a chart buried under a table, is the smallest type too small, is an
|
|
7
|
+
opens-in-place placeholder still empty, does a heading end in a full stop.
|
|
8
|
+
|
|
9
|
+
Severity split (ported behaviour): `fail` findings are structural and block
|
|
10
|
+
delivery; `review` findings are judgement calls the author should look at.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 no blocking finding, 1 blocking finding, 2 unreadable input.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
|
|
23
|
+
from ooxml_helpers import ( # noqa: E402
|
|
24
|
+
box,
|
|
25
|
+
emit,
|
|
26
|
+
default_size_pt,
|
|
27
|
+
estimated_text_height_in,
|
|
28
|
+
finding,
|
|
29
|
+
has_visible_text,
|
|
30
|
+
is_chart,
|
|
31
|
+
is_connector,
|
|
32
|
+
is_picture,
|
|
33
|
+
is_table,
|
|
34
|
+
iter_shapes,
|
|
35
|
+
label,
|
|
36
|
+
open_presentation,
|
|
37
|
+
overlap_ratio,
|
|
38
|
+
placeholder_kind,
|
|
39
|
+
rects_intersect,
|
|
40
|
+
run_size_pt,
|
|
41
|
+
slide_size_in,
|
|
42
|
+
text_of,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
TITLE_OUTSIDE_MARGIN_IN = 0.0
|
|
46
|
+
DEFAULT_TITLE_FLOOR_PT = 28.0
|
|
47
|
+
DEFAULT_BODY_FLOOR_PT = 14.0
|
|
48
|
+
OVERLAP_HINT_RATIO = 0.35
|
|
49
|
+
TEXT_OVERFLOW_SLACK_IN = 0.08
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def inspect(pptx_path: str | Path, title_floor: float = DEFAULT_TITLE_FLOOR_PT, body_floor: float = DEFAULT_BODY_FLOOR_PT) -> dict:
|
|
53
|
+
presentation = open_presentation(pptx_path)
|
|
54
|
+
slide_w, slide_h = slide_size_in(presentation)
|
|
55
|
+
findings: list[dict] = []
|
|
56
|
+
slides_report: list[dict] = []
|
|
57
|
+
title_sizes: list[tuple[int, int]] = []
|
|
58
|
+
|
|
59
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
60
|
+
shapes = [shape for shape in iter_shapes(slide.shapes)]
|
|
61
|
+
slide_findings: list[dict] = []
|
|
62
|
+
text_boxes: list[tuple[str, tuple[float, float, float, float], str]] = []
|
|
63
|
+
|
|
64
|
+
for shape in shapes:
|
|
65
|
+
geometry = box(shape)
|
|
66
|
+
name = label(shape)
|
|
67
|
+
if geometry is None:
|
|
68
|
+
slide_findings.append(finding("shape_without_frame", "review", f"'{name}' has no position or size", shape=name))
|
|
69
|
+
continue
|
|
70
|
+
left, top, width, height = geometry
|
|
71
|
+
|
|
72
|
+
if left < -TITLE_OUTSIDE_MARGIN_IN or top < -TITLE_OUTSIDE_MARGIN_IN or left + width > slide_w + 0.01 or top + height > slide_h + 0.01:
|
|
73
|
+
slide_findings.append(
|
|
74
|
+
finding(
|
|
75
|
+
"outside_slide",
|
|
76
|
+
"fail",
|
|
77
|
+
f"'{name}' extends past the slide edge",
|
|
78
|
+
shape=name,
|
|
79
|
+
bbox=[round(left, 2), round(top, 2), round(width, 2), round(height, 2)],
|
|
80
|
+
bleed={
|
|
81
|
+
"right": round(max(0.0, left + width - slide_w), 2),
|
|
82
|
+
"bottom": round(max(0.0, top + height - slide_h), 2),
|
|
83
|
+
"left": round(max(0.0, -left), 2),
|
|
84
|
+
"top": round(max(0.0, -top), 2),
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if width <= 0 or height <= 0:
|
|
90
|
+
slide_findings.append(finding("degenerate_size", "fail", f"'{name}' has zero width or height", shape=name))
|
|
91
|
+
|
|
92
|
+
needed = estimated_text_height_in(shape)
|
|
93
|
+
if needed is not None and needed > height + TEXT_OVERFLOW_SLACK_IN:
|
|
94
|
+
slide_findings.append(
|
|
95
|
+
finding(
|
|
96
|
+
"text_overflow",
|
|
97
|
+
"fail",
|
|
98
|
+
f"'{name}' needs about {needed:.2f} in of text height but its frame is {height:.2f} in",
|
|
99
|
+
shape=name,
|
|
100
|
+
neededIn=round(needed, 2),
|
|
101
|
+
frameIn=round(height, 2),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
kind = (placeholder_kind(shape) or "").upper()
|
|
106
|
+
if kind and not has_visible_text(shape) and not is_picture(shape):
|
|
107
|
+
slide_findings.append(finding("empty_placeholder", "review", f"empty {kind} placeholder is still on the slide", shape=name))
|
|
108
|
+
|
|
109
|
+
if has_visible_text(shape):
|
|
110
|
+
text_boxes.append((name, geometry, text_of(shape)))
|
|
111
|
+
size = run_size_pt(shape) or 0.0
|
|
112
|
+
if "TITLE" in kind or name.lower().startswith("title"):
|
|
113
|
+
if 0 < size < title_floor:
|
|
114
|
+
slide_findings.append(finding("small_title", "review", f"title '{name}' is {size:.0f} pt", shape=name, fontPt=size))
|
|
115
|
+
if text_of(shape).rstrip().endswith("."):
|
|
116
|
+
slide_findings.append(finding("heading_full_stop", "review", f"heading '{name}' ends with a full stop", shape=name))
|
|
117
|
+
elif 0 < size < body_floor:
|
|
118
|
+
slide_findings.append(finding("small_body_text", "review", f"'{name}' is {size:.0f} pt", shape=name, fontPt=size))
|
|
119
|
+
|
|
120
|
+
if is_table(shape):
|
|
121
|
+
table_bottom = top + height
|
|
122
|
+
if table_bottom > slide_h:
|
|
123
|
+
slide_findings.append(finding("table_outside_slide", "fail", f"table '{name}' runs {table_bottom - slide_h:.2f} in past the bottom edge", shape=name))
|
|
124
|
+
|
|
125
|
+
# text block collisions
|
|
126
|
+
for first in range(len(text_boxes)):
|
|
127
|
+
for second in range(first + 1, len(text_boxes)):
|
|
128
|
+
name_a, box_a, text_a = text_boxes[first]
|
|
129
|
+
name_b, box_b, text_b = text_boxes[second]
|
|
130
|
+
if not rects_intersect(box_a, box_b):
|
|
131
|
+
continue
|
|
132
|
+
ratio = overlap_ratio(box_a, box_b)
|
|
133
|
+
if ratio >= OVERLAP_HINT_RATIO:
|
|
134
|
+
slide_findings.append(
|
|
135
|
+
finding(
|
|
136
|
+
"text_overlap",
|
|
137
|
+
"review",
|
|
138
|
+
f"'{name_a}' and '{name_b}' overlap by {ratio * 100:.0f}% of the smaller box",
|
|
139
|
+
shapes=[name_a, name_b],
|
|
140
|
+
overlap=round(ratio, 2),
|
|
141
|
+
excerpt=[text_a[:60], text_b[:60]],
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# a chart or table hidden under a picture/other block
|
|
146
|
+
heavy = [(label(shape), box(shape)) for shape in shapes if (is_chart(shape) or is_table(shape)) and box(shape)]
|
|
147
|
+
for name, geometry in heavy:
|
|
148
|
+
for other in shapes:
|
|
149
|
+
if label(other) == name:
|
|
150
|
+
continue
|
|
151
|
+
other_box = box(other)
|
|
152
|
+
if other_box is None or is_connector(other) or (is_chart(other) or is_table(other)):
|
|
153
|
+
continue
|
|
154
|
+
if not has_visible_text(other) and not is_picture(other):
|
|
155
|
+
continue
|
|
156
|
+
ratio = overlap_ratio(geometry, other_box)
|
|
157
|
+
if ratio >= 0.5:
|
|
158
|
+
slide_findings.append(
|
|
159
|
+
finding(
|
|
160
|
+
"evidence_covered",
|
|
161
|
+
"fail",
|
|
162
|
+
f"'{label(other)}' covers {ratio * 100:.0f}% of '{name}'",
|
|
163
|
+
shapes=[name, label(other)],
|
|
164
|
+
overlap=round(ratio, 2),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# connectors crossing text
|
|
169
|
+
for shape in shapes:
|
|
170
|
+
if not is_connector(shape) or box(shape) is None:
|
|
171
|
+
continue
|
|
172
|
+
for name, text_box, _ in text_boxes:
|
|
173
|
+
if rects_intersect(box(shape), text_box) and overlap_ratio(box(shape), text_box) > 0.15:
|
|
174
|
+
slide_findings.append(finding("connector_over_text", "review", f"connector '{label(shape)}' crosses '{name}'", shapes=[label(shape), name]))
|
|
175
|
+
|
|
176
|
+
# chart and table repeating the same numbers on one slide
|
|
177
|
+
if any(is_chart(shape) for shape in shapes) and any(is_table(shape) for shape in shapes):
|
|
178
|
+
chart_labels = {
|
|
179
|
+
str(category).strip()
|
|
180
|
+
for shape in shapes
|
|
181
|
+
if is_chart(shape)
|
|
182
|
+
for category in _chart_categories(shape)
|
|
183
|
+
}
|
|
184
|
+
table_labels = {
|
|
185
|
+
str(cell.text).strip()
|
|
186
|
+
for shape in shapes
|
|
187
|
+
if is_table(shape)
|
|
188
|
+
for row in shape.table.rows
|
|
189
|
+
for cell in row.cells
|
|
190
|
+
}
|
|
191
|
+
shared = {value for value in chart_labels & table_labels if value and not value.replace(".", "").isdigit()}
|
|
192
|
+
if len(shared) >= 2:
|
|
193
|
+
slide_findings.append(
|
|
194
|
+
finding(
|
|
195
|
+
"duplicate_evidence",
|
|
196
|
+
"review",
|
|
197
|
+
"the chart and the table restate the same categories",
|
|
198
|
+
shared=sorted(shared)[:6],
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
findings.extend({**item, "slide": slide_no} for item in slide_findings)
|
|
203
|
+
slides_report.append(
|
|
204
|
+
{
|
|
205
|
+
"slide": slide_no,
|
|
206
|
+
"shapes": len(shapes),
|
|
207
|
+
"charts": sum(1 for shape in shapes if is_chart(shape)),
|
|
208
|
+
"tables": sum(1 for shape in shapes if is_table(shape)),
|
|
209
|
+
"pictures": sum(1 for shape in shapes if is_picture(shape)),
|
|
210
|
+
"findings": len(slide_findings),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
for shape in shapes:
|
|
215
|
+
kind = (placeholder_kind(shape) or "").upper()
|
|
216
|
+
# A cover is meant to run larger than the body slides, so it is excluded.
|
|
217
|
+
if "CENTER_TITLE" in kind or not has_visible_text(shape):
|
|
218
|
+
continue
|
|
219
|
+
if "TITLE" in kind or label(shape).lower().startswith("title"):
|
|
220
|
+
# Inherited sizes only resolve at render time; approximate them with the
|
|
221
|
+
# pack's placeholder defaults so drift between slides is still visible.
|
|
222
|
+
size = run_size_pt(shape) or default_size_pt(shape)
|
|
223
|
+
if size:
|
|
224
|
+
title_sizes.append((slide_no, round(size)))
|
|
225
|
+
|
|
226
|
+
if len({size for _, size in title_sizes}) > 1:
|
|
227
|
+
findings.append(
|
|
228
|
+
finding(
|
|
229
|
+
"inconsistent_title_size",
|
|
230
|
+
"review",
|
|
231
|
+
"slide titles are set at different sizes; pick one size for the deck",
|
|
232
|
+
sizes=sorted({size for _, size in title_sizes}),
|
|
233
|
+
slides=[slide_no for slide_no, _ in title_sizes],
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
blocking = [item for item in findings if item["severity"] == "fail"]
|
|
237
|
+
return {
|
|
238
|
+
"gate": "layout-geometry",
|
|
239
|
+
"presentation": str(Path(pptx_path).resolve()),
|
|
240
|
+
"slideSizeIn": [round(slide_w, 3), round(slide_h, 3)],
|
|
241
|
+
"slides": slides_report,
|
|
242
|
+
"findings": findings,
|
|
243
|
+
"summary": {
|
|
244
|
+
"slides": len(slides_report),
|
|
245
|
+
"fail": len(blocking),
|
|
246
|
+
"review": len(findings) - len(blocking),
|
|
247
|
+
},
|
|
248
|
+
"passed": not blocking,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _chart_categories(shape) -> list[str]:
|
|
253
|
+
try:
|
|
254
|
+
return [str(category) for category in shape.chart.plots[0].categories]
|
|
255
|
+
except Exception: # noqa: BLE001 - charts without a cache simply contribute nothing
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv: list[str] | None = None) -> int:
|
|
260
|
+
import argparse
|
|
261
|
+
|
|
262
|
+
parser = argparse.ArgumentParser(description="Report slide-geometry problems (out-of-bounds shapes, text overflow, overlaps).")
|
|
263
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
264
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
265
|
+
parser.add_argument("--title-floor", type=float, default=DEFAULT_TITLE_FLOOR_PT, help="minimum title size in pt (default 28)")
|
|
266
|
+
parser.add_argument("--body-floor", type=float, default=DEFAULT_BODY_FLOOR_PT, help="minimum body size in pt (default 14)")
|
|
267
|
+
parser.add_argument("--strict", action="store_true", help="treat review findings as blocking too")
|
|
268
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
269
|
+
args = parser.parse_args(argv)
|
|
270
|
+
path = args.input_path or args.presentation
|
|
271
|
+
if not path:
|
|
272
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
273
|
+
|
|
274
|
+
report = inspect(path, args.title_floor, args.body_floor)
|
|
275
|
+
if args.strict:
|
|
276
|
+
report["strict"] = True
|
|
277
|
+
report["passed"] = not report["findings"]
|
|
278
|
+
if args.json_out:
|
|
279
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
280
|
+
emit(report)
|
|
281
|
+
return 0 if report["passed"] else 1
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
raise SystemExit(main())
|
package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
20
|
+
CT_NS = "{http://schemas.openxmlformats.org/package/2006/content-types}"
|
|
21
|
+
P_NS = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
|
|
22
|
+
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rels_targets(archive: zipfile.ZipFile, part: str) -> list[str]:
|
|
26
|
+
folder, _, name = part.rpartition("/")
|
|
27
|
+
rels_name = f"{folder}/_rels/{name}.rels" if folder else f"_rels/{name}.rels"
|
|
28
|
+
if rels_name not in archive.namelist():
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
root = ElementTree.fromstring(archive.read(rels_name))
|
|
32
|
+
except ElementTree.ParseError:
|
|
33
|
+
return []
|
|
34
|
+
targets: list[str] = []
|
|
35
|
+
for relationship in root.findall(f"{REL_NS}Relationship"):
|
|
36
|
+
if relationship.get("TargetMode") == "External":
|
|
37
|
+
continue
|
|
38
|
+
target = relationship.get("Target", "")
|
|
39
|
+
if target.startswith("/"):
|
|
40
|
+
resolved = target.lstrip("/")
|
|
41
|
+
else:
|
|
42
|
+
resolved = f"{folder}/{target}" if folder else target
|
|
43
|
+
parts: list[str] = []
|
|
44
|
+
for segment in resolved.split("/"):
|
|
45
|
+
if segment in {"", "."}:
|
|
46
|
+
continue
|
|
47
|
+
if segment == "..":
|
|
48
|
+
if parts:
|
|
49
|
+
parts.pop()
|
|
50
|
+
continue
|
|
51
|
+
parts.append(segment)
|
|
52
|
+
targets.append("/".join(parts))
|
|
53
|
+
return targets
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def inspect_package(pptx_path: str | Path) -> dict:
|
|
57
|
+
path = Path(pptx_path)
|
|
58
|
+
problems: list[dict] = []
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "missing_file", "detail": str(path)}]}
|
|
61
|
+
if not zipfile.is_zipfile(path):
|
|
62
|
+
return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "not_a_zip", "detail": "not a readable OOXML archive"}]}
|
|
63
|
+
|
|
64
|
+
with zipfile.ZipFile(path) as archive:
|
|
65
|
+
names = set(archive.namelist())
|
|
66
|
+
for required in ("[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"):
|
|
67
|
+
if required not in names:
|
|
68
|
+
problems.append({"kind": "missing_part", "part": required})
|
|
69
|
+
|
|
70
|
+
slides = sorted(name for name in names if name.startswith("ppt/slides/slide") and name.endswith(".xml"))
|
|
71
|
+
layouts = sorted(name for name in names if name.startswith("ppt/slideLayouts/slideLayout") and name.endswith(".xml"))
|
|
72
|
+
masters = sorted(name for name in names if name.startswith("ppt/slideMasters/slideMaster") and name.endswith(".xml"))
|
|
73
|
+
charts = sorted(name for name in names if name.startswith("ppt/charts/chart") and name.endswith(".xml"))
|
|
74
|
+
media = sorted(name for name in names if name.startswith("ppt/media/"))
|
|
75
|
+
embeddings = sorted(name for name in names if name.startswith("ppt/embeddings/"))
|
|
76
|
+
|
|
77
|
+
if not slides:
|
|
78
|
+
problems.append({"kind": "no_slides", "detail": "the package has no ppt/slides parts"})
|
|
79
|
+
if not masters:
|
|
80
|
+
problems.append({"kind": "no_master", "detail": "the package has no slide master"})
|
|
81
|
+
|
|
82
|
+
for part in slides + charts + layouts + masters:
|
|
83
|
+
for target in _rels_targets(archive, part):
|
|
84
|
+
if target and target not in names:
|
|
85
|
+
problems.append({"kind": "dangling_relationship", "part": part, "target": target})
|
|
86
|
+
|
|
87
|
+
for part in slides:
|
|
88
|
+
try:
|
|
89
|
+
root = ElementTree.fromstring(archive.read(part))
|
|
90
|
+
except ElementTree.ParseError as exc:
|
|
91
|
+
problems.append({"kind": "unparsable_xml", "part": part, "detail": str(exc)})
|
|
92
|
+
continue
|
|
93
|
+
if root.tag != f"{P_NS}sld":
|
|
94
|
+
problems.append({"kind": "wrong_root_element", "part": part, "detail": root.tag})
|
|
95
|
+
|
|
96
|
+
presentation_xml = archive.read("ppt/presentation.xml") if "ppt/presentation.xml" in names else b""
|
|
97
|
+
declared_slides = 0
|
|
98
|
+
if presentation_xml:
|
|
99
|
+
try:
|
|
100
|
+
root = ElementTree.fromstring(presentation_xml)
|
|
101
|
+
slide_ids = root.find(f"{P_NS}sldIdLst")
|
|
102
|
+
declared_slides = len(list(slide_ids)) if slide_ids is not None else 0
|
|
103
|
+
except ElementTree.ParseError:
|
|
104
|
+
problems.append({"kind": "unparsable_xml", "part": "ppt/presentation.xml"})
|
|
105
|
+
if declared_slides and declared_slides != len(slides):
|
|
106
|
+
problems.append(
|
|
107
|
+
{
|
|
108
|
+
"kind": "slide_count_mismatch",
|
|
109
|
+
"detail": f"presentation.xml lists {declared_slides} slides but the package carries {len(slides)} parts",
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
duplicates = len(slides) - len({name.lower() for name in slides})
|
|
114
|
+
if duplicates:
|
|
115
|
+
problems.append({"kind": "duplicate_parts", "count": duplicates})
|
|
116
|
+
|
|
117
|
+
notes = [name for name in names if name.startswith("ppt/notesSlides/notesSlide") and name.endswith(".xml")]
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"gate": "package-integrity",
|
|
121
|
+
"presentation": str(path.resolve()),
|
|
122
|
+
"parts": {
|
|
123
|
+
"slides": len(slides),
|
|
124
|
+
"layouts": len(layouts),
|
|
125
|
+
"masters": len(masters),
|
|
126
|
+
"charts": len(charts),
|
|
127
|
+
"notesSlides": len(notes),
|
|
128
|
+
"media": len(media),
|
|
129
|
+
"embeddings": len(embeddings),
|
|
130
|
+
},
|
|
131
|
+
"problems": problems,
|
|
132
|
+
"passed": not problems,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv: list[str] | None = None) -> int:
|
|
137
|
+
import argparse
|
|
138
|
+
|
|
139
|
+
parser = argparse.ArgumentParser(description="Verify OOXML part/relationship integrity of a .pptx.")
|
|
140
|
+
parser.add_argument("presentation", nargs="?", help="path to the .pptx")
|
|
141
|
+
parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
|
|
142
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
path = args.input_path or args.presentation
|
|
145
|
+
if not path:
|
|
146
|
+
parser.error("a .pptx path is required (positional or --input)")
|
|
147
|
+
|
|
148
|
+
report = inspect_package(path)
|
|
149
|
+
if args.json_out:
|
|
150
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
151
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
152
|
+
sys.stdout.write("\n")
|
|
153
|
+
return 0 if report["passed"] else 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|