pptx-cli 1.2.5__tar.gz → 1.2.6__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 (74) hide show
  1. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/skills/pptx/SKILL.md +4 -0
  2. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/skills/pptx-deck-builder/SKILL.md +7 -1
  3. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/PKG-INFO +16 -1
  4. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/PRD.md +11 -0
  5. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/README.md +15 -0
  6. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/__init__.py +1 -1
  7. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/cli.py +25 -4
  8. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/compose.py +47 -5
  9. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/guide.py +8 -2
  10. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/composition.py +40 -2
  11. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/models/manifest.py +1 -0
  12. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/tests/test_cli.py +176 -0
  13. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.editorconfig +0 -0
  14. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/copilot-instructions.md +0 -0
  19. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/instructions/backend.instructions.md +0 -0
  20. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/instructions/testing.instructions.md +0 -0
  21. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/skills/pptx/references/deck-spec.md +0 -0
  22. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/skills/pptx-deck-builder/references/excal-diagrams.md +0 -0
  23. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/skills/pptx-deck-builder/references/mckinsey-style.md +0 -0
  24. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/skills/pptx-deck-builder/references/pptx-workflow.md +0 -0
  25. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/workflows/ci.yml +0 -0
  26. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.github/workflows/publish.yml +0 -0
  27. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.gitignore +0 -0
  28. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/.python-version +0 -0
  29. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/AGENTS.md +0 -0
  30. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/ARCHITECTURE.md +0 -0
  31. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/CHANGELOG.md +0 -0
  32. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/CLI-MANIFEST.md +0 -0
  33. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/CONTRIBUTING.md +0 -0
  34. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/DECISIONS/ADR-0001-initial-architecture.md +0 -0
  35. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/LICENSE +0 -0
  36. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/PREVIEW-FUTURE.md +0 -0
  37. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/PROJECT.md +0 -0
  38. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/SCAFFOLD.md +0 -0
  39. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/SECURITY.md +0 -0
  40. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/TESTING.md +0 -0
  41. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/docs/DOMAIN.md +0 -0
  42. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/docs/GLOSSARY.md +0 -0
  43. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/docs/ROADMAP.md +0 -0
  44. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/docs/SCAFFOLDING-NOTES.md +0 -0
  45. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/guardrails/.gitignore +0 -0
  46. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/guardrails/guardrails.jsonl +0 -0
  47. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/guardrails/links.jsonl +0 -0
  48. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/guardrails/references.jsonl +0 -0
  49. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/guardrails/taxonomy.json +0 -0
  50. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/guardrails-explorer.html +0 -0
  51. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/pyproject.toml +0 -0
  52. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/pyrightconfig.json +0 -0
  53. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/scripts/bump_version.py +0 -0
  54. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/__main__.py +0 -0
  55. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/__init__.py +0 -0
  56. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/init.py +0 -0
  57. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/inspect.py +0 -0
  58. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/manifest_ops.py +0 -0
  59. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/validate.py +0 -0
  60. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/commands/wrapper.py +0 -0
  61. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/__init__.py +0 -0
  62. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/ids.py +0 -0
  63. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/io.py +0 -0
  64. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/manifest_store.py +0 -0
  65. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/markdown.py +0 -0
  66. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/runtime.py +0 -0
  67. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/template.py +0 -0
  68. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/validation.py +0 -0
  69. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/core/versioning.py +0 -0
  70. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/models/__init__.py +0 -0
  71. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/src/pptx_cli/models/envelope.py +0 -0
  72. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/tests/conftest.py +0 -0
  73. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/tests/test_versioning.py +0 -0
  74. {pptx_cli-1.2.5 → pptx_cli-1.2.6}/uv.lock +0 -0
@@ -154,6 +154,10 @@ pptx validate --manifest ./.pptx --deck ./out/deck.pptx --strict --format json
154
154
  - `chart` — chart data
155
155
  - `markdown-to-text` — markdown converted to formatted text
156
156
 
157
+ Speaker notes are supported separately from placeholder content types. Use a
158
+ slide-level `notes` field in deck specs, or `pptx slide create --notes` / `--notes-file`
159
+ for single-slide generation.
160
+
157
161
  ## Error handling
