fcp-slides 0.1.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 (42) hide show
  1. fcp_slides-0.1.0/.github/workflows/ci.yml +34 -0
  2. fcp_slides-0.1.0/.github/workflows/release.yml +22 -0
  3. fcp_slides-0.1.0/.gitignore +9 -0
  4. fcp_slides-0.1.0/CLAUDE.md +45 -0
  5. fcp_slides-0.1.0/LICENSE +21 -0
  6. fcp_slides-0.1.0/PKG-INFO +13 -0
  7. fcp_slides-0.1.0/README.md +99 -0
  8. fcp_slides-0.1.0/pyproject.toml +37 -0
  9. fcp_slides-0.1.0/src/fcp_slides/__init__.py +0 -0
  10. fcp_slides-0.1.0/src/fcp_slides/__main__.py +3 -0
  11. fcp_slides-0.1.0/src/fcp_slides/adapter.py +149 -0
  12. fcp_slides-0.1.0/src/fcp_slides/lib/__init__.py +0 -0
  13. fcp_slides-0.1.0/src/fcp_slides/lib/chart_types.py +40 -0
  14. fcp_slides-0.1.0/src/fcp_slides/lib/colors.py +57 -0
  15. fcp_slides-0.1.0/src/fcp_slides/lib/layout_names.py +72 -0
  16. fcp_slides-0.1.0/src/fcp_slides/lib/shape_types.py +70 -0
  17. fcp_slides-0.1.0/src/fcp_slides/lib/units.py +72 -0
  18. fcp_slides-0.1.0/src/fcp_slides/main.py +28 -0
  19. fcp_slides-0.1.0/src/fcp_slides/model/__init__.py +0 -0
  20. fcp_slides-0.1.0/src/fcp_slides/model/index.py +259 -0
  21. fcp_slides-0.1.0/src/fcp_slides/model/refs.py +20 -0
  22. fcp_slides-0.1.0/src/fcp_slides/model/snapshot.py +58 -0
  23. fcp_slides-0.1.0/src/fcp_slides/server/__init__.py +0 -0
  24. fcp_slides-0.1.0/src/fcp_slides/server/ops_charts.py +334 -0
  25. fcp_slides-0.1.0/src/fcp_slides/server/ops_images.py +176 -0
  26. fcp_slides-0.1.0/src/fcp_slides/server/ops_layout.py +204 -0
  27. fcp_slides-0.1.0/src/fcp_slides/server/ops_notes.py +144 -0
  28. fcp_slides-0.1.0/src/fcp_slides/server/ops_shapes.py +345 -0
  29. fcp_slides-0.1.0/src/fcp_slides/server/ops_slides.py +288 -0
  30. fcp_slides-0.1.0/src/fcp_slides/server/ops_style.py +188 -0
  31. fcp_slides-0.1.0/src/fcp_slides/server/ops_tables.py +383 -0
  32. fcp_slides-0.1.0/src/fcp_slides/server/ops_text.py +275 -0
  33. fcp_slides-0.1.0/src/fcp_slides/server/queries.py +279 -0
  34. fcp_slides-0.1.0/src/fcp_slides/server/reference_card.py +88 -0
  35. fcp_slides-0.1.0/src/fcp_slides/server/resolvers.py +124 -0
  36. fcp_slides-0.1.0/src/fcp_slides/server/verb_registry.py +138 -0
  37. fcp_slides-0.1.0/tests/__init__.py +0 -0
  38. fcp_slides-0.1.0/tests/test_adapter.py +381 -0
  39. fcp_slides-0.1.0/tests/test_colors.py +36 -0
  40. fcp_slides-0.1.0/tests/test_model.py +154 -0
  41. fcp_slides-0.1.0/tests/test_units.py +51 -0
  42. fcp_slides-0.1.0/uv.lock +1436 -0
