pptx-cli 1.1.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (73) hide show
  1. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/skills/pptx-deck-builder/SKILL.md +58 -56
  2. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/skills/pptx-deck-builder/references/excal-diagrams.md +2 -2
  3. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/skills/pptx-deck-builder/references/pptx-workflow.md +10 -1
  4. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/PKG-INFO +6 -1
  5. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/README.md +5 -0
  6. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/__init__.py +1 -1
  7. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/template.py +203 -2
  8. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/models/manifest.py +11 -0
  9. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/tests/test_cli.py +27 -0
  10. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.editorconfig +0 -0
  11. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  13. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  14. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/copilot-instructions.md +0 -0
  16. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/instructions/backend.instructions.md +0 -0
  17. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/instructions/testing.instructions.md +0 -0
  18. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/skills/pptx/SKILL.md +0 -0
  19. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/skills/pptx/references/deck-spec.md +0 -0
  20. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/skills/pptx-deck-builder/references/mckinsey-style.md +0 -0
  21. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  22. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.github/workflows/publish.yml +0 -0
  23. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.gitignore +0 -0
  24. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/.python-version +0 -0
  25. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/AGENTS.md +0 -0
  26. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/ARCHITECTURE.md +0 -0
  27. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/CHANGELOG.md +0 -0
  28. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/CLI-MANIFEST.md +0 -0
  29. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/CONTRIBUTING.md +0 -0
  30. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/DECISIONS/ADR-0001-initial-architecture.md +0 -0
  31. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/LICENSE +0 -0
  32. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/PRD.md +0 -0
  33. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/PREVIEW-FUTURE.md +0 -0
  34. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/PROJECT.md +0 -0
  35. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/SCAFFOLD.md +0 -0
  36. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/SECURITY.md +0 -0
  37. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/TESTING.md +0 -0
  38. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/docs/DOMAIN.md +0 -0
  39. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/docs/GLOSSARY.md +0 -0
  40. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/docs/ROADMAP.md +0 -0
  41. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/docs/SCAFFOLDING-NOTES.md +0 -0
  42. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/guardrails/.gitignore +0 -0
  43. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/guardrails/guardrails.jsonl +0 -0
  44. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/guardrails/links.jsonl +0 -0
  45. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/guardrails/references.jsonl +0 -0
  46. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/guardrails/taxonomy.json +0 -0
  47. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/guardrails-explorer.html +0 -0
  48. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/pyproject.toml +0 -0
  49. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/pyrightconfig.json +0 -0
  50. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/scripts/bump_version.py +0 -0
  51. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/__main__.py +0 -0
  52. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/cli.py +0 -0
  53. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/__init__.py +0 -0
  54. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/compose.py +0 -0
  55. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/guide.py +0 -0
  56. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/init.py +0 -0
  57. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/inspect.py +0 -0
  58. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/manifest_ops.py +0 -0
  59. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/validate.py +0 -0
  60. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/commands/wrapper.py +0 -0
  61. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/__init__.py +0 -0
  62. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/composition.py +0 -0
  63. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/ids.py +0 -0
  64. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/io.py +0 -0
  65. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/manifest_store.py +0 -0
  66. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/runtime.py +0 -0
  67. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/validation.py +0 -0
  68. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/core/versioning.py +0 -0
  69. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/models/__init__.py +0 -0
  70. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/src/pptx_cli/models/envelope.py +0 -0
  71. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/tests/conftest.py +0 -0
  72. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/tests/test_versioning.py +0 -0
  73. {pptx_cli-1.1.0 → pptx_cli-1.2.0}/uv.lock +0 -0
@@ -66,10 +66,6 @@ Then run the doctor to verify compatibility:
66
66
  pptx doctor --manifest ./corp-template --format json