158
162
 
159
163
  All commands return a structured JSON envelope with `ok`, `errors`, and `warnings` fields. Key exit codes:
@@ -184,6 +184,11 @@ slides:
184
184
  - Bullet point one
185
185
  - Bullet point two
186
186
  source: "Source: Data attribution"
187
+ notes: |
188
+ # Speaker notes
189
+
190
+ - Open with the takeaway before reading the slide
191
+ - Keep timing to under 90 seconds
187
192
 
188
193
  - layout: picture-layout-id
189
194
  content:
@@ -202,6 +207,7 @@ slides:
202
207
  - `@notes.md` via `--set key=@notes.md` or `{ kind: "markdown-text", value:
203
208
  "..." }` → markdown parsed with headings, lists, inline emphasis, and light
204
209
  presentation-aware spacing
210
+ - Slide-level speaker notes → `notes: |` in the deck spec, or `pptx slide create --notes/--notes-file` for single-slide generation
205
211
  - `{ kind: "image", path: "path/to/file.png" }` → image insertion
206
212
  - `{ kind: "table", columns: [...], rows: [[...], ...] }` → table
207
213
  - `{ kind: "chart", chart_type: "column_clustered", categories: [...], series: [{name: "...", values: [...]}] }` → chart
@@ -399,7 +405,7 @@ pptx theme show --manifest ./manifest-dir --format json
399
405
  pptx assets list --manifest ./manifest-dir --format json
400
406
 
401
407
  # Single slide (quick test)
402
- pptx slide create --manifest ./manifest-dir --layout <id> --set title="Hello" --set content_1=@notes.md --out slide.pptx
408
+ pptx slide create --manifest ./manifest-dir --layout <id> --set title="Hello" --notes-file ./speaker-notes.md --out slide.pptx
403
409
 
404
410
  # Full deck
405
411
  pptx deck build --manifest ./manifest-dir --spec deck.yaml --out deck.pptx --format json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pptx-cli
3
- Version: 1.2.5
3
+ Version: 1.2.6
4
4
  Summary: Template-bound PowerPoint generation for enterprise decks
5
5
  Author: Thomas Rohde
6
6
  License: MIT License
@@ -155,6 +155,7 @@ pptx slide create \
155
155
  --layout title-only \
156
156
  --set title="Enterprise AI Operating Model" \
157
157
  --set subtitle="March 2026" \
158
+ --notes-file ./speaker-notes.md \
158
159
  --out ./out/operating-model-slide.pptx
159
160
  ```
160
161
 
@@ -209,6 +210,11 @@ slides:
209
210
  content:
210
211
  title: Enterprise AI Operating Model
211
212
  subtitle: March 2026
213
+ notes: |
214
+ # Opening talk track
215
+
216
+ - Lead with why preserving the template matters
217
+ - Call out that validation stays available for CI
212
218
  - layout: 1-breaker-with-pattern
213
219
  content:
214
220
  title: Why this change
@@ -281,6 +287,11 @@ corp-template/
281
287
  - chart
282
288
  - markdown-text
283
289
 
290
+ Speaker notes are also supported in v1 as optional slide-level metadata via
291
+ `slides[].notes` in deck specs or `pptx slide create --notes/--notes-file`.
292
+ They are not placeholder content types and do not change layout placeholder
293
+ contracts.
294
+
284
295
  `markdown-text` is parsed with `markdown-it-py` and currently maps CommonMark blocks into
285
296
  PowerPoint paragraphs. Headings become plain paragraphs, bullet lists use native PowerPoint
286
297
  bullet levels, ordered lists render as numbered paragraph text, and basic inline emphasis such
@@ -288,6 +299,10 @@ as bold/italic/code spans is preserved where PowerPoint run formatting can expre
288
299
  blocks also receive light presentation-aware spacing so headings, paragraphs, and lists do not
289
300
  collapse into a dense wall of text.
290
301
 
302
+ The same markdown-to-text parsing pipeline is used for speaker notes, so headings,
303
+ bullets, ordered lists, and basic inline emphasis remain available in presenter
304
+ notes without introducing a separate formatting model.
305
+
291
306
  ## Structured content objects
292
307
 
293
308
  `pptx slide create --set picture=@diagram.png` automatically normalizes the file into an
@@ -248,6 +248,7 @@ pptx slide create \
248
248
  --set title="Enterprise AI Operating Model" \
249
249
  --set left_body=@left.md \
250
250
  --set right_body=@right.md \
251
+ --notes-file speaker-notes.md \
251
252
  --out slide.pptx
252
253
  ```
253
254
 
@@ -264,6 +265,7 @@ Composition rules:
264
265
  - only declared placeholders can be filled
265
266
  - content types must match placeholder types
266
267
  - v1 supported placeholder content types are text, images, tables, charts, and markdown-to-text mappings
268
+ - speaker notes are optional per-slide metadata, not placeholder content types, and may use the same markdown-to-text formatting pipeline
267
269
  - v1 tables and charts preserve approved placeholder geometry and accept structured data population, but do not guarantee full preservation of advanced workbook internals or highly custom styling behaviors
268
270
  - static brand elements remain untouched unless explicitly marked overridable
269
271
  - theme and master dependencies must be preserved
@@ -378,6 +380,11 @@ The v1 supported placeholder content types shall include:
378
380
  - chart
379
381
  - markdown-to-text
380
382
 
383
+ The system shall also support optional per-slide speaker notes supplied either
384
+ through the structured deck spec or dedicated direct-command inputs. These notes
385
+ are slide metadata rather than placeholders and reuse the markdown-to-text
386
+ formatting pipeline for rich presenter text.
387
+
381
388
  ### FR-9: Validate output
382
389
 
383
390
  The system shall validate output decks against the manifest and return machine-friendly errors.
@@ -669,6 +676,9 @@ slides:
669
676
  - layout: section-divider
670
677
  content:
671
678
  title: Why this change
679
+ notes: |
680
+ - Pause before the transition
681
+ - Re-anchor the audience on governance and brand fidelity
672
682
  - layout: executive-two-column
673
683
  content:
674
684
  title: Core idea
@@ -1132,6 +1142,7 @@ The following product decisions are now fixed for v1:
1132
1142
  12. Preview metadata uses a single canonical preview path field per layout.
1133
1143
  13. The CLI adopts an agent-first machine contract with a structured response envelope, stable error codes, exit-code mapping, and a built-in `guide` command.
1134
1144
  14. Mutating commands support `--dry-run` and structured change summaries.
1145
+ 15. V1 supports optional per-slide speaker notes using text/markdown formatting, but notes are not part of the placeholder contract and are never required by default.
1135
1146
 
1136
1147
  ## 26. Recommendation
1137
1148
 
@@ -108,6 +108,7 @@ pptx slide create \
108
108
  --layout title-only \
109
109
  --set title="Enterprise AI Operating Model" \
110
110
  --set subtitle="March 2026" \
111
+ --notes-file ./speaker-notes.md \
111
112
  --out ./out/operating-model-slide.pptx
