RFC
April 12, 2026 · View on GitHub
Status: Draft v0.1
Last updated: 2026-04-11
PRD reference: docs/prd.md
This RFC turns the product requirements into a concrete technical design. It answers "how" for every feature in PRD Phase 1. Questions marked as "open" in the PRD are resolved here.
1. Package layout
pptx-skill/
├── pyproject.toml
├── .venv/ (uv-created, gitignored)
├── README.md
├── AGENTS.md
├── .gitignore
├── docs/
│ ├── prd.md
│ ├── rfc.md (this file)
│ ├── skill.md (full skill guide for AI agents)
│ ├── working.md (changelog + lessons, empty initially)
│ └── test.md (test strategy)
├── src/
│ └── pptx_skill/
│ ├── __init__.py (re-exports public API)
│ ├── cli.py (argparse dispatcher)
│ ├── deck.py (Deck class: open/save/list/add/delete slides)
│ ├── slide.py (Slide wrapper: shape queries, notes, dump)
│ ├── shape.py (Shape wrapper: text/fill/position/font)
│ ├── units.py (parse "1.5in" / "4cm" / "100pt" → EMU)
│ ├── render.py (LibreOffice headless backend)
│ ├── fonts.py (normalize-fonts implementation)
│ ├── overflow.py (check-overflow heuristic with fonttools)
│ ├── dump.py (to-dict / to-markdown serializers)
│ ├── xml_patch.py (raw-xml-patch escape hatch)
│ └── errors.py (custom exception classes, all preserve cause)
├── scripts/
│ └── pptx-skill (thin shell wrapper around .venv/bin/pptx-skill)
└── tests/
├── fixtures/
│ ├── minimal.pptx (2-slide deck for unit tests)
│ └── sammi_v5.pptx (symlink or copy of the real deck for e2e)
├── test_deck.py
├── test_slide.py
├── test_shape.py
├── test_units.py
├── test_cli.py
├── test_render.py (marked @pytest.mark.libreoffice)
├── test_fonts.py
├── test_overflow.py
├── test_dump.py
└── test_xml_patch.py
Each module has a clear responsibility. Cross-module coupling should be minimal: cli.py depends on all the others, but the others only import units.py and errors.py among themselves.
2. Core abstractions
2.1 Deck class (deck.py)
class Deck:
def __init__(self, path: Path, pptx: Presentation):
self.path = path
self._pptx = pptx # underlying python-pptx Presentation
@classmethod
def open(cls, path: str | Path) -> "Deck": ...
def save(self, path: str | Path | None = None, *, backup: bool = True) -> None:
"""Save to `path` (default: self.path). If backup is True, write `path + '.bak'`."""
@property
def slides(self) -> list["Slide"]:
"""All slides, ordered."""
def slide(self, index: int) -> "Slide":
"""Get slide by 0-based index. Raises IndexError if out of range."""
def add_slide(self, after: int | None = None, clone_from: int | None = None) -> "Slide":
"""Insert a new slide. If clone_from is given, deep-copy that slide's shapes.
Returns the new Slide wrapper."""
def delete_slide(self, index: int) -> None: ...
def move_slide(self, from_idx: int, to_idx: int) -> None: ...
def to_dict(self) -> dict: ...
def to_markdown(self) -> str: ...
def normalize_fonts(self, en: str = "Inter", zh: str = "Noto Sans SC") -> None: ...
def check_overflow(self) -> list["OverflowReport"]: ...
def render_png(self, out_dir: Path, slide_index: int | None = None) -> list[Path]: ...
2.2 Slide class (slide.py)
class Slide:
def __init__(self, deck: Deck, index: int, pptx_slide):
self.deck = deck
self.index = index
self._slide = pptx_slide # underlying python-pptx Slide
@property
def shapes(self) -> list["Shape"]: ...
def shape(self, id: int | str) -> "Shape":
"""Find shape by shape_id (int) or name (str). Raises KeyError if not found."""
def shape_by_text(self, substring: str) -> "Shape":
"""Find the first shape whose text contains substring. Useful for AI when
it knows the content but not the id. Raises KeyError if not found."""
@property
def notes(self) -> str:
"""Speaker notes as plain text."""
def set_notes(self, text: str) -> None:
"""Replace speaker notes (before/after printed to stderr)."""
def to_dict(self) -> dict:
"""Full structured dump: shapes, positions, text, fonts, colors."""
def to_markdown(self) -> str:
"""Human-readable text summary."""
def render_png(self, out_path: Path) -> Path:
"""Render this single slide via LibreOffice."""
def raw_xml_patch(self, xpath: str, xml_fragment: str) -> None:
"""Escape hatch: apply an XML patch to this slide's .xml part."""
2.3 Shape class (shape.py)
class Shape:
def __init__(self, slide: Slide, pptx_shape):
self.slide = slide
self._shape = pptx_shape
@property
def id(self) -> int:
"""Shape's OOXML cNvPr id — stable across edits."""
@property
def name(self) -> str: ...
@property
def type(self) -> str:
"""Human-readable type string: 'rectangle', 'picture', 'text_box', 'group', ..."""
@property
def position(self) -> tuple[int, int]:
"""(left, top) in EMU."""
@property
def size(self) -> tuple[int, int]:
"""(width, height) in EMU."""
@property
def text(self) -> str: ...
def set_text(self, text: str, *, paragraph: int | None = None, run: int | None = None) -> None:
"""Replace text. If paragraph and run are both None, replace entire text frame.
If paragraph is given and run is None, replace that paragraph's entire text.
If both given, replace that specific run's text."""
def set_position(self, left: str | int, top: str | int) -> None:
"""Values are either EMU ints or unit strings like '1.5in'."""
def set_size(self, width: str | int, height: str | int) -> None: ...
def set_fill(self, color: str) -> None:
"""Set solid fill color. color is '#RRGGBB' hex string."""
def set_font(self, *, name: str | None = None, zh_name: str | None = None,
size: float | None = None, bold: bool | None = None,
italic: bool | None = None, color: str | None = None,
paragraph: int | None = None, run: int | None = None) -> None:
"""Set font properties. Only non-None kwargs are applied.
name is Latin font, zh_name is East Asian font (ea typeface in OOXML).
"""
def to_dict(self) -> dict: ...
2.4 Resolved PRD open questions
- Q1 Shape ID stability: Use OOXML
cNvPr id(shape_idin python-pptx). Fall back toslide.shapes.index(shape)if OOXML lookup fails. CLI accepts both integer (treated as shape_id) and string (treated as name) via--shape <id_or_name>. - Q2 Text granularity:
set-textdefaults to "replace entire text frame's text, collapsing to single paragraph / single run".--paragraph <i>addresses a specific paragraph (0-based),--run <j>addresses a specific run within that paragraph. Rationale: 80% of the time AI just wants to replace the visible text of a shape, not preserve multi-run formatting. - Q3 CLI library:
argparse(standard library, no new dependency). - Q4 Exit codes:
0success,1domain error (raised by our library),2argparse error,3unexpected exception (passed through with traceback). - Q5 Logging: Direct
print(..., file=sys.stderr)calls. Nologgingmodule. Rationale: each command's output format is a contract with the caller; logging adds indirection that obscures contract. - Q6 Unit parser: Hand-written regex in
units.py, supportingin,cm,mm,pt,px(wherepx = 1/96inper OOXML convention), and plain EMU integers. No new dependency.
3. CLI command shape
3.1 Global
pptx-skill [--version] [--verbose] <subcommand> [args...]
--verbose dumps extra info to stderr (e.g., show EMU values alongside unit strings).
3.2 Subcommand conventions
Every subcommand follows the same convention:
- Positional arguments:
<deck_path>is first, then<slide_index>(if command is slide-specific) - Options for targeting:
--shape <id_or_name>,--paragraph <i>,--run <j> - Options for values:
--text,--color,--left,--top,--width,--height, etc. - Options for output:
--format json|md(read commands),--out <path>(render commands),--dry-run(write commands) - Destructive commands require
--yes(currently onlydelete-slide)
3.3 Full command list
# Discovery
pptx-skill doctor [<deck>]
Reports LibreOffice availability, python-pptx version,
and if <deck> is given: slide count, fonts used, warnings.
# Read
pptx-skill list-slides <deck> [--format table|json]
pptx-skill list-shapes <deck> <slide> [--format table|json]
pptx-skill dump-slide <deck> <slide> --format json|md
pptx-skill dump-deck <deck> --format json|md
pptx-skill get-text <deck> <slide> --shape <id>
pptx-skill get-notes <deck> <slide>
# Write (basic)
pptx-skill set-text <deck> <slide> --shape <id> --text "..." [--paragraph i] [--run j] [--dry-run]
pptx-skill set-notes <deck> <slide> --text "..." [--dry-run]
pptx-skill set-position <deck> <slide> --shape <id> [--left 1in] [--top 2in] [--dry-run]
pptx-skill set-size <deck> <slide> --shape <id> [--width 4in] [--height 2in] [--dry-run]
pptx-skill set-fill <deck> <slide> --shape <id> --color "#RRGGBB" [--dry-run]
pptx-skill set-font <deck> <slide> --shape <id> [--name Inter] [--zh-name "Noto Sans SC"] \
[--size 14] [--bold true|false] [--italic true|false] [--color "#RRGGBB"] \
[--paragraph i] [--run j] [--dry-run]
# Slide ops
pptx-skill add-slide <deck> --after <slide> [--clone-from <slide>] [--dry-run]
pptx-skill delete-slide <deck> --slide <slide> --yes [--dry-run]
pptx-skill move-slide <deck> --from <i> --to <j> [--dry-run]
# Pictures
pptx-skill add-picture <deck> <slide> --file <img> --left <pos> --top <pos> [--width <size>] [--height <size>] [--dry-run]
pptx-skill replace-picture <deck> <slide> --shape <id> --file <img> [--dry-run]
# Render
pptx-skill render-slide <deck> <slide> --out <png_path>
pptx-skill render-deck <deck> --out-dir <dir>
# Font & layout
pptx-skill normalize-fonts <deck> [--en Inter] [--zh "Noto Sans SC"] [--dry-run]
pptx-skill check-overflow <deck> [--slide <slide>] [--format table|json]
# Escape hatch
pptx-skill raw-xml-patch <deck> <slide> --xpath "..." --xml-fragment "..." [--dry-run]
3.4 Output format conventions
stdout: structured or primary content only.
- Read commands print to stdout in the format requested (
--format jsonor--format table/md) - Write commands print nothing to stdout on success (all output is on stderr)
get-textandget-notesprint the requested text to stdout (raw, no decoration)render-slideprints the path of the output file to stdout
stderr: progress, warnings, before/after, errors.
- Every write command prints
[pptx-skill] <op>, thenbefore: ..., then (after write)after: ..., then[pptx-skill] saved <path> - Warnings use the
[pptx-skill WARNING]prefix - Errors are raised as exceptions and printed by the top-level
main()with traceback intact
3.5 Example session
$ pptx-skill doctor presentation.pptx
[pptx-skill] doctor
LibreOffice: /usr/local/bin/soffice (version 25.2.0.0)
python-pptx: 1.0.2
deck: 18 slides, 1 slide master
fonts: Calibri, Georgia (both may need normalization for LibreOffice render)
warnings: 0
$ pptx-skill list-slides presentation.pptx
# | layout | shapes | title
1 | DEFAULT | 19 | (no title; agenda slide)
2 | DEFAULT | 10 | PART 01 · 我们是谁
...
$ pptx-skill dump-slide presentation.pptx 0 --format json > slide1.json
$ jq '.shapes[] | select(.name == "Text 15") | .text' slide1.json
"Technical Demo · 实战案例现场演示"
$ pptx-skill set-text presentation.pptx 0 --shape "Text 15" --text "两个实战案例 · 落地方式"
[pptx-skill] set-text slide=0 shape="Text 15"
before: "Technical Demo · 实战案例现场演示"
after: "两个实战案例 · 落地方式"
[pptx-skill] saved presentation.pptx (backup: presentation.pptx.bak)
$ pptx-skill set-text presentation.pptx 0 --shape "Text 16" --text "15 min"
[pptx-skill] set-text slide=0 shape="Text 16"
before: "30 min"
after: "15 min"
[pptx-skill] saved presentation.pptx (backup: presentation.pptx.bak)
$ pptx-skill render-slide presentation.pptx 0 --out /tmp/slide1.png
[pptx-skill] render-slide slide=0
[pptx-skill WARNING] LibreOffice render may differ from PowerPoint due to font fallback
/tmp/slide1.png
The user (AI) reads the stdout (final PNG path) and knows where to find the preview. stderr is log-level and can be ignored for automation.
4. Key implementations
4.1 Units parser (units.py)
UNIT_REGEX = re.compile(r'^(-?\d+(?:\.\d+)?)\s*(in|cm|mm|pt|px|emu)?$', re.IGNORECASE)
UNIT_TO_EMU = {
'in': 914400,
'cm': 360000,
'mm': 36000,
'pt': 12700,
'px': 9525, # px = 1/96in → 914400/96
'emu': 1,
None: 1, # bare int
}
def parse_unit(value: str | int) -> int:
"""Parse a value like '1.5in' to EMU integer. Accepts plain int as EMU."""
if isinstance(value, int):
return value
m = UNIT_REGEX.match(value.strip())
if not m:
raise ValueError(f"Cannot parse unit: {value!r}")
num, unit = m.group(1), (m.group(2) or '').lower() or None
return int(float(num) * UNIT_TO_EMU[unit])
def format_unit(emu: int, unit: str = 'in') -> str:
"""Inverse: format EMU back to a human string for display."""
factor = UNIT_TO_EMU[unit]
return f"{emu / factor:.3f}{unit}"
4.2 LibreOffice render (render.py)
Render is not a "nice-to-have preview" — it is the infrastructure that closes the feedback loop for AI. Without rendering, AI operates open-loop: it can modify a deck structurally (change text, move shapes) but cannot verify the visual result. Every change would require a human to open PowerPoint, take a screenshot, and feed it back to the AI. With rendering, the AI can autonomously iterate: modify → render → inspect PNG → adjust → render again, delivering a visually verified result without human involvement in the loop.
import shutil
import subprocess
import tempfile
from pathlib import Path
def find_soffice() -> Path | None:
path = shutil.which("soffice")
if path:
return Path(path)
mac_bundle = Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")
if mac_bundle.exists():
return mac_bundle
return None
def render_deck_to_pdf(deck_path: Path, out_dir: Path) -> Path:
"""Convert the entire deck to PDF via soffice headless. Returns PDF path."""
soffice = find_soffice()
if not soffice:
raise LibreOfficeNotFoundError("soffice not found. Install with: brew install --cask libreoffice")
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
str(soffice), "--headless", "--convert-to", "pdf",
"--outdir", str(out_dir),
str(deck_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise LibreOfficeError(
f"soffice convert-to pdf failed (exit {result.returncode}):\n"
f"stderr:\n{result.stderr}\n"
f"stdout:\n{result.stdout}"
)
return out_dir / f"{deck_path.stem}.pdf"
def render_slide_png(deck_path: Path, slide_index: int, out_path: Path) -> Path:
"""
Render a specific slide to PNG.
Strategy: soffice can't directly render a single slide to PNG. We convert the
whole deck to PDF, then use Pillow + pdf2image (no, avoid that new dep) → use
pdftoppm or pillow's built-in pdf rendering via subprocess.
Actually simplest: soffice --convert-to pdf → extract page N via pdftoppm (comes
with poppler, also optional dep) → save as PNG.
Alternative: soffice --convert-to png only gives you the first slide. For multi-
slide, we use: soffice has no "export slide N to PNG" native; the cleanest path
is PDF intermediate + page extraction.
Decision: require poppler (pdftoppm) as an additional optional dep. If missing,
fall back to converting the whole deck to PNG one slide at a time using a
different approach: soffice --convert-to png exports slide 1 only. For other
slides, we'd need to reorder the deck, which mutates the file — not acceptable.
So: render requires BOTH soffice AND pdftoppm. Document this in doctor output.
"""
# implementation details elided; see tests/test_render.py for expected behavior
...
Key decision on render: LibreOffice's --convert-to png exports only slide 1 of a pptx. To render slide N specifically, we go through PDF:
soffice --convert-to pdf deck.pptx→ producesdeck.pdfpdftoppm -png -f N -l N -r 150 deck.pdf /tmp/out→ produces/tmp/out-N.png- Move/rename to user's requested out_path
This makes pdftoppm (from poppler-utils) a second optional dependency. Mac: brew install poppler. Linux: apt install poppler-utils. doctor reports both.
Alternative considered but rejected: Using Pillow + pdf reading library. Pillow alone doesn't read PDF. Adding pdf2image or PyMuPDF as a dependency is more weight for less benefit; poppler is already widely available.
4.3 Font normalize (fonts.py)
def normalize_deck_fonts(deck: Deck, en: str = "Inter", zh: str = "Noto Sans SC") -> list[tuple[str, str]]:
"""
For every run in every text shape on every slide:
- If run.font.name in ("Calibri", "Calibri Light", "Segoe UI", ...): set to `en`
- Set the East Asian font (a:ea typeface) to `zh` regardless
Returns a list of (slide_idx, shape_name) pairs that were modified.
"""
PLATFORM_FONTS = {
"Calibri", "Calibri Light", "Segoe UI", "Helvetica", "Arial",
# Georgia, Times New Roman intentionally NOT in this list — they are
# cross-platform and usually meant to preserve visual design.
}
...
Rationale for the allowlist approach: we only replace fonts we know are platform-specific and problematic. Georgia in the v5 deck is intentional (it's the big title font) and is cross-platform, so we leave it alone.
4.4 Overflow check (overflow.py)
from fontTools.ttLib import TTFont
def measure_text_width(text: str, font_path: Path, font_size_pt: float) -> float:
"""Return rendered text width in points using font metrics."""
font = TTFont(font_path)
cmap = font.getBestCmap()
hmtx = font["hmtx"]
units_per_em = font["head"].unitsPerEm
total = 0
for ch in text:
glyph = cmap.get(ord(ch))
if glyph is None:
# fallback: average glyph width
total += units_per_em * 0.5
continue
total += hmtx.metrics[glyph][0] # advance width in font units
return total / units_per_em * font_size_pt
def check_slide_overflow(slide: Slide, font_finder: FontFinder) -> list[OverflowReport]:
"""For each text shape on the slide, estimate total text width and compare
to the shape's width. Report any where width > shape width and autofit != 'shrink'."""
...
This avoids the render loop entirely. It's more accurate than a rendered PNG inspection because font metrics are deterministic math.
FontFinder is a small helper that resolves a font name (e.g., "Inter") to an actual .ttf file path on the current system. It looks in:
~/Library/Fonts//Library/Fonts//System/Library/Fonts//usr/share/fonts/(Linux)
If a font isn't found, check-overflow emits a warning and falls back to a heuristic width estimate (characters × average width), which is less accurate but still directional.
4.5 Error classes (errors.py)
class PptxSkillError(Exception):
"""Base class for domain errors. Always preserve the cause."""
class DeckNotFoundError(PptxSkillError): ...
class SlideIndexError(PptxSkillError): ...
class ShapeNotFoundError(PptxSkillError): ...
class LibreOfficeNotFoundError(PptxSkillError): ...
class LibreOfficeError(PptxSkillError): ...
class PopplerNotFoundError(PptxSkillError): ...
class FontNotFoundError(PptxSkillError): ...
class InvalidUnitError(PptxSkillError): ...
CLI main() catches PptxSkillError, prints [pptx-skill ERROR] <message> to stderr, and exits with code 1. Any other exception is allowed to bubble up with full traceback and exits with code 3. argparse errors exit with code 2 (argparse default).
5. Test strategy
See docs/test.md for the full test plan. Summary:
- Unit tests for
units.py,dump.py,overflow.py(pure functions) - Integration tests for
deck.py,slide.py,shape.pyusing small fixture pptx files - Roundtrip tests:
open → modify → save → reopen → verifyfor every write operation - CLI tests via
subprocess.runwith captured stdout/stderr, asserting exit codes and output format - Render tests using a minimal 2-slide fixture, marked
@pytest.mark.libreoffice, skipped by default - E2E test: apply a real agenda update (as described in PRD success criteria 1) and assert the resulting deck has the expected text
6. Implementation plan
Day 2-3 build order, designed so each milestone is independently verifiable:
- Milestone 1 — Skeleton runs:
units.py,errors.py, emptydeck.py/slide.py/shape.py,cli.pywith justdoctorsubcommand. Test:pptx-skill doctorruns and reports environment. - Milestone 2 — Read operations: implement
Deck.open,Deck.slides,Slide.shapes,Shape.text,Slide.notes. Add CLI:list-slides,list-shapes,get-text,get-notes,dump-slide --format md. Test: dump a test deck's slide 1 and verify it contains expected text. - Milestone 3 — Basic write:
Shape.set_text,Slide.set_notes,Deck.save. Add CLI:set-text,set-notes. Test: roundtrip — open deck, change a text field, save, reopen, verify. - Milestone 4 — Geometric write:
Shape.set_position,Shape.set_size,Shape.set_fill,Shape.set_font. CLI:set-position,set-size,set-fill,set-font. Test: roundtrip on each. - Milestone 5 — JSON dump:
Slide.to_dictandShape.to_dict. CLI:dump-slide --format json,dump-deck. Test: dump a slide, assert JSON schema. - Milestone 6 — Render:
find_soffice,find_pdftoppm,render_slide_png. CLI:render-slide,render-deck. Test: render a slide, assert non-empty PNG produced. - Milestone 7 — Slide ops:
Deck.add_slide(with clone),Deck.delete_slide,Deck.move_slide. CLI:add-slide,delete-slide,move-slide. Test: roundtrip — add a slide cloned from an existing one, verify shape structure matches. - Milestone 8 — Pictures:
Slide.add_picture,Shape.replace_picture. CLI equivalents. Test: add a PNG to slide and verify it's visible in dump. - Milestone 9 — Font normalize:
normalize_deck_fonts. CLI:normalize-fonts. Test: normalize a deck, assert no more platform-specific fonts in font names. - Milestone 10 — Overflow check:
check_slide_overflow,FontFinder. CLI:check-overflow. Test: create a fixture where text is longer than shape width, assert it's flagged. - Milestone 11 — Escape hatch:
raw_xml_patch. CLI:raw-xml-patch. Test: patch a slide background color via XML, verify via dump. - Milestone 12 — E2E: apply a real agenda update using the CLI commands, render the result, visually verify.
Each milestone's tests must pass before the next is started. working.md gets updated at the end of each milestone with "done ✓" and any lessons learned.
7. What's NOT in this RFC
The following topics are mentioned in the PRD but deliberately deferred to either Phase 2 or a future RFC:
- MSOffice render backend (Mac-only, AppleScript-driven)
- Structured diff (
diffsubcommand) - Template system (
--from-template) - Batch patch format (YAML-driven bulk operations)
- Animation / transition support
- Chart / SmartArt editing
These will be considered once Phase 1 is stable and we've used the skill enough to know which deferred features are most valuable.