67
67
  ```
68
68
 
69
- **Important:** After init, picture-type placeholders are missing `image` in
70
- their `supported_content_types`. Fix this before using images — see the
71
- image handling section below.
72
-
73
69
  ### Step 2 — Discover layouts and placeholders
74
70
 
75
71
  List available layouts, then inspect placeholders for the ones you plan to use:
@@ -87,7 +83,29 @@ placeholder:
87
83
  - `supported_content_types` — What it accepts (`text`, `markdown-text`,
88
84
  `image`, `table`, `chart`)
89
85
  - `required` — Whether it must be filled
90
- - `text_defaults` — Suggested font size, max lines
86
+ - `estimated_text_capacity` — Preferred normalized guidance for text-capable
87
+ placeholders. Read `max_lines` first, then check `confidence`, `source`,
88
+ and `font_size_pt`.
89
+ - `text_defaults` — Raw extracted placeholder hints such as suggested font
90
+ size or explicit `max_lines` from the template text
91
+ - `left_emu`, `top_emu`, `width_emu`, `height_emu` — The actual geometry.
92
+ Treat `width_emu` and `height_emu` as hard constraints for how much content
93
+ the placeholder can realistically hold.
94
+
95
+ For slide drafting, prefer `estimated_text_capacity.max_lines` over parsing
96
+ `text_defaults.max_lines` directly. Treat it as guidance, not a hard rule:
97
+ stay at or below the line estimate when possible, and be more conservative
98
+ when `confidence` is `low`.
99
+
100
+ Respect placeholder size in every content decision:
101
+
102
+ - Text: do not write beyond the likely line budget for the box. If the message
103
+ does not fit, tighten the wording or choose a layout with a larger text area.
104
+ - Images and diagrams: match the placeholder aspect ratio and expected visual
105
+ density to the available width and height.
106
+ - Tables and charts: reduce rows, columns, labels, or series when the
107
+ placeholder is small. Prefer a larger layout over forcing dense content into
108
+ a small box.
91
109
 
92
110
  Also inspect theme colors and assets if you need to match the visual identity:
93
111
 
@@ -112,11 +130,14 @@ letterboxing.
112
130
  ```bash
113
131
  # Create the .excalidraw file, then:
114
132
  excal validate diagrams/my-diagram.excalidraw
115
- excal render diagrams/my-diagram.excalidraw --outDir ./out/diagrams --png --scale 2 --no-background
133
+ excal render diagrams/my-diagram.excalidraw --outDir ./out/diagrams --png --scale 4 --no-background
116
134
  ```
117
135
 
118
136
  Use `--no-background` for transparent backgrounds that blend with slide
119
- backgrounds. Use `--scale 2` for crisp output.
137
+ backgrounds. Use `--scale 4` for crisp output — scale 2 looks blurry on
138
+ large slide placeholders because the pixel density is too low for the
139
+ physical size. Scale 4 ensures diagrams stay sharp even on high-DPI
140
+ displays and when projected.
120
141
 
121
142
  **Match the template's visual identity.** Diagrams should feel like they belong
122
143
  on the slide, not like foreign objects pasted in. Two things matter most:
@@ -190,59 +211,29 @@ Use `--dry-run` on build to preview without writing files.
190
211
 
191
212
  ---
192
213
 
193
- ## Image handling — known issues and fixes
194
-
195
- ### Picture placeholders need `image` added to supported_content_types
196
-
197
- After `pptx init`, picture-type placeholders only list `["text",
198
- "markdown-text"]`. You must edit `manifest.yaml` to add `- image`:
214
+ ## Image handling
199
215
 
200
- ```yaml
201
- # Find all placeholder_type: picture entries and add image:
202
- placeholder_type: picture
203
- guidance_lines: []
204
- supported_content_types:
205
- - text
206
- - markdown-text
207
- - image # ← add this line
208
- ```
216
+ ### Image scaling — fit vs crop
209
217
 
210
- Use a replace-all to fix every occurrence at once.
218
+ Picture placeholders should already advertise `image` in
219
+ `supported_content_types`. Do not patch `manifest.yaml` unless inspection
220
+ output proves the template contract is actually wrong.
211
221
 
212
- ### Image scaling fit vs crop
222
+ The current build behavior defaults to `image_fit: fit`, which preserves the
223
+ full image inside the placeholder. Use `image_fit: cover` when you explicitly
224
+ want crop-to-fill behavior.
213
225
 
214
- The default `insert_picture` behavior crops to fill, which clips content when
215
- aspect ratios differ. The composition code should be patched to scale images to
216
- fit within placeholder bounds instead. The fix in
217
- `pptx_cli/core/composition.py` replaces the image insertion block:
218
-
219
- ```python
220
- # After insert_picture, reset crops and scale to fit:
221
- ph_left, ph_top = shape.left, shape.top
222
- ph_width, ph_height = shape.width, shape.height
223
- pic = shape.insert_picture(str(image_path))
224
- pic.crop_left = pic.crop_right = pic.crop_top = pic.crop_bottom = 0
225
- img = pic.image
226
- img_w, img_h = img.size
227
- img_aspect = img_w / img_h
228
- ph_aspect = ph_width / ph_height
229
- if img_aspect > ph_aspect:
230
- new_width = ph_width
231
- new_height = int(ph_width / img_aspect)
232
- else:
233
- new_height = ph_height
234
- new_width = int(ph_height * img_aspect)
235
- pic.left = ph_left + (ph_width - new_width) // 2
236
- pic.top = ph_top + (ph_height - new_height) // 2
237
- pic.width = new_width
238
- pic.height = new_height
226
+ ```yaml
227
+ slides:
228
+ - layout: picture-layout-id
229
+ content:
230
+ title: Workflow
231
+ picture:
232
+ kind: image
233
+ path: out/diagrams/workflow.png
234
+ image_fit: fit
239
235
  ```
