pptx-cli 1.2.5__tar.gz → 1.3.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 (77) hide show
  1. pptx_cli-1.3.0/.claude/settings.local.json +7 -0
  2. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/skills/pptx/SKILL.md +4 -0
  3. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/skills/pptx-deck-builder/SKILL.md +7 -1
  4. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/PKG-INFO +16 -1
  5. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/PRD.md +11 -0
  6. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/README.md +15 -0
  7. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/__init__.py +1 -1
  8. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/cli.py +47 -4
  9. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/compose.py +47 -5
  10. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/guide.py +18 -2
  11. pptx_cli-1.3.0/src/pptx_cli/commands/schema.py +224 -0
  12. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/composition.py +40 -2
  13. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/models/manifest.py +1 -0
  14. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/tests/test_cli.py +176 -0
  15. pptx_cli-1.3.0/tests/test_schema.py +80 -0
  16. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.editorconfig +0 -0
  17. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  20. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/copilot-instructions.md +0 -0
  22. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/instructions/backend.instructions.md +0 -0
  23. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/instructions/testing.instructions.md +0 -0
  24. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/skills/pptx/references/deck-spec.md +0 -0
  25. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/skills/pptx-deck-builder/references/excal-diagrams.md +0 -0
  26. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/skills/pptx-deck-builder/references/mckinsey-style.md +0 -0
  27. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/skills/pptx-deck-builder/references/pptx-workflow.md +0 -0
  28. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/workflows/ci.yml +0 -0
  29. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  30. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.gitignore +0 -0
  31. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/.python-version +0 -0
  32. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/AGENTS.md +0 -0
  33. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/ARCHITECTURE.md +0 -0
  34. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/CHANGELOG.md +0 -0
  35. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/CLI-MANIFEST.md +0 -0
  36. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/CONTRIBUTING.md +0 -0
  37. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/DECISIONS/ADR-0001-initial-architecture.md +0 -0
  38. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/LICENSE +0 -0
  39. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/PREVIEW-FUTURE.md +0 -0
  40. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/PROJECT.md +0 -0
  41. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/SCAFFOLD.md +0 -0
  42. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/SECURITY.md +0 -0
  43. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/TESTING.md +0 -0
  44. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/docs/DOMAIN.md +0 -0
  45. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/docs/GLOSSARY.md +0 -0
  46. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/docs/ROADMAP.md +0 -0
  47. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/docs/SCAFFOLDING-NOTES.md +0 -0
  48. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/guardrails/.gitignore +0 -0
  49. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/guardrails/guardrails.jsonl +0 -0
  50. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/guardrails/links.jsonl +0 -0
  51. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/guardrails/references.jsonl +0 -0
  52. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/guardrails/taxonomy.json +0 -0
  53. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/guardrails-explorer.html +0 -0
  54. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/pyproject.toml +0 -0
  55. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/pyrightconfig.json +0 -0
  56. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/scripts/bump_version.py +0 -0
  57. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/__main__.py +0 -0
  58. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/__init__.py +0 -0
  59. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/init.py +0 -0
  60. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/inspect.py +0 -0
  61. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/manifest_ops.py +0 -0
  62. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/validate.py +0 -0
  63. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/commands/wrapper.py +0 -0
  64. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/__init__.py +0 -0
  65. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/ids.py +0 -0
  66. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/io.py +0 -0
  67. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/manifest_store.py +0 -0
  68. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/markdown.py +0 -0
  69. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/runtime.py +0 -0
  70. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/template.py +0 -0
  71. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/validation.py +0 -0
  72. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/core/versioning.py +0 -0
  73. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/models/__init__.py +0 -0
  74. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/src/pptx_cli/models/envelope.py +0 -0
  75. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/tests/conftest.py +0 -0
  76. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/tests/test_versioning.py +0 -0
  77. {pptx_cli-1.2.5 → pptx_cli-1.3.0}/uv.lock +0 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run pyright src/pptx_cli/commands/schema.py tests/test_schema.py)"
5
+ ]
6
+ }
7
+ }
@@ -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.3.0
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.3.0"
@@ -20,6 +20,7 @@ from pptx_cli.commands.inspect import (
20
20
  show_theme,
21
21
  )
22
22
  from pptx_cli.commands.manifest_ops import manifest_diff, manifest_schema
23
+ from pptx_cli.commands.schema import build_schema_document, copy_to_clipboard
23
24
  from pptx_cli.commands.validate import validate_command
24
25
  from pptx_cli.commands.wrapper import wrapper_generate