112
113
  ```
113
114
 
@@ -162,6 +163,11 @@ slides:
162
163
  content:
163
164
  title: Enterprise AI Operating Model
164
165
  subtitle: March 2026
166
+ notes: |
167
+ # Opening talk track
168
+
169
+ - Lead with why preserving the template matters
170
+ - Call out that validation stays available for CI
165
171
  - layout: 1-breaker-with-pattern
166
172
  content:
167
173
  title: Why this change
@@ -234,6 +240,11 @@ corp-template/
234
240
  - chart
235
241
  - markdown-text
236
242
 
243
+ Speaker notes are also supported in v1 as optional slide-level metadata via
244
+ `slides[].notes` in deck specs or `pptx slide create --notes/--notes-file`.
245
+ They are not placeholder content types and do not change layout placeholder
246
+ contracts.
247
+
237
248
  `markdown-text` is parsed with `markdown-it-py` and currently maps CommonMark blocks into
238
249
  PowerPoint paragraphs. Headings become plain paragraphs, bullet lists use native PowerPoint
239
250
  bullet levels, ordered lists render as numbered paragraph text, and basic inline emphasis such
@@ -241,6 +252,10 @@ as bold/italic/code spans is preserved where PowerPoint run formatting can expre
241
252
  blocks also receive light presentation-aware spacing so headings, paragraphs, and lists do not
242
253
  collapse into a dense wall of text.
243
254
 
255
+ The same markdown-to-text parsing pipeline is used for speaker notes, so headings,
256
+ bullets, ordered lists, and basic inline emphasis remain available in presenter
257
+ notes without introducing a separate formatting model.
258
+
244
259
  ## Structured content objects
245
260
 
246
261
  `pptx slide create --set picture=@diagram.png` automatically normalizes the file into an
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "1.2.5"
5
+ __version__ = "1.2.6"
@@ -30,8 +30,8 @@ from pptx_cli.models.envelope import CliMessage, Envelope, Metrics
30
30
  app = typer.Typer(
31
31
  help=(
32
32
  "Template-bound PowerPoint generation for enterprise decks. Supports text, "
33
- "images, tables, charts, and markdown-text content in template-approved "
34
- "placeholders."
33
+ "images, tables, charts, markdown-text content in template-approved "
34
+ "placeholders, and optional per-slide speaker notes."
35
35
  ),
36
36
  no_args_is_help=True,
37
37
  )
@@ -41,7 +41,10 @@ theme_app = typer.Typer(help="Inspect extracted theme metadata.")
41
41
  assets_app = typer.Typer(help="Inspect extracted asset references.")
42
42
  slide_app = typer.Typer(help="Create slides from approved layouts.")
43
43
  deck_app = typer.Typer(
44
- help="Build full decks from structured specs, including markdown-text content."
44
+ help=(
45
+ "Build full decks from structured specs, including markdown-text content "
46
+ "and optional per-slide speaker notes."
47
+ )
45
48
  )
46
49
  manifest_app = typer.Typer(help="Work with manifest packages and schemas.")
47
50
  wrapper_app = typer.Typer(help="Generate thin template-specific wrapper CLIs.")
@@ -338,6 +341,20 @@ def slide_create_command(
338
341
  ),
339
342
  ),
340
343
  ] = None,
344
+ notes: Annotated[
345
+ str | None,
346
+ typer.Option(
347
+ "--notes",
348
+ help="Speaker notes text for the slide. Markdown-looking multiline text is supported.",
349
+ ),
350
+ ] = None,
351
+ notes_file: Annotated[
352
+ Path | None,
353
+ typer.Option(
354
+ "--notes-file",
355
+ help="Path to a UTF-8 text or markdown file to use as speaker notes.",
356
+ ),
357
+ ] = None,
341
358
  dry_run: DryRunOption = False,
342
359
  overwrite: OverwriteOption = False,
343
360
  format: FormatOption = None,
@@ -346,6 +363,7 @@ def slide_create_command(
346
363
 
347
364
  Use --set key=@notes.md or a multiline markdown-looking value to populate
348
365
  markdown-text placeholders with headings, lists, and inline emphasis.
366
+ Use --notes or --notes-file for slide-level speaker notes.
349
367
  """
350
368
 
351
369
  execute(
@@ -356,6 +374,8 @@ def slide_create_command(
356
374
  layout,
357
375
  list(set_values or []),
358
376
  out,
377
+ notes=notes,
378
+ notes_file=notes_file,
359
379
  dry_run=dry_run,
360
380
  overwrite=overwrite,
361
381
  ),
@@ -383,7 +403,8 @@ def deck_build_command(
383
403
  """Build a deck from a structured spec.
384
404
 
385
405
  Deck specs can provide markdown-text content explicitly or rely on multiline
386
- markdown-looking strings for headings, lists, and inline emphasis.
406
+ markdown-looking strings for headings, lists, and inline emphasis. Each slide
407
+ may also provide an optional `notes` field for speaker notes.
387
408
  """
388
409
 
389
410
  execute(
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Any
5
5
 
6
6
  from pptx_cli.core.composition import (
7
+ CompositionError,
7
8
  build_presentation,
8
9
  create_single_slide_spec,
9
10
  parse_set_arguments,
@@ -19,13 +20,17 @@ def slide_create(
19
20
  set_values: list[str],
20
21
  output_path: Path,
21
22
  *,
23
+ notes: str | None,
24
+ notes_file: Path | None,
22
25
  dry_run: bool,
23
26
  overwrite: bool,
24
27
  ) -> dict[str, Any]:
25
28
  manifest = load_effective_manifest(manifest_dir)
26
29
  content = parse_set_arguments(set_values)
27
- spec = create_single_slide_spec(layout_id, content)
28
- planned_changes = [plan_output_change(output_path, overwrite=overwrite)]
30
+ resolved_notes = _resolve_notes_input(notes, notes_file)
31
+ spec = create_single_slide_spec(layout_id, content, notes=resolved_notes)
32
+ notes_changes = _plan_notes_changes(spec)
33
+ planned_changes = [plan_output_change(output_path, overwrite=overwrite), *notes_changes]
29
34
  if not dry_run:
30
35
  prs = build_presentation(manifest_dir, manifest, spec)
31
36
  save_presentation(prs, output_path, overwrite=overwrite)
@@ -36,7 +41,7 @@ def slide_create(
36
41
  "out": str(output_path),
37
42
  "overwrite": overwrite,
38
43
  "changes": planned_changes,
39
- "summary": {"slides": 1, "artifacts": 1},
44
+ "summary": {"slides": 1, "artifacts": 1, "notes_slides": len(notes_changes)},
40
45
  }
41
46
 
42
47
 
@@ -50,7 +55,8 @@ def deck_build(
50
55
  ) -> dict[str, Any]:
51
56
  manifest = load_effective_manifest(manifest_dir)
52
57
  spec = load_deck_spec(spec_path)
53
- planned_changes = [plan_output_change(output_path, overwrite=overwrite)]
58
+ notes_changes = _plan_notes_changes(spec)
59
+ planned_changes = [plan_output_change(output_path, overwrite=overwrite), *notes_changes]
54
60
  if not dry_run:
55
61
  prs = build_presentation(manifest_dir, manifest, spec)
56
62
  save_presentation(prs, output_path, overwrite=overwrite)
@@ -61,6 +67,42 @@ def deck_build(
61
67
  "out": str(output_path),
62
68
  "overwrite": overwrite,
63
69
  "changes": planned_changes,
64
- "summary": {"slides": len(spec.slides), "artifacts": 1},
70
+ "summary": {
71
+ "slides": len(spec.slides),
72
+ "artifacts": 1,
73
+ "notes_slides": len(notes_changes),
74
+ },
65
75
  "metadata": spec.metadata,
66
76
  }
77
+
78
+
79
+ def _resolve_notes_input(notes: str | None, notes_file: Path | None) -> str | None:
80
+ if notes is not None and notes_file is not None:
81
+ raise CompositionError(
82
+ "ERR_VALIDATION_INPUT",
83
+ "Use either --notes or --notes-file, not both.",
84
+ )
85
+ if notes_file is None:
86
+ return notes
87
+ if not notes_file.exists():
88
+ raise CompositionError(
89
+ "ERR_IO_NOT_FOUND",
90
+ f"Speaker notes file not found: {notes_file}",
91
+ )
92
+ return notes_file.read_text(encoding="utf-8")
93
+
94
+
95
+ def _plan_notes_changes(spec: Any) -> list[dict[str, Any]]:
96
+ changes: list[dict[str, Any]] = []
97
+ for slide_index, slide in enumerate(spec.slides, start=1):
98
+ if slide.notes is None:
99
+ continue
100
+ changes.append(
101
+ {
102
+ "target": f"slide[{slide_index}].notes",
103
+ "operation": "create",
104
+ "artifact_type": "speaker-notes",
105
+ "text_length": len(slide.notes),
106
+ }
107
+ )
108
+ return changes
@@ -64,12 +64,15 @@ def build_guide_document() -> GuideDocument:
64
64
  ),
65
65
  GuideCommand(
66
66
  id="slide.create",
67
- summary="Create a slide from an approved layout",
67
+ summary="Create a slide from an approved layout with optional speaker notes",
68
68
  mutates=True,
69
69
  input_schema=DeckSpec.model_json_schema(),
70
70
  examples=[
71
71
  "pptx slide create --manifest ./corp-template --layout title-only "
72
72
  "--set title=Hello --out ./out/slide.pptx --dry-run",
73
+ "pptx slide create --manifest ./corp-template --layout title-only "
74
+ "--set title=Hello --notes-file ./speaker-notes.md "
75
+ "--out ./out/slide-with-notes.pptx",
73
76
  "pptx slide create --manifest ./corp-template "
74
77
  "--layout 3-front-page-title-and-picture "
75
78
  "--set title=Workflow --set picture=@out/workflow.png "
@@ -78,7 +81,7 @@ def build_guide_document() -> GuideDocument:
78
81
  ),
79
82
  GuideCommand(
80
83
  id="deck.build",
81
- summary="Build a deck from a structured spec",
84
+ summary="Build a deck from a structured spec with optional per-slide speaker notes",
82
85
  mutates=True,
83
86
  input_schema=DeckSpec.model_json_schema(),
84
87
  examples=[
@@ -151,6 +154,9 @@ def build_guide_document() -> GuideDocument:
151
154
  "placeholder_keys": (
152
155
  "logical placeholder keys such as title, subtitle, content_1, or picture"
153
156
  ),
157
+ "slide_notes": (
158
+ "optional per-slide speaker notes via SlideSpec.notes or slide create --notes"
159
+ ),
154
160
  "manifest_path": "path to a manifest package directory containing manifest.yaml",
155
161
  },
156
162
  concurrency={
@@ -118,8 +118,13 @@ def _load_inline_or_file_value(raw_value: str) -> Any:
118
118
  return raw_value
119
119
 
120
120
 
121
- def create_single_slide_spec(layout: str, content: dict[str, Any]) -> DeckSpec:
122
- return DeckSpec(slides=[SlideSpec(layout=layout, content=content)])
121
+ def create_single_slide_spec(
122
+ layout: str,
123
+ content: dict[str, Any],
124
+ *,
125
+ notes: str | None = None,
126
+ ) -> DeckSpec:
127
+ return DeckSpec(slides=[SlideSpec(layout=layout, content=content, notes=notes)])
123
128
 
124
129
 
125
130
  def build_presentation(manifest_dir: Path, manifest: ManifestDocument, spec: DeckSpec) -> Any:
@@ -137,6 +142,8 @@ def build_presentation(manifest_dir: Path, manifest: ManifestDocument, spec: Dec
137
142
  slide_layout = prs.slide_layouts[layout_contract.source_layout_index]
138
143
  slide = prs.slides.add_slide(slide_layout)
139
144
  _populate_slide(slide, layout_contract, slide_spec.content)
145
+ if slide_spec.notes is not None:
146
+ _apply_slide_notes(slide, slide_spec.notes)
140
147
  except CompositionError as exc:
141
148
  raise CompositionError(
142
149
  exc.code,
@@ -302,6 +309,37 @@ def _apply_text(shape: Any, text: str, *, markdown: bool) -> None:
302
309
  paragraphs = parse_markdown_paragraphs(text) if markdown else parse_plain_text_paragraphs(text)
303
310
  list_level_offset = _first_markdown_list_level(shape) if markdown else 0
304
311
 
312
+ _write_text_frame_paragraphs(
313
+ text_frame,
314
+ paragraphs,
315
+ markdown=markdown,
316
+ list_level_offset=list_level_offset,
317
+ )
318
+
319
+
320
+ def _apply_slide_notes(slide: Any, text: str) -> None:
321
+ notes_slide = slide.notes_slide
322
+ text_frame = notes_slide.notes_text_frame
323
+ text_frame.clear()
324
+ markdown = looks_like_markdown(text)
325
+ paragraphs = parse_markdown_paragraphs(text) if markdown else parse_plain_text_paragraphs(text)
326
+
327
+ _write_text_frame_paragraphs(
328
+ text_frame,
329
+ paragraphs,
330
+ markdown=markdown,
331
+ list_level_offset=0,
332
+ )
333
+
334
+
335
+ def _write_text_frame_paragraphs(
336
+ text_frame: Any,
337
+ paragraphs: list[ParsedParagraph],
338
+ *,
339
+ markdown: bool,
340
+ list_level_offset: int,
341
+ ) -> None:
342
+
305
343
  previous: ParsedParagraph | None = None
306
344
  for index, parsed in enumerate(paragraphs):
307
345
  paragraph = text_frame.paragraphs[0] if index == 0 else text_frame.add_paragraph()
@@ -162,6 +162,7 @@ class InitReport(BaseModel):
162
162
  class SlideSpec(BaseModel):
163
163
  layout: str
164
164
  content: dict[str, Any] = Field(default_factory=dict)
165
+ notes: str | None = None
165
166
 
166
167
 
167
168
  class DeckSpec(BaseModel):
@@ -59,6 +59,11 @@ def test_guide_returns_structured_envelope_with_catalog() -> None:
59
59
  command_ids = {command["id"] for command in payload["result"]["commands"]}
60
60
  assert "layouts.list" in command_ids
61
61
  assert "deck.build" in command_ids
62
+ slide_create_command = next(
63
+ command for command in payload["result"]["commands"] if command["id"] == "slide.create"
64
+ )
65
+ slide_spec_properties = slide_create_command["input_schema"]["$defs"]["SlideSpec"]["properties"]
66
+ assert "notes" in slide_spec_properties
62
67
  assert payload["result"]["exit_codes"]["validation_error"] == 10
63
68
  assert "ERR_IO_NOT_FOUND" in payload["result"]["error_codes"]
64
69
  assert payload["result"]["content_objects"]["image"]["example"]["kind"] == "image"
@@ -257,6 +262,64 @@ def test_slide_create_and_validate_round_trip(
257
262
  assert title_shape.text_frame.vertical_anchor == template_title_shape.text_frame.vertical_anchor
258
263
 
259
264
 
265
+ def test_slide_create_supports_speaker_notes_from_markdown_file(
266
+ template_path: Path,
267
+ manifest_dir: Path,
268
+ tmp_path: Path,
269
+ ) -> None:
270
+ out_file = tmp_path / "slide-with-notes.pptx"
271
+ notes_file = tmp_path / "speaker-notes.md"
272
+ notes_file.write_text(
273
+ "# Talking points\n\n- Lead with customer outcomes\n- Close on delivery confidence\n",
274
+ encoding="utf-8",
275
+ )
276
+ _init_manifest(template_path, manifest_dir)
277
+
278
+ payload = _invoke_json(
279
+ [
280
+ "slide",
281
+ "create",
282
+ "--manifest",
283
+ str(manifest_dir),
284
+ "--layout",
285
+ "title-only",
286
+ "--set",
287
+ "title=Quarterly Update",
288
+ "--notes-file",
289
+ str(notes_file),
290
+ "--out",
291
+ str(out_file),
292
+ ]
293
+ )
294
+
295
+ assert payload["result"]["summary"]["notes_slides"] == 1
296
+ assert any(change["target"] == "slide[1].notes" for change in payload["result"]["changes"])
297
+
298
+ generated = Presentation(str(out_file))
299
+ notes_frame = generated.slides[0].notes_slide.notes_text_frame
300
+ assert notes_frame is not None
301
+ paragraphs = notes_frame.paragraphs
302
+ assert [paragraph.text for paragraph in paragraphs] == [
303
+ "Talking points",
304
+ "Lead with customer outcomes",
305
+ "Close on delivery confidence",
306
+ ]
307
+ assert paragraphs[0].runs[0].font.bold is True
308
+ assert paragraphs[1].level == 0
309
+ assert paragraphs[2].level == 0
310
+
311
+ validation = _invoke_json(
312
+ [
313
+ "validate",
314
+ "--manifest",
315
+ str(manifest_dir),
316
+ "--deck",
317
+ str(out_file),
318
+ ]
319
+ )
320
+ assert validation["result"]["ok"] is True
321
+
322
+
260
323
  def test_slide_create_picture_placeholder_uses_fit_by_default(
261
324
  template_path: Path,
262
325
  manifest_dir: Path,
@@ -409,6 +472,119 @@ def test_deck_build_supports_structured_image_table_and_chart_content(
409
472
  assert chart_shape.has_chart is True
410
473
 
411
474
 
475
+ def test_deck_build_supports_speaker_notes_and_reports_them_in_dry_run(
476
+ template_path: Path,
477
+ manifest_dir: Path,
478
+ tmp_path: Path,
479
+ ) -> None:
480
+ deck_spec = tmp_path / "speaker-notes-deck.yaml"
481
+ out_file = tmp_path / "speaker-notes-deck.pptx"
482
+ _init_manifest(template_path, manifest_dir)
483
+
484
+ deck_spec.write_text(
485
+ yaml.safe_dump(
486
+ {
487
+ "slides": [
488
+ {
489
+ "layout": "title-only",
490
+ "content": {"title": "Opening"},
491
+ },
492
+ {
493
+ "layout": "1-title-and-content",
494
+ "content": {
495
+ "title": "Operating model",
496
+ "content_1": "Preserve the template contract.",
497
+ },
498
+ "notes": "Lead with the governance angle.\n\n- Mention change control",
499
+ },
500
+ ]
501
+ },
502
+ sort_keys=False,
503
+ ),
504
+ encoding="utf-8",
505
+ )
506
+
507
+ dry_run_payload = _invoke_json(
508
+ [
509
+ "deck",
510
+ "build",
511
+ "--manifest",
512
+ str(manifest_dir),
513
+ "--spec",
514
+ str(deck_spec),
515
+ "--out",
516
+ str(out_file),
517
+ "--dry-run",
518
+ ]
519
+ )
520
+
521
+ assert dry_run_payload["result"]["summary"]["notes_slides"] == 1
522
+ assert any(
523
+ change["target"] == "slide[2].notes" and change["artifact_type"] == "speaker-notes"
524
+ for change in dry_run_payload["result"]["changes"]
525
+ )
526
+ assert out_file.exists() is False
527
+
528
+ build_payload = _invoke_json(
529
+ [
530
+ "deck",
531
+ "build",
532
+ "--manifest",
533
+ str(manifest_dir),
534
+ "--spec",
535
+ str(deck_spec),
536
+ "--out",
537
+ str(out_file),
538
+ ]
539
+ )
540
+
541
+ assert build_payload["result"]["summary"]["notes_slides"] == 1
542
+ generated = Presentation(str(out_file))
543
+ notes_frame = generated.slides[1].notes_slide.notes_text_frame
544
+ assert notes_frame is not None
545
+ assert [paragraph.text for paragraph in notes_frame.paragraphs] == [
546
+ "Lead with the governance angle.",
547
+ "Mention change control",
548
+ ]
549
+
550
+
551
+ def test_slide_create_rejects_conflicting_speaker_notes_inputs(
552
+ template_path: Path,
553
+ manifest_dir: Path,
554
+ tmp_path: Path,
555
+ ) -> None:
556
+ out_file = tmp_path / "slide-conflicting-notes.pptx"
557
+ notes_file = tmp_path / "speaker-notes.txt"
558
+ notes_file.write_text("Use one notes input only.", encoding="utf-8")
559
+ _init_manifest(template_path, manifest_dir)
560
+
561
+ result = runner.invoke(
562
+ app,
563
+ [
564
+ "slide",
565
+ "create",
566
+ "--manifest",
567
+ str(manifest_dir),
568
+ "--layout",
569
+ "title-only",
570
+ "--set",
571
+ "title=Quarterly Update",
572
+ "--notes",
573
+ "Inline notes",
574
+ "--notes-file",
575
+ str(notes_file),
576
+ "--out",
577
+ str(out_file),
578
+ "--format",
579
+ "json",
580
+ ],
581
+ )
582
+
583
+ assert result.exit_code == 10
584
+ payload = json.loads(result.stdout)
585
+ assert payload["errors"][0]["code"] == "ERR_VALIDATION_INPUT"
586
+
587
+
412
588
  def test_deck_build_auto_detects_markdown_bullets_and_preserves_text_opt_out(
413
589
  template_path: Path,
414
590
  manifest_dir: Path,
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