240
236
 
241
- Check whether this patch is already applied before applying it. Read the
242
- `_apply_content_value` function in `composition.py` — if it only has
243
- `shape.insert_picture(str(image_path))` with no scaling logic, the patch
244
- is needed.
245
-
246
237
  ---
247
238
 
248
239
  ## Slide writing principles
@@ -262,6 +253,17 @@ conclude — not just name the topic.
262
253
  If a slide has two insights, split it into two slides. The body must prove
263
254
  the headline.
264
255
 
256
+ ### Respect placeholder geometry
257
+
258
+ Never treat placeholder size as flexible. The template geometry is part of the
259
+ contract.
260
+
261
+ - If text exceeds the placeholder's likely capacity, shorten it or split it.
262
+ - If a table or chart needs more room, switch to a layout with a larger object
263
+ placeholder.
264
+ - If a diagram becomes unreadable at the placeholder's size, simplify the
265
+ diagram rather than shrinking text and shapes until they are illegible.
266
+
265
267
  ### Source attribution
266
268
 
267
269
  Data-driven slides need source lines. Use the `source` placeholder when
@@ -285,7 +287,7 @@ Match content to the right layout:
285
287
  | Content type | Suggested layout patterns |
286
288
  |---|---|
287
289
  | Opening / title | front-page layouts (with pattern or picture) |
288
- | Agenda / TOC | agenda layouts |
290
+ | Agenda / TOC | agenda layouts (use plain text items without numbered prefixes — the layout often auto-numbers) |
289
291
  | Section divider | breaker layouts |
290
292
  | Single topic with bullets | title-and-content |
291
293
  | Side-by-side comparison | two-contents |
@@ -323,7 +325,7 @@ pptx validate --manifest ./manifest-dir --deck deck.pptx --strict --format json
323
325
 
324
326
  # Diagrams
325
327
  excal validate diagram.excalidraw
326
- excal render diagram.excalidraw --outDir ./out --png --scale 2 --no-background
328
+ excal render diagram.excalidraw --outDir ./out --png --scale 4 --no-background
327
329
 
328
330
  # Template versioning
329
331
  pptx manifest diff ./v1-manifest ./v2-manifest --format json
@@ -18,7 +18,7 @@ excal validate diagram.excalidraw --check-assets
18
18
  ### excal render <file|->
19
19
  Render to SVG, PNG, or PDF. PNG/PDF require Playwright.