25
26
  from pptx_cli.core.composition import CompositionError
@@ -30,8 +31,8 @@ from pptx_cli.models.envelope import CliMessage, Envelope, Metrics
30
31
  app = typer.Typer(
31
32
  help=(
32
33
  "Template-bound PowerPoint generation for enterprise decks. Supports text, "
33
- "images, tables, charts, and markdown-text content in template-approved "
34
- "placeholders."
34
+ "images, tables, charts, markdown-text content in template-approved "
35
+ "placeholders, and optional per-slide speaker notes."
35
36
  ),
36
37
  no_args_is_help=True,
37
38
  )
@@ -41,7 +42,10 @@ theme_app = typer.Typer(help="Inspect extracted theme metadata.")
41
42
  assets_app = typer.Typer(help="Inspect extracted asset references.")
42
43
  slide_app = typer.Typer(help="Create slides from approved layouts.")
43
44
  deck_app = typer.Typer(
44
- help="Build full decks from structured specs, including markdown-text content."
45
+ help=(
46
+ "Build full decks from structured specs, including markdown-text content "
47
+ "and optional per-slide speaker notes."
48
+ )
45
49
  )
46
50
  manifest_app = typer.Typer(help="Work with manifest packages and schemas.")
47
51
  wrapper_app = typer.Typer(help="Generate thin template-specific wrapper CLIs.")
@@ -235,6 +239,27 @@ def guide(format: FormatOption = None) -> None:
235
239
  execute("guide.show", format, lambda: build_guide_document().model_dump(mode="json"))
236
240
 
237
241
 
242
+ @app.command("schema")
243
+ def schema_command(
244
+ template: Annotated[
245
+ Path | None,
246
+ typer.Option("--template", help="Path to a manifest package directory."),
247
+ ] = None,
248
+ no_copy: Annotated[
249
+ bool,
250
+ typer.Option("--no-copy", help="Skip copying output to the clipboard."),
251
+ ] = False,
252
+ ) -> None:
253
+ """Print the deck-spec YAML reference (for pasting into LLM prompts)."""
254
+ text = build_schema_document(template)
255
+ typer.echo(text)
256
+ if not no_copy:
257
+ if copy_to_clipboard(text):
258
+ typer.echo("(copied to clipboard)", err=True)
259
+ else:
260
+ typer.echo("(clipboard copy failed – install xclip or pipe manually)", err=True)
261
+
262
+
238
263
  @app.command("init")
239
264
  def init_command(
240
265
  template: Annotated[Path, typer.Argument(help="Path to the source .pptx template")],
@@ -338,6 +363,20 @@ def slide_create_command(
338
363
  ),
339
364
  ),
340
365
  ] = None,
366
+ notes: Annotated[
367
+ str | None,
368
+ typer.Option(
369
+ "--notes",
370
+ help="Speaker notes text for the slide. Markdown-looking multiline text is supported.",
371
+ ),
372
+ ] = None,
373
+ notes_file: Annotated[
374
+ Path | None,
375
+ typer.Option(
376
+ "--notes-file",
377
+ help="Path to a UTF-8 text or markdown file to use as speaker notes.",
378
+ ),
379
+ ] = None,
341
380
  dry_run: DryRunOption = False,
342
381
  overwrite: OverwriteOption = False,
343
382
  format: FormatOption = None,
@@ -346,6 +385,7 @@ def slide_create_command(
346
385
 
347
386
  Use --set key=@notes.md or a multiline markdown-looking value to populate
348
387
  markdown-text placeholders with headings, lists, and inline emphasis.
388
+ Use --notes or --notes-file for slide-level speaker notes.
349
389
  """
350
390
 
351
391
  execute(
@@ -356,6 +396,8 @@ def slide_create_command(
356
396
  layout,
357
397
  list(set_values or []),
358
398
  out,
399
+ notes=notes,
400
+ notes_file=notes_file,
359
401
  dry_run=dry_run,
360
402
  overwrite=overwrite,
361
403
  ),
@@ -383,7 +425,8 @@ def deck_build_command(
383
425
  """Build a deck from a structured spec.
384
426
 
385
427
  Deck specs can provide markdown-text content explicitly or rely on multiline
386
- markdown-looking strings for headings, lists, and inline emphasis.
428
+ markdown-looking strings for headings, lists, and inline emphasis. Each slide
429
+ may also provide an optional `notes` field for speaker notes.
387
430
  """
388
431
 
389
432
  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
@@ -17,6 +17,16 @@ def build_guide_document() -> GuideDocument:
17
17
  mutates=False,
18
18
  examples=["pptx guide --format json"],
19
19
  ),
20
+ GuideCommand(
21
+ id="schema.show",
22
+ summary="Print the deck-spec YAML reference for pasting into LLM prompts",
23
+ mutates=False,
24
+ examples=[
25
+ "pptx schema",
26
+ "pptx schema --template ./corp-template",
27
+ "pptx schema --no-copy",
28
+ ],
29
+ ),
20
30
  GuideCommand(
21
31
  id="template.init",
22
32
  summary="Initialize a manifest package from a source template",
@@ -64,12 +74,15 @@ def build_guide_document() -> GuideDocument:
64
74
  ),
65
75
  GuideCommand(
66
76
  id="slide.create",
67
- summary="Create a slide from an approved layout",
77
+ summary="Create a slide from an approved layout with optional speaker notes",
68
78
  mutates=True,
69
79
  input_schema=DeckSpec.model_json_schema(),
70
80
  examples=[
71
81
  "pptx slide create --manifest ./corp-template --layout title-only "
72
82
  "--set title=Hello --out ./out/slide.pptx --dry-run",
83
+ "pptx slide create --manifest ./corp-template --layout title-only "
84
+ "--set title=Hello --notes-file ./speaker-notes.md "
85
+ "--out ./out/slide-with-notes.pptx",
73
86
  "pptx slide create --manifest ./corp-template "
74
87
  "--layout 3-front-page-title-and-picture "
75
88
  "--set title=Workflow --set picture=@out/workflow.png "
@@ -78,7 +91,7 @@ def build_guide_document() -> GuideDocument:
78
91
  ),
79
92
  GuideCommand(
80
93
  id="deck.build",
81
- summary="Build a deck from a structured spec",
94
+ summary="Build a deck from a structured spec with optional per-slide speaker notes",
82
95
  mutates=True,
83
96
  input_schema=DeckSpec.model_json_schema(),
84
97
  examples=[
@@ -151,6 +164,9 @@ def build_guide_document() -> GuideDocument:
151
164
  "placeholder_keys": (
152
165
  "logical placeholder keys such as title, subtitle, content_1, or picture"
153
166
  ),
167
+ "slide_notes": (
168
+ "optional per-slide speaker notes via SlideSpec.notes or slide create --notes"
169
+ ),
154
170
  "manifest_path": "path to a manifest package directory containing manifest.yaml",
155
171
  },
156
172
  concurrency={
@@ -0,0 +1,224 @@
1
+ """Build a human-readable YAML reference for deck specs.
2
+
3
+ Designed to be pasted into LLM prompts so the model knows how to
4
+ author valid ``pptx deck build`` input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ from pptx_cli.core.manifest_store import load_effective_manifest
17
+ from pptx_cli.models.manifest import LayoutContract, ManifestDocument
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Clipboard helper
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def copy_to_clipboard(text: str) -> bool:
24
+ """Copy *text* to the system clipboard. Returns True on success."""
25
+ if sys.platform == "win32":
26
+ cmd = ["clip"]
27
+ elif sys.platform == "darwin":
28
+ cmd = ["pbcopy"]
29
+ else:
30
+ cmd = ["xclip", "-selection", "clipboard"]
31
+ try:
32
+ subprocess.run(
33
+ cmd,
34
+ input=text.encode("utf-8"),
35
+ check=True,
36
+ timeout=5,
37
+ )
38
+ except (FileNotFoundError, subprocess.SubprocessError):
39
+ return False
40
+ return True
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Generic (template-free) schema
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _GENERIC_SCHEMA = """\
48
+ <slide-schema>
49
+ Use this schema to draft presentation content. Layout assignment and
50
+ template binding happen later, so focus on the slides themselves.
51
+
52
+ metadata:
53
+ title: "<deck title>"
54
+ author: "<author name>"
55
+
56
+ slides:
57
+ - title: "Slide heading"
58
+ body: |
59
+ - Bullet one
60
+ - Bullet two
61
+ notes: |
62
+ Talk track for this slide.
63
+
64
+ - title: "Data slide"
65
+ body: |
66
+ Key observations from the quarter.
67
+ table:
68
+ kind: table
69
+ columns: [Col A, Col B]
70
+ rows:
71
+ - [val1, val2]
72
+ - [val3, val4]
73
+ notes: Additional context.
74
+
75
+ - title: "Trend slide"
76
+ body: |
77
+ Revenue grew 50% year-over-year.
78
+ chart:
79
+ kind: chart
80
+ chart_type: column_clustered
81
+ categories: [Q1, Q2, Q3]
82
+ series:
83
+ - name: Revenue
84
+ values: [12, 15, 18]
85
+
86
+ - title: "Visual slide"
87
+ image:
88
+ kind: image
89
+ path: path/to/image.png
90
+ image_fit: fit
91
+ </slide-schema>
92
+
93
+ <content-rules>
94
+ - Each slide MUST have a "title".
95
+ - Use "body" for the main text area. Markdown is auto-detected
96
+ (headings, bullets, **bold**, *italic*).
97
+ - Use "table", "chart", or "image" keys for structured content.
98
+ chart_type accepts: column_clustered, bar_clustered, line, pie.
99
+ image_fit accepts: "fit" (default) or "cover".
100
+ - "notes" is optional speaker-notes text (markdown ok).
101
+ - Do NOT include a "layout" key – layout is assigned later when
102
+ binding to a corporate template.
103
+ </content-rules>
104
+
105
+ <style-guide>
106
+ Follow these principles to produce decision-grade slides.
107
+
108
+ Structure
109
+ - Lead with the answer. Do not build up to the conclusion.
110
+ - Pyramid Principle: governing thought -> supporting arguments -> evidence.
111
+ - Arguments must be MECE (mutually exclusive, collectively exhaustive).
112
+ - One slide = one message. Two insights -> two slides.
113
+
114
+ Titles
115
+ - Use action titles, not topic labels. The title states the takeaway.
116
+ Bad: "Market overview"
117
+ Good: "Nordic retail banking margins will remain under pressure through 2027"
118
+ - Title storyline reads on its own – skim only titles and understand the
119
+ full argument.
120
+ - Formulas:
121
+ Insight: "[What happened] because [driver]"
122
+ Comparison: "[A] outperforms [B] on [criterion]"
123
+ Implication: "[Fact] puts [objective] at risk"
124
+ Recommendation: "[Org] should [action] to achieve [outcome]"
125
+
126
+ Executive summary slide
127
+ - Situation: what context everyone agrees on.
128
+ - Complication: what changed or created urgency.
129
+ - Answer: the recommended response.
130
+ - Support: 2-3 reasons the answer is correct.
131
+
132
+ Deck sequence (typical)
133
+ 1. Title page
134
+ 2. Executive summary (situation -> complication -> resolution)
135
+ 3. Context / problem framing (only enough to orient)
136
+ 4. Analysis body (current state -> root causes -> options -> recommendation)
137
+ 5. Recommendation (explicit, decision-ready)
138
+ 6. Implementation / roadmap
139
+ 7. Risks and mitigations
140
+ 8. Appendix (source data, benchmarks, methodology)
141
+
142
+ Body content
143
+ - Every claim backed by evidence. The body proves the headline.
144
+ - Quantify. Replace vague adjectives with measured claims.
145
+ - Keep text tight: verbs, short bullets, no filler.
146
+ - Move backup detail to appendix – main story stays focused.
147
+ </style-guide>
148
+ """
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Template-enriched schema
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def _placeholder_summary(ph: Any) -> dict[str, Any]:
156
+ """Compact summary of a placeholder contract."""
157
+ entry: dict[str, Any] = {
158
+ "types": ph.supported_content_types,
159
+ }
160
+ if ph.required:
161
+ entry["required"] = True
162
+ if ph.guidance_text:
163
+ entry["guidance"] = ph.guidance_text
164
+ cap = ph.estimated_text_capacity
165
+ if cap is not None:
166
+ entry["max_lines"] = cap.max_lines
167
+ return entry
168
+
169
+
170
+ def _layout_section(layout: LayoutContract) -> dict[str, Any]:
171
+ """Build a dict describing one layout for the reference doc."""
172
+ section: dict[str, Any] = {}
173
+ if layout.description:
174
+ section["description"] = layout.description
175
+ if layout.aliases:
176
+ section["aliases"] = layout.aliases
177
+ placeholders: dict[str, Any] = {}
178
+ for ph in layout.placeholders:
179
+ placeholders[ph.logical_name] = _placeholder_summary(ph)
180
+ section["placeholders"] = placeholders
181
+ return section
182
+
183
+
184
+ def _example_slide(layout: LayoutContract) -> dict[str, Any]:
185
+ """Generate an example slide entry using real placeholder names."""
186
+ content: dict[str, str] = {}
187
+ for ph in layout.placeholders:
188
+ if "image" in ph.supported_content_types and "text" not in ph.supported_content_types:
189
+ content[ph.logical_name] = "{ kind: image, path: path/to/image.png }"
190
+ else:
191
+ content[ph.logical_name] = f"<{ph.logical_name} text>"
192
+ slide: dict[str, Any] = {"layout": layout.id, "content": content}
193
+ return slide
194
+
195
+
196
+ def _build_template_section(manifest: ManifestDocument) -> str:
197
+ """Return the template-specific portion of the reference doc."""
198
+ layouts_ref: dict[str, Any] = {}
199
+ examples: list[dict[str, Any]] = []
200
+
201
+ for layout in manifest.layouts:
202
+ layouts_ref[layout.id] = _layout_section(layout)
203
+ examples.append(_example_slide(layout))
204
+
205
+ doc: dict[str, Any] = {
206
+ "template": manifest.template.name,
207
+ "layouts": layouts_ref,
208
+ "example_slides": examples,
209
+ }
210
+ return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=120)
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Public API
215
+ # ---------------------------------------------------------------------------
216
+
217
+ def build_schema_document(template_dir: Path | None = None) -> str:
218
+ """Return the full reference document as a string."""
219
+ parts = [_GENERIC_SCHEMA]
220
+ if template_dir is not None:
221
+ manifest = load_effective_manifest(template_dir)
222
+ parts.append("\n# ── Template-specific layouts ──────────────────────────────────\n")
223
+ parts.append(_build_template_section(manifest))
224
+ return "\n".join(parts)
@@ -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,
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ from typer.testing import CliRunner
6
+
7
+ from pptx_cli.cli import app
8
+ from pptx_cli.commands.schema import build_schema_document
9
+
10
+ runner = CliRunner()
11
+
12
+
13
+ def test_generic_schema_contains_deck_structure() -> None:
14
+ text = build_schema_document()
15
+ assert "<slide-schema>" in text
16
+ assert "metadata:" in text
17
+ assert "slides:" in text
18
+ assert "title:" in text
19
+ assert "body:" in text
20
+ assert "notes:" in text
21
+
22
+
23
+ def test_generic_schema_documents_content_types() -> None:
24
+ text = build_schema_document()
25
+ assert "kind: image" in text
26
+ assert "kind: table" in text
27
+ assert "kind: chart" in text
28
+
29
+
30
+ def test_generic_schema_uses_xml_sections() -> None:
31
+ text = build_schema_document()
32
+ assert "<slide-schema>" in text
33
+ assert "</slide-schema>" in text
34
+ assert "<content-rules>" in text
35
+ assert "</content-rules>" in text
36
+ assert "<style-guide>" in text
37
+ assert "</style-guide>" in text
38
+
39
+
40
+ def test_generic_schema_omits_layout() -> None:
41
+ text = build_schema_document()
42
+ assert "Do NOT include" in text
43
+ assert "layout is assigned later" in text
44
+
45
+
46
+ def test_schema_command_outputs_text(tmp_path: str) -> None:
47
+ result = runner.invoke(app, ["schema", "--no-copy"])
48
+ assert result.exit_code == 0
49
+ assert "metadata:" in result.stdout
50
+ assert "slides:" in result.stdout
51
+
52
+
53
+ def test_schema_command_copies_to_clipboard() -> None:
54
+ with patch("pptx_cli.cli.copy_to_clipboard", return_value=True) as mock_copy:
55
+ result = runner.invoke(app, ["schema"])
56
+ assert result.exit_code == 0
57
+ mock_copy.assert_called_once()
58
+ assert "copied to clipboard" in result.stderr
59
+
60
+
61
+ def test_schema_command_no_copy_skips_clipboard() -> None:
62
+ with patch("pptx_cli.cli.copy_to_clipboard") as mock_copy:
63
+ result = runner.invoke(app, ["schema", "--no-copy"])
64
+ assert result.exit_code == 0
65
+ mock_copy.assert_not_called()
66
+
67
+
68
+ def test_schema_with_template(template_path: str, manifest_dir: str) -> None:
69
+ # First init a manifest so we can point --template at it
70
+ init_result = runner.invoke(
71
+ app,
72
+ ["init", str(template_path), "--out", str(manifest_dir), "--format", "json"],
73
+ )
74
+ assert init_result.exit_code == 0
75
+
76
+ result = runner.invoke(app, ["schema", "--template", str(manifest_dir), "--no-copy"])
77
+ assert result.exit_code == 0
78
+ assert "template:" in result.stdout
79
+ assert "layouts:" in result.stdout
80
+ assert "placeholders:" in result.stdout
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