dsh-office-kit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +99 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
- package/assets/skills/office-documents/scripts/comments_add.py +143 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
- package/assets/skills/office-documents/scripts/content_controls.py +199 -0
- package/assets/skills/office-documents/scripts/core_properties.py +131 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
- package/assets/skills/office-documents/scripts/fields_report.py +66 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
- package/assets/skills/office-documents/scripts/images_audit.py +87 -0
- package/assets/skills/office-documents/scripts/insert_note.py +133 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
- package/assets/skills/office-documents/scripts/replace_text.py +161 -0
- package/assets/skills/office-documents/scripts/section_audit.py +98 -0
- package/assets/skills/office-documents/scripts/set_protection.py +99 -0
- package/assets/skills/office-documents/scripts/style_lint.py +118 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +143 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +183 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +260 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +246 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +213 -254
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Replace text inside runs of an existing deck.
|
|
2
|
+
|
|
3
|
+
Run-level by design: a match only counts when the whole `find` string lives in a
|
|
4
|
+
single run. Text split across runs by earlier formatting is *not* merged, which
|
|
5
|
+
keeps formatting intact and makes the outcome predictable — the tool reports
|
|
6
|
+
what it matched, and reports what it could not match.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
<python> replace_text.py --input deck.pptx --find "Q4 2025" --replace "Q4 2026"
|
|
11
|
+
<python> replace_text.py --input deck.pptx --spec replacements.json --out fixed.pptx
|
|
12
|
+
|
|
13
|
+
Exit codes
|
|
14
|
+
----------
|
|
15
|
+
0 every requested pattern was applied at least once
|
|
16
|
+
1 a pattern matched nothing (the file is still written; check the report)
|
|
17
|
+
2 bad arguments, unreadable input or invalid spec
|
|
18
|
+
3 the output file could not be written
|
|
19
|
+
|
|
20
|
+
The JSON report on stdout is `{ok, input, output, total, slides[], unmatched[]}`.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
30
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
31
|
+
try:
|
|
32
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
33
|
+
except (AttributeError, ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _emit(payload: dict, code: int = 0) -> int:
|
|
38
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
39
|
+
return code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_spec(path: str | None) -> list[dict]:
|
|
43
|
+
if not path:
|
|
44
|
+
return []
|
|
45
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
46
|
+
if isinstance(data, dict):
|
|
47
|
+
entries = data.get("replacements") or []
|
|
48
|
+
elif isinstance(data, list):
|
|
49
|
+
entries = data
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError("spec must be an object with 'replacements' or a list")
|
|
52
|
+
resolved = []
|
|
53
|
+
for entry in entries:
|
|
54
|
+
if not isinstance(entry, dict) or "find" not in entry:
|
|
55
|
+
raise ValueError(f"replacement needs a 'find' key: {entry!r}")
|
|
56
|
+
resolved.append(
|
|
57
|
+
{
|
|
58
|
+
"find": str(entry["find"]),
|
|
59
|
+
"replace": str(entry.get("replace", "")),
|
|
60
|
+
"slide": entry.get("slide"),
|
|
61
|
+
"match_case": bool(entry.get("match_case", True)),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return resolved
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _frames(slide):
|
|
68
|
+
for shape in slide.shapes:
|
|
69
|
+
if shape.has_text_frame:
|
|
70
|
+
yield shape.text_frame
|
|
71
|
+
if getattr(shape, "has_table", False) and shape.has_table:
|
|
72
|
+
for row in shape.table.rows:
|
|
73
|
+
for cell in row.cells:
|
|
74
|
+
yield cell.text_frame
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def replace_in_slide(slide, rules: list[dict]) -> list[dict]:
|
|
78
|
+
hits = []
|
|
79
|
+
for frame in _frames(slide):
|
|
80
|
+
for paragraph in frame.paragraphs:
|
|
81
|
+
for run in paragraph.runs:
|
|
82
|
+
original = run.text
|
|
83
|
+
updated = original
|
|
84
|
+
for rule in rules:
|
|
85
|
+
needle = rule["find"] if rule["match_case"] else rule["find"].lower()
|
|
86
|
+
haystack = updated if rule["match_case"] else updated.lower()
|
|
87
|
+
if needle and needle in haystack:
|
|
88
|
+
updated = updated.replace(rule["find"], rule["replace"])
|
|
89
|
+
hits.append({"find": rule["find"], "before": original, "after": updated})
|
|
90
|
+
if updated != original:
|
|
91
|
+
run.text = updated
|
|
92
|
+
return hits
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
description="Replace text inside runs of a .pptx (never across runs).",
|
|
98
|
+
epilog="Exit codes: 0 all patterns applied, 1 a pattern matched nothing, 2 bad input, 3 write failure.",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument("--input", required=True, help="source deck")
|
|
101
|
+
parser.add_argument("--find", action="append", default=[], help="text to find (repeatable)")
|
|
102
|
+
parser.add_argument("--replace", action="append", default=[], help="replacement (repeatable, pairs with --find)")
|
|
103
|
+
parser.add_argument("--spec", help="JSON file: {replacements:[{find, replace, slide?, match_case?}]}")
|
|
104
|
+
parser.add_argument("--out", help="output deck (default: <input>-edited.pptx)")
|
|
105
|
+
args = parser.parse_args()
|
|
106
|
+
|
|
107
|
+
rules: list[dict] = []
|
|
108
|
+
try:
|
|
109
|
+
for index, needle in enumerate(args.find):
|
|
110
|
+
rules.append(
|
|
111
|
+
{
|
|
112
|
+
"find": needle,
|
|
113
|
+
"replace": args.replace[index] if index < len(args.replace) else "",
|
|
114
|
+
"slide": None,
|
|
115
|
+
"match_case": True,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
rules.extend(_load_spec(args.spec))
|
|
119
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
120
|
+
return _emit({"ok": False, "error": f"bad spec: {error}"}, 2)
|
|
121
|
+
|
|
122
|
+
if not rules:
|
|
123
|
+
return _emit({"ok": False, "error": "nothing to replace: pass --find/--replace or --spec"}, 2)
|
|
124
|
+
|
|
125
|
+
source = Path(args.input)
|
|
126
|
+
if not source.is_file():
|
|
127
|
+
return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
|
|
128
|
+
output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
from pptx import Presentation
|
|
132
|
+
except ImportError as error:
|
|
133
|
+
return _emit({"ok": False, "error": f"python-pptx is required: {error}"}, 2)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
prs = Presentation(str(source))
|
|
137
|
+
except Exception as error: # noqa: BLE001 - report, never traceback at the user
|
|
138
|
+
return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
|
|
139
|
+
|
|
140
|
+
slides_report = []
|
|
141
|
+
total = 0
|
|
142
|
+
matched_patterns: set[str] = set()
|
|
143
|
+
for index, slide in enumerate(prs.slides, start=1):
|
|
144
|
+
applicable = [rule for rule in rules if rule["slide"] in (None, index)]
|
|
145
|
+
hits = replace_in_slide(slide, applicable)
|
|
146
|
+
for hit in hits:
|
|
147
|
+
matched_patterns.add(hit["find"])
|
|
148
|
+
total += len(hits)
|
|
149
|
+
if hits:
|
|
150
|
+
slides_report.append({"slide": index, "replacements": len(hits), "details": hits[:10]})
|
|
151
|
+
|
|
152
|
+
unmatched = sorted({rule["find"] for rule in rules} - matched_patterns)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
prs.save(str(output))
|
|
157
|
+
except Exception as error: # noqa: BLE001
|
|
158
|
+
return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
|
|
159
|
+
|
|
160
|
+
report = {
|
|
161
|
+
"schema": "replace_text/1",
|
|
162
|
+
"ok": not unmatched,
|
|
163
|
+
"input": str(source),
|
|
164
|
+
"output": str(output),
|
|
165
|
+
"rules": len(rules),
|
|
166
|
+
"total": total,
|
|
167
|
+
"slides": slides_report,
|
|
168
|
+
"unmatched": unmatched,
|
|
169
|
+
"note": "matches only inside a single run; text split by formatting is left alone",
|
|
170
|
+
}
|
|
171
|
+
return _emit(report, 0 if not unmatched else 1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Compare a finished deck against the template it claims to follow.
|
|
2
|
+
|
|
3
|
+
Purpose: "it looks similar" is not fidelity. This gate takes explicit
|
|
4
|
+
requirements — which source slides must be represented, how close the slide
|
|
5
|
+
size and placeholder geometry must be, whether a photographic background must
|
|
6
|
+
survive — and answers yes or no. No requirements supplied means nothing to
|
|
7
|
+
enforce, so the gate passes neutrally instead of inventing a standard.
|
|
8
|
+
|
|
9
|
+
Requirements file:
|
|
10
|
+
{
|
|
11
|
+
"reference_slide_indices": [1, 3, 4],
|
|
12
|
+
"mapping": {"1": 1, "2": 3, "3": 4},
|
|
13
|
+
"minimum_coverage_ratio": 1.0,
|
|
14
|
+
"require_exact_dimensions": true,
|
|
15
|
+
"require_placeholder_geometry": true,
|
|
16
|
+
"require_photographic_background": false,
|
|
17
|
+
"geometry_tolerance_emu": 9144
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Exit codes: 0 pass, 1 fail, 2 unreadable input.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
30
|
+
|
|
31
|
+
from ooxml_helpers import box, is_picture, slide_size_in # noqa: E402
|
|
32
|
+
|
|
33
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
34
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
35
|
+
try:
|
|
36
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
37
|
+
except (AttributeError, ValueError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
EMU_PER_INCH = 914400
|
|
41
|
+
DEFAULT_TOLERANCE_EMU = 9144 # 0.01 in
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _placeholder_map(slide) -> dict[tuple[int, str], tuple[float, float, float, float]]:
|
|
45
|
+
entries: dict[tuple[int, str], tuple[float, float, float, float]] = {}
|
|
46
|
+
for shape in slide.shapes:
|
|
47
|
+
if not shape.is_placeholder:
|
|
48
|
+
continue
|
|
49
|
+
geometry = box(shape)
|
|
50
|
+
if geometry is None:
|
|
51
|
+
continue
|
|
52
|
+
entries[(shape.placeholder_format.idx, shape.name)] = geometry
|
|
53
|
+
return entries
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _background_picture(slide, slide_w: float, slide_h: float) -> bool:
|
|
57
|
+
for shape in slide.shapes:
|
|
58
|
+
if not is_picture(shape):
|
|
59
|
+
continue
|
|
60
|
+
geometry = box(shape)
|
|
61
|
+
if geometry is None:
|
|
62
|
+
continue
|
|
63
|
+
left, top, width, height = geometry
|
|
64
|
+
if left <= 0.02 and top <= 0.02 and width >= slide_w * 0.98 and height >= slide_h * 0.98:
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_fidelity(template_path: str | Path, final_path: str | Path, requirements: dict | None) -> dict:
|
|
70
|
+
from pptx import Presentation
|
|
71
|
+
|
|
72
|
+
requirements = requirements or {}
|
|
73
|
+
template_file = Path(template_path)
|
|
74
|
+
final_file = Path(final_path)
|
|
75
|
+
for candidate in (template_file, final_file):
|
|
76
|
+
if not candidate.is_file():
|
|
77
|
+
return {"gate": "template-source-fidelity", "passed": False, "findings": [{"kind": "missing_file", "detail": str(candidate)}]}
|
|
78
|
+
|
|
79
|
+
if not requirements:
|
|
80
|
+
return {
|
|
81
|
+
"gate": "template-source-fidelity",
|
|
82
|
+
"passed": True,
|
|
83
|
+
"neutral": True,
|
|
84
|
+
"note": "no fidelity requirements were supplied, so nothing was enforced",
|
|
85
|
+
"findings": [],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
template = Presentation(str(template_file))
|
|
89
|
+
final = Presentation(str(final_file))
|
|
90
|
+
template_w, template_h = slide_size_in(template)
|
|
91
|
+
final_w, final_h = slide_size_in(final)
|
|
92
|
+
tolerance_in = float(requirements.get("geometry_tolerance_emu", DEFAULT_TOLERANCE_EMU)) / EMU_PER_INCH
|
|
93
|
+
findings: list[dict] = []
|
|
94
|
+
checks: list[dict] = []
|
|
95
|
+
|
|
96
|
+
if requirements.get("require_exact_dimensions", True):
|
|
97
|
+
same = abs(template_w - final_w) <= tolerance_in and abs(template_h - final_h) <= tolerance_in
|
|
98
|
+
checks.append({"check": "dimensions", "passed": same, "template": [template_w, template_h], "final": [final_w, final_h]})
|
|
99
|
+
if not same:
|
|
100
|
+
findings.append(
|
|
101
|
+
{
|
|
102
|
+
"kind": "dimension_mismatch",
|
|
103
|
+
"severity": "fail",
|
|
104
|
+
"message": f"final deck is {final_w:.2f}x{final_h:.2f} in, template is {template_w:.2f}x{template_h:.2f} in",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
template_slides = list(template.slides)
|
|
109
|
+
final_slides = list(final.slides)
|
|
110
|
+
mapping = requirements.get("mapping") or {}
|
|
111
|
+
pairs: list[tuple[int, int]] = []
|
|
112
|
+
for output_index, source_index in mapping.items():
|
|
113
|
+
pairs.append((int(output_index), int(source_index)))
|
|
114
|
+
if not pairs:
|
|
115
|
+
pairs = [(index, index) for index in range(1, min(len(template_slides), len(final_slides)) + 1)]
|
|
116
|
+
|
|
117
|
+
matched = 0
|
|
118
|
+
for output_index, source_index in pairs:
|
|
119
|
+
if not (1 <= output_index <= len(final_slides) and 1 <= source_index <= len(template_slides)):
|
|
120
|
+
findings.append(
|
|
121
|
+
{
|
|
122
|
+
"kind": "mapping_out_of_range",
|
|
123
|
+
"severity": "fail",
|
|
124
|
+
"message": f"mapping {output_index}->{source_index} is outside the deck",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
source_slide = template_slides[source_index - 1]
|
|
129
|
+
output_slide = final_slides[output_index - 1]
|
|
130
|
+
slide_pass = True
|
|
131
|
+
|
|
132
|
+
if source_slide.slide_layout.name != output_slide.slide_layout.name:
|
|
133
|
+
slide_pass = False
|
|
134
|
+
findings.append(
|
|
135
|
+
{
|
|
136
|
+
"kind": "layout_changed",
|
|
137
|
+
"severity": "fail",
|
|
138
|
+
"slide": output_index,
|
|
139
|
+
"sourceSlide": source_index,
|
|
140
|
+
"message": f"layout went from '{source_slide.slide_layout.name}' to '{output_slide.slide_layout.name}'",
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if requirements.get("require_placeholder_geometry", True):
|
|
145
|
+
source_placeholders = _placeholder_map(source_slide)
|
|
146
|
+
output_placeholders = _placeholder_map(output_slide)
|
|
147
|
+
for key, source_box in source_placeholders.items():
|
|
148
|
+
output_box = output_placeholders.get(key)
|
|
149
|
+
if output_box is None:
|
|
150
|
+
continue
|
|
151
|
+
drift = max(abs(a - b) for a, b in zip(source_box, output_box))
|
|
152
|
+
if drift > tolerance_in:
|
|
153
|
+
slide_pass = False
|
|
154
|
+
findings.append(
|
|
155
|
+
{
|
|
156
|
+
"kind": "placeholder_geometry_drift",
|
|
157
|
+
"severity": "fail",
|
|
158
|
+
"slide": output_index,
|
|
159
|
+
"placeholder": key[1],
|
|
160
|
+
"driftIn": round(drift, 3),
|
|
161
|
+
"message": f"placeholder '{key[1]}' moved {drift:.2f} in from the template position",
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if requirements.get("require_photographic_background"):
|
|
166
|
+
if _background_picture(source_slide, template_w, template_h) and not _background_picture(output_slide, final_w, final_h):
|
|
167
|
+
slide_pass = False
|
|
168
|
+
findings.append(
|
|
169
|
+
{
|
|
170
|
+
"kind": "background_lost",
|
|
171
|
+
"severity": "fail",
|
|
172
|
+
"slide": output_index,
|
|
173
|
+
"sourceSlide": source_index,
|
|
174
|
+
"message": "the template slide carries a full-bleed background image; the output does not",
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
checks.append({"check": "slide", "outputSlide": output_index, "sourceSlide": source_index, "passed": slide_pass})
|
|
179
|
+
matched += 1 if slide_pass else 0
|
|
180
|
+
|
|
181
|
+
coverage = matched / len(pairs) if pairs else 0.0
|
|
182
|
+
minimum = float(requirements.get("minimum_coverage_ratio", 1.0))
|
|
183
|
+
checks.append({"check": "coverage", "ratio": round(coverage, 4), "minimum": minimum, "passed": coverage + 1e-9 >= minimum})
|
|
184
|
+
if coverage + 1e-9 < minimum:
|
|
185
|
+
findings.append(
|
|
186
|
+
{
|
|
187
|
+
"kind": "coverage_below_minimum",
|
|
188
|
+
"severity": "fail",
|
|
189
|
+
"message": f"{coverage:.0%} of mapped slides preserved the template, minimum is {minimum:.0%}",
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
reference = requirements.get("reference_slide_indices") or []
|
|
194
|
+
if reference and not pairs:
|
|
195
|
+
findings.append({"kind": "no_usable_mapping", "severity": "fail", "message": f"reference slides {reference} could not be mapped to output slides"})
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"gate": "template-source-fidelity",
|
|
199
|
+
"template": str(template_file.resolve()),
|
|
200
|
+
"final": str(final_file.resolve()),
|
|
201
|
+
"requirements": requirements,
|
|
202
|
+
"checks": checks,
|
|
203
|
+
"findings": findings,
|
|
204
|
+
"coverage": round(coverage, 4),
|
|
205
|
+
"passed": not [item for item in findings if item["severity"] == "fail"],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main(argv: list[str] | None = None) -> int:
|
|
210
|
+
import argparse
|
|
211
|
+
|
|
212
|
+
parser = argparse.ArgumentParser(description="Verify that a deck preserved the template it was built from.")
|
|
213
|
+
parser.add_argument("--template", required=True, help="the source template .pptx")
|
|
214
|
+
parser.add_argument("--final", required=True, help="the finished .pptx")
|
|
215
|
+
parser.add_argument("--requirements", help="JSON file with the fidelity requirements; omit for a neutral pass")
|
|
216
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
217
|
+
args = parser.parse_args(argv)
|
|
218
|
+
|
|
219
|
+
requirements = None
|
|
220
|
+
if args.requirements:
|
|
221
|
+
try:
|
|
222
|
+
requirements = json.loads(Path(args.requirements).read_text(encoding="utf-8"))
|
|
223
|
+
except Exception as exc: # noqa: BLE001
|
|
224
|
+
json.dump({"gate": "template-source-fidelity", "passed": False, "error": {"code": "bad_requirements", "message": str(exc)}}, sys.stdout)
|
|
225
|
+
sys.stdout.write("\n")
|
|
226
|
+
return 2
|
|
227
|
+
report = check_fidelity(args.template, args.final, requirements)
|
|
228
|
+
if args.json_out:
|
|
229
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
230
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
231
|
+
sys.stdout.write("\n")
|
|
232
|
+
return 0 if report["passed"] else 1
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Finance decks
|
|
2
|
+
|
|
3
|
+
The mechanics are the same as any deck; the failure modes are specific.
|
|
4
|
+
|
|
5
|
+
## What a finance audience checks first
|
|
6
|
+
|
|
7
|
+
1. Does the total add up? A slide whose numbers do not sum loses the argument
|
|
8
|
+
before the argument starts. Run the arithmetic gate on every declared total.
|
|
9
|
+
2. Is the period stated? "Revenue up 12%" without a base period is not a claim.
|
|
10
|
+
3. Is the unit stated? Thousands, millions, currency, and whether it is gross or
|
|
11
|
+
net. Put it in the header, once.
|
|
12
|
+
4. Is the comparison like-for-like? Full quarter against full quarter; do not
|
|
13
|
+
put a 13-week quarter beside a 12-week one without saying so.
|
|
14
|
+
5. Does the chart axis start at zero? For bars, yes, always — a truncated bar
|
|
15
|
+
chart in a finance deck reads as manipulation even when it is not.
|
|
16
|
+
|
|
17
|
+
## The financial slide kit
|
|
18
|
+
|
|
19
|
+
| Slide | Content | Note |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Headline | One number, one claim, one source | The number the meeting is about |
|
|
22
|
+
| Bridge | Waterfall from last period to this one | Native chart; label the middle bars, not the ends |
|
|
23
|
+
| Results table | Period rows, metric columns, total row | Totals computed, not typed |
|
|
24
|
+
| Variance | Actual vs plan vs prior, and why | The "why" is a sentence, not a column |
|
|
25
|
+
| Outlook | Ranges, not points | A single forecast point implies precision you do not have |
|
|
26
|
+
| Ask | The decision, the amount, the date | Last slide, one line |
|
|
27
|
+
|
|
28
|
+
## Numbers on slides
|
|
29
|
+
|
|
30
|
+
- Two significant figures is usually enough for a headline (`£2.4m`, not
|
|
31
|
+
`£2,384,117`). Keep full precision in the appendix table.
|
|
32
|
+
- Percentages: one decimal at most, and say what they are a percentage *of*.
|
|
33
|
+
- Round consistently: if the headline says 2.4m, the table should not say
|
|
34
|
+
2.38m in one row and 2.4m in the next.
|
|
35
|
+
- Negative numbers: use a minus sign consistently (`−12%`) and keep
|
|
36
|
+
parentheses for tables where accounting convention is expected.
|
|
37
|
+
- A variance is meaningless without its sign and its direction; state whether
|
|
38
|
+
favourable is up or down (revenue vs cost).
|
|
39
|
+
|
|
40
|
+
## Charts that survive a CFO
|
|
41
|
+
|
|
42
|
+
- Waterfall for a bridge, column for periods, line for a trend, never pie for
|
|
43
|
+
more than three slices.
|
|
44
|
+
- Label the axis unit once, in the caption: "£m, FY2026 actuals".
|
|
45
|
+
- One series per chart unless the comparison *is* the point.
|
|
46
|
+
- Do not chart a table of six numbers; the table is more honest and faster to
|
|
47
|
+
read.
|
|
48
|
+
- Forecast periods are visually distinct (lighter fill, dashed line) and named
|
|
49
|
+
in the caption.
|
|
50
|
+
|
|
51
|
+
## Provenance
|
|
52
|
+
|
|
53
|
+
Every non-obvious number gets a note with: source file or system, extraction
|
|
54
|
+
date, period covered, and any adjustment made. The speaker notes are the right
|
|
55
|
+
place; a footnote-heavy slide is a slide nobody reads.
|
|
56
|
+
|
|
57
|
+
## Check before delivery
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
"<python>" "<skill>/container_tools/native_table_arithmetic_gate.py" --input deck.pptx --contracts totals.json
|
|
61
|
+
"<python>" "<skill>/container_tools/native_quantitative_chart_gate.py" --input deck.pptx --all-slides
|
|
62
|
+
"<python>" "<skill>/container_tools/inspect_presentation_layout_geometry.py" --input deck.pptx
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then read the slides as a sceptic: which number would you challenge first, and
|
|
66
|
+
does the deck already answer it?
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Slide mechanics and paste-runnable recipes
|
|
2
|
+
|
|
3
|
+
The helper CLI covers the common path: a small deck with a title, bullet slides,
|
|
4
|
+
a table, a chart and a picture. Everything else — precise coordinates, master
|
|
5
|
+
edits, notes, theme tokens, connectors — is ordinary `python-pptx` work, and
|
|
6
|
+
these recipes are the honest route.
|
|
7
|
+
|
|
8
|
+
Read the mechanics first, then paste the recipe. Every snippet runs as-is from a
|
|
9
|
+
shell, from any working directory:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
"<python>" - <<'PY'
|
|
13
|
+
from pptx import Presentation
|
|
14
|
+
...
|
|
15
|
+
PY
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## The four numbers that decide every layout
|
|
19
|
+
|
|
20
|
+
| Question | Answer |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Where is this shape? | `left`, `top`, `width`, `height` in **EMU** (English Metric Units). |
|
|
23
|
+
| What is an inch? | `914400` EMU. `pptx.util.Inches(1)` is the polite wrapper. |
|
|
24
|
+
| What is a point? | `12700` EMU. Font sizes use points, geometry uses EMU, and mixing them silently breaks layout. |
|
|
25
|
+
| What is a slide? | `prs.slide_width` / `prs.slide_height`; check them, never assume 16:9. |
|
|
26
|
+
|
|
27
|
+
Helper conversions worth memorising: `Emu(914400) == Inches(1)`,
|
|
28
|
+
`Pt(28) == Emu(355600)`, and a fraction of the slide is
|
|
29
|
+
`int(0.1 * prs.slide_width)`.
|
|
30
|
+
|
|
31
|
+
## Z-order
|
|
32
|
+
|
|
33
|
+
Shapes paint in the order they live in `slide.shapes`. A shape added later
|
|
34
|
+
covers one added earlier. To reorder without deleting:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
tree = slide.shapes._spTree
|
|
38
|
+
shape = slide.shapes[3]
|
|
39
|
+
tree.remove(shape._element)
|
|
40
|
+
tree.insert(2, shape._element) # after nvGrpSpPr and grpSpPr
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Index `2` is the first painted shape; that is where a full-bleed background
|
|
44
|
+
image belongs.
|
|
45
|
+
|
|
46
|
+
## What this pack ships
|
|
47
|
+
|
|
48
|
+
| Recipe | Use it when |
|
|
49
|
+
|---|---|
|
|
50
|
+
| [textbox-at-coordinate](textbox-at-coordinate.md) | Text must land on an exact rect, not "somewhere under the title". |
|
|
51
|
+
| [autolayout](autolayout.md) | The same build has to fit 3 bullets or 9 without hand-tuning. |
|
|
52
|
+
| [tables](tables.md) | Numbers need a grid with column widths and per-cell formatting. |
|
|
53
|
+
| [native-charts](native-charts.md) | Data must stay editable, not become a picture of a chart. |
|
|
54
|
+
| [master-and-layouts](master-and-layouts.md) | The whole deck changes at once: title bar, accent line, footer. |
|
|
55
|
+
| [speaker-notes](speaker-notes.md) | The presenter needs a script attached to each slide. |
|
|
56
|
+
| [theme-tokens](theme-tokens.md) | Colours and fonts must come from one place. |
|
|
57
|
+
| [rich-text-runs](rich-text-runs.md) | One paragraph mixes bold, accent colour and a footnote. |
|
|
58
|
+
| [images](images.md) | A picture needs a crop, a cover fit or a real alt text. |
|
|
59
|
+
| [connectors](connectors.md) | Two boxes are joined by a line that must survive a move. |
|
|
60
|
+
|
|
61
|
+
## Order of work that keeps decks out of trouble
|
|
62
|
+
|
|
63
|
+
1. Read the target geometry off the template or the user's reference
|
|
64
|
+
(`container_tools/inspect_presentation_layout_geometry.py` prints it as JSON).
|
|
65
|
+
2. Place shapes in EMU, top-down: background, chrome, title, body, evidence.
|
|
66
|
+
3. Set type sizes by the *smallest* screen the deck will be shown on; body 18 pt
|
|
67
|
+
and up, footnotes 12 pt and up.
|
|
68
|
+
4. Fill every placeholder you keep and delete the ones you do not — an empty
|
|
69
|
+
placeholder prints "Click to add text" in edit view and looks unfinished.
|
|
70
|
+
5. Render, look, then fix. `container_tools/render_slides.py` plus `read_image`
|
|
71
|
+
is the loop; a deck nobody looked at is not verified.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Auto-layout rules
|
|
2
|
+
|
|
3
|
+
## Mechanics
|
|
4
|
+
|
|
5
|
+
PowerPoint has two different "autofit" ideas and mixing them up wastes a round
|
|
6
|
+
trip:
|
|
7
|
+
|
|
8
|
+
- `word_wrap` decides whether text wraps inside the width. Turn it on for every
|
|
9
|
+
authored box.
|
|
10
|
+
- `auto_size` decides whether the *frame* follows the text (`MSO_AUTO_SIZE.
|
|
11
|
+
SHAPE_TO_FIT_TEXT`) or the text shrinks to fit the frame
|
|
12
|
+
(`TEXT_TO_FIT_SHAPE`). `TEXT_TO_FIT_SHAPE` is a **render-time** behaviour:
|
|
13
|
+
PowerPoint recalculates it when the file is opened, so a deck that looks fine
|
|
14
|
+
in a draft preview can still shrink to 8 pt on a real screen.
|
|
15
|
+
|
|
16
|
+
The reliable pattern is: fixed frame, explicit sizes, content measured against
|
|
17
|
+
the frame, no text-to-fit. Estimate, then verify by rendering.
|
|
18
|
+
|
|
19
|
+
## Recipe: fit a variable number of bullets
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
"<python>" - <<'PY'
|
|
23
|
+
from pptx import Presentation
|
|
24
|
+
from pptx.util import Inches, Pt
|
|
25
|
+
from pptx.enum.text import MSO_ANCHOR
|
|
26
|
+
|
|
27
|
+
BULLETS = [
|
|
28
|
+
"Picking is 41% of floor labour hours",
|
|
29
|
+
"Travel between aisles adds 12 minutes per order",
|
|
30
|
+
"Manual cycle counts stop the line twice a week",
|
|
31
|
+
"Two cells idle below 60% utilisation",
|
|
32
|
+
"Overtime covers the Friday peak",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def fits(text: str, width_in: float, height_in: float, size_pt: float) -> bool:
|
|
36
|
+
per_line = max(1, int(width_in * 96 / (0.55 * size_pt)))
|
|
37
|
+
lines = max(1, -(-len(text) // per_line))
|
|
38
|
+
return lines * 1.2 * size_pt / 72 <= height_in
|
|
39
|
+
|
|
40
|
+
prs = Presentation()
|
|
41
|
+
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
|
|
42
|
+
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
43
|
+
|
|
44
|
+
width, height = 8.4, 4.6 # the body rect you reserved
|
|
45
|
+
box = slide.shapes.add_textbox(Inches(0.9), Inches(1.6), Inches(width), Inches(height))
|
|
46
|
+
frame = box.text_frame
|
|
47
|
+
frame.word_wrap = True
|
|
48
|
+
frame.vertical_anchor = MSO_ANCHOR.TOP
|
|
49
|
+
|
|
50
|
+
size = next(pt for pt in (24, 22, 20, 18) if fits(" ".join(BULLETS), width, height, pt))
|
|
51
|
+
needed = sum(
|
|
52
|
+
max(1, -(-len(item) // max(1, int(width * 96 / (0.55 * size))))) * 1.2 * size / 72
|
|
53
|
+
for item in BULLETS
|
|
54
|
+
)
|
|
55
|
+
for index, item in enumerate(BULLETS):
|
|
56
|
+
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
|
|
57
|
+
paragraph.text = item
|
|
58
|
+
paragraph.font.size = Pt(size)
|
|
59
|
+
paragraph.space_after = Pt(10)
|
|
60
|
+
|
|
61
|
+
prs.save("autofit.pptx")
|
|
62
|
+
print(f"size={size}pt lines_estimate_in={needed:.2f} frame_in={height}")
|
|
63
|
+
PY
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Rules that hold regardless of content
|
|
67
|
+
|
|
68
|
+
1. Body text is 18 pt or larger; below that the back row cannot read it.
|
|
69
|
+
2. Six bullets or three lines of body is the point where a slide should split.
|
|
70
|
+
3. Longer text shrinks the number of *words*, not the type size. If the fit loop
|
|
71
|
+
lands below the body floor, cut copy or split the slide.
|
|
72
|
+
4. Keep 0.5 in of bottom margin for the footer and for projectors that crop.
|
|
73
|
+
5. Two columns beat one narrow column: a 4.2 in column of 18 pt text is a wall.
|
|
74
|
+
|
|
75
|
+
## Pitfalls this avoids
|
|
76
|
+
|
|
77
|
+
- `frame.text` then `paragraph.font.size` on paragraph 0 only: a multi-paragraph
|
|
78
|
+
assignment leaves paragraphs 1..n at the inherited 18 pt.
|
|
79
|
+
- Text-to-fit hiding the overflow: the file opens fine and the audience reads
|
|
80
|
+
9 pt. Measure, do not delegate the fit to the renderer.
|
|
81
|
+
- A frame height that exactly equals the text height: one extra word on edit and
|
|
82
|
+
the block runs into the evidence below. Reserve 10% slack.
|
|
83
|
+
|
|
84
|
+
## CLI fast path
|
|
85
|
+
|
|
86
|
+
`pptx create` with `layout: "bullets"` fills the layout's body placeholder at
|
|
87
|
+
its own size. Use the loop above when the number of bullets is not known before
|
|
88
|
+
the content is written.
|