20
20
  ```bash
21
- excal render diagram.excalidraw --outDir ./out --png --scale 2 --no-background
21
+ excal render diagram.excalidraw --outDir ./out --png --scale 4 --no-background
22
22
  excal render diagram.excalidraw --outDir ./out --svg
23
23
  excal render diagram.excalidraw --outDir ./out --png --dark-mode
24
24
  excal render diagram.excalidraw --outDir ./out --png --frame "Frame Name"
@@ -31,7 +31,7 @@ Flags:
31
31
  - `--pdf` — Export PDF (requires Playwright)
32
32
  - `--dark-mode` — Dark theme
33
33
  - `--no-background` — Transparent background
34
- - `--scale <n>` — Scale factor for PNG (default: 2)
34
+ - `--scale <n>` — Scale factor for PNG (default: 2, use 4 for slide-quality output)
35
35
  - `--padding <n>` — Padding in pixels (default: 20)
36
36
  - `--frame <id|name>` — Export specific frame only
37
37
  - `--element <id>` — Export specific element only
@@ -58,9 +58,18 @@ Key fields per placeholder:
58
58
  - `supported_content_types` — Array of: text, markdown-text, image, table, chart
59
59
  - `required` — Boolean
60
60
  - `overflow_policy` — fit, warn, or truncate
61
- - `text_defaults` — Object with suggested_font_size_pt, max_lines, alignment
61
+ - `estimated_text_capacity` — Preferred agent guidance with `max_lines`,
62
+ `confidence`, `source`, and inferred typography metadata
63
+ - `text_defaults` — Raw extracted placeholder hints with fields such as
64
+ `suggested_font_size_pt`, `max_lines`, and `alignment`
62
65
  - `left_emu`, `top_emu`, `width_emu`, `height_emu` — Position/size in EMUs
63
66
 
67
+ Treat placeholder geometry as a hard layout constraint:
68
+ - Text should stay within the likely line budget for the box.
69
+ - Images and diagrams should match the placeholder aspect ratio.
70
+ - Tables and charts should be simplified or moved to a larger layout when the
71
+ placeholder is too small for legible output.
72
+
64
73
  ### pptx theme show --manifest <dir>
65
74
  Show theme metadata: colors, fonts, effects.
66
75
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pptx-cli
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Template-bound PowerPoint generation for enterprise decks
5
5
  Author: Thomas Rohde
6
6
  License: MIT License
@@ -81,6 +81,7 @@ The result is a CLI that behaves more like a compiler toolchain than a drawing t
81
81
 
82
82
  - Initialize a manifest package from a real enterprise `.pptx`
83
83
  - Inspect layouts, placeholders, themes, assets, and compatibility warnings
84
+ - Estimate text placeholder line capacity for agent guidance during inspection
84
85
  - Build slides from approved layouts only
85
86
  - Build full decks from JSON/YAML specs
86
87
  - Preserve template-bound masters, themes, geometry, and protected elements
@@ -139,6 +140,10 @@ pptx layouts show title-only --manifest ./corp-template
139
140
  pptx placeholders list 1-title-and-content --manifest ./corp-template
140
141
  ```
141
142
 
143
+ `pptx placeholders list --format json` includes both explicit placeholder guidance and
144
+ an `estimated_text_capacity` block for text-capable placeholders so agents can stay
145
+ inside likely line limits when drafting slide content.
146
+
142
147
  Create a single slide:
143
148
 
144
149
  ```bash
@@ -35,6 +35,7 @@ The result is a CLI that behaves more like a compiler toolchain than a drawing t
35
35
 
36
36
  - Initialize a manifest package from a real enterprise `.pptx`
37
37
  - Inspect layouts, placeholders, themes, assets, and compatibility warnings
38
+ - Estimate text placeholder line capacity for agent guidance during inspection
38
39
  - Build slides from approved layouts only
39
40
  - Build full decks from JSON/YAML specs
40
41
  - Preserve template-bound masters, themes, geometry, and protected elements
@@ -93,6 +94,10 @@ pptx layouts show title-only --manifest ./corp-template
93
94
  pptx placeholders list 1-title-and-content --manifest ./corp-template
94
95
  ```
95
96
 