@@ -0,0 +1,34 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Check for local path references
15
+ run: |
16
+ if grep -v '^\s*#' pyproject.toml | grep -q 'path\s*='; then
17
+ echo "::error::pyproject.toml contains uncommented local path references that break CI installs"
18
+ exit 1
19
+ fi
20
+
21
+ test:
22
+ needs: lint
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ matrix:
26
+ python-version: ['3.11', '3.12', '3.13']
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: astral-sh/setup-uv@v5
30
+ - uses: actions/setup-python@v5
31
+ with:
32
+ python-version: ${{ matrix.python-version }}
33
+ - run: uv sync --extra dev
34
+ - run: uv run pytest
@@ -0,0 +1,22 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v5
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: '3.12'
19
+ - run: uv sync --extra dev
20
+ - run: uv run pytest
21
+ - run: uv build
22
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .DS_Store
8
+ .pytest_cache/
9
+ .ruff_cache/
@@ -0,0 +1,45 @@
1
+ # fcp-slides
2
+
3
+ ## Project Overview
4
+ MCP server that lets LLMs create and edit PowerPoint presentations through a semantic verb DSL.
5
+ Uses python-pptx as the native library (Tier 2 architecture).
6
+
7
+ ## Architecture
8
+ - `src/fcp_slides/model/` — Thin wrapper around python-pptx Presentation, slide/shape index, shape refs
9
+ - `src/fcp_slides/server/` — Verb handlers (ops_*.py), queries, verb registry, resolvers
10
+ - `src/fcp_slides/lib/` — Unit conversion, color palette, shape types, layout names, chart types
11
+ - `src/fcp_slides/adapter.py` — FcpDomainAdapter bridging fcp-core to python-pptx
12
+ - `src/fcp_slides/main.py` — Server entry point
13
+
14
+ ## Key Patterns
15
+ - Each `ops_*.py` exports a `HANDLERS` dict mapping verb names to handler functions
16
+ - The adapter merges all HANDLERS at import time for dispatch
17
+ - `queries.py` exports `QUERY_HANDLERS` for query dispatch
18
+ - Undo/redo: byte snapshots via `prs.save(BytesIO)` / `Presentation(BytesIO)`
19
+ - Batch atomicity: pre-batch snapshot, rollback on any op failure
20
+ - Positions/sizes use EMU internally, lib/units.py converts from `2in`/`5cm`
21
+ - Shapes and slides referenced by labels (auto-assigned or user-specified)
22
+
23
+ ## python-pptx Gotchas
24
+ - Slide removal requires XML manipulation (`_sldIdLst`)
25
+ - Slide copy is partial — uses XML deepcopy
26
+ - Slide hide/unhide via XML attribute (`show="0"`)
27
+ - Chart data is set at creation; modifications use `chart.replace_data()`
28
+ - Positions use EMUs (914400/inch)
29
+
30
+ ## Commands
31
+ - `uv run pytest` — Run tests (70 tests)
32
+ - `uv run python -c "from fcp_slides.main import main"` — Verify import
33
+
34
+ ## Verb Categories
35
+ | Category | Verbs |
36
+ |----------|-------|
37
+ | Slides | slide (add/remove/rename/copy/move/hide/unhide/activate) |
38
+ | Shapes | shape (add/remove/move/resize/duplicate), textbox, connector |
39
+ | Text | text (set/append/clear), placeholder (set), bullet |
40
+ | Tables | table (add/set/style/row/header/merge/remove) |
41
+ | Charts | chart (add/data/series/axis/remove) |
42
+ | Images | image (add/placeholder/remove) |
43
+ | Layout | align, distribute, z-order |
44
+ | Style | style (fill/outline/shadow/rotation), text-style (font/size/color/bold) |
45
+ | Meta | notes (set/append/clear), deck (widescreen/standard/size) |
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Scott Meyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: fcp-slides
3
+ Version: 0.1.0
4
+ Summary: Presentation File Context Protocol — semantic slide operations for LLMs
5
+ License-File: LICENSE
6
+ Requires-Python: <3.14,>=3.11
7
+ Requires-Dist: fastmcp>=3.0
8
+ Requires-Dist: fcp-core>=0.1.14
9
+ Requires-Dist: python-pptx>=1.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pyright>=1.1; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: ruff>=0.4; extra == 'dev'
@@ -0,0 +1,99 @@
1
+ # fcp-slides
2
+
3
+ MCP server for semantic presentation operations.
4
+
5
+ ## What It Does
6
+
7
+ fcp-slides lets LLMs create and edit PowerPoint presentations by describing slide intent -- layouts, shapes, tables, charts, text styling -- and renders it into standard `.pptx` files. Instead of writing python-pptx code, the LLM works with operations like `slide add layout:title`, `placeholder set title "Q4 Report"`, and `table add 5 4 label:metrics`. Built on the [FCP](https://github.com/aetherwing-io/fcp) framework, powered by python-pptx for serialization.
8
+
9
+ ## Quick Example
10
+
11
+ ```
12
+ slides_session('new "Q4 Review"')
13
+
14
+ slides([
15
+ 'slide add layout:title',
16
+ 'placeholder set title "Q4 Business Review"',
17
+ 'placeholder set subtitle "Prepared by Finance Team"',
18
+ 'slide add layout:blank',
19
+ 'table add 5 4 label:metrics x:1in y:1.5in w:8in h:4in',
20
+ 'table header metrics "Region" "Revenue" "Costs" "Margin"',
21
+ 'table set metrics 2 1 "North"',
22
+ 'table set metrics 2 2 "$1.25M"',
23
+ 'chart add bar label:revenue title:"Revenue by Region" x:1in y:1.5in w:8in h:4.5in',
24
+ 'notes set "Discuss regional performance trends"',
25
+ ])
26
+
27
+ slides_session('save as:./q4_review.pptx')
28
+ ```
29
+
30
+ ### Available MCP Tools
31
+
32
+ | Tool | Purpose |
33
+ |------|---------|
34
+ | `slides(ops)` | Batch mutations -- slides, shapes, text, tables, charts, images, styling |
35
+ | `slides_query(q)` | Inspect the presentation -- slides, shapes, placeholders, layout info |
36
+ | `slides_session(action)` | Lifecycle -- new, open, save, checkpoint, undo, redo |
37
+ | `slides_help()` | Full reference card |
38
+
39
+ ## Installation
40
+
41
+ Requires Python >= 3.11.
42
+
43
+ ```bash
44
+ pip install fcp-slides
45
+ ```
46
+
47
+ ### MCP Client Configuration
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "slides": {
53
+ "command": "uv",
54
+ "args": ["run", "python", "-m", "fcp_slides"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Architecture
61
+
62
+ 3-layer architecture:
63
+
64
+ ```
65
+ MCP Server (Intent Layer)
66
+ Parses op strings, dispatches to verb handlers
67
+ |
68
+ Semantic Model
69
+ Thin wrapper around python-pptx Presentation
70
+ Slide/shape index, shape refs, undo/redo via byte snapshots
71
+ |
72
+ Serialization (python-pptx)
73
+ Semantic model -> .pptx binary output
74
+ ```
75
+
76
+ Key features:
77
+
78
+ - **Slide layouts** -- Title, blank, content, section header, and custom layouts
79
+ - **Shapes** -- Rectangles, ellipses, arrows, textboxes, connectors
80
+ - **Tables** -- Create, populate, style with headers and merged cells
81
+ - **Charts** -- Bar, line, pie, scatter, area, doughnut, and more
82
+ - **Images** -- Add from file or set as slide background
83
+ - **Text styling** -- Font, size, color, bold, italic, alignment, spacing
84
+ - **Layout ops** -- Align, distribute, z-order for shape arrangement
85
+ - **Undo/redo** -- Full presentation snapshots with event sourcing
86
+ - **Keynote-compatible** -- Standard PPTX output opens in Keynote, Google Slides, LibreOffice
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ uv sync
92
+ uv run pytest # 70 tests
93
+ uv run ruff check # linting
94
+ uv run pyright # type checking
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "fcp-slides"
3
+ version = "0.1.0"
4
+ description = "Presentation File Context Protocol — semantic slide operations for LLMs"
5
+ requires-python = ">=3.11,<3.14"
6
+ dependencies = [
7
+ "fcp-core>=0.1.14",
8
+ "fastmcp>=3.0",
9
+ "python-pptx>=1.0",
10
+ ]
11
+
12
+ # [tool.uv.sources]
13
+ # For local development, uncomment below:
14
+ # fcp-core = { path = "../fcp-core/python", editable = true }
15
+
16
+ [project.scripts]
17
+ fcp-slides = "fcp_slides.main:main"
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "ruff>=0.4",
23
+ "pyright>=1.1",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/fcp_slides"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+
36
+ [tool.ruff]
37
+ src = ["src"]
File without changes
@@ -0,0 +1,3 @@
1
+ from fcp_slides.main import main
2
+
3
+ main()
@@ -0,0 +1,149 @@
1
+ """SlidesAdapter — FcpDomainAdapter implementation for python-pptx presentations.
2
+
3
+ Bridges fcp-core to python-pptx via SlidesModel (thin wrapper for in-place
4
+ undo/redo). Handles batch atomicity and snapshot-based undo/redo.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pptx import Presentation
10
+
11
+ from fcp_core import EventLog, OpResult, ParsedOp
12
+
13
+ from fcp_slides.lib.units import format_length
14
+ from fcp_slides.model.index import SlideIndex
15
+ from fcp_slides.model.snapshot import SlidesModel, SnapshotEvent
16
+ from fcp_slides.server.queries import dispatch_query
17
+ from fcp_slides.server.resolvers import SlidesOpContext
18
+
19
+ # Import all handler dicts
20
+ from fcp_slides.server.ops_slides import HANDLERS as SLIDES_HANDLERS
21
+ from fcp_slides.server.ops_shapes import HANDLERS as SHAPES_HANDLERS
22
+ from fcp_slides.server.ops_text import HANDLERS as TEXT_HANDLERS
23
+ from fcp_slides.server.ops_tables import HANDLERS as TABLES_HANDLERS
24
+ from fcp_slides.server.ops_charts import HANDLERS as CHARTS_HANDLERS
25
+ from fcp_slides.server.ops_images import HANDLERS as IMAGES_HANDLERS
26
+ from fcp_slides.server.ops_layout import HANDLERS as LAYOUT_HANDLERS
27
+ from fcp_slides.server.ops_style import HANDLERS as STYLE_HANDLERS
28
+ from fcp_slides.server.ops_notes import HANDLERS as NOTES_HANDLERS
29
+
30
+ # Max snapshot events in undo history
31
+ MAX_EVENTS = 15
32
+
33
+
34
+ class SlidesAdapter:
35
+ """FcpDomainAdapter[SlidesModel, SnapshotEvent] for presentation operations."""
36
+
37
+ def __init__(self) -> None:
38
+ self.index = SlideIndex()
39
+
40
+ # Merge all verb handlers
41
+ self._handlers: dict[str, callable] = {}
42
+ for h in (
43
+ SLIDES_HANDLERS, SHAPES_HANDLERS, TEXT_HANDLERS,
44
+ TABLES_HANDLERS, CHARTS_HANDLERS, IMAGES_HANDLERS,
45
+ LAYOUT_HANDLERS, STYLE_HANDLERS, NOTES_HANDLERS,
46
+ ):
47
+ self._handlers.update(h)
48
+
49
+ # -- FcpDomainAdapter protocol --
50
+
51
+ def create_empty(self, title: str, params: dict[str, str]) -> SlidesModel:
52
+ """Create a new empty presentation."""
53
+ prs = Presentation()
54
+ model = SlidesModel(title=title, prs=prs)
55
+ self.index.clear()
56
+ return model
57
+
58
+ def serialize(self, model: SlidesModel, path: str) -> None:
59
+ """Save presentation to file."""
60
+ model.prs.save(path)
61
+ model.file_path = path
62
+
63
+ def deserialize(self, path: str) -> SlidesModel:
64
+ """Load presentation from file."""
65
+ prs = Presentation(path)
66
+ # Extract title from path
67
+ title = path.rsplit("/", 1)[-1]
68
+ model = SlidesModel(title=title, prs=prs)
69
+ model.file_path = path
70
+ self.index.rebuild(model)
71
+ return model
72
+
73
+ def rebuild_indices(self, model: SlidesModel) -> None:
74
+ """Rebuild index after undo/redo."""
75
+ self.index.rebuild(model)
76
+
77
+ def get_digest(self, model: SlidesModel) -> str:
78
+ """Return a compact state fingerprint."""
79
+ prs = model.prs
80
+ slide_count = len(prs.slides)
81
+ shape_count = sum(len(list(s.shapes)) for s in prs.slides)
82
+ active = self.index.active_slide + 1
83
+ size = ""
84
+ if prs.slide_width and prs.slide_height:
85
+ size = f", Size: {format_length(prs.slide_width)}x{format_length(prs.slide_height)}"
86
+ return f"Active: slide {active}, Slides: {slide_count}, Shapes: {shape_count}{size}"
87
+
88
+ def dispatch_op(
89
+ self, op: ParsedOp, model: SlidesModel, log: EventLog
90
+ ) -> OpResult:
91
+ """Execute a parsed operation on the model."""
92
+ handler = self._handlers.get(op.verb)
93
+ if handler is None:
94
+ from fcp_core import suggest
95
+ s = suggest(op.verb, list(self._handlers.keys()))
96
+ msg = f"Unknown verb: {op.verb!r}"
97
+ if s:
98
+ msg += f"\n try: {s}"
99
+ return OpResult(success=False, message=msg)
100
+
101
+ # Take pre-op snapshot
102
+ before = model.snapshot()
103
+
104
+ # Build context
105
+ ctx = SlidesOpContext(
106
+ prs=model.prs,
107
+ index=self.index,
108
+ model=model,
109
+ )
110
+
111
+ # Dispatch
112
+ try:
113
+ result = handler(op, ctx)
114
+ except NotImplementedError as exc:
115
+ return OpResult(success=False, message=str(exc))
116
+ except (ValueError, KeyError, TypeError, AttributeError) as exc:
117
+ return OpResult(success=False, message=f"Error: {exc}")
118
+
119
+ if not result.success:
120
+ return result
121
+
122
+ # Log snapshot for undo
123
+ after = model.snapshot()
124
+ log.append(SnapshotEvent(before=before, after=after, summary=op.raw))
125
+
126
+ return result
127
+
128
+ def take_snapshot(self, model: SlidesModel) -> bytes:
129
+ """Return byte snapshot for batch rollback."""
130
+ return model.snapshot()
131
+
132
+ def restore_snapshot(self, model: SlidesModel, snapshot: bytes) -> None:
133
+ """Restore model from snapshot and rebuild indices."""
134
+ model.restore(snapshot)
135
+ self.rebuild_indices(model)
136
+
137
+ def dispatch_query(self, query: str, model: SlidesModel) -> str:
138
+ """Execute a query against the model."""
139
+ return dispatch_query(query, model, self.index)
140
+
141
+ def reverse_event(self, event: SnapshotEvent, model: SlidesModel) -> None:
142
+ """Undo — restore from before-snapshot."""
143
+ model.restore(event.before)
144
+ self.index.rebuild(model)
145
+
146
+ def replay_event(self, event: SnapshotEvent, model: SlidesModel) -> None:
147
+ """Redo — restore from after-snapshot."""
148
+ model.restore(event.after)
149
+ self.index.rebuild(model)
File without changes
@@ -0,0 +1,40 @@
1
+ """Chart type mapping for presentation charts.
2
+
3
+ Maps friendly names to python-pptx chart type constants.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pptx.enum.chart import XL_CHART_TYPE
9
+
10
+ # Friendly name → XL_CHART_TYPE constant
11
+ CHART_TYPES: dict[str, int] = {
12
+ "column": XL_CHART_TYPE.COLUMN_CLUSTERED,
13
+ "column-stacked": XL_CHART_TYPE.COLUMN_STACKED,
14
+ "column-100": XL_CHART_TYPE.COLUMN_STACKED_100,
15
+ "bar": XL_CHART_TYPE.BAR_CLUSTERED,
16
+ "bar-stacked": XL_CHART_TYPE.BAR_STACKED,
17
+ "bar-100": XL_CHART_TYPE.BAR_STACKED_100,
18
+ "line": XL_CHART_TYPE.LINE,
19
+ "line-markers": XL_CHART_TYPE.LINE_MARKERS,
20
+ "line-stacked": XL_CHART_TYPE.LINE_STACKED,
21
+ "pie": XL_CHART_TYPE.PIE,
22
+ "pie-exploded": XL_CHART_TYPE.PIE_EXPLODED,
23
+ "doughnut": XL_CHART_TYPE.DOUGHNUT,
24
+ "area": XL_CHART_TYPE.AREA,
25
+ "area-stacked": XL_CHART_TYPE.AREA_STACKED,
26
+ "scatter": XL_CHART_TYPE.XY_SCATTER,
27
+ "scatter-lines": XL_CHART_TYPE.XY_SCATTER_LINES,
28
+ "radar": XL_CHART_TYPE.RADAR,
29
+ "radar-filled": XL_CHART_TYPE.RADAR_FILLED,
30
+ }
31
+
32
+
33
+ def resolve_chart_type(name: str) -> int | None:
34
+ """Resolve a friendly chart type name to XL_CHART_TYPE constant."""
35
+ return CHART_TYPES.get(name.lower())
36
+
37
+
38
+ def list_chart_types() -> list[str]:
39
+ """Return sorted list of available chart type names."""
40
+ return sorted(CHART_TYPES.keys())
@@ -0,0 +1,57 @@
1
+ """Named color palette and hex parsing for presentation formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pptx.util import Pt
6
+ from pptx.dml.color import RGBColor
7
+
8
+ # Standard Office/PowerPoint color palette
9
+ NAMED_COLORS: dict[str, str] = {
10
+ "blue": "4472C4",
11
+ "orange": "ED7D31",
12
+ "gray": "A5A5A5",
13
+ "gold": "FFC000",
14
+ "lt-blue": "5B9BD5",
15
+ "green": "70AD47",
16
+ "red": "FF0000",
17
+ "dk-green": "00B050",
18
+ "white": "FFFFFF",
19
+ "black": "000000",
20
+ "yellow": "FFFF00",
21
+ "purple": "7030A0",
22
+ "teal": "00B0F0",
23
+ "dk-blue": "002060",
24
+ "dk-red": "C00000",
25
+ "lt-gray": "D9D9D9",
26
+ "dk-gray": "404040",
27
+ }
28
+
29
+
30
+ def parse_color(color_str: str) -> str:
31
+ """Parse a color string to a 6-char hex value (no #).
32
+
33
+ Accepts:
34
+ - Named colors: "blue", "red"
35
+ - Hex with #: "#4472C4"
36
+ - Hex without #: "4472C4"
37
+ - 3-char shorthand: "F0F" → "FF00FF"
38
+ """
39
+ name = color_str.lower().strip()
40
+ if name in NAMED_COLORS:
41
+ return NAMED_COLORS[name]
42
+
43
+ hex_str = color_str.lstrip("#").strip()
44
+
45
+ if len(hex_str) == 6 and all(c in "0123456789ABCDEFabcdef" for c in hex_str):
46
+ return hex_str.upper()
47
+
48
+ if len(hex_str) == 3 and all(c in "0123456789ABCDEFabcdef" for c in hex_str):
49
+ return "".join(c + c for c in hex_str).upper()
50
+
51
+ raise ValueError(f"Invalid color: {color_str!r}")
52
+
53
+
54
+ def to_rgb(color_str: str) -> RGBColor:
55
+ """Parse a color string directly to an RGBColor object."""
56
+ hex_str = parse_color(color_str)
57
+ return RGBColor.from_string(hex_str)
@@ -0,0 +1,72 @@
1
+ """Slide layout name resolution.
2
+
3
+ Maps friendly names to slide layout indices in standard PowerPoint templates.
4
+ Falls back to fuzzy matching against the actual layout names in the presentation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from difflib import SequenceMatcher
10
+
11
+ # Friendly name → common layout name in standard templates
12
+ LAYOUT_ALIASES: dict[str, str] = {
13
+ "title": "Title Slide",
14
+ "title-slide": "Title Slide",
15
+ "title-content": "Title and Content",
16
+ "section": "Section Header",
17
+ "section-header": "Section Header",
18
+ "two-content": "Two Content",
19
+ "comparison": "Comparison",
20
+ "title-only": "Title Only",
21
+ "blank": "Blank",
22
+ "content-caption": "Content with Caption",
23
+ "picture-caption": "Picture with Caption",
24
+ }
25
+
26
+
27
+ def resolve_layout(name: str, prs) -> "SlideLayout | None":
28
+ """Resolve a layout name to a SlideLayout object.
29
+
30
+ Tries in order:
31
+ 1. Friendly alias (e.g., "title" → "Title Slide")
32
+ 2. Exact match against actual layout names
33
+ 3. Case-insensitive match
34
+ 4. Fuzzy match (>0.6 similarity)
35
+
36
+ Returns None if no match found.
37
+ """
38
+ # Try alias first
39
+ alias = LAYOUT_ALIASES.get(name.lower())
40
+ target = alias or name
41
+
42
+ layouts = prs.slide_layouts
43
+
44
+ # Exact match
45
+ for layout in layouts:
46
+ if layout.name == target:
47
+ return layout
48
+
49
+ # Case-insensitive match
50
+ target_lower = target.lower()
51
+ for layout in layouts:
52
+ if layout.name.lower() == target_lower:
53
+ return layout
54
+
55
+ # Fuzzy match
56
+ best_match = None
57
+ best_score = 0.0
58
+ for layout in layouts:
59
+ score = SequenceMatcher(None, target_lower, layout.name.lower()).ratio()
60
+ if score > best_score:
61
+ best_score = score
62
+ best_match = layout
63
+
64
+ if best_match and best_score > 0.6:
65
+ return best_match
66
+
67
+ return None
68
+
69
+
70
+ def list_layouts(prs) -> list[str]:
71
+ """Return list of available layout names in the presentation."""
72
+ return [layout.name for layout in prs.slide_layouts]