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.
- fcp_slides-0.1.0/.github/workflows/ci.yml +34 -0
- fcp_slides-0.1.0/.github/workflows/release.yml +22 -0
- fcp_slides-0.1.0/.gitignore +9 -0
- fcp_slides-0.1.0/CLAUDE.md +45 -0
- fcp_slides-0.1.0/LICENSE +21 -0
- fcp_slides-0.1.0/PKG-INFO +13 -0
- fcp_slides-0.1.0/README.md +99 -0
- fcp_slides-0.1.0/pyproject.toml +37 -0
- fcp_slides-0.1.0/src/fcp_slides/__init__.py +0 -0
- fcp_slides-0.1.0/src/fcp_slides/__main__.py +3 -0
- fcp_slides-0.1.0/src/fcp_slides/adapter.py +149 -0
- fcp_slides-0.1.0/src/fcp_slides/lib/__init__.py +0 -0
- fcp_slides-0.1.0/src/fcp_slides/lib/chart_types.py +40 -0
- fcp_slides-0.1.0/src/fcp_slides/lib/colors.py +57 -0
- fcp_slides-0.1.0/src/fcp_slides/lib/layout_names.py +72 -0
- fcp_slides-0.1.0/src/fcp_slides/lib/shape_types.py +70 -0
- fcp_slides-0.1.0/src/fcp_slides/lib/units.py +72 -0
- fcp_slides-0.1.0/src/fcp_slides/main.py +28 -0
- fcp_slides-0.1.0/src/fcp_slides/model/__init__.py +0 -0
- fcp_slides-0.1.0/src/fcp_slides/model/index.py +259 -0
- fcp_slides-0.1.0/src/fcp_slides/model/refs.py +20 -0
- fcp_slides-0.1.0/src/fcp_slides/model/snapshot.py +58 -0
- fcp_slides-0.1.0/src/fcp_slides/server/__init__.py +0 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_charts.py +334 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_images.py +176 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_layout.py +204 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_notes.py +144 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_shapes.py +345 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_slides.py +288 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_style.py +188 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_tables.py +383 -0
- fcp_slides-0.1.0/src/fcp_slides/server/ops_text.py +275 -0
- fcp_slides-0.1.0/src/fcp_slides/server/queries.py +279 -0
- fcp_slides-0.1.0/src/fcp_slides/server/reference_card.py +88 -0
- fcp_slides-0.1.0/src/fcp_slides/server/resolvers.py +124 -0
- fcp_slides-0.1.0/src/fcp_slides/server/verb_registry.py +138 -0
- fcp_slides-0.1.0/tests/__init__.py +0 -0
- fcp_slides-0.1.0/tests/test_adapter.py +381 -0
- fcp_slides-0.1.0/tests/test_colors.py +36 -0
- fcp_slides-0.1.0/tests/test_model.py +154 -0
- fcp_slides-0.1.0/tests/test_units.py +51 -0
- 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,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) |
|
fcp_slides-0.1.0/LICENSE
ADDED
|
@@ -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,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]
|