97
+ `pptx placeholders list --format json` includes both explicit placeholder guidance and
98
+ an `estimated_text_capacity` block for text-capable placeholders so agents can stay
99
+ inside likely line limits when drafting slide content.
100
+
96
101
  Create a single slide:
97
102
 
98
103
  ```bash
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "1.1.0"
5
+ __version__ = "1.2.0"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
+ import math
4
5
  import re
5
6
  import shutil
6
7
  import zipfile
@@ -29,13 +30,18 @@ from pptx_cli.models.manifest import (
29
30
  PlaceholderContract,
30
31
  ProtectedElement,
31
32
  TemplateInfo,
33
+ TextCapacityGuidance,
32
34
  ThemeModel,
33
35
  )
34
36
 
35
37
  _DRAWINGML_NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
38
+ _PRESENTATIONML_NS = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
39
+ _PPTX_NS = {**_DRAWINGML_NS, **_PRESENTATIONML_NS}
36
40
  _PLACEHOLDER_TYPE_NAMES = {item.value: item.name.lower() for item in PP_PLACEHOLDER}
37
41
  _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".tif", ".tiff"}
38
42
  _MEDIA_SUFFIXES = {".mp4", ".wmv", ".avi", ".mov", ".mp3", ".wav", ".m4v"}
43
+ _EMU_PER_POINT = 12700
44
+ _DEFAULT_LINE_HEIGHT_MULTIPLIER = 1.2
39
45
  _NUMBER_WORDS = {
40
46
  "one": 1,
41
47
  "two": 2,
@@ -172,6 +178,188 @@ def _extract_text_defaults(shape: Any) -> dict[str, Any]:
172
178
  return {key: value for key, value in defaults.items() if value not in (None, [], "")}
173
179
 
174
180
 
181
+ def _emu_to_points(value: int) -> float:
182
+ return round(value / _EMU_PER_POINT, 2)
183
+
184
+
185
+ def _placeholder_text_style_bucket(placeholder_type: str) -> str:
186
+ if placeholder_type in {"title", "center_title", "vertical_title"}:
187
+ return "title"
188
+ if placeholder_type in {"body", "object", "content", "text", "subtitle"}:
189
+ return "body"
190
+ return "other"
191
+
192
+
193
+ def _extract_master_text_styles(master: Any) -> dict[str, dict[int, float]]:
194
+ styles: dict[str, dict[int, float]] = {}
195
+ for bucket, tag_name in {
196
+ "title": "titleStyle",
197
+ "body": "bodyStyle",
198
+ "other": "otherStyle",
199
+ }.items():
200
+ style_root = master.element.find(f".//p:{tag_name}", namespaces=_PPTX_NS)
201
+ if style_root is None:
202
+ styles[bucket] = {}
203
+ continue
204
+
205
+ level_sizes: dict[int, float] = {}
206
+ default_size_pt: float | None = None
207
+ default_paragraph = style_root.find("./a:defPPr", namespaces=_PPTX_NS)
208
+ if default_paragraph is not None:
209
+ default_run = default_paragraph.find("./a:defRPr", namespaces=_PPTX_NS)
210
+ default_size_pt = _font_size_from_xml(default_run)
211
+
212
+ for level in range(1, 10):
213
+ paragraph_style = style_root.find(f"./a:lvl{level}pPr", namespaces=_PPTX_NS)
214
+ if paragraph_style is None:
215
+ continue
216
+ default_run = paragraph_style.find("./a:defRPr", namespaces=_PPTX_NS)
217
+ font_size_pt = _font_size_from_xml(default_run)
218
+ if font_size_pt is not None:
219
+ level_sizes[level - 1] = font_size_pt
220
+
221
+ if default_size_pt is not None:
222
+ level_sizes.setdefault(0, default_size_pt)
223
+ styles[bucket] = level_sizes
224
+
225
+ return styles
226
+
227
+
228
+ def _font_size_from_xml(element: Any) -> float | None:
229
+ if element is None:
230
+ return None
231
+ size = element.get("sz")
232
+ if size is None:
233
+ return None
234
+ try:
235
+ return int(size) / 100
236
+ except ValueError:
237
+ return None
238
+
239
+
240
+ def _resolve_font_size_pt(
241
+ shape: Any,
242
+ placeholder_type: str,
243
+ text_defaults: dict[str, Any],
244
+ master_text_styles: dict[str, dict[int, float]],
245
+ ) -> tuple[float | None, str | None]:
246
+ suggested_size = text_defaults.get("suggested_font_size_pt")
247
+ if suggested_size is not None:
248
+ return float(suggested_size), "guidance_text"
249
+
250
+ text_frame = getattr(shape, "text_frame", None)
251
+ if text_frame is not None:
252
+ for paragraph in text_frame.paragraphs:
253
+ if paragraph.font.size is not None:
254
+ return float(paragraph.font.size.pt), "paragraph_font"
255
+ for run in paragraph.runs:
256
+ if run.font.size is not None:
257
+ return float(run.font.size.pt), "run_font"
258
+
259
+ bucket = _placeholder_text_style_bucket(placeholder_type)
260
+ text_level = 0
261
+ if text_frame is not None and text_frame.paragraphs:
262
+ text_level = int(text_frame.paragraphs[0].level or 0)
263
+ bucket_styles = master_text_styles.get(bucket, {})
264
+ font_size_pt = bucket_styles.get(text_level)
265
+ if font_size_pt is not None:
266
+ return float(font_size_pt), "master_text_style"
267
+ if bucket != "other":
268
+ font_size_pt = master_text_styles.get("other", {}).get(text_level)
269
+ if font_size_pt is not None:
270
+ return float(font_size_pt), "master_text_style"
271
+ return None, None
272
+
273
+
274
+ def _resolve_font_family(
275
+ shape: Any,
276
+ placeholder_type: str,
277
+ text_defaults: dict[str, Any],
278
+ theme: ThemeModel,
279
+ ) -> tuple[str | None, str | None]:
280
+ suggested_family = text_defaults.get("suggested_font_family")
281
+ if suggested_family is not None:
282
+ return str(suggested_family), "guidance_text"
283
+
284
+ text_frame = getattr(shape, "text_frame", None)
285
+ if text_frame is not None:
286
+ for paragraph in text_frame.paragraphs:
287
+ if paragraph.font.name:
288
+ return str(paragraph.font.name), "paragraph_font"
289
+ for run in paragraph.runs:
290
+ if run.font.name:
291
+ return str(run.font.name), "run_font"
292
+
293
+ bucket = _placeholder_text_style_bucket(placeholder_type)
294
+ if bucket == "title":
295
+ theme_family = theme.fonts.get("major") or theme.fonts.get("major_latin")
296
+ else:
297
+ theme_family = theme.fonts.get("minor") or theme.fonts.get("minor_latin")
298
+ if theme_family is None:
299
+ theme_family = theme.fonts.get("major") or theme.fonts.get("minor")
300
+ if theme_family is None:
301
+ return None, None
302
+ return theme_family, "theme"
303
+
304
+
305
+ def _estimate_text_capacity(
306
+ shape: Any,
307
+ placeholder_type: str,
308
+ supported_content_types: list[str],
309
+ text_defaults: dict[str, Any],
310
+ master_text_styles: dict[str, dict[int, float]],
311
+ theme: ThemeModel,
312
+ ) -> TextCapacityGuidance | None:
313
+ if "text" not in supported_content_types and "markdown-text" not in supported_content_types:
314
+ return None
315
+
316
+ text_frame = getattr(shape, "text_frame", None)
317
+ if text_frame is None:
318
+ return None
319
+
320
+ font_size_pt, font_size_source = _resolve_font_size_pt(
321
+ shape,
322
+ placeholder_type,
323
+ text_defaults,
324
+ master_text_styles,
325
+ )
326
+ if font_size_pt is None:
327
+ return None
328
+
329
+ font_family, _ = _resolve_font_family(shape, placeholder_type, text_defaults, theme)
330
+ margin_top = int(text_frame.margin_top or 0)
331
+ margin_bottom = int(text_frame.margin_bottom or 0)
332
+ usable_height_emu = max(int(shape.height) - margin_top - margin_bottom, 0)
333
+ usable_height_pt = _emu_to_points(usable_height_emu)
334
+ if usable_height_pt <= 0:
335
+ return None
336
+
337
+ line_height_pt = round(font_size_pt * _DEFAULT_LINE_HEIGHT_MULTIPLIER, 2)
338
+ explicit_max_lines = text_defaults.get("max_lines")
339
+ if explicit_max_lines is not None:
340
+ return TextCapacityGuidance(
341
+ max_lines=int(explicit_max_lines),
342
+ source="explicit_guidance",
343
+ confidence="high",
344
+ font_size_pt=round(font_size_pt, 2),
345
+ font_family=font_family,
346
+ usable_height_pt=usable_height_pt,
347
+ line_height_pt=line_height_pt,
348
+ )
349
+
350
+ inferred_lines = max(1, math.floor(usable_height_pt / line_height_pt))
351
+ confidence = "medium" if font_size_source is not None else "low"
352
+ return TextCapacityGuidance(
353
+ max_lines=inferred_lines,
354
+ source="inferred",
355
+ confidence=confidence,
356
+ font_size_pt=round(font_size_pt, 2),
357
+ font_family=font_family,
358
+ usable_height_pt=usable_height_pt,
359
+ line_height_pt=line_height_pt,
360
+ )
361
+
362
+
175
363
  def _extract_theme(zip_file: zipfile.ZipFile) -> ThemeModel:
176
364
  theme_candidates = [
177
365
  name
@@ -237,6 +425,7 @@ def _is_protected_placeholder(shape_name: str) -> bool:
237
425
 
238
426
  def _build_layouts(
239
427
  prs: Any,
428
+ theme: ThemeModel,
240
429
  ) -> tuple[list[MasterContract], list[LayoutContract], list[LayoutAnnotation]]:
241
430
  master_ids: list[MasterContract] = []
242
431
  layouts: list[LayoutContract] = []
@@ -244,9 +433,11 @@ def _build_layouts(
244
433
  layout_ids_seen: set[str] = set()
245
434
 
246
435
  master_id_map: dict[int, str] = {}
436
+ master_style_map: dict[int, dict[str, dict[int, float]]] = {}
247
437
  for master_index, master in enumerate(prs.slide_masters):
248
438
  master_id = f"master-{master_index + 1}"
249
439
  master_id_map[id(master)] = master_id
440
+ master_style_map[id(master)] = _extract_master_text_styles(master)
250
441
  master_ids.append(
251
442
  MasterContract(
252
443
  id=master_id,
@@ -286,6 +477,15 @@ def _build_layouts(
286
477
  logical_names_seen,
287
478
  )
288
479
  supported_content_types = _supports_content_types(placeholder_type, shape.name)
480
+ text_defaults = _extract_text_defaults(shape)
481
+ estimated_text_capacity = _estimate_text_capacity(
482
+ shape,
483
+ placeholder_type,
484
+ supported_content_types,
485
+ text_defaults,
486
+ master_style_map.get(id(layout.slide_master), {}),
487
+ theme,
488
+ )
289
489
  placeholders.append(
290
490
  PlaceholderContract(
291
491
  logical_name=logical_name,
@@ -300,7 +500,8 @@ def _build_layouts(
300
500
  width_emu=int(shape.width),
301
501
  height_emu=int(shape.height),
302
502
  required=logical_name == "title",
303
- text_defaults=_extract_text_defaults(shape),
503
+ text_defaults=text_defaults,
504
+ estimated_text_capacity=estimated_text_capacity,
304
505
  inheritance_chain=[master_id, layout_id],
305
506
  )
306
507
  )
@@ -511,7 +712,7 @@ def build_manifest_package(
511
712
  prs = Presentation(str(template))
512
713
  with zipfile.ZipFile(template) as zip_file:
513
714
  theme = _extract_theme(zip_file)
514
- masters, layouts, annotations = _build_layouts(prs)
715
+ masters, layouts, annotations = _build_layouts(prs, theme)
515
716
  assets = _copy_template_and_assets(template, output_dir)
516
717
  findings = _compatibility_findings(template)
517
718
  has_errors = any(item.severity == "error" for item in findings)
@@ -40,6 +40,16 @@ class ProtectedElement(BaseModel):
40
40
  fingerprint: str
41
41
 
42
42
 
43
+ class TextCapacityGuidance(BaseModel):
44
+ max_lines: int
45
+ source: Literal["explicit_guidance", "inferred"] = "inferred"
46
+ confidence: Literal["high", "medium", "low"] = "medium"
47
+ font_size_pt: float | None = None
48
+ font_family: str | None = None
49
+ usable_height_pt: float | None = None
50
+ line_height_pt: float | None = None
51
+
52
+
43
53
  class PlaceholderContract(BaseModel):
44
54
  logical_name: str
45
55
  source_name: str
@@ -55,6 +65,7 @@ class PlaceholderContract(BaseModel):
55
65
  required: bool = False
56
66
  overflow_policy: Literal["fit", "warn", "truncate"] = "warn"
57
67
  text_defaults: dict[str, Any] = Field(default_factory=dict)
68
+ estimated_text_capacity: TextCapacityGuidance | None = None
58
69
  inheritance_chain: list[str] = Field(default_factory=list)
59
70
  allowed_formatting_overrides: list[str] = Field(default_factory=list)
60
71
 
@@ -164,6 +164,29 @@ def test_inspection_commands_use_manifest_contract(
164
164
  assert title_placeholder["text_defaults"]["max_lines"] == 2
165
165
  assert title_placeholder["text_defaults"]["suggested_font_size_pt"] == 24.0
166
166
  assert title_placeholder["text_defaults"]["suggested_font_family"] == "DB Regular"
167
+ assert title_placeholder["estimated_text_capacity"]["max_lines"] == 2
168
+ assert title_placeholder["estimated_text_capacity"]["source"] == "explicit_guidance"
169
+ assert title_placeholder["estimated_text_capacity"]["confidence"] == "high"
170
+ assert title_placeholder["estimated_text_capacity"]["font_size_pt"] == 24.0
171
+
172
+ subtitle_placeholder = next(
173
+ placeholder for placeholder in placeholders if placeholder["logical_name"] == "subtitle"
174
+ )
175
+ assert subtitle_placeholder["estimated_text_capacity"]["max_lines"] == 1
176
+ assert subtitle_placeholder["estimated_text_capacity"]["source"] == "inferred"
177
+ assert subtitle_placeholder["estimated_text_capacity"]["confidence"] == "medium"
178
+
179
+ content_layout_payload = _invoke_json(
180
+ ["placeholders", "list", "1-title-and-content", "--manifest", str(manifest_dir)]
181
+ )
182
+ content_placeholder = next(
183
+ placeholder
184
+ for placeholder in content_layout_payload["result"]["placeholders"]
185
+ if placeholder["logical_name"] == "content_1"
186
+ )
187
+ assert content_placeholder["estimated_text_capacity"]["max_lines"] == 20
188
+ assert content_placeholder["estimated_text_capacity"]["source"] == "inferred"
189
+ assert content_placeholder["estimated_text_capacity"]["font_size_pt"] == 14.0
167
190
 
168
191
  theme_payload = _invoke_json(["theme", "show", "--manifest", str(manifest_dir)])
169
192
  assert "fonts" in theme_payload["result"]
@@ -567,6 +590,10 @@ def test_deck_build_validate_schema_and_diff(
567
590
  schema_payload = _invoke_json(["manifest", "schema"])
568
591
  assert "template" in schema_payload["result"]["properties"]
569
592
  assert "$defs" in schema_payload["result"]
593
+ assert (
594
+ "estimated_text_capacity"
595
+ in schema_payload["result"]["$defs"]["PlaceholderContract"]["properties"]
596
+ )
570
597
 
571
598
  diff_payload = _invoke_json(["manifest", "diff", str(manifest_dir), str(manifest_dir)])
572
599
  assert diff_payload["result"]["breaking_changes"] == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes