Hands On Deck
Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes reading, analyzing, or extracting content from presentations; editing text, images, styles, or layout of existing decks; creating new slides, shapes, tables, or pictures; reordering, duplicating, or merging slides across decks; and verifying decks visually. Trigger whenever the user mentions a deck, slides, a presentation, or a .pptx filename.
Package
Skill files
SKILL.md
markdown
name: hands-on-deck
description: "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes reading, analyzing, or extracting content from presentations; editing text, images, styles, or layout of existing decks; creating new slides, shapes, tables, or pictures; reordering, duplicating, or merging slides across decks; and verifying decks visually. Trigger whenever the user mentions a deck, slides, a presentation, or a .pptx filename."
hands-on-deck — agent-native PPTX manipulation
One tool does everything: scripts/deck.py. You write a JSON patch describing your edits; deck.py validates and executes it atomically, then lints the result. You never need to open slide XML for routine work.
Golden rules (the tool enforces and repeats these):
- ALL slide indices are 0-based, everywhere. Shapes are addressed by stable native ids (
s12) or unique shape name. - Positions and sizes are inches from the slide's top-left.
- Patches are atomic: every op is pre-validated (all errors reported at once, with the slide's real shape listing), then applied all-or-nothing.
Full reference: python scripts/deck.py docs — prints every op with semantics and recipes; needs no file. Read it before writing your first patch.
The edit loop
# 1. READ — cheap orientation first, full JSON only when about to write a patch
python scripts/deck.py deck.pptx inspect --slide 3 --brief # one line per shape
python scripts/deck.py deck.pptx inspect --slide 3 # full JSON: text+formatting, image rIds, geometry, issues
python scripts/deck.py deck.pptx inspect --issues # only shapes with geometric problems
# 2. WRITE — one patch, many ops, atomic; chain repair + render of touched slides
python scripts/deck.py deck.pptx apply patch.json -o out.pptx --fix --render img/
# 3. VERIFY — look at what you changed
# (render names files slide-<0-based-index>.jpg to match inspect)
python scripts/deck.py deck.pptx diff out.pptx # structural changelog, no rendering
--fix runs deterministic geometry repair on the slides you touched (grows/shrinks/nudges overflowing text; never auto-moves pictures). Its residue report lists what still needs judgment, with a suggested op. Always look at the rendered image before declaring success.
Patch ops at a glance
{"ops": [
{"op":"set-text", "slide":3, "shape":"s12", "text":["Title", "Subtitle"]},
{"op":"swap-image", "slide":3, "shape":"s9", "image":"/abs/new.png"},
{"op":"swap-image", "media":"image13.png", "image":"/abs/logo.png"},
{"op":"replace-text","scope":"deck", "from":"Old Name", "to":"New Name"},
{"op":"replace-color","scope":"deck", "from":"E8A33D", "to":"F8DE6E"},
{"op":"set-theme", "colors":{"accent1":"BB7B19"}, "fonts":{"major":"Georgia"}},
{"op":"set-props", "title":"Q3 Review", "author":"Acme"},
{"op":"set-slide", "slide":3, "hidden":true, "background":"0F5258"},
{"op":"set-slide", "slide":3, "transition":{"type":"fade","speed":"med"}},
{"op":"set-notes", "slide":3, "notes":"speaker notes"},
{"op":"move", "slide":3, "shape":"s12", "to":[1.0,2.5]},
{"op":"resize", "slide":3, "shape":"s12", "size":[4.0,1.5]},
{"op":"set-style", "slide":3, "shape":"s12", "font_size":18, "fill":"0B3D3A", "rotation":6, "anchor":"MIDDLE"},
{"op":"delete", "slide":3, "shape":"s12"},
{"op":"duplicate", "slide":3, "shape":"s12", "offset":[0,1.2], "text":["Fourth pillar"]},
{"op":"copy-shape", "from_slide":8, "shape":"s12", "slide":3, "at":[1.0,2.0]},
{"op":"reorder", "slide":3, "shape":"s12", "z":"back"},
{"op":"add-shape", "slide":3, "kind":"textbox", "at":[1,2], "size":[4,1.5], "text":["…"], "name":"card"},
{"op":"add-picture", "slide":3, "image":"/abs/img.png", "at":[1,2], "width":4},
{"op":"add-table", "slide":3, "at":[1,2], "size":[8,3], "rows":[["A","B"],["1","2"]]},
{"op":"add-slide", "layout":"Blank", "at":5},
{"op":"add-row", "slide":3, "shape":"s12", "cells":["a","b"]},
{"op":"delete-col", "slide":3, "shape":"s12", "col":1}
]}
Key semantics (details in docs):
- Vertical centering is
anchor, notalignment:alignment(set-text) is HORIZONTAL; to center text in a box taller than the text, set"anchor":"MIDDLE"(TOP|MIDDLE|BOTTOM) on set-style or set-text.inspectreports it when it's not the default top. html2patch sets it automatically from the slide's CSS (flex/grid centering). - set-text inherits formatting: new paragraph i inherits ALL formatting of old paragraph i — pass plain strings for routine replacement. Pass objects to override (
{"text":"Big","font_size":28}), or"runs"for mixed in-paragraph formatting. Table cells: add"cell":[row,col]. - Prefer duplicate/copy-shape over add-shape when a styled donor exists — new shapes start from PowerPoint defaults, not the deck's design language.
- add-shape
"name"lets later ops in the same patch target the shape it creates. - Transitions only on request: never add slide transitions unless the user explicitly asks — applied uninvited or inconsistently they're annoying, not polish. When asked, default to one subtle type (fade) across the whole deck.
- Alignment is linted, advisory:
inspect/applyreport amisalignedissue when a shape's load-bearing edge sits a hair (0.03"–0.15") off a gridline ≥3 other shapes share — the near-miss that reads as sloppy. Act on it (move/resize the edge to the named coordinate) or ignore it: intentional asymmetry is real, so it never fails the build, and apply reports only a newly introduced near-miss. - Keep new text comparable in length to the old, or let
fixrepair the overflow.
Creating slides from HTML (html2patch)
When a slide should be DESIGNED from scratch — free-form layout, no styled donor to duplicate — write it as HTML/CSS and compile it into a patch. The browser is used as a measuring engine; the output is ordinary deck.py ops.
Before writing any slide HTML, read designing-slides.md — how to design for this medium: subject-derived palettes, refusing the default AI-deck looks, the token plan, projection type sizes, and what survives compilation. The pipeline below is mechanical; that file is taste.
python scripts/html2patch.py slide.html --deck deck.pptx --layout Blank -o patch.json
python scripts/deck.py deck.pptx apply patch.json -o out.pptx --render img/
- The
<body>is the slide at 96px/inch: 16:9 →width:1280px; height:720px. Content overflowing the body is a compile error. - Text lives in
<p>,<h1>–<h6>,<ul>/<ol>(numbered),<table>, or any element with inline-only content (figcaption, blockquote…). Divs with background / border / border-radius / linear-gradient become styled rects;<img>becomes a picture (local files only;object-fit: coverbecomes a real crop);<table>becomes a real table with per-cell fills and measured column widths. - Inline
<b>/<i>/<u>/<span style>become formatted runs;<a href>becomes a real hyperlink. CSS padding maps to text insets;text-transform,transform:rotate, and verticalwriting-modeare honored. Use installed fonts (Arial, Georgia, …). - By default each HTML file appends a new slide (
add-slide,--layoutpicks the template layout);--slide Ntargets an existing slide instead. Multiple files compile into ONE atomic patch. - Apply compiled patches WITHOUT
--fixfirst — geometry is browser-measured, so look at the render before repairing; runfixonly if the render shows a real problem. - NEVER dismiss an overflow or
covered_byflag on display-size text without zooming that exact shape:render --slide N --crop l,t,w,h --scale 2. Serif faces wrap differently in PowerPoint than in the browser — a clipped last line is invisible at thumbnail size, especially when it falls behind a picture. - Extra dependency:
pip install playwright && playwright install chromium.
Deck structure (subcommands, not patch ops)
python scripts/deck.py deck.pptx slides 0,3,3,5 -o out.pptx # keep these, in this order; repeat = duplicate
python scripts/deck.py deck.pptx merge module.pptx --slides 0,2 --at 12 -o out.pptx
python scripts/deck.py deck.pptx merge --list-layouts # choose a layout for imported slides
Building a deck from a template (the human workflow)
slidesthe template down to the target slide sequence (duplicating repeated layouts).mergein any reusable modules.- One
replace-textpatch for global renames (scopemastercatches footers). - Per-slide patches:
inspect --slide Nfor shape ids, thenset-text/swap-imageonly what you name — nothing else is touched. apply --fix --render img/and look at every touched slide.
Verification
render -o img/ --slide 3,7— JPGs namedslide-<index>.jpg;--crop l,t,w,h --scale 2zooms a region (inches, same coordinates as inspect). Hidden slides render only when explicitly listed.diff other.pptx— text/geometry/media/notes changelog without rendering.- Thumbnail grids for whole-deck review:
python scripts/thumbnail.py deck.pptx --cols 4(optional second arg = output filename prefix).
Reviewing more than a slide or two — fan out, don't accumulate
Rendered images and full inspect dumps are the heaviest things you put in context, and they never leave it: one agent that builds the deck and then reviews every slide re-reads every image and dump it has ever seen on every later turn, so cost grows with the square of the work. The fix is context discipline, not fewer checks.
When more than a slide or two needs visual review, AND you have a sub-agent / Task tool available, fan the review out — one sub-agent per slide (or per small batch):
- Give each sub-agent a fresh, minimal context: the deck path + that one slide's render + its
inspect --slide N. NOT your build history, the other slides, or their renders. This is the whole point — each review context stays small, and they run in parallel. - Each sub-agent returns a verdict only —
pass, or the specific defects with shape ids and thefix/patch ops to apply. It does not hand the image back to you. - You apply the fixes from the verdicts, then (if needed) fan out one more round on only the slides that changed.
Even during the build, don't hoard renders in your own thread. Look at a render, act on it, and move on — don't carry a growing pile of slide JPGs forward. If you're visually checking many slides, delegate the looking so the images live in the sub-agents' contexts, not yours.
No sub-agent tool available? Render and review in small batches and drop earlier slides' images from context once you've judged them — same principle, manual.
Escape hatch
When no op expresses the change (animations, exotic effects):
python scripts/deck.py deck.pptx xml get --slide 5 -o slide5.xml # pretty-printed, editable
python scripts/deck.py deck.pptx xml set slide5.xml --slide 5 -o out.pptx # parse-checked, lint-checked
Out of scope by design (use the escape hatch or PowerPoint itself): creating native charts, shape animations (slide transitions ARE covered — set-slide "transition"; verify them with inspect/diff, since renders are static), embedded video/OLE, merged table cells.
Dependencies
- Python 3.9+ with
python-pptxandPillow(pip install python-pptx Pillow) - For
renderand thumbnails: LibreOffice (soffice) and Poppler (pdftoppm)
---
name: hands-on-deck
description: "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes reading, analyzing, or extracting content from presentations; editing text, images, styles, or layout of existing decks; creating new slides, shapes, tables, or pictures; reordering, duplicating, or merging slides across decks; and verifying decks visually. Trigger whenever the user mentions a deck, slides, a presentation, or a .pptx filename."
---
# hands-on-deck — agent-native PPTX manipulation
One tool does everything: `scripts/deck.py`. You write a JSON *patch* describing your edits; deck.py validates and executes it atomically, then lints the result. You never need to open slide XML for routine work.
**Golden rules** (the tool enforces and repeats these):
- ALL slide indices are 0-based, everywhere. Shapes are addressed by stable native ids (`s12`) or unique shape name.
- Positions and sizes are inches from the slide's top-left.
- Patches are atomic: every op is pre-validated (all errors reported at once, with the slide's real shape listing), then applied all-or-nothing.
**Full reference**: `python scripts/deck.py docs` — prints every op with semantics and recipes; needs no file. Read it before writing your first patch.
## The edit loop
```bash
# 1. READ — cheap orientation first, full JSON only when about to write a patch
python scripts/deck.py deck.pptx inspect --slide 3 --brief # one line per shape
python scripts/deck.py deck.pptx inspect --slide 3 # full JSON: text+formatting, image rIds, geometry, issues
python scripts/deck.py deck.pptx inspect --issues # only shapes with geometric problems
# 2. WRITE — one patch, many ops, atomic; chain repair + render of touched slides
python scripts/deck.py deck.pptx apply patch.json -o out.pptx --fix --render img/
# 3. VERIFY — look at what you changed
# (render names files slide-<0-based-index>.jpg to match inspect)
python scripts/deck.py deck.pptx diff out.pptx # structural changelog, no rendering
```
`--fix` runs deterministic geometry repair on the slides you touched (grows/shrinks/nudges overflowing text; never auto-moves pictures). Its *residue* report lists what still needs judgment, with a suggested op. Always look at the rendered image before declaring success.
## Patch ops at a glance
```json
{"ops": [
{"op":"set-text", "slide":3, "shape":"s12", "text":["Title", "Subtitle"]},
{"op":"swap-image", "slide":3, "shape":"s9", "image":"/abs/new.png"},
{"op":"swap-image", "media":"image13.png", "image":"/abs/logo.png"},
{"op":"replace-text","scope":"deck", "from":"Old Name", "to":"New Name"},
{"op":"replace-color","scope":"deck", "from":"E8A33D", "to":"F8DE6E"},
{"op":"set-theme", "colors":{"accent1":"BB7B19"}, "fonts":{"major":"Georgia"}},
{"op":"set-props", "title":"Q3 Review", "author":"Acme"},
{"op":"set-slide", "slide":3, "hidden":true, "background":"0F5258"},
{"op":"set-slide", "slide":3, "transition":{"type":"fade","speed":"med"}},
{"op":"set-notes", "slide":3, "notes":"speaker notes"},
{"op":"move", "slide":3, "shape":"s12", "to":[1.0,2.5]},
{"op":"resize", "slide":3, "shape":"s12", "size":[4.0,1.5]},
{"op":"set-style", "slide":3, "shape":"s12", "font_size":18, "fill":"0B3D3A", "rotation":6, "anchor":"MIDDLE"},
{"op":"delete", "slide":3, "shape":"s12"},
{"op":"duplicate", "slide":3, "shape":"s12", "offset":[0,1.2], "text":["Fourth pillar"]},
{"op":"copy-shape", "from_slide":8, "shape":"s12", "slide":3, "at":[1.0,2.0]},
{"op":"reorder", "slide":3, "shape":"s12", "z":"back"},
{"op":"add-shape", "slide":3, "kind":"textbox", "at":[1,2], "size":[4,1.5], "text":["…"], "name":"card"},
{"op":"add-picture", "slide":3, "image":"/abs/img.png", "at":[1,2], "width":4},
{"op":"add-table", "slide":3, "at":[1,2], "size":[8,3], "rows":[["A","B"],["1","2"]]},
{"op":"add-slide", "layout":"Blank", "at":5},
{"op":"add-row", "slide":3, "shape":"s12", "cells":["a","b"]},
{"op":"delete-col", "slide":3, "shape":"s12", "col":1}
]}
```
Key semantics (details in `docs`):
- **Vertical centering is `anchor`, not `alignment`**: `alignment` (set-text) is HORIZONTAL; to center text in a box taller than the text, set `"anchor":"MIDDLE"` (TOP|MIDDLE|BOTTOM) on set-style or set-text. `inspect` reports it when it's not the default top. html2patch sets it automatically from the slide's CSS (flex/grid centering).
- **set-text inherits formatting**: new paragraph *i* inherits ALL formatting of old paragraph *i* — pass plain strings for routine replacement. Pass objects to override (`{"text":"Big","font_size":28}`), or `"runs"` for mixed in-paragraph formatting. Table cells: add `"cell":[row,col]`.
- **Prefer duplicate/copy-shape over add-shape** when a styled donor exists — new shapes start from PowerPoint defaults, not the deck's design language.
- **add-shape `"name"`** lets later ops in the same patch target the shape it creates.
- **Transitions only on request**: never add slide transitions unless the user explicitly asks — applied uninvited or inconsistently they're annoying, not polish. When asked, default to one subtle type (fade) across the whole deck.
- **Alignment is linted, advisory**: `inspect`/`apply` report a `misaligned` issue when a shape's load-bearing edge sits a hair (0.03"–0.15") off a gridline ≥3 other shapes share — the near-miss that reads as sloppy. Act on it (move/resize the edge to the named coordinate) or ignore it: intentional asymmetry is real, so it never fails the build, and apply reports only a *newly introduced* near-miss.
- Keep new text comparable in length to the old, or let `fix` repair the overflow.
## Creating slides from HTML (html2patch)
When a slide should be DESIGNED from scratch — free-form layout, no styled
donor to duplicate — write it as HTML/CSS and compile it into a patch. The
browser is used as a measuring engine; the output is ordinary deck.py ops.
**Before writing any slide HTML, read [designing-slides.md](references/designing-slides.md)**
— how to design for this medium: subject-derived palettes, refusing the
default AI-deck looks, the token plan, projection type sizes, and what
survives compilation. The pipeline below is mechanical; that file is taste.
```bash
python scripts/html2patch.py slide.html --deck deck.pptx --layout Blank -o patch.json
python scripts/deck.py deck.pptx apply patch.json -o out.pptx --render img/
```
- The `<body>` is the slide at 96px/inch: 16:9 → `width:1280px; height:720px`.
Content overflowing the body is a compile error.
- Text lives in `<p>`, `<h1>`–`<h6>`, `<ul>`/`<ol>` (numbered), `<table>`, or
any element with inline-only content (figcaption, blockquote…). Divs with
background / border / border-radius / linear-gradient become styled rects;
`<img>` becomes a picture (local files only; `object-fit: cover` becomes a
real crop); `<table>` becomes a real table with per-cell fills and measured
column widths.
- Inline `<b>`/`<i>`/`<u>`/`<span style>` become formatted runs; `<a href>`
becomes a real hyperlink. CSS padding
maps to text insets; `text-transform`, `transform:rotate`, and vertical
`writing-mode` are honored. Use installed fonts (Arial, Georgia, …).
- By default each HTML file appends a new slide (`add-slide`, `--layout` picks
the template layout); `--slide N` targets an existing slide instead. Multiple
files compile into ONE atomic patch.
- Apply compiled patches WITHOUT `--fix` first — geometry is browser-measured,
so look at the render before repairing; run `fix` only if the render shows
a real problem.
- NEVER dismiss an overflow or `covered_by` flag on display-size text without
zooming that exact shape: `render --slide N --crop l,t,w,h --scale 2`.
Serif faces wrap differently in PowerPoint than in the browser — a clipped
last line is invisible at thumbnail size, especially when it falls behind
a picture.
- Extra dependency: `pip install playwright && playwright install chromium`.
## Deck structure (subcommands, not patch ops)
```bash
python scripts/deck.py deck.pptx slides 0,3,3,5 -o out.pptx # keep these, in this order; repeat = duplicate
python scripts/deck.py deck.pptx merge module.pptx --slides 0,2 --at 12 -o out.pptx
python scripts/deck.py deck.pptx merge --list-layouts # choose a layout for imported slides
```
## Building a deck from a template (the human workflow)
1. `slides` the template down to the target slide sequence (duplicating repeated layouts).
2. `merge` in any reusable modules.
3. One `replace-text` patch for global renames (scope `master` catches footers).
4. Per-slide patches: `inspect --slide N` for shape ids, then `set-text` / `swap-image` only what you name — nothing else is touched.
5. `apply --fix --render img/` and look at every touched slide.
## Verification
- `render -o img/ --slide 3,7` — JPGs named `slide-<index>.jpg`; `--crop l,t,w,h --scale 2` zooms a region (inches, same coordinates as inspect). Hidden slides render only when explicitly listed.
- `diff other.pptx` — text/geometry/media/notes changelog without rendering.
- Thumbnail grids for whole-deck review: `python scripts/thumbnail.py deck.pptx --cols 4` (optional second arg = output filename prefix).
### Reviewing more than a slide or two — fan out, don't accumulate
Rendered images and full `inspect` dumps are the heaviest things you put in context, and they never leave it: one agent that builds the deck and then reviews every slide re-reads every image and dump it has ever seen on every later turn, so cost grows with the square of the work. The fix is context discipline, not fewer checks.
**When more than a slide or two needs visual review, AND you have a sub-agent / Task tool available, fan the review out — one sub-agent per slide (or per small batch):**
- Give each sub-agent a **fresh, minimal context**: the deck path + that one slide's render + its `inspect --slide N`. NOT your build history, the other slides, or their renders. This is the whole point — each review context stays small, and they run in parallel.
- Each sub-agent returns a **verdict only** — `pass`, or the specific defects with shape ids and the `fix`/patch ops to apply. It does not hand the image back to you.
- You apply the fixes from the verdicts, then (if needed) fan out one more round on only the slides that changed.
**Even during the build, don't hoard renders in your own thread.** Look at a render, act on it, and move on — don't carry a growing pile of slide JPGs forward. If you're visually checking many slides, delegate the looking so the images live in the sub-agents' contexts, not yours.
No sub-agent tool available? Render and review in small batches and drop earlier slides' images from context once you've judged them — same principle, manual.
## Escape hatch
When no op expresses the change (animations, exotic effects):
```bash
python scripts/deck.py deck.pptx xml get --slide 5 -o slide5.xml # pretty-printed, editable
python scripts/deck.py deck.pptx xml set slide5.xml --slide 5 -o out.pptx # parse-checked, lint-checked
```
Out of scope by design (use the escape hatch or PowerPoint itself): creating native charts, shape animations (slide transitions ARE covered — `set-slide` `"transition"`; verify them with `inspect`/`diff`, since renders are static), embedded video/OLE, merged table cells.
## Dependencies
- Python 3.9+ with `python-pptx` and `Pillow` (`pip install python-pptx Pillow`)
- For `render` and thumbnails: LibreOffice (`soffice`) and Poppler (`pdftoppm`)
references/designing-slides.md
markdown
Designing slides worth presenting
Read this before writing create-path HTML (html2patch). It is about the
design, not the tooling — SKILL.md covers the pipeline.
Work as if you are the deck designer a team hires after firing their template. They didn't pay for "professional"; they paid for a deck that could only be about this subject. Every slide you ship is a choice someone could disagree with — if no one could, you haven't made one.
Start from the subject, not from style
Before any visual decision, pin three things: what this deck is about (one concrete subject), who is in the room, and what the deck must accomplish. Then mine the subject's own world for the design: its materials, instruments, diagrams, vocabulary, era, texture. A deck about shipping logistics has manifests, container codes, route lines; a deck about a poetry archive does not. Distinctiveness comes from the subject — style applied from outside is what templates do.
Know the default look, then refuse it
Unprompted AI decks cluster into a few recognizable looks: white slides with a blue header bar and bullet lists; a near-black deck with one neon-gradient accent, a giant statistic, and three rounded cards; cream-paper editorial serif with a terracotta accent. Each is fine when chosen; none should be where you land by gravity. If the brief specifies a direction, follow it exactly. Where the brief is silent, your test is: would this design survive having a different subject poured into it? If yes, it isn't designed yet.
The same applies to structure. Numbered section markers, eyebrow labels, and divider slides should encode something true (a real sequence, a real taxonomy) — not perform organization the content doesn't have.
Plan in tokens before writing any HTML
First pass, before any slide file: write a compact design plan.
- Palette — 4–6 named hex values, derived from the subject. One color dominates (60–70% of visual weight); one is the sharp accent, spent sparingly. Name what each is for, not just what it is.
- Type — two or three roles: a display face with real character (used with restraint, at scale), a quiet text face, optionally a utility/mono face for labels, code, and data. PPTX has no webfonts — choose from faces that exist on the target machine (Georgia, Palatino, Arial, Helvetica, Verdana, Trebuchet MS, Courier New, Impact…) and let size, weight, case, and color do the differentiating work.
- Grid — the margins, eyebrow row, and footer line every slide will
share. Decide the left margin ONCE, in pixels, and put it in a shared CSS
block. Slides are viewed in sequence: a margin that jumps between slides
reads as a mistake even when each slide is fine alone.
inspect/applyenforce this by measurement: amisalignedissue means an edge landed a hair off a gridline its siblings share — the near-miss that reads as sloppy. Snap it to the reported coordinate. - Signature — the one element this deck will be remembered by: a recurring motif, an unusual headline treatment, a diagram language, a full-bleed texture. One. Everything else stays quiet so it can speak.
Second pass: critique the plan before building. For each token ask whether it's a choice for this brief or the thing you'd produce for any similar prompt. Rework what fails. Only then write slide HTML, deriving every value from the plan.
A slide is not a webpage
- One idea per slide. A page scrolls; a slide is read in about six seconds from across a room. The headline should carry the idea by itself; everything else is evidence.
- Type sizes are projection sizes. Body text below ~16px (12pt) is invisible from the back of the room; captions/labels bottom out around 13px. Headlines start where webpages stop — 40px is modest, 90px is a statement. When in doubt, cut words, not point size.
- The canvas is fixed and overflow is a compile error. Design to the 1280×720 box: generous margins, deliberate empty space. Vertical balance matters — content that ends two-thirds up the slide leaves a dead zone that reads as unfinished.
- The deck's "motion" is the page turn. There is no scroll, no hover, no reveal. Rhythm comes from layout variation against a constant grid: a full-bleed moment after three structured slides, a near-empty statement slide after a dense one. Vary the layout, never the system.
- Copy is design material. Headlines in the deck's voice, labels that label, no filler. Slides force the discipline webpages let you skip: if a sentence doesn't help the room understand, it's decoration — cut it.
Design with the compiler's grain
html2patch translates faithfully — but only what PPTX can hold. Spend your craft on what survives:
- Survives: flex/grid/absolute layout, linear gradients, solid fills,
uniform and partial borders (accent bars!), true corner radii, real tables
with per-cell fills and measured column widths,
object-fit: covercrops,transform: rotate,text-transform, numbered and bulleted lists, padding (becomes text insets), per-run bold/italic/color/size mixing, vertical centering of text in a taller box (flex/grid/explicit-height → ananchor). - Doesn't: box-shadow, letter-spacing, text gradients, blend modes, filters, custom webfonts, animation of any kind. If a treatment depends on these, it will quietly vanish — design something that doesn't need it.
- Decorative depth that PPTX can't fake natively (textures, soft shadows, grain) can be baked into images and placed as full-bleed or cropped pictures. The compiler carries pixels faithfully; use that.
Critique with your eyes, then remove one thing
The render loop is the mirror: compile, --render, and LOOK at every slide
image — never declare a deck done from the patch alone. Check it as a
sequence, not as nine separate compositions: do the margins hold? does the
eyebrow row sit at the same height? does the accent color appear on every
slide or pool on one? Heed the linter on overflows and collisions — and
before you call any flag a false positive, zoom the exact shape
(render --crop l,t,w,h --scale 2). Serif display headlines are where
browser and PowerPoint metrics diverge most, and a re-wrapped last line
hides at thumbnail size, especially when it falls behind a picture. Only a
clean zoom earns the words "false positive".
Then the last pass: find the least necessary element on each slide — the extra divider, the third accent, the label nobody needs — and take it off. A deck that survives that pass was designed; one that collapses was decorated. Small geometry corrections (a 0.25" alignment drift, one run that should be mono) are cheaper as deck.py ops on the compiled file than as a recompile — that's what the patch engine is for.
# Designing slides worth presenting Read this before writing create-path HTML (`html2patch`). It is about the design, not the tooling — SKILL.md covers the pipeline. Work as if you are the deck designer a team hires after firing their template. They didn't pay for "professional"; they paid for a deck that could only be about *this* subject. Every slide you ship is a choice someone could disagree with — if no one could, you haven't made one. ## Start from the subject, not from style Before any visual decision, pin three things: what this deck is about (one concrete subject), who is in the room, and what the deck must accomplish. Then mine the subject's own world for the design: its materials, instruments, diagrams, vocabulary, era, texture. A deck about shipping logistics has manifests, container codes, route lines; a deck about a poetry archive does not. Distinctiveness comes from the subject — style applied from outside is what templates do. ## Know the default look, then refuse it Unprompted AI decks cluster into a few recognizable looks: white slides with a blue header bar and bullet lists; a near-black deck with one neon-gradient accent, a giant statistic, and three rounded cards; cream-paper editorial serif with a terracotta accent. Each is fine *when chosen*; none should be where you land by gravity. If the brief specifies a direction, follow it exactly. Where the brief is silent, your test is: would this design survive having a different subject poured into it? If yes, it isn't designed yet. The same applies to structure. Numbered section markers, eyebrow labels, and divider slides should encode something true (a real sequence, a real taxonomy) — not perform organization the content doesn't have. ## Plan in tokens before writing any HTML First pass, before any slide file: write a compact design plan. - **Palette** — 4–6 named hex values, derived from the subject. One color dominates (60–70% of visual weight); one is the sharp accent, spent sparingly. Name what each is *for*, not just what it is. - **Type** — two or three roles: a display face with real character (used with restraint, at scale), a quiet text face, optionally a utility/mono face for labels, code, and data. PPTX has no webfonts — choose from faces that exist on the target machine (Georgia, Palatino, Arial, Helvetica, Verdana, Trebuchet MS, Courier New, Impact…) and let size, weight, case, and color do the differentiating work. - **Grid** — the margins, eyebrow row, and footer line every slide will share. Decide the left margin ONCE, in pixels, and put it in a shared CSS block. Slides are viewed in sequence: a margin that jumps between slides reads as a mistake even when each slide is fine alone. `inspect`/`apply` enforce this by measurement: a `misaligned` issue means an edge landed a hair off a gridline its siblings share — the near-miss that reads as sloppy. Snap it to the reported coordinate. - **Signature** — the one element this deck will be remembered by: a recurring motif, an unusual headline treatment, a diagram language, a full-bleed texture. One. Everything else stays quiet so it can speak. Second pass: critique the plan before building. For each token ask whether it's a choice for this brief or the thing you'd produce for any similar prompt. Rework what fails. Only then write slide HTML, deriving every value from the plan. ## A slide is not a webpage - **One idea per slide.** A page scrolls; a slide is read in about six seconds from across a room. The headline should carry the idea by itself; everything else is evidence. - **Type sizes are projection sizes.** Body text below ~16px (12pt) is invisible from the back of the room; captions/labels bottom out around 13px. Headlines start where webpages stop — 40px is modest, 90px is a statement. When in doubt, cut words, not point size. - **The canvas is fixed and overflow is a compile error.** Design *to* the 1280×720 box: generous margins, deliberate empty space. Vertical balance matters — content that ends two-thirds up the slide leaves a dead zone that reads as unfinished. - **The deck's "motion" is the page turn.** There is no scroll, no hover, no reveal. Rhythm comes from layout variation against a constant grid: a full-bleed moment after three structured slides, a near-empty statement slide after a dense one. Vary the layout, never the system. - **Copy is design material.** Headlines in the deck's voice, labels that label, no filler. Slides force the discipline webpages let you skip: if a sentence doesn't help the room understand, it's decoration — cut it. ## Design with the compiler's grain html2patch translates faithfully — but only what PPTX can hold. Spend your craft on what survives: - **Survives**: flex/grid/absolute layout, linear gradients, solid fills, uniform and partial borders (accent bars!), true corner radii, real tables with per-cell fills and measured column widths, `object-fit: cover` crops, `transform: rotate`, `text-transform`, numbered and bulleted lists, padding (becomes text insets), per-run bold/italic/color/size mixing, vertical centering of text in a taller box (flex/grid/explicit-height → an `anchor`). - **Doesn't**: box-shadow, letter-spacing, text gradients, blend modes, filters, custom webfonts, animation of any kind. If a treatment depends on these, it will quietly vanish — design something that doesn't need it. - Decorative depth that PPTX can't fake natively (textures, soft shadows, grain) can be **baked into images** and placed as full-bleed or cropped pictures. The compiler carries pixels faithfully; use that. ## Critique with your eyes, then remove one thing The render loop is the mirror: compile, `--render`, and LOOK at every slide image — never declare a deck done from the patch alone. Check it as a sequence, not as nine separate compositions: do the margins hold? does the eyebrow row sit at the same height? does the accent color appear on every slide or pool on one? Heed the linter on overflows and collisions — and before you call any flag a false positive, zoom the exact shape (`render --crop l,t,w,h --scale 2`). Serif display headlines are where browser and PowerPoint metrics diverge most, and a re-wrapped last line hides at thumbnail size, especially when it falls behind a picture. Only a clean zoom earns the words "false positive". Then the last pass: find the least necessary element on each slide — the extra divider, the third accent, the label nobody needs — and take it off. A deck that survives that pass was designed; one that collapses was decorated. Small geometry corrections (a 0.25" alignment drift, one run that should be mono) are cheaper as deck.py ops on the compiled file than as a recompile — that's what the patch engine is for.
scripts/deck.py
python
This file is too large to preview inline.
Open the raw file to inspect the full contents.
scripts/html2patch.py
python
#!/usr/bin/env python3
"""html2patch — compile an HTML slide into a deck.py patch.
The agent writes a slide as HTML/CSS; a headless browser (Playwright/Chromium)
is used purely as a MEASURING engine. Every rendered element's box and computed
style is read back and translated into deck.py ops (add-shape / add-picture /
add-table / set-text). The output is a standard patch: apply it with
python deck.py deck.pptx apply patch.json -o out.pptx --fix --render img/
Design spec: docs/html2patch-spec.md in the hands-on-deck repo. No code is shared
with any other HTML-to-PPTX implementation.
Usage:
html2patch.py slide.html [slide2.html ...] --deck deck.pptx [--layout NAME] -o patch.json
html2patch.py overlay.html --deck deck.pptx --slide 3 -o patch.json
html2patch.py slide.html --size 13.333x7.5 --slide 0 -o patch.json
"""
import argparse
import base64
import json
import re
import sys
import tempfile
from pathlib import Path
PX_PER_IN = 96.0
PT_PER_PX = 0.75
# Browser-side extractor. Runs inside the page; returns a JSON-able tree of
# "items" in document order (= back-to-front z-order) plus page metadata.
# All geometry is in CSS px; Python converts to inches/points.
EXTRACT_JS = r"""
() => {
const out = { body: {}, items: [], warnings: [] };
const cs = (el) => window.getComputedStyle(el);
const TEXT_TAGS = new Set(['P','H1','H2','H3','H4','H5','H6']);
const SINGLE_WEIGHT = new Set(['impact']); // faux-bold widens text in PPT
// an element whose rendered children are all inline (or text/BR) is a text
// block even if it isn't a <p>/<h*> — figcaption, blockquote, dt, a bare div
const isInlineOnlyTextBlock = (el) => {
if (!el.textContent.trim()) return false;
return Array.from(el.childNodes).every(n => {
if (n.nodeType === Node.TEXT_NODE) return true;
if (n.nodeType !== Node.ELEMENT_NODE) return true;
if (n.tagName === 'BR') return true;
const d = cs(n).display;
return d === 'inline' || d === 'none';
});
};
const bodyStyle = cs(document.body);
out.body.w = parseFloat(bodyStyle.width);
out.body.h = parseFloat(bodyStyle.height);
out.body.scrollW = document.body.scrollWidth;
out.body.scrollH = document.body.scrollHeight;
out.body.bg = bodyStyle.backgroundColor;
out.body.bgImage = bodyStyle.backgroundImage;
out.body.bgSize = bodyStyle.backgroundSize;
const parseColor = (str) => {
// -> {hex, alpha} or null for fully transparent / unparseable
if (!str || str === 'transparent') return null;
const m = str.match(/rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\)/);
if (!m) return null;
const a = m[4] === undefined ? 1 : parseFloat(m[4]);
if (a === 0) return null;
const hex = [m[1], m[2], m[3]]
.map(n => parseInt(n).toString(16).padStart(2, '0')).join('').toUpperCase();
return { hex, alpha: a };
};
const firstFont = (stack) => {
if (!stack) return null;
const fam = stack.split(',')[0].replace(/['"]/g, '').trim();
const generic = { 'sans-serif': 'Arial', serif: 'Georgia', monospace: 'Courier New' };
return generic[fam.toLowerCase()] || fam;
};
const transformText = (text, mode) => {
// textContent does NOT reflect CSS text-transform; apply it ourselves
if (mode === 'uppercase') return text.toUpperCase();
if (mode === 'lowercase') return text.toLowerCase();
if (mode === 'capitalize') return text.replace(/(^|\s)\S/g, c => c.toUpperCase());
return text;
};
const rotationOf = (style) => {
let deg = 0;
if (style.writingMode === 'vertical-rl') deg = 90;
else if (style.writingMode === 'vertical-lr') deg = 270;
const t = style.transform;
if (t && t !== 'none') {
const m = t.match(/matrix\(([^)]+)\)/);
if (m) {
const v = m[1].split(',').map(parseFloat);
deg += Math.atan2(v[1], v[0]) * 180 / Math.PI;
}
}
deg = ((Math.round(deg * 10) / 10) % 360 + 360) % 360;
return deg === 0 ? null : deg;
};
// PPT rotates the PRE-rotation box about its center; undo the browser's
// rotated bounding box to recover it.
const boxOf = (el, rot) => {
const r = el.getBoundingClientRect();
if (rot === null) return { x: r.left, y: r.top, w: r.width, h: r.height };
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
if (rot === 90 || rot === 270) {
return { x: cx - r.height / 2, y: cy - r.width / 2, w: r.height, h: r.width };
}
return { x: cx - el.offsetWidth / 2, y: cy - el.offsetHeight / 2,
w: el.offsetWidth, h: el.offsetHeight };
};
// Where does the text actually sit inside its box? Measured, not guessed:
// the browser has already applied flex/grid centering, line-height, explicit
// heights, etc. We read the rendered text ink box and compare it to the
// element's content box. A box that hugs its text → top (the default, no
// anchor needed); text floating centered or pinned to the bottom of a taller
// box → MIDDLE / BOTTOM, which PPTX needs as an explicit vertical anchor.
const measureVAnchor = (el, style) => {
const r = el.getBoundingClientRect();
const top = r.top + (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0);
const bottom = r.bottom - (parseFloat(style.borderBottomWidth) || 0) - (parseFloat(style.paddingBottom) || 0);
const contentH = bottom - top;
if (contentH <= 0) return null;
let tr;
try {
const range = document.createRange();
range.selectNodeContents(el);
tr = range.getBoundingClientRect();
} catch (e) { return null; }
if (!tr || tr.height <= 0) return null;
const slack = contentH - tr.height;
if (slack < 4) return null; // box hugs the text → default top
const topGap = tr.top - top, botGap = bottom - tr.bottom;
const tol = Math.max(2, slack * 0.2);
if (Math.abs(topGap - botGap) <= tol) return 'MIDDLE';
if (botGap <= tol && topGap > tol) return 'BOTTOM';
return null; // top-anchored or ambiguous → leave as default
};
const visible = (el, style) => {
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity) === 0) return false;
const r = el.getBoundingClientRect();
return r.width > 0.5 && r.height > 0.5;
};
// Resolve the effective run style at an inline node
const runStyle = (style) => {
const color = parseColor(style.color);
const fam = firstFont(style.fontFamily);
const w = style.fontWeight === 'bold' ? 700 : parseInt(style.fontWeight) || 400;
return {
bold: w >= 600 && !SINGLE_WEIGHT.has((fam || '').toLowerCase()),
italic: style.fontStyle === 'italic',
underline: (style.textDecorationLine || style.textDecoration || '').includes('underline'),
sizePx: parseFloat(style.fontSize),
font: fam,
color: color ? color.hex : '000000',
alpha: color ? color.alpha : 1,
};
};
// Flatten an element's inline content into runs; <br> splits paragraphs.
const collectRuns = (el, baseTransform) => {
const paras = [[]];
const walk = (node, transform) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = transformText(node.textContent.replace(/\s+/g, ' '), transform);
if (text) {
const st = runStyle(cs(node.parentElement));
const a = node.parentElement.closest('a[href]');
if (a) st.link = a.href;
const cur = paras[paras.length - 1];
const prev = cur[cur.length - 1];
if (prev && JSON.stringify(prev.style) === JSON.stringify(st)) prev.text += text;
else cur.push({ text, style: st });
}
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'BR') { paras.push([]); return; }
if (node.tagName === 'UL' || node.tagName === 'OL') return; // nested lists are their own paragraphs
const st = cs(node);
if (st.display === 'none' || st.visibility === 'hidden') return;
const t = st.textTransform !== 'none' ? st.textTransform : transform;
node.childNodes.forEach(c => walk(c, t));
};
walk(el, baseTransform);
// trim paragraph edges, drop empty runs/paragraphs
return paras.map(runs => {
if (runs.length) {
runs[0].text = runs[0].text.replace(/^\s+/, '');
runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
}
return runs.filter(r => r.text.length > 0);
}).filter(runs => runs.length > 0);
};
const blockMeta = (el, style) => {
const align = { start: 'left', left: 'left', center: 'center', right: 'right',
justify: 'justify', end: 'right' }[style.textAlign] || 'left';
const lh = style.lineHeight === 'normal'
? parseFloat(style.fontSize) * 1.2 : parseFloat(style.lineHeight);
return {
align, lineHeightPx: lh, fontSizePx: parseFloat(style.fontSize),
padding: [style.paddingLeft, style.paddingTop, style.paddingRight, style.paddingBottom]
.map(parseFloat),
};
};
const borderInfo = (style) => {
const sides = ['Top', 'Right', 'Bottom', 'Left'].map(s => ({
w: parseFloat(style['border' + s + 'Width']) || 0,
color: parseColor(style['border' + s + 'Color']),
style: style['border' + s + 'Style'],
}));
const painted = sides.filter(s => s.w > 0 && s.style !== 'none' && s.color);
if (!painted.length) return { kind: 'none' };
const uniform = sides.every(s =>
s.w === sides[0].w && s.style === sides[0].style &&
JSON.stringify(s.color) === JSON.stringify(sides[0].color));
return { kind: uniform ? 'uniform' : 'partial', sides };
};
const gradientInfo = (bgImage) => {
if (!bgImage || bgImage === 'none' || !bgImage.includes('linear-gradient')) return null;
const inner = bgImage.match(/linear-gradient\((.*)\)/s);
if (!inner) return null;
// split top-level commas only
const parts = []; let depth = 0, cur = '';
for (const ch of inner[1]) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; }
else cur += ch;
}
parts.push(cur.trim());
let cssDeg = 180; // CSS default: to bottom
if (/^-?[\d.]+deg$/.test(parts[0])) cssDeg = parseFloat(parts.shift());
else if (parts[0].startsWith('to ')) {
const dir = parts.shift().slice(3).trim();
cssDeg = { top: 0, right: 90, bottom: 180, left: 270,
'top right': 45, 'right top': 45, 'bottom right': 135, 'right bottom': 135,
'bottom left': 225, 'left bottom': 225, 'top left': 315, 'left top': 315 }[dir] ?? 180;
}
const stops = parts.map(p => {
const colorMatch = p.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}/);
const posMatch = p.match(/([\d.]+)%/);
let col = colorMatch ? parseColor(colorMatch[0]) : null;
if (!col && colorMatch && colorMatch[0][0] === '#') {
let h = colorMatch[0].slice(1);
if (h.length === 3) h = h.split('').map(c => c + c).join('');
col = { hex: h.slice(0, 6).toUpperCase(), alpha: 1 };
}
return col ? { hex: col.hex, pos: posMatch ? parseFloat(posMatch[1]) / 100 : null } : null;
}).filter(Boolean);
if (stops.length < 2) return null;
return { cssDeg, stops };
};
// box paint extraction, shared by the body, painted text blocks, and boxes
const paintOf = (el2, style2, rot2, box2) => {
const fill = parseColor(style2.backgroundColor);
const grad = gradientInfo(style2.backgroundImage);
const border = borderInfo(style2);
const bgUrl = (style2.backgroundImage.match(/url\(["']?([^"')]+)["']?\)/) || [])[1];
if (!fill && !grad && border.kind === 'none' && !bgUrl) return null;
const radius = parseFloat(style2.borderTopLeftRadius) || 0;
const radiusIsPct = String(style2.borderTopLeftRadius).includes('%');
if (style2.boxShadow && style2.boxShadow !== 'none')
out.warnings.push('box-shadow is not supported and was dropped');
return {
bgUrl,
bgFit: (style2.backgroundSize || '').includes('cover') ? 'cover'
: (style2.backgroundSize || '').includes('contain') ? 'contain' : 'fill',
item: {
type: 'box', box: box2, rotation: rot2,
fill: fill ? fill.hex : null,
fillAlpha: fill ? fill.alpha : 1,
gradient: grad,
radiusPx: radiusIsPct
? Math.min(box2.w, box2.h) * Math.min(parseFloat(style2.borderTopLeftRadius), 50) / 100
: radius,
border: border.kind === 'uniform'
? { w: border.sides[0].w, color: border.sides[0].color.hex,
dashed: ['dashed', 'dotted'].includes(border.sides[0].style) }
: null,
partialBorders: border.kind === 'partial'
? border.sides.map((s, i) => s.w > 0 && s.color
? { side: i, w: s.w, color: s.color.hex,
dashed: ['dashed', 'dotted'].includes(s.style) } : null).filter(Boolean)
: [],
},
};
};
// ---- walk ----
const emit = (el) => {
const style = cs(el);
if (!visible(el, style)) return;
const tag = el.tagName;
if (tag === 'IMG') {
const r = el.getBoundingClientRect();
out.items.push({ type: 'image', src: el.currentSrc || el.src,
box: { x: r.left, y: r.top, w: r.width, h: r.height },
fit: style.objectFit || 'fill' });
return;
}
if (tag === 'TABLE') {
const r = el.getBoundingClientRect();
const rows = [];
const cellStyles = [];
el.querySelectorAll('tr').forEach(tr => {
const row = [], rowSt = [];
tr.querySelectorAll('th,td').forEach(cell => {
if (cell.colSpan > 1 || cell.rowSpan > 1)
out.warnings.push('table cell spans are not supported; layout will be off');
const cellCs = cs(cell);
// textContent flattens <br> to nothing, gluing words together —
// walk the cell so explicit breaks survive as newlines
const withBreaks = (function walk(node) {
let s = '';
node.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) s += n.textContent;
else if (n.nodeType === Node.ELEMENT_NODE)
s += n.tagName === 'BR' ? '\n' : walk(n);
});
return s;
})(cell);
row.push(transformText(
withBreaks.split('\n').map(l => l.replace(/\s+/g, ' ').trim())
.filter(Boolean).join('\n'),
cellCs.textTransform));
const st = runStyle(cellCs);
const bg = parseColor(cellCs.backgroundColor);
rowSt.push({ ...st, fill: bg ? bg.hex : null,
align: { start: 'left' }[cellCs.textAlign] || cellCs.textAlign });
});
if (row.length) { rows.push(row); cellStyles.push(rowSt); }
});
if (rows.length) {
const firstTr = el.querySelector('tr');
const colWidths = firstTr
? Array.from(firstTr.querySelectorAll('th,td')).map(c => c.getBoundingClientRect().width)
: [];
out.items.push({ type: 'table', box: { x: r.left, y: r.top, w: r.width, h: r.height },
rows, cellStyles, colWidths,
fontSizePx: parseFloat(cs(el).fontSize) });
}
return; // never descend into tables
}
if (TEXT_TAGS.has(tag) || isInlineOnlyTextBlock(el)) {
const rot = rotationOf(style);
const box = boxOf(el, rot);
const paint = paintOf(el, style, rot, box);
if (paint) {
out.items.push(paint.item); // badge/pill: the box paints under its text
if (paint.bgUrl) out.items.push({ type: 'image', src: paint.bgUrl, box, fit: paint.bgFit });
}
const paras = collectRuns(el, style.textTransform);
if (paras.length)
out.items.push({ type: 'text', box, rotation: rot,
paragraphs: paras.map(runs => ({ runs })),
vanchor: measureVAnchor(el, style),
meta: blockMeta(el, style) });
return;
}
if (tag === 'UL' || tag === 'OL') {
const rot = rotationOf(style);
const paragraphs = [];
el.querySelectorAll(':scope li').forEach(li => {
let level = 0;
for (let a = li.parentElement; a && a !== el; a = a.parentElement)
if (a.tagName === 'UL' || a.tagName === 'OL') level++;
const liStyle = cs(li);
const listEl = li.parentElement;
const ordered = listEl && listEl.tagName === 'OL';
collectRuns(li, liStyle.textTransform).forEach(runs => {
// strip hand-typed bullet glyphs; the bullet comes from PPT
runs[0].text = runs[0].text.replace(/^[•▪▸◦‣–-]\s*/, '');
paragraphs.push({ runs, bullet: ordered ? 'number' : true, level,
lineHeightPx: blockMeta(li, liStyle).lineHeightPx });
});
});
if (paragraphs.length)
out.items.push({ type: 'text', box: boxOf(el, rot), rotation: rot,
paragraphs, meta: blockMeta(el, style) });
return; // li content fully consumed
}
// BOX: anything painting a background or border; children still walked
{
const rot = rotationOf(style);
const paint = paintOf(el, style, rot, boxOf(el, rot));
if (paint) {
out.items.push(paint.item);
if (paint.bgUrl)
out.items.push({ type: 'image', src: paint.bgUrl, box: paint.item.box, fit: paint.bgFit });
// loose text mixed with block children is invisible to us — warn
for (const n of el.childNodes)
if (n.nodeType === Node.TEXT_NODE && n.textContent.trim())
out.warnings.push(
'text "' + n.textContent.trim().slice(0, 40) +
'" sits directly in a styled container with block children; wrap it in <p>/<h*>');
}
}
el.childNodes.forEach(n => { if (n.nodeType === Node.ELEMENT_NODE) emit(n); });
};
// the body's own paint is the slide background (back layer, first item)
{
const r = { x: 0, y: 0, w: out.body.w, h: out.body.h };
const paint = paintOf(document.body, bodyStyle, null, r);
if (paint) {
if (paint.item.fill || paint.item.gradient) {
paint.item.border = null;
paint.item.partialBorders = [];
paint.item.radiusPx = 0;
out.items.push(paint.item);
}
if (paint.bgUrl)
out.items.push({ type: 'image', src: paint.bgUrl, box: r, fit: paint.bgFit });
}
}
document.body.childNodes.forEach(n => { if (n.nodeType === Node.ELEMENT_NODE) emit(n); });
return out;
}
"""
# Faces whose PowerPoint metrics run widest of the browser's. A re-wrapped
# line is the worst drift there is: the last line clips or hides under
# whatever sits below, so these get double the width safety margin.
SERIF_FACES = {
"georgia", "times", "times new roman", "palatino", "palatino linotype",
"garamond", "book antiqua", "baskerville", "didot", "cambria",
"constantia", "hoefler text",
}
def px2in(v):
return round(v / PX_PER_IN, 3)
def px2pt(v):
return round(v * PT_PER_PX, 1)
def die(msg):
sys.stderr.write("html2patch: error: %s\n" % msg)
sys.exit(1)
def parse_size(s):
m = re.match(r"^([\d.]+)x([\d.]+)$", str(s))
if not m:
die('--size must look like "13.333x7.5" (inches)')
return float(m.group(1)), float(m.group(2))
def resolve_image(src, html_dir, tmpdir, warnings):
"""Local file path for an image reference; data: URIs are materialized."""
if src.startswith("data:"):
m = re.match(r"data:image/(\w+);base64,(.*)", src, re.S)
if not m:
return None
ext = {"jpeg": "jpg", "svg+xml": "svg"}.get(m.group(1), m.group(1))
p = Path(tempfile.mkstemp(suffix="." + ext, dir=tmpdir)[1])
p.write_bytes(base64.b64decode(m.group(2)))
return p
if src.startswith("file://"):
p = Path(src[7:].split("?")[0])
elif re.match(r"^https?://", src):
warnings.append("remote image %s skipped — download it locally first" % src[:60])
return None
else:
p = (html_dir / src.split("?")[0]).resolve()
if not p.exists():
warnings.append("image not found: %s" % p)
return None
return p
def run_to_spec(run):
"""deck.py run object carrying the run's FULL resolved style. The textbox
is brand new (nothing to inherit), so every run is explicit; shape-level
font keys are never used (they would clobber per-run overrides, since
add-shape applies style keys after writing text)."""
st = run["style"]
spec = {"text": run["text"],
"font_size": px2pt(st["sizePx"]),
"color": st["color"]}
if st["font"]:
spec["font_name"] = st["font"]
for k in ("bold", "italic", "underline"):
if st[k]:
spec[k] = True
if st.get("link"):
spec["link"] = st["link"]
return spec
def text_block_ops(item, slide_ref, name, warnings):
"""One add-shape textbox op for a text/list item."""
meta = item["meta"]
box = dict(item["box"])
paras = item["paragraphs"]
# PPT draws text a touch wider than the browser, and serif faces drift the
# most — enough to re-wrap a line, and the re-wrapped last line clips or
# hides under whatever sits below. Widen EVERY box in the direction that
# keeps the anchored edge still, so PPT-side wrap points match the browser's.
fonts = {(r["style"]["font"] or "").lower() for p in paras for r in p["runs"]}
extra = box["w"] * (0.04 if fonts & SERIF_FACES else 0.02)
if meta["align"] == "center":
box["x"] -= extra / 2
elif meta["align"] == "right":
box["x"] -= extra
box["w"] += extra
if any(r["style"].get("alpha", 1) < 1 for p in paras for r in p["runs"]):
warnings.append("text alpha < 1 dropped (no transparency in this model)")
text_items = []
for p in paras:
# line spacing must track the LARGEST run on the line, like the browser
max_px = max(r["style"]["sizePx"] for r in p["runs"])
lh_px = p.get("lineHeightPx", meta["lineHeightPx"])
if max_px > meta["fontSizePx"] > 0:
lh_px = lh_px * max_px / meta["fontSizePx"]
para = {"alignment": meta["align"].upper(), "line_spacing": px2pt(lh_px),
"space_before": 0, "space_after": 0}
if p.get("bullet"):
para["bullet"] = p["bullet"] # True or "number"
if p.get("level"):
para["level"] = p["level"]
runs = [run_to_spec(r) for r in p["runs"]]
if len(runs) == 1:
# single run: fold its font keys into the paragraph object
para.update(runs[0])
else:
para["runs"] = runs
text_items.append(para)
op = {
"op": "add-shape", "slide": slide_ref, "kind": "textbox", "name": name,
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])],
"insets": [round(v / PX_PER_IN, 3) for v in meta["padding"]],
"text": text_items,
}
if item.get("rotation"):
op["rotation"] = item["rotation"]
if item.get("vanchor"):
op["anchor"] = item["vanchor"]
return [op]
def box_ops(item, slide_ref, name, warnings):
ops = []
box = item["box"]
has_face = item["fill"] or item["gradient"] or item["border"]
if has_face:
radius_px = item.get("radiusPx") or 0
op = {
"op": "add-shape", "slide": slide_ref, "name": name,
"kind": "rounded_rect" if radius_px > 0 else "rect",
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])],
}
if radius_px > 0:
adj = radius_px / max(min(box["w"], box["h"]), 1)
op["adjustments"] = [round(min(adj, 0.5), 4)]
if item["gradient"]:
g = item["gradient"]
stops = g["stops"]
n = len(stops)
if n > 2:
warnings.append("gradient has %d stops; keeping first and last" % n)
stops = [stops[0], stops[-1]]
positions = [s["pos"] if s["pos"] is not None else float(i)
for i, s in enumerate(stops)]
op["gradient"] = {
"colors": [s["hex"] for s in stops],
"positions": positions,
# CSS: 0deg points up, grows clockwise. python-pptx
# gradient_angle: 0deg points right, grows counterclockwise.
"angle": (90 - g["cssDeg"]) % 360,
}
elif item["fill"]:
op["fill"] = item["fill"]
if item.get("fillAlpha", 1) < 1:
warnings.append("fill alpha %.2f dropped (solid color emitted)" % item["fillAlpha"])
else:
op["fill"] = "none" # border-only frame stays hollow
op["shadow"] = False # browsers don't draw PPT's theme shadow
if item["border"]:
op["line_color"] = item["border"]["color"]
op["line_width"] = px2pt(item["border"]["w"])
if item["border"]["dashed"]:
op["line_dash"] = "dash"
else:
op["line"] = "none"
if item.get("rotation"):
op["rotation"] = item["rotation"]
ops.append(op)
# partial borders → centered line shapes per painted side
x, y, w, h = box["x"], box["y"], box["w"], box["h"]
for pb in item.get("partialBorders", []):
half = pb["w"] / 2.0
seg = {
0: [(x, y + half), (x + w, y + half)], # top
1: [(x + w - half, y), (x + w - half, y + h)], # right
2: [(x, y + h - half), (x + w, y + h - half)], # bottom
3: [(x + half, y), (x + half, y + h)], # left
}[pb["side"]]
line_op = {
"op": "add-shape", "slide": slide_ref, "kind": "line",
"from": [px2in(seg[0][0]), px2in(seg[0][1])],
"to": [px2in(seg[1][0]), px2in(seg[1][1])],
"line_color": pb["color"], "line_width": px2pt(pb["w"]),
}
if pb["dashed"]:
line_op["line_dash"] = "dash"
ops.append(line_op)
return ops
def table_ops(item, slide_ref, name, warnings):
box = item["box"]
rows = item["rows"]
ncols = max(len(r) for r in rows)
if any(len(r) != ncols for r in rows):
warnings.append("table rows are ragged; padding short rows with empty cells")
rows = [r + [""] * (ncols - len(r)) for r in rows]
# neutralize the theme's banded table style; transparent HTML cells become
# no-fill cells so the slide background shows through, like the browser
fill_set = {st.get("fill") for row in item["cellStyles"] for st in row}
all_styles = [st for row in item["cellStyles"] for st in row]
base_color = max((st["color"] for st in all_styles),
key=[st["color"] for st in all_styles].count)
op = {
"op": "add-table", "slide": slide_ref, "name": name,
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])],
"rows": rows,
"font_size": px2pt(item["fontSizePx"]),
"color": base_color,
"first_row": False, "banding": False,
}
if len(fill_set) == 1:
only = fill_set.pop()
op["fill"] = only or "none"
else:
op["fills"] = [[st.get("fill") or "none" for st in row]
for row in item["cellStyles"]]
if item.get("colWidths") and len(item["colWidths"]) == ncols:
op["col_widths"] = [px2in(w) for w in item["colWidths"]]
ops = [op]
# cells whose style differs from the table default get a set-text follow-up
base_pt = px2pt(item["fontSizePx"])
for ri, row_styles in enumerate(item["cellStyles"]):
for ci, st in enumerate(row_styles):
overrides = {}
if st["bold"]:
overrides["bold"] = True
if st["italic"]:
overrides["italic"] = True
if px2pt(st["sizePx"]) != base_pt:
overrides["font_size"] = px2pt(st["sizePx"])
if st["color"] != base_color:
overrides["color"] = st["color"]
if st.get("align") and st["align"] not in ("left", "start"):
overrides["alignment"] = st["align"].upper()
if overrides and rows[ri][ci]:
overrides["text"] = rows[ri][ci]
ops.append({"op": "set-text", "slide": slide_ref, "shape": name,
"cell": [ri, ci], "text": [overrides]})
return ops
def picture_op(path, box, fit, slide_ref):
"""add-picture op honoring object-fit / background-size semantics."""
op = {"op": "add-picture", "slide": slide_ref, "image": str(path),
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])]}
if fit in ("cover", "contain"):
from PIL import Image as PILImage
nw, nh = PILImage.open(path).size
nat_ar, box_ar = nw / nh, box["w"] / box["h"]
if fit == "cover": # crop the overflowing dimension
if nat_ar > box_ar:
c = round((1 - box_ar / nat_ar) / 2, 4)
op["crop"] = [c, 0, c, 0]
elif nat_ar < box_ar:
c = round((1 - nat_ar / box_ar) / 2, 4)
op["crop"] = [0, c, 0, c]
else: # contain: letterbox — shrink the target rect, centered
if nat_ar > box_ar:
h = box["w"] / nat_ar
op["at"] = [px2in(box["x"]), px2in(box["y"] + (box["h"] - h) / 2)]
op["size"] = [px2in(box["w"]), px2in(h)]
elif nat_ar < box_ar:
w = box["h"] * nat_ar
op["at"] = [px2in(box["x"] + (box["w"] - w) / 2), px2in(box["y"])]
op["size"] = [px2in(w), px2in(box["h"])]
return op
def compile_page(extract, slide_ref, html_path, tmpdir, prefix, warnings):
"""All ops for one extracted page targeting slide_ref."""
ops = []
seq = [0]
def next_name(kind):
seq[0] += 1
return "%s-%s-%d" % (prefix, kind, seq[0])
# the body's own paint arrives as the first item(s) from the extractor
for item in extract["items"]:
if item["type"] == "image":
p = resolve_image(item["src"], html_path.parent, tmpdir, warnings)
if not p:
continue
ops.append(picture_op(p, item["box"], item.get("fit", "fill"), slide_ref))
elif item["type"] == "text":
ops += text_block_ops(item, slide_ref, next_name("text"), warnings)
elif item["type"] == "box":
ops += box_ops(item, slide_ref, next_name("box"), warnings)
elif item["type"] == "table":
ops += table_ops(item, slide_ref, next_name("table"), warnings)
return ops
def main():
ap = argparse.ArgumentParser(
description="Compile HTML slides into a deck.py patch (see deck.py docs).")
ap.add_argument("html", nargs="+", help="HTML file(s), one per slide")
ap.add_argument("--deck", help=".pptx to read slide size + count from")
ap.add_argument("--slide", type=int,
help="target EXISTING slide index (single HTML file only)")
ap.add_argument("--layout", default=None,
help="layout for created slides (deck.py add-slide semantics)")
ap.add_argument("--size", help='slide size in inches, e.g. "13.333x7.5" (no --deck)')
ap.add_argument("--prefix", default=None,
help="shape name prefix (default: h2p-<n>)")
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
ap.add_argument("-o", "--out", help="output patch path (default: stdout)")
args = ap.parse_args()
if args.slide is not None and len(args.html) > 1:
die("--slide targets one existing slide; pass one HTML file")
if not args.deck and not args.size:
die("give --deck deck.pptx or --size WxH so geometry can be validated")
base_count = None
if args.deck:
try:
from pptx import Presentation
except ImportError:
die("python-pptx is required for --deck (pip install python-pptx)")
prs = Presentation(args.deck)
slide_w = prs.slide_width / 914400
slide_h = prs.slide_height / 914400
base_count = len(prs.slides._sldIdLst)
if args.slide is not None and not (0 <= args.slide < base_count):
die("--slide %d out of range (deck has %d slides)" % (args.slide, base_count))
else:
slide_w, slide_h = parse_size(args.size)
if args.slide is None:
die("without --deck, give --slide N (add-slide needs a real deck to count from)")
try:
from playwright.sync_api import sync_playwright
except ImportError:
die("playwright is required: pip install playwright && playwright install chromium")
warnings = []
all_ops = []
tmpdir = tempfile.mkdtemp(prefix="html2patch-")
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page(viewport={"width": int(slide_w * 96), "height": int(slide_h * 96)})
for i, html_file in enumerate(args.html):
html_path = Path(html_file).resolve()
if not html_path.exists():
die("no such file: %s" % html_file)
page.goto(html_path.as_uri())
extract = page.evaluate(EXTRACT_JS)
body = extract["body"]
errs = []
if abs(body["w"] / PX_PER_IN - slide_w) > 0.05 or abs(body["h"] / PX_PER_IN - slide_h) > 0.05:
errs.append(
"%s: body is %.2fx%.2fin but the slide is %.2fx%.2fin — set "
"body {width:%dpx; height:%dpx}" % (
html_file, body["w"] / PX_PER_IN, body["h"] / PX_PER_IN,
slide_w, slide_h, round(slide_w * 96), round(slide_h * 96)))
over_w = max(0, body["scrollW"] - body["w"] - 1)
over_h = max(0, body["scrollH"] - body["h"] - 1)
if over_w or over_h:
errs.append("%s: content overflows the body by %dpx horizontally / %dpx "
"vertically — fix the HTML before compiling" % (html_file, over_w, over_h))
if errs:
die("\n".join(errs))
if args.slide is not None:
slide_ref = args.slide
else:
slide_ref = base_count + i
all_ops.append({"op": "add-slide",
**({"layout": args.layout} if args.layout else {})})
prefix = args.prefix or ("h2p-%d" % (i + 1) if len(args.html) > 1 else "h2p")
for w in extract["warnings"]:
warnings.append("%s: %s" % (html_file, w))
all_ops += compile_page(extract, slide_ref, html_path, tmpdir, prefix, warnings)
browser.close()
for w in sorted(set(warnings)):
sys.stderr.write("html2patch: warning: %s\n" % w)
if warnings and args.strict:
die("%d warning(s) with --strict" % len(warnings))
patch = json.dumps({"ops": all_ops}, indent=1, ensure_ascii=False)
if args.out:
Path(args.out).write_text(patch + "\n")
sys.stderr.write("html2patch: %d op(s) -> %s\n" % (len(all_ops), args.out))
else:
print(patch)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""html2patch — compile an HTML slide into a deck.py patch.
The agent writes a slide as HTML/CSS; a headless browser (Playwright/Chromium)
is used purely as a MEASURING engine. Every rendered element's box and computed
style is read back and translated into deck.py ops (add-shape / add-picture /
add-table / set-text). The output is a standard patch: apply it with
python deck.py deck.pptx apply patch.json -o out.pptx --fix --render img/
Design spec: docs/html2patch-spec.md in the hands-on-deck repo. No code is shared
with any other HTML-to-PPTX implementation.
Usage:
html2patch.py slide.html [slide2.html ...] --deck deck.pptx [--layout NAME] -o patch.json
html2patch.py overlay.html --deck deck.pptx --slide 3 -o patch.json
html2patch.py slide.html --size 13.333x7.5 --slide 0 -o patch.json
"""
import argparse
import base64
import json
import re
import sys
import tempfile
from pathlib import Path
PX_PER_IN = 96.0
PT_PER_PX = 0.75
# Browser-side extractor. Runs inside the page; returns a JSON-able tree of
# "items" in document order (= back-to-front z-order) plus page metadata.
# All geometry is in CSS px; Python converts to inches/points.
EXTRACT_JS = r"""
() => {
const out = { body: {}, items: [], warnings: [] };
const cs = (el) => window.getComputedStyle(el);
const TEXT_TAGS = new Set(['P','H1','H2','H3','H4','H5','H6']);
const SINGLE_WEIGHT = new Set(['impact']); // faux-bold widens text in PPT
// an element whose rendered children are all inline (or text/BR) is a text
// block even if it isn't a <p>/<h*> — figcaption, blockquote, dt, a bare div
const isInlineOnlyTextBlock = (el) => {
if (!el.textContent.trim()) return false;
return Array.from(el.childNodes).every(n => {
if (n.nodeType === Node.TEXT_NODE) return true;
if (n.nodeType !== Node.ELEMENT_NODE) return true;
if (n.tagName === 'BR') return true;
const d = cs(n).display;
return d === 'inline' || d === 'none';
});
};
const bodyStyle = cs(document.body);
out.body.w = parseFloat(bodyStyle.width);
out.body.h = parseFloat(bodyStyle.height);
out.body.scrollW = document.body.scrollWidth;
out.body.scrollH = document.body.scrollHeight;
out.body.bg = bodyStyle.backgroundColor;
out.body.bgImage = bodyStyle.backgroundImage;
out.body.bgSize = bodyStyle.backgroundSize;
const parseColor = (str) => {
// -> {hex, alpha} or null for fully transparent / unparseable
if (!str || str === 'transparent') return null;
const m = str.match(/rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\)/);
if (!m) return null;
const a = m[4] === undefined ? 1 : parseFloat(m[4]);
if (a === 0) return null;
const hex = [m[1], m[2], m[3]]
.map(n => parseInt(n).toString(16).padStart(2, '0')).join('').toUpperCase();
return { hex, alpha: a };
};
const firstFont = (stack) => {
if (!stack) return null;
const fam = stack.split(',')[0].replace(/['"]/g, '').trim();
const generic = { 'sans-serif': 'Arial', serif: 'Georgia', monospace: 'Courier New' };
return generic[fam.toLowerCase()] || fam;
};
const transformText = (text, mode) => {
// textContent does NOT reflect CSS text-transform; apply it ourselves
if (mode === 'uppercase') return text.toUpperCase();
if (mode === 'lowercase') return text.toLowerCase();
if (mode === 'capitalize') return text.replace(/(^|\s)\S/g, c => c.toUpperCase());
return text;
};
const rotationOf = (style) => {
let deg = 0;
if (style.writingMode === 'vertical-rl') deg = 90;
else if (style.writingMode === 'vertical-lr') deg = 270;
const t = style.transform;
if (t && t !== 'none') {
const m = t.match(/matrix\(([^)]+)\)/);
if (m) {
const v = m[1].split(',').map(parseFloat);
deg += Math.atan2(v[1], v[0]) * 180 / Math.PI;
}
}
deg = ((Math.round(deg * 10) / 10) % 360 + 360) % 360;
return deg === 0 ? null : deg;
};
// PPT rotates the PRE-rotation box about its center; undo the browser's
// rotated bounding box to recover it.
const boxOf = (el, rot) => {
const r = el.getBoundingClientRect();
if (rot === null) return { x: r.left, y: r.top, w: r.width, h: r.height };
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
if (rot === 90 || rot === 270) {
return { x: cx - r.height / 2, y: cy - r.width / 2, w: r.height, h: r.width };
}
return { x: cx - el.offsetWidth / 2, y: cy - el.offsetHeight / 2,
w: el.offsetWidth, h: el.offsetHeight };
};
// Where does the text actually sit inside its box? Measured, not guessed:
// the browser has already applied flex/grid centering, line-height, explicit
// heights, etc. We read the rendered text ink box and compare it to the
// element's content box. A box that hugs its text → top (the default, no
// anchor needed); text floating centered or pinned to the bottom of a taller
// box → MIDDLE / BOTTOM, which PPTX needs as an explicit vertical anchor.
const measureVAnchor = (el, style) => {
const r = el.getBoundingClientRect();
const top = r.top + (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0);
const bottom = r.bottom - (parseFloat(style.borderBottomWidth) || 0) - (parseFloat(style.paddingBottom) || 0);
const contentH = bottom - top;
if (contentH <= 0) return null;
let tr;
try {
const range = document.createRange();
range.selectNodeContents(el);
tr = range.getBoundingClientRect();
} catch (e) { return null; }
if (!tr || tr.height <= 0) return null;
const slack = contentH - tr.height;
if (slack < 4) return null; // box hugs the text → default top
const topGap = tr.top - top, botGap = bottom - tr.bottom;
const tol = Math.max(2, slack * 0.2);
if (Math.abs(topGap - botGap) <= tol) return 'MIDDLE';
if (botGap <= tol && topGap > tol) return 'BOTTOM';
return null; // top-anchored or ambiguous → leave as default
};
const visible = (el, style) => {
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity) === 0) return false;
const r = el.getBoundingClientRect();
return r.width > 0.5 && r.height > 0.5;
};
// Resolve the effective run style at an inline node
const runStyle = (style) => {
const color = parseColor(style.color);
const fam = firstFont(style.fontFamily);
const w = style.fontWeight === 'bold' ? 700 : parseInt(style.fontWeight) || 400;
return {
bold: w >= 600 && !SINGLE_WEIGHT.has((fam || '').toLowerCase()),
italic: style.fontStyle === 'italic',
underline: (style.textDecorationLine || style.textDecoration || '').includes('underline'),
sizePx: parseFloat(style.fontSize),
font: fam,
color: color ? color.hex : '000000',
alpha: color ? color.alpha : 1,
};
};
// Flatten an element's inline content into runs; <br> splits paragraphs.
const collectRuns = (el, baseTransform) => {
const paras = [[]];
const walk = (node, transform) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = transformText(node.textContent.replace(/\s+/g, ' '), transform);
if (text) {
const st = runStyle(cs(node.parentElement));
const a = node.parentElement.closest('a[href]');
if (a) st.link = a.href;
const cur = paras[paras.length - 1];
const prev = cur[cur.length - 1];
if (prev && JSON.stringify(prev.style) === JSON.stringify(st)) prev.text += text;
else cur.push({ text, style: st });
}
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'BR') { paras.push([]); return; }
if (node.tagName === 'UL' || node.tagName === 'OL') return; // nested lists are their own paragraphs
const st = cs(node);
if (st.display === 'none' || st.visibility === 'hidden') return;
const t = st.textTransform !== 'none' ? st.textTransform : transform;
node.childNodes.forEach(c => walk(c, t));
};
walk(el, baseTransform);
// trim paragraph edges, drop empty runs/paragraphs
return paras.map(runs => {
if (runs.length) {
runs[0].text = runs[0].text.replace(/^\s+/, '');
runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
}
return runs.filter(r => r.text.length > 0);
}).filter(runs => runs.length > 0);
};
const blockMeta = (el, style) => {
const align = { start: 'left', left: 'left', center: 'center', right: 'right',
justify: 'justify', end: 'right' }[style.textAlign] || 'left';
const lh = style.lineHeight === 'normal'
? parseFloat(style.fontSize) * 1.2 : parseFloat(style.lineHeight);
return {
align, lineHeightPx: lh, fontSizePx: parseFloat(style.fontSize),
padding: [style.paddingLeft, style.paddingTop, style.paddingRight, style.paddingBottom]
.map(parseFloat),
};
};
const borderInfo = (style) => {
const sides = ['Top', 'Right', 'Bottom', 'Left'].map(s => ({
w: parseFloat(style['border' + s + 'Width']) || 0,
color: parseColor(style['border' + s + 'Color']),
style: style['border' + s + 'Style'],
}));
const painted = sides.filter(s => s.w > 0 && s.style !== 'none' && s.color);
if (!painted.length) return { kind: 'none' };
const uniform = sides.every(s =>
s.w === sides[0].w && s.style === sides[0].style &&
JSON.stringify(s.color) === JSON.stringify(sides[0].color));
return { kind: uniform ? 'uniform' : 'partial', sides };
};
const gradientInfo = (bgImage) => {
if (!bgImage || bgImage === 'none' || !bgImage.includes('linear-gradient')) return null;
const inner = bgImage.match(/linear-gradient\((.*)\)/s);
if (!inner) return null;
// split top-level commas only
const parts = []; let depth = 0, cur = '';
for (const ch of inner[1]) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; }
else cur += ch;
}
parts.push(cur.trim());
let cssDeg = 180; // CSS default: to bottom
if (/^-?[\d.]+deg$/.test(parts[0])) cssDeg = parseFloat(parts.shift());
else if (parts[0].startsWith('to ')) {
const dir = parts.shift().slice(3).trim();
cssDeg = { top: 0, right: 90, bottom: 180, left: 270,
'top right': 45, 'right top': 45, 'bottom right': 135, 'right bottom': 135,
'bottom left': 225, 'left bottom': 225, 'top left': 315, 'left top': 315 }[dir] ?? 180;
}
const stops = parts.map(p => {
const colorMatch = p.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}/);
const posMatch = p.match(/([\d.]+)%/);
let col = colorMatch ? parseColor(colorMatch[0]) : null;
if (!col && colorMatch && colorMatch[0][0] === '#') {
let h = colorMatch[0].slice(1);
if (h.length === 3) h = h.split('').map(c => c + c).join('');
col = { hex: h.slice(0, 6).toUpperCase(), alpha: 1 };
}
return col ? { hex: col.hex, pos: posMatch ? parseFloat(posMatch[1]) / 100 : null } : null;
}).filter(Boolean);
if (stops.length < 2) return null;
return { cssDeg, stops };
};
// box paint extraction, shared by the body, painted text blocks, and boxes
const paintOf = (el2, style2, rot2, box2) => {
const fill = parseColor(style2.backgroundColor);
const grad = gradientInfo(style2.backgroundImage);
const border = borderInfo(style2);
const bgUrl = (style2.backgroundImage.match(/url\(["']?([^"')]+)["']?\)/) || [])[1];
if (!fill && !grad && border.kind === 'none' && !bgUrl) return null;
const radius = parseFloat(style2.borderTopLeftRadius) || 0;
const radiusIsPct = String(style2.borderTopLeftRadius).includes('%');
if (style2.boxShadow && style2.boxShadow !== 'none')
out.warnings.push('box-shadow is not supported and was dropped');
return {
bgUrl,
bgFit: (style2.backgroundSize || '').includes('cover') ? 'cover'
: (style2.backgroundSize || '').includes('contain') ? 'contain' : 'fill',
item: {
type: 'box', box: box2, rotation: rot2,
fill: fill ? fill.hex : null,
fillAlpha: fill ? fill.alpha : 1,
gradient: grad,
radiusPx: radiusIsPct
? Math.min(box2.w, box2.h) * Math.min(parseFloat(style2.borderTopLeftRadius), 50) / 100
: radius,
border: border.kind === 'uniform'
? { w: border.sides[0].w, color: border.sides[0].color.hex,
dashed: ['dashed', 'dotted'].includes(border.sides[0].style) }
: null,
partialBorders: border.kind === 'partial'
? border.sides.map((s, i) => s.w > 0 && s.color
? { side: i, w: s.w, color: s.color.hex,
dashed: ['dashed', 'dotted'].includes(s.style) } : null).filter(Boolean)
: [],
},
};
};
// ---- walk ----
const emit = (el) => {
const style = cs(el);
if (!visible(el, style)) return;
const tag = el.tagName;
if (tag === 'IMG') {
const r = el.getBoundingClientRect();
out.items.push({ type: 'image', src: el.currentSrc || el.src,
box: { x: r.left, y: r.top, w: r.width, h: r.height },
fit: style.objectFit || 'fill' });
return;
}
if (tag === 'TABLE') {
const r = el.getBoundingClientRect();
const rows = [];
const cellStyles = [];
el.querySelectorAll('tr').forEach(tr => {
const row = [], rowSt = [];
tr.querySelectorAll('th,td').forEach(cell => {
if (cell.colSpan > 1 || cell.rowSpan > 1)
out.warnings.push('table cell spans are not supported; layout will be off');
const cellCs = cs(cell);
// textContent flattens <br> to nothing, gluing words together —
// walk the cell so explicit breaks survive as newlines
const withBreaks = (function walk(node) {
let s = '';
node.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) s += n.textContent;
else if (n.nodeType === Node.ELEMENT_NODE)
s += n.tagName === 'BR' ? '\n' : walk(n);
});
return s;
})(cell);
row.push(transformText(
withBreaks.split('\n').map(l => l.replace(/\s+/g, ' ').trim())
.filter(Boolean).join('\n'),
cellCs.textTransform));
const st = runStyle(cellCs);
const bg = parseColor(cellCs.backgroundColor);
rowSt.push({ ...st, fill: bg ? bg.hex : null,
align: { start: 'left' }[cellCs.textAlign] || cellCs.textAlign });
});
if (row.length) { rows.push(row); cellStyles.push(rowSt); }
});
if (rows.length) {
const firstTr = el.querySelector('tr');
const colWidths = firstTr
? Array.from(firstTr.querySelectorAll('th,td')).map(c => c.getBoundingClientRect().width)
: [];
out.items.push({ type: 'table', box: { x: r.left, y: r.top, w: r.width, h: r.height },
rows, cellStyles, colWidths,
fontSizePx: parseFloat(cs(el).fontSize) });
}
return; // never descend into tables
}
if (TEXT_TAGS.has(tag) || isInlineOnlyTextBlock(el)) {
const rot = rotationOf(style);
const box = boxOf(el, rot);
const paint = paintOf(el, style, rot, box);
if (paint) {
out.items.push(paint.item); // badge/pill: the box paints under its text
if (paint.bgUrl) out.items.push({ type: 'image', src: paint.bgUrl, box, fit: paint.bgFit });
}
const paras = collectRuns(el, style.textTransform);
if (paras.length)
out.items.push({ type: 'text', box, rotation: rot,
paragraphs: paras.map(runs => ({ runs })),
vanchor: measureVAnchor(el, style),
meta: blockMeta(el, style) });
return;
}
if (tag === 'UL' || tag === 'OL') {
const rot = rotationOf(style);
const paragraphs = [];
el.querySelectorAll(':scope li').forEach(li => {
let level = 0;
for (let a = li.parentElement; a && a !== el; a = a.parentElement)
if (a.tagName === 'UL' || a.tagName === 'OL') level++;
const liStyle = cs(li);
const listEl = li.parentElement;
const ordered = listEl && listEl.tagName === 'OL';
collectRuns(li, liStyle.textTransform).forEach(runs => {
// strip hand-typed bullet glyphs; the bullet comes from PPT
runs[0].text = runs[0].text.replace(/^[•▪▸◦‣–-]\s*/, '');
paragraphs.push({ runs, bullet: ordered ? 'number' : true, level,
lineHeightPx: blockMeta(li, liStyle).lineHeightPx });
});
});
if (paragraphs.length)
out.items.push({ type: 'text', box: boxOf(el, rot), rotation: rot,
paragraphs, meta: blockMeta(el, style) });
return; // li content fully consumed
}
// BOX: anything painting a background or border; children still walked
{
const rot = rotationOf(style);
const paint = paintOf(el, style, rot, boxOf(el, rot));
if (paint) {
out.items.push(paint.item);
if (paint.bgUrl)
out.items.push({ type: 'image', src: paint.bgUrl, box: paint.item.box, fit: paint.bgFit });
// loose text mixed with block children is invisible to us — warn
for (const n of el.childNodes)
if (n.nodeType === Node.TEXT_NODE && n.textContent.trim())
out.warnings.push(
'text "' + n.textContent.trim().slice(0, 40) +
'" sits directly in a styled container with block children; wrap it in <p>/<h*>');
}
}
el.childNodes.forEach(n => { if (n.nodeType === Node.ELEMENT_NODE) emit(n); });
};
// the body's own paint is the slide background (back layer, first item)
{
const r = { x: 0, y: 0, w: out.body.w, h: out.body.h };
const paint = paintOf(document.body, bodyStyle, null, r);
if (paint) {
if (paint.item.fill || paint.item.gradient) {
paint.item.border = null;
paint.item.partialBorders = [];
paint.item.radiusPx = 0;
out.items.push(paint.item);
}
if (paint.bgUrl)
out.items.push({ type: 'image', src: paint.bgUrl, box: r, fit: paint.bgFit });
}
}
document.body.childNodes.forEach(n => { if (n.nodeType === Node.ELEMENT_NODE) emit(n); });
return out;
}
"""
# Faces whose PowerPoint metrics run widest of the browser's. A re-wrapped
# line is the worst drift there is: the last line clips or hides under
# whatever sits below, so these get double the width safety margin.
SERIF_FACES = {
"georgia", "times", "times new roman", "palatino", "palatino linotype",
"garamond", "book antiqua", "baskerville", "didot", "cambria",
"constantia", "hoefler text",
}
def px2in(v):
return round(v / PX_PER_IN, 3)
def px2pt(v):
return round(v * PT_PER_PX, 1)
def die(msg):
sys.stderr.write("html2patch: error: %s\n" % msg)
sys.exit(1)
def parse_size(s):
m = re.match(r"^([\d.]+)x([\d.]+)$", str(s))
if not m:
die('--size must look like "13.333x7.5" (inches)')
return float(m.group(1)), float(m.group(2))
def resolve_image(src, html_dir, tmpdir, warnings):
"""Local file path for an image reference; data: URIs are materialized."""
if src.startswith("data:"):
m = re.match(r"data:image/(\w+);base64,(.*)", src, re.S)
if not m:
return None
ext = {"jpeg": "jpg", "svg+xml": "svg"}.get(m.group(1), m.group(1))
p = Path(tempfile.mkstemp(suffix="." + ext, dir=tmpdir)[1])
p.write_bytes(base64.b64decode(m.group(2)))
return p
if src.startswith("file://"):
p = Path(src[7:].split("?")[0])
elif re.match(r"^https?://", src):
warnings.append("remote image %s skipped — download it locally first" % src[:60])
return None
else:
p = (html_dir / src.split("?")[0]).resolve()
if not p.exists():
warnings.append("image not found: %s" % p)
return None
return p
def run_to_spec(run):
"""deck.py run object carrying the run's FULL resolved style. The textbox
is brand new (nothing to inherit), so every run is explicit; shape-level
font keys are never used (they would clobber per-run overrides, since
add-shape applies style keys after writing text)."""
st = run["style"]
spec = {"text": run["text"],
"font_size": px2pt(st["sizePx"]),
"color": st["color"]}
if st["font"]:
spec["font_name"] = st["font"]
for k in ("bold", "italic", "underline"):
if st[k]:
spec[k] = True
if st.get("link"):
spec["link"] = st["link"]
return spec
def text_block_ops(item, slide_ref, name, warnings):
"""One add-shape textbox op for a text/list item."""
meta = item["meta"]
box = dict(item["box"])
paras = item["paragraphs"]
# PPT draws text a touch wider than the browser, and serif faces drift the
# most — enough to re-wrap a line, and the re-wrapped last line clips or
# hides under whatever sits below. Widen EVERY box in the direction that
# keeps the anchored edge still, so PPT-side wrap points match the browser's.
fonts = {(r["style"]["font"] or "").lower() for p in paras for r in p["runs"]}
extra = box["w"] * (0.04 if fonts & SERIF_FACES else 0.02)
if meta["align"] == "center":
box["x"] -= extra / 2
elif meta["align"] == "right":
box["x"] -= extra
box["w"] += extra
if any(r["style"].get("alpha", 1) < 1 for p in paras for r in p["runs"]):
warnings.append("text alpha < 1 dropped (no transparency in this model)")
text_items = []
for p in paras:
# line spacing must track the LARGEST run on the line, like the browser
max_px = max(r["style"]["sizePx"] for r in p["runs"])
lh_px = p.get("lineHeightPx", meta["lineHeightPx"])
if max_px > meta["fontSizePx"] > 0:
lh_px = lh_px * max_px / meta["fontSizePx"]
para = {"alignment": meta["align"].upper(), "line_spacing": px2pt(lh_px),
"space_before": 0, "space_after": 0}
if p.get("bullet"):
para["bullet"] = p["bullet"] # True or "number"
if p.get("level"):
para["level"] = p["level"]
runs = [run_to_spec(r) for r in p["runs"]]
if len(runs) == 1:
# single run: fold its font keys into the paragraph object
para.update(runs[0])
else:
para["runs"] = runs
text_items.append(para)
op = {
"op": "add-shape", "slide": slide_ref, "kind": "textbox", "name": name,
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])],
"insets": [round(v / PX_PER_IN, 3) for v in meta["padding"]],
"text": text_items,
}
if item.get("rotation"):
op["rotation"] = item["rotation"]
if item.get("vanchor"):
op["anchor"] = item["vanchor"]
return [op]
def box_ops(item, slide_ref, name, warnings):
ops = []
box = item["box"]
has_face = item["fill"] or item["gradient"] or item["border"]
if has_face:
radius_px = item.get("radiusPx") or 0
op = {
"op": "add-shape", "slide": slide_ref, "name": name,
"kind": "rounded_rect" if radius_px > 0 else "rect",
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])],
}
if radius_px > 0:
adj = radius_px / max(min(box["w"], box["h"]), 1)
op["adjustments"] = [round(min(adj, 0.5), 4)]
if item["gradient"]:
g = item["gradient"]
stops = g["stops"]
n = len(stops)
if n > 2:
warnings.append("gradient has %d stops; keeping first and last" % n)
stops = [stops[0], stops[-1]]
positions = [s["pos"] if s["pos"] is not None else float(i)
for i, s in enumerate(stops)]
op["gradient"] = {
"colors": [s["hex"] for s in stops],
"positions": positions,
# CSS: 0deg points up, grows clockwise. python-pptx
# gradient_angle: 0deg points right, grows counterclockwise.
"angle": (90 - g["cssDeg"]) % 360,
}
elif item["fill"]:
op["fill"] = item["fill"]
if item.get("fillAlpha", 1) < 1:
warnings.append("fill alpha %.2f dropped (solid color emitted)" % item["fillAlpha"])
else:
op["fill"] = "none" # border-only frame stays hollow
op["shadow"] = False # browsers don't draw PPT's theme shadow
if item["border"]:
op["line_color"] = item["border"]["color"]
op["line_width"] = px2pt(item["border"]["w"])
if item["border"]["dashed"]:
op["line_dash"] = "dash"
else:
op["line"] = "none"
if item.get("rotation"):
op["rotation"] = item["rotation"]
ops.append(op)
# partial borders → centered line shapes per painted side
x, y, w, h = box["x"], box["y"], box["w"], box["h"]
for pb in item.get("partialBorders", []):
half = pb["w"] / 2.0
seg = {
0: [(x, y + half), (x + w, y + half)], # top
1: [(x + w - half, y), (x + w - half, y + h)], # right
2: [(x, y + h - half), (x + w, y + h - half)], # bottom
3: [(x + half, y), (x + half, y + h)], # left
}[pb["side"]]
line_op = {
"op": "add-shape", "slide": slide_ref, "kind": "line",
"from": [px2in(seg[0][0]), px2in(seg[0][1])],
"to": [px2in(seg[1][0]), px2in(seg[1][1])],
"line_color": pb["color"], "line_width": px2pt(pb["w"]),
}
if pb["dashed"]:
line_op["line_dash"] = "dash"
ops.append(line_op)
return ops
def table_ops(item, slide_ref, name, warnings):
box = item["box"]
rows = item["rows"]
ncols = max(len(r) for r in rows)
if any(len(r) != ncols for r in rows):
warnings.append("table rows are ragged; padding short rows with empty cells")
rows = [r + [""] * (ncols - len(r)) for r in rows]
# neutralize the theme's banded table style; transparent HTML cells become
# no-fill cells so the slide background shows through, like the browser
fill_set = {st.get("fill") for row in item["cellStyles"] for st in row}
all_styles = [st for row in item["cellStyles"] for st in row]
base_color = max((st["color"] for st in all_styles),
key=[st["color"] for st in all_styles].count)
op = {
"op": "add-table", "slide": slide_ref, "name": name,
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])],
"rows": rows,
"font_size": px2pt(item["fontSizePx"]),
"color": base_color,
"first_row": False, "banding": False,
}
if len(fill_set) == 1:
only = fill_set.pop()
op["fill"] = only or "none"
else:
op["fills"] = [[st.get("fill") or "none" for st in row]
for row in item["cellStyles"]]
if item.get("colWidths") and len(item["colWidths"]) == ncols:
op["col_widths"] = [px2in(w) for w in item["colWidths"]]
ops = [op]
# cells whose style differs from the table default get a set-text follow-up
base_pt = px2pt(item["fontSizePx"])
for ri, row_styles in enumerate(item["cellStyles"]):
for ci, st in enumerate(row_styles):
overrides = {}
if st["bold"]:
overrides["bold"] = True
if st["italic"]:
overrides["italic"] = True
if px2pt(st["sizePx"]) != base_pt:
overrides["font_size"] = px2pt(st["sizePx"])
if st["color"] != base_color:
overrides["color"] = st["color"]
if st.get("align") and st["align"] not in ("left", "start"):
overrides["alignment"] = st["align"].upper()
if overrides and rows[ri][ci]:
overrides["text"] = rows[ri][ci]
ops.append({"op": "set-text", "slide": slide_ref, "shape": name,
"cell": [ri, ci], "text": [overrides]})
return ops
def picture_op(path, box, fit, slide_ref):
"""add-picture op honoring object-fit / background-size semantics."""
op = {"op": "add-picture", "slide": slide_ref, "image": str(path),
"at": [px2in(box["x"]), px2in(box["y"])],
"size": [px2in(box["w"]), px2in(box["h"])]}
if fit in ("cover", "contain"):
from PIL import Image as PILImage
nw, nh = PILImage.open(path).size
nat_ar, box_ar = nw / nh, box["w"] / box["h"]
if fit == "cover": # crop the overflowing dimension
if nat_ar > box_ar:
c = round((1 - box_ar / nat_ar) / 2, 4)
op["crop"] = [c, 0, c, 0]
elif nat_ar < box_ar:
c = round((1 - nat_ar / box_ar) / 2, 4)
op["crop"] = [0, c, 0, c]
else: # contain: letterbox — shrink the target rect, centered
if nat_ar > box_ar:
h = box["w"] / nat_ar
op["at"] = [px2in(box["x"]), px2in(box["y"] + (box["h"] - h) / 2)]
op["size"] = [px2in(box["w"]), px2in(h)]
elif nat_ar < box_ar:
w = box["h"] * nat_ar
op["at"] = [px2in(box["x"] + (box["w"] - w) / 2), px2in(box["y"])]
op["size"] = [px2in(w), px2in(box["h"])]
return op
def compile_page(extract, slide_ref, html_path, tmpdir, prefix, warnings):
"""All ops for one extracted page targeting slide_ref."""
ops = []
seq = [0]
def next_name(kind):
seq[0] += 1
return "%s-%s-%d" % (prefix, kind, seq[0])
# the body's own paint arrives as the first item(s) from the extractor
for item in extract["items"]:
if item["type"] == "image":
p = resolve_image(item["src"], html_path.parent, tmpdir, warnings)
if not p:
continue
ops.append(picture_op(p, item["box"], item.get("fit", "fill"), slide_ref))
elif item["type"] == "text":
ops += text_block_ops(item, slide_ref, next_name("text"), warnings)
elif item["type"] == "box":
ops += box_ops(item, slide_ref, next_name("box"), warnings)
elif item["type"] == "table":
ops += table_ops(item, slide_ref, next_name("table"), warnings)
return ops
def main():
ap = argparse.ArgumentParser(
description="Compile HTML slides into a deck.py patch (see deck.py docs).")
ap.add_argument("html", nargs="+", help="HTML file(s), one per slide")
ap.add_argument("--deck", help=".pptx to read slide size + count from")
ap.add_argument("--slide", type=int,
help="target EXISTING slide index (single HTML file only)")
ap.add_argument("--layout", default=None,
help="layout for created slides (deck.py add-slide semantics)")
ap.add_argument("--size", help='slide size in inches, e.g. "13.333x7.5" (no --deck)')
ap.add_argument("--prefix", default=None,
help="shape name prefix (default: h2p-<n>)")
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
ap.add_argument("-o", "--out", help="output patch path (default: stdout)")
args = ap.parse_args()
if args.slide is not None and len(args.html) > 1:
die("--slide targets one existing slide; pass one HTML file")
if not args.deck and not args.size:
die("give --deck deck.pptx or --size WxH so geometry can be validated")
base_count = None
if args.deck:
try:
from pptx import Presentation
except ImportError:
die("python-pptx is required for --deck (pip install python-pptx)")
prs = Presentation(args.deck)
slide_w = prs.slide_width / 914400
slide_h = prs.slide_height / 914400
base_count = len(prs.slides._sldIdLst)
if args.slide is not None and not (0 <= args.slide < base_count):
die("--slide %d out of range (deck has %d slides)" % (args.slide, base_count))
else:
slide_w, slide_h = parse_size(args.size)
if args.slide is None:
die("without --deck, give --slide N (add-slide needs a real deck to count from)")
try:
from playwright.sync_api import sync_playwright
except ImportError:
die("playwright is required: pip install playwright && playwright install chromium")
warnings = []
all_ops = []
tmpdir = tempfile.mkdtemp(prefix="html2patch-")
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page(viewport={"width": int(slide_w * 96), "height": int(slide_h * 96)})
for i, html_file in enumerate(args.html):
html_path = Path(html_file).resolve()
if not html_path.exists():
die("no such file: %s" % html_file)
page.goto(html_path.as_uri())
extract = page.evaluate(EXTRACT_JS)
body = extract["body"]
errs = []
if abs(body["w"] / PX_PER_IN - slide_w) > 0.05 or abs(body["h"] / PX_PER_IN - slide_h) > 0.05:
errs.append(
"%s: body is %.2fx%.2fin but the slide is %.2fx%.2fin — set "
"body {width:%dpx; height:%dpx}" % (
html_file, body["w"] / PX_PER_IN, body["h"] / PX_PER_IN,
slide_w, slide_h, round(slide_w * 96), round(slide_h * 96)))
over_w = max(0, body["scrollW"] - body["w"] - 1)
over_h = max(0, body["scrollH"] - body["h"] - 1)
if over_w or over_h:
errs.append("%s: content overflows the body by %dpx horizontally / %dpx "
"vertically — fix the HTML before compiling" % (html_file, over_w, over_h))
if errs:
die("\n".join(errs))
if args.slide is not None:
slide_ref = args.slide
else:
slide_ref = base_count + i
all_ops.append({"op": "add-slide",
**({"layout": args.layout} if args.layout else {})})
prefix = args.prefix or ("h2p-%d" % (i + 1) if len(args.html) > 1 else "h2p")
for w in extract["warnings"]:
warnings.append("%s: %s" % (html_file, w))
all_ops += compile_page(extract, slide_ref, html_path, tmpdir, prefix, warnings)
browser.close()
for w in sorted(set(warnings)):
sys.stderr.write("html2patch: warning: %s\n" % w)
if warnings and args.strict:
die("%d warning(s) with --strict" % len(warnings))
patch = json.dumps({"ops": all_ops}, indent=1, ensure_ascii=False)
if args.out:
Path(args.out).write_text(patch + "\n")
sys.stderr.write("html2patch: %d op(s) -> %s\n" % (len(all_ops), args.out))
else:
print(patch)
if __name__ == "__main__":
main()
scripts/inventory.py
python
#!/usr/bin/env python3
"""
Extract structured text content from PowerPoint presentations.
This module provides functionality to:
- Extract all text content from PowerPoint shapes
- Preserve paragraph formatting (alignment, bullets, fonts, spacing)
- Handle nested GroupShapes recursively with correct absolute positions
- Sort shapes by visual position on slides
- Filter out slide numbers and non-content placeholders
- Export to JSON with clean, structured data
Classes:
ParagraphData: Represents a text paragraph with formatting
ShapeData: Represents a shape with position and text content
Main Functions:
extract_text_inventory: Extract all text from a presentation
save_inventory: Save extracted data to JSON
Usage:
python inventory.py input.pptx output.json
"""
import argparse
import json
import platform
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw, ImageFont
from pptx import Presentation
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.shapes.base import BaseShape
# Type aliases for cleaner signatures
JsonValue = Union[str, int, float, bool, None]
ParagraphDict = Dict[str, JsonValue]
ShapeDict = Dict[
str, Union[str, float, bool, List[ParagraphDict], List[str], Dict[str, Any], None]
]
InventoryData = Dict[
str, Dict[str, "ShapeData"]
] # Dict of slide_id -> {shape_id -> ShapeData}
InventoryDict = Dict[str, Dict[str, ShapeDict]] # JSON-serializable inventory
def main():
"""Main entry point for command-line usage."""
parser = argparse.ArgumentParser(
description="Extract text inventory from PowerPoint with proper GroupShape support.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python inventory.py presentation.pptx inventory.json
Extracts text inventory with correct absolute positions for grouped shapes
python inventory.py presentation.pptx inventory.json --issues-only
Extracts only text shapes that have overflow or overlap issues
The output JSON includes:
- All text content organized by slide and shape
- Correct absolute positions for shapes in groups
- Visual position and size in inches
- Paragraph properties and formatting
- Issue detection: text overflow and shape overlaps
""",
)
parser.add_argument("input", help="Input PowerPoint file (.pptx)")
parser.add_argument("output", help="Output JSON file for inventory")
parser.add_argument(
"--issues-only",
action="store_true",
help="Include only text shapes that have overflow or overlap issues",
)
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}")
sys.exit(1)
if not input_path.suffix.lower() == ".pptx":
print("Error: Input must be a PowerPoint file (.pptx)")
sys.exit(1)
try:
print(f"Extracting text inventory from: {args.input}")
if args.issues_only:
print(
"Filtering to include only text shapes with issues (overflow/overlap)"
)
inventory = extract_text_inventory(input_path, issues_only=args.issues_only)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
save_inventory(inventory, output_path)
print(f"Output saved to: {args.output}")
# Report statistics
total_slides = len(inventory)
total_shapes = sum(len(shapes) for shapes in inventory.values())
if args.issues_only:
if total_shapes > 0:
print(
f"Found {total_shapes} text elements with issues in {total_slides} slides"
)
else:
print("No issues discovered")
else:
print(
f"Found text in {total_slides} slides with {total_shapes} text elements"
)
except Exception as e:
print(f"Error processing presentation: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
SERIF_HINTS = ("caslon", "georgia", "times", "serif", "lora", "playfair", "garamond", "baskerville")
_SANS_FALLBACKS = [
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
]
_SERIF_FALLBACKS = [
"/System/Library/Fonts/Supplemental/Georgia.ttf",
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
]
def load_measure_font(font_name: str, size: int):
"""Load a font for text measurement at the CORRECT SIZE.
Critical: PIL's bare load_default() is an ~11px bitmap font that ignores
the requested size, which silently disables overflow detection for any
font not installed on this machine. Always fall back to a sized system
font instead (serif-aware so metrics stay roughly comparable)."""
path = ShapeData.get_font_path(font_name) if font_name else None
if path:
try:
return ImageFont.truetype(path, size=size)
except Exception:
pass
serif = any(h in (font_name or "").lower() for h in SERIF_HINTS)
candidates = (_SERIF_FALLBACKS + _SANS_FALLBACKS) if serif else (_SANS_FALLBACKS + _SERIF_FALLBACKS)
for cand in candidates:
if Path(cand).exists():
try:
return ImageFont.truetype(cand, size=size)
except Exception:
continue
try:
return ImageFont.load_default(size=size) # Pillow >= 10.1
except TypeError:
return ImageFont.load_default()
@dataclass
class ShapeWithPosition:
"""A shape with its absolute position on the slide."""
shape: BaseShape
absolute_left: int # in EMUs
absolute_top: int # in EMUs
class ParagraphData:
"""Data structure for paragraph properties extracted from a PowerPoint paragraph."""
def __init__(self, paragraph: Any):
"""Initialize from a PowerPoint paragraph object.
Args:
paragraph: The PowerPoint paragraph object
"""
self.text: str = paragraph.text.strip()
self.bullet: bool = False
self.level: Optional[int] = None
self.alignment: Optional[str] = None
self.space_before: Optional[float] = None
self.space_after: Optional[float] = None
self.font_name: Optional[str] = None
self.font_size: Optional[float] = None
self.bold: Optional[bool] = None
self.italic: Optional[bool] = None
self.underline: Optional[bool] = None
self.color: Optional[str] = None
self.theme_color: Optional[str] = None
self.line_spacing: Optional[float] = None
# Check for bullet formatting
if (
hasattr(paragraph, "_p")
and paragraph._p is not None
and paragraph._p.pPr is not None
):
pPr = paragraph._p.pPr
ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
if (
pPr.find(f"{ns}buChar") is not None
or pPr.find(f"{ns}buAutoNum") is not None
):
self.bullet = True
if hasattr(paragraph, "level"):
self.level = paragraph.level
# Add alignment if not LEFT (default)
if hasattr(paragraph, "alignment") and paragraph.alignment is not None:
alignment_map = {
PP_ALIGN.CENTER: "CENTER",
PP_ALIGN.RIGHT: "RIGHT",
PP_ALIGN.JUSTIFY: "JUSTIFY",
}
if paragraph.alignment in alignment_map:
self.alignment = alignment_map[paragraph.alignment]
# Add spacing properties if set
if hasattr(paragraph, "space_before") and paragraph.space_before:
self.space_before = paragraph.space_before.pt
if hasattr(paragraph, "space_after") and paragraph.space_after:
self.space_after = paragraph.space_after.pt
# Extract font properties from first run
if paragraph.runs:
first_run = paragraph.runs[0]
if hasattr(first_run, "font"):
font = first_run.font
if font.name:
self.font_name = font.name
if font.size:
self.font_size = font.size.pt
if font.bold is not None:
self.bold = font.bold
if font.italic is not None:
self.italic = font.italic
if font.underline is not None:
self.underline = font.underline
# Handle color - both RGB and theme colors
try:
# Try RGB color first
if font.color.rgb:
self.color = str(font.color.rgb)
except (AttributeError, TypeError):
# Fall back to theme color
try:
if font.color.theme_color:
self.theme_color = font.color.theme_color.name
except (AttributeError, TypeError):
pass
# Add line spacing if set
if hasattr(paragraph, "line_spacing") and paragraph.line_spacing is not None:
if hasattr(paragraph.line_spacing, "pt"):
self.line_spacing = round(paragraph.line_spacing.pt, 2)
else:
# Multiplier - convert to points
font_size = self.font_size if self.font_size else 12.0
self.line_spacing = round(paragraph.line_spacing * font_size, 2)
def to_dict(self) -> ParagraphDict:
"""Convert to dictionary for JSON serialization, excluding None values."""
result: ParagraphDict = {"text": self.text}
# Add optional fields only if they have values
if self.bullet:
result["bullet"] = self.bullet
if self.level is not None:
result["level"] = self.level
if self.alignment:
result["alignment"] = self.alignment
if self.space_before is not None:
result["space_before"] = self.space_before
if self.space_after is not None:
result["space_after"] = self.space_after
if self.font_name:
result["font_name"] = self.font_name
if self.font_size is not None:
result["font_size"] = self.font_size
if self.bold is not None:
result["bold"] = self.bold
if self.italic is not None:
result["italic"] = self.italic
if self.underline is not None:
result["underline"] = self.underline
if self.color:
result["color"] = self.color
if self.theme_color:
result["theme_color"] = self.theme_color
if self.line_spacing is not None:
result["line_spacing"] = self.line_spacing
return result
class ShapeData:
"""Data structure for shape properties extracted from a PowerPoint shape."""
@staticmethod
def emu_to_inches(emu: int) -> float:
"""Convert EMUs (English Metric Units) to inches."""
return emu / 914400.0
@staticmethod
def inches_to_pixels(inches: float, dpi: int = 96) -> int:
"""Convert inches to pixels at given DPI."""
return int(inches * dpi)
@staticmethod
def get_font_path(font_name: str) -> Optional[str]:
"""Get the font file path for a given font name.
Args:
font_name: Name of the font (e.g., 'Arial', 'Calibri')
Returns:
Path to the font file, or None if not found
"""
system = platform.system()
# Common font file variations to try
font_variations = [
font_name,
font_name.lower(),
font_name.replace(" ", ""),
font_name.replace(" ", "-"),
]
# Define font directories and extensions by platform
if system == "Darwin": # macOS
font_dirs = [
"/System/Library/Fonts/",
"/Library/Fonts/",
"~/Library/Fonts/",
]
extensions = [".ttf", ".otf", ".ttc", ".dfont"]
else: # Linux
font_dirs = [
"/usr/share/fonts/truetype/",
"/usr/local/share/fonts/",
"~/.fonts/",
]
extensions = [".ttf", ".otf"]
# Try to find the font file
from pathlib import Path
for font_dir in font_dirs:
font_dir_path = Path(font_dir).expanduser()
if not font_dir_path.exists():
continue
# First try exact matches
for variant in font_variations:
for ext in extensions:
font_path = font_dir_path / f"{variant}{ext}"
if font_path.exists():
return str(font_path)
# Then try fuzzy matching - find files containing the font name
try:
for file_path in font_dir_path.iterdir():
if file_path.is_file():
file_name_lower = file_path.name.lower()
font_name_lower = font_name.lower().replace(" ", "")
if font_name_lower in file_name_lower and any(
file_name_lower.endswith(ext) for ext in extensions
):
return str(file_path)
except (OSError, PermissionError):
continue
return None
@staticmethod
def get_slide_dimensions(slide: Any) -> tuple[Optional[int], Optional[int]]:
"""Get slide dimensions from slide object.
Args:
slide: Slide object
Returns:
Tuple of (width_emu, height_emu) or (None, None) if not found
"""
try:
prs = slide.part.package.presentation_part.presentation
return prs.slide_width, prs.slide_height
except (AttributeError, TypeError):
return None, None
@staticmethod
def get_default_font_size(shape: BaseShape, slide_layout: Any) -> Optional[float]:
"""Extract default font size from slide layout for a placeholder shape.
Args:
shape: Placeholder shape
slide_layout: Slide layout containing the placeholder definition
Returns:
Default font size in points, or None if not found
"""
try:
if not hasattr(shape, "placeholder_format"):
return None
shape_type = shape.placeholder_format.type # type: ignore
for layout_placeholder in slide_layout.placeholders:
if layout_placeholder.placeholder_format.type == shape_type:
# Find first defRPr element with sz (size) attribute
for elem in layout_placeholder.element.iter():
if "defRPr" in elem.tag and (sz := elem.get("sz")):
return float(sz) / 100.0 # Convert EMUs to points
break
except Exception:
pass
return None
def __init__(
self,
shape: BaseShape,
absolute_left: Optional[int] = None,
absolute_top: Optional[int] = None,
slide: Optional[Any] = None,
):
"""Initialize from a PowerPoint shape object.
Args:
shape: The PowerPoint shape object (should be pre-validated)
absolute_left: Absolute left position in EMUs (for shapes in groups)
absolute_top: Absolute top position in EMUs (for shapes in groups)
slide: Optional slide object to get dimensions and layout information
"""
self.shape = shape # Store reference to original shape
self.shape_id: str = "" # Will be set after sorting
# Get slide dimensions from slide object
self.slide_width_emu, self.slide_height_emu = (
self.get_slide_dimensions(slide) if slide else (None, None)
)
# Get placeholder type if applicable
self.placeholder_type: Optional[str] = None
self.default_font_size: Optional[float] = None
if hasattr(shape, "is_placeholder") and shape.is_placeholder: # type: ignore
if shape.placeholder_format and shape.placeholder_format.type: # type: ignore
self.placeholder_type = (
str(shape.placeholder_format.type).split(".")[-1].split(" ")[0] # type: ignore
)
# Get default font size from layout
if slide and hasattr(slide, "slide_layout"):
self.default_font_size = self.get_default_font_size(
shape, slide.slide_layout
)
# Get position information
# Use absolute positions if provided (for shapes in groups), otherwise use shape's position
left_emu = (
absolute_left
if absolute_left is not None
else (shape.left if hasattr(shape, "left") else 0)
)
top_emu = (
absolute_top
if absolute_top is not None
else (shape.top if hasattr(shape, "top") else 0)
)
self.left: float = round(self.emu_to_inches(left_emu), 2) # type: ignore
self.top: float = round(self.emu_to_inches(top_emu), 2) # type: ignore
self.width: float = round(
self.emu_to_inches(shape.width if hasattr(shape, "width") else 0),
2, # type: ignore
)
self.height: float = round(
self.emu_to_inches(shape.height if hasattr(shape, "height") else 0),
2, # type: ignore
)
# Store EMU positions for overflow calculations
self.left_emu = left_emu
self.top_emu = top_emu
self.width_emu = shape.width if hasattr(shape, "width") else 0
self.height_emu = shape.height if hasattr(shape, "height") else 0
# Calculate overflow status
self.frame_overflow_bottom: Optional[float] = None
self.slide_overflow_right: Optional[float] = None
self.slide_overflow_bottom: Optional[float] = None
self.overlapping_shapes: Dict[
str, float
] = {} # Dict of shape_id -> overlap area in sq inches
self.warnings: List[str] = []
self._estimate_frame_overflow()
self._calculate_slide_overflow()
self._detect_bullet_issues()
@property
def vertical_anchor(self) -> Optional[str]:
"""Vertical anchor of the text frame (TOP|MIDDLE|BOTTOM).
Returns None when the anchor is the default top (or unreadable) so the
inspect output stays uncluttered — mirrors how alignment is only shown
when it is not the LEFT default."""
if not self.shape or not getattr(self.shape, "has_text_frame", False):
return None
try:
va = self.shape.text_frame.vertical_anchor
except Exception:
return None
return {MSO_ANCHOR.MIDDLE: "MIDDLE", MSO_ANCHOR.BOTTOM: "BOTTOM"}.get(va)
@property
def paragraphs(self) -> List[ParagraphData]:
"""Calculate paragraphs from the shape's text frame."""
if not self.shape or not hasattr(self.shape, "text_frame"):
return []
paragraphs = []
for paragraph in self.shape.text_frame.paragraphs: # type: ignore
if paragraph.text.strip():
paragraphs.append(ParagraphData(paragraph))
return paragraphs
def _get_default_font_size(self) -> int:
"""Get default font size from theme text styles or use conservative default."""
try:
if not (
hasattr(self.shape, "part") and hasattr(self.shape.part, "slide_layout")
):
return 14
slide_master = self.shape.part.slide_layout.slide_master # type: ignore
if not hasattr(slide_master, "element"):
return 14
# Determine theme style based on placeholder type
style_name = "bodyStyle" # Default
if self.placeholder_type and "TITLE" in self.placeholder_type:
style_name = "titleStyle"
# Find font size in theme styles
for child in slide_master.element.iter():
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == style_name:
for elem in child.iter():
if "sz" in elem.attrib:
return int(elem.attrib["sz"]) // 100
except Exception:
pass
return 14 # Conservative default for body text
def _get_usable_dimensions(self, text_frame) -> Tuple[int, int]:
"""Get usable width and height in pixels after accounting for margins."""
# Default PowerPoint margins in inches
margins = {"top": 0.05, "bottom": 0.05, "left": 0.1, "right": 0.1}
# Override with actual margins if set (0 is a real value — explicit
# zero insets must not fall back to the defaults)
if getattr(text_frame, "margin_top", None) is not None:
margins["top"] = self.emu_to_inches(text_frame.margin_top)
if getattr(text_frame, "margin_bottom", None) is not None:
margins["bottom"] = self.emu_to_inches(text_frame.margin_bottom)
if getattr(text_frame, "margin_left", None) is not None:
margins["left"] = self.emu_to_inches(text_frame.margin_left)
if getattr(text_frame, "margin_right", None) is not None:
margins["right"] = self.emu_to_inches(text_frame.margin_right)
# Calculate usable area
usable_width = self.width - margins["left"] - margins["right"]
usable_height = self.height - margins["top"] - margins["bottom"]
# Convert to pixels
return (
self.inches_to_pixels(usable_width),
self.inches_to_pixels(usable_height),
)
def _wrap_text_line(self, line: str, max_width_px: int, draw, font) -> List[str]:
"""Wrap a single line of text to fit within max_width_px."""
if not line:
return [""]
# Use textlength for efficient width calculation
if draw.textlength(line, font=font) <= max_width_px:
return [line]
# Need to wrap - split into words
wrapped = []
words = line.split(" ")
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
if draw.textlength(test_line, font=font) <= max_width_px:
current_line = test_line
else:
if current_line:
wrapped.append(current_line)
current_line = word
if current_line:
wrapped.append(current_line)
return wrapped
def _estimate_frame_overflow(self) -> None:
"""Estimate if text overflows the shape bounds using PIL text measurement."""
if not self.shape or not hasattr(self.shape, "text_frame"):
return
text_frame = self.shape.text_frame # type: ignore
if not text_frame or not text_frame.paragraphs:
return
# Get usable dimensions after accounting for margins
usable_width_px, usable_height_px = self._get_usable_dimensions(text_frame)
if usable_width_px <= 0 or usable_height_px <= 0:
return
# Set up PIL for text measurement
dummy_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(dummy_img)
# Get default font size from placeholder or use conservative estimate
default_font_size = self._get_default_font_size()
# Calculate total height of all paragraphs
total_height_px = 0
for para_idx, paragraph in enumerate(text_frame.paragraphs):
if not paragraph.text.strip():
continue
para_data = ParagraphData(paragraph)
# Load font for this paragraph (sized fallback when not installed —
# a bare load_default() ignores size and breaks overflow detection)
font_name = para_data.font_name or "Arial"
font_size = int(para_data.font_size or default_font_size)
font = load_measure_font(font_name, font_size)
# Wrap all lines in this paragraph
all_wrapped_lines = []
for line in paragraph.text.split("\n"):
wrapped = self._wrap_text_line(line, usable_width_px, draw, font)
all_wrapped_lines.extend(wrapped)
if all_wrapped_lines:
# Calculate line height
if para_data.line_spacing:
# Custom line spacing explicitly set
line_height_px = para_data.line_spacing * 96 / 72
else:
# PowerPoint default single spacing (1.0x font size)
line_height_px = font_size * 96 / 72
# Add space_before (except first paragraph)
if para_idx > 0 and para_data.space_before:
total_height_px += para_data.space_before * 96 / 72
# Add paragraph text height
total_height_px += len(all_wrapped_lines) * line_height_px
# Add space_after
if para_data.space_after:
total_height_px += para_data.space_after * 96 / 72
# Check for overflow (ignore negligible overflows <= 0.05")
if total_height_px > usable_height_px:
overflow_px = total_height_px - usable_height_px
overflow_inches = round(overflow_px / 96.0, 2)
if overflow_inches > 0.05: # Only report significant overflows
self.frame_overflow_bottom = overflow_inches
def _calculate_slide_overflow(self) -> None:
"""Calculate if shape overflows the slide boundaries."""
if self.slide_width_emu is None or self.slide_height_emu is None:
return
# Check right overflow (ignore negligible overflows <= 0.01")
right_edge_emu = self.left_emu + self.width_emu
if right_edge_emu > self.slide_width_emu:
overflow_emu = right_edge_emu - self.slide_width_emu
overflow_inches = round(self.emu_to_inches(overflow_emu), 2)
if overflow_inches > 0.01: # Only report significant overflows
self.slide_overflow_right = overflow_inches
# Check bottom overflow (ignore negligible overflows <= 0.01")
bottom_edge_emu = self.top_emu + self.height_emu
if bottom_edge_emu > self.slide_height_emu:
overflow_emu = bottom_edge_emu - self.slide_height_emu
overflow_inches = round(self.emu_to_inches(overflow_emu), 2)
if overflow_inches > 0.01: # Only report significant overflows
self.slide_overflow_bottom = overflow_inches
def _detect_bullet_issues(self) -> None:
"""Detect bullet point formatting issues in paragraphs."""
if not self.shape or not hasattr(self.shape, "text_frame"):
return
text_frame = self.shape.text_frame # type: ignore
if not text_frame or not text_frame.paragraphs:
return
# Common bullet symbols that indicate manual bullets
bullet_symbols = ["•", "●", "○"]
for paragraph in text_frame.paragraphs:
text = paragraph.text.strip()
# Check for manual bullet symbols
if text and any(text.startswith(symbol + " ") for symbol in bullet_symbols):
self.warnings.append(
"manual_bullet_symbol: use proper bullet formatting"
)
break
@property
def has_any_issues(self) -> bool:
"""Check if shape has any issues (overflow, overlap, or warnings)."""
return (
self.frame_overflow_bottom is not None
or self.slide_overflow_right is not None
or self.slide_overflow_bottom is not None
or len(self.overlapping_shapes) > 0
or len(self.warnings) > 0
)
def to_dict(self) -> ShapeDict:
"""Convert to dictionary for JSON serialization."""
result: ShapeDict = {
"left": self.left,
"top": self.top,
"width": self.width,
"height": self.height,
}
# Add optional fields if present
if self.placeholder_type:
result["placeholder_type"] = self.placeholder_type
if self.default_font_size:
result["default_font_size"] = self.default_font_size
# Add overflow information only if there is overflow
overflow_data = {}
# Add frame overflow if present
if self.frame_overflow_bottom is not None:
overflow_data["frame"] = {"overflow_bottom": self.frame_overflow_bottom}
# Add slide overflow if present
slide_overflow = {}
if self.slide_overflow_right is not None:
slide_overflow["overflow_right"] = self.slide_overflow_right
if self.slide_overflow_bottom is not None:
slide_overflow["overflow_bottom"] = self.slide_overflow_bottom
if slide_overflow:
overflow_data["slide"] = slide_overflow
# Only add overflow field if there is overflow
if overflow_data:
result["overflow"] = overflow_data
# Add overlap field if there are overlapping shapes
if self.overlapping_shapes:
result["overlap"] = {"overlapping_shapes": self.overlapping_shapes}
# Add warnings field if there are warnings
if self.warnings:
result["warnings"] = self.warnings
# Vertical anchor — only when it differs from the default top
anchor = self.vertical_anchor
if anchor:
result["anchor"] = anchor
# Add paragraphs after placeholder_type
result["paragraphs"] = [para.to_dict() for para in self.paragraphs]
return result
def is_valid_shape(shape: BaseShape) -> bool:
"""Check if a shape contains meaningful text content."""
# Must have a text frame with content
if not hasattr(shape, "text_frame") or not shape.text_frame: # type: ignore
return False
text = shape.text_frame.text.strip() # type: ignore
if not text:
return False
# Skip slide numbers and numeric footers
if hasattr(shape, "is_placeholder") and shape.is_placeholder: # type: ignore
if shape.placeholder_format and shape.placeholder_format.type: # type: ignore
placeholder_type = (
str(shape.placeholder_format.type).split(".")[-1].split(" ")[0] # type: ignore
)
if placeholder_type == "SLIDE_NUMBER":
return False
if placeholder_type == "FOOTER" and text.isdigit():
return False
return True
def collect_shapes_with_absolute_positions(
shape: BaseShape, parent_left: int = 0, parent_top: int = 0
) -> List[ShapeWithPosition]:
"""Recursively collect all shapes with valid text, calculating absolute positions.
For shapes within groups, their positions are relative to the group.
This function calculates the absolute position on the slide by accumulating
parent group offsets.
Args:
shape: The shape to process
parent_left: Accumulated left offset from parent groups (in EMUs)
parent_top: Accumulated top offset from parent groups (in EMUs)
Returns:
List of ShapeWithPosition objects with absolute positions
"""
if hasattr(shape, "shapes"): # GroupShape
result = []
# Get this group's position
group_left = shape.left if hasattr(shape, "left") else 0
group_top = shape.top if hasattr(shape, "top") else 0
# Calculate absolute position for this group
abs_group_left = parent_left + group_left
abs_group_top = parent_top + group_top
# Process children with accumulated offsets
for child in shape.shapes: # type: ignore
result.extend(
collect_shapes_with_absolute_positions(
child, abs_group_left, abs_group_top
)
)
return result
# Regular shape - check if it has valid text
if is_valid_shape(shape):
# Calculate absolute position
shape_left = shape.left if hasattr(shape, "left") else 0
shape_top = shape.top if hasattr(shape, "top") else 0
return [
ShapeWithPosition(
shape=shape,
absolute_left=parent_left + shape_left,
absolute_top=parent_top + shape_top,
)
]
return []
def sort_shapes_by_position(shapes: List[ShapeData]) -> List[ShapeData]:
"""Sort shapes by visual position (top-to-bottom, left-to-right).
Shapes within 0.5 inches vertically are considered on the same row.
"""
if not shapes:
return shapes
# Sort by top position first
shapes = sorted(shapes, key=lambda s: (s.top, s.left))
# Group shapes by row (within 0.5 inches vertically)
result = []
row = [shapes[0]]
row_top = shapes[0].top
for shape in shapes[1:]:
if abs(shape.top - row_top) <= 0.5:
row.append(shape)
else:
# Sort current row by left position and add to result
result.extend(sorted(row, key=lambda s: s.left))
row = [shape]
row_top = shape.top
# Don't forget the last row
result.extend(sorted(row, key=lambda s: s.left))
return result
def calculate_overlap(
rect1: Tuple[float, float, float, float],
rect2: Tuple[float, float, float, float],
tolerance: float = 0.05,
) -> Tuple[bool, float]:
"""Calculate if and how much two rectangles overlap.
Args:
rect1: (left, top, width, height) of first rectangle in inches
rect2: (left, top, width, height) of second rectangle in inches
tolerance: Minimum overlap in inches to consider as overlapping (default: 0.05")
Returns:
Tuple of (overlaps, overlap_area) where:
- overlaps: True if rectangles overlap by more than tolerance
- overlap_area: Area of overlap in square inches
"""
left1, top1, w1, h1 = rect1
left2, top2, w2, h2 = rect2
# Calculate overlap dimensions
overlap_width = min(left1 + w1, left2 + w2) - max(left1, left2)
overlap_height = min(top1 + h1, top2 + h2) - max(top1, top2)
# Check if there's meaningful overlap (more than tolerance)
if overlap_width > tolerance and overlap_height > tolerance:
# Calculate overlap area in square inches
overlap_area = overlap_width * overlap_height
return True, round(overlap_area, 2)
return False, 0
def detect_overlaps(shapes: List[ShapeData]) -> None:
"""Detect overlapping shapes and update their overlapping_shapes dictionaries.
This function requires each ShapeData to have its shape_id already set.
It modifies the shapes in-place, adding shape IDs with overlap areas in square inches.
Args:
shapes: List of ShapeData objects with shape_id attributes set
"""
n = len(shapes)
# Compare each pair of shapes
for i in range(n):
for j in range(i + 1, n):
shape1 = shapes[i]
shape2 = shapes[j]
# Ensure shape IDs are set
assert shape1.shape_id, f"Shape at index {i} has no shape_id"
assert shape2.shape_id, f"Shape at index {j} has no shape_id"
rect1 = (shape1.left, shape1.top, shape1.width, shape1.height)
rect2 = (shape2.left, shape2.top, shape2.width, shape2.height)
overlaps, overlap_area = calculate_overlap(rect1, rect2)
if overlaps:
# Add shape IDs with overlap area in square inches
shape1.overlapping_shapes[shape2.shape_id] = overlap_area
shape2.overlapping_shapes[shape1.shape_id] = overlap_area
def cluster_1d(items, eps):
"""Greedy 1-D clustering of edge coordinates.
items: iterable of (key, coord) pairs (key identifies who owns the edge).
Sorts by coord and starts a new cluster wherever the gap to the previous
coord exceeds eps. Returns a list of clusters, each a list of (key, coord).
Single-linkage drift is irrelevant here: real gridlines are far tighter
than eps, so a populated cluster stays tight."""
out = []
cur = []
last = None
for key, coord in sorted(items, key=lambda kc: kc[1]):
if last is not None and coord - last > eps:
out.append(cur)
cur = []
cur.append((key, coord))
last = coord
if cur:
out.append(cur)
return out
def extract_text_inventory(
pptx_path: Path, prs: Optional[Any] = None, issues_only: bool = False
) -> InventoryData:
"""Extract text content from all slides in a PowerPoint presentation.
Args:
pptx_path: Path to the PowerPoint file
prs: Optional Presentation object to use. If not provided, will load from pptx_path.
issues_only: If True, only include shapes that have overflow or overlap issues
Returns a nested dictionary: {slide-N: {shape-N: ShapeData}}
Shapes are sorted by visual position (top-to-bottom, left-to-right).
The ShapeData objects contain the full shape information and can be
converted to dictionaries for JSON serialization using to_dict().
"""
if prs is None:
prs = Presentation(str(pptx_path))
inventory: InventoryData = {}
for slide_idx, slide in enumerate(prs.slides):
# Collect all valid shapes from this slide with absolute positions
shapes_with_positions = []
for shape in slide.shapes: # type: ignore
shapes_with_positions.extend(collect_shapes_with_absolute_positions(shape))
if not shapes_with_positions:
continue
# Convert to ShapeData with absolute positions and slide reference
shape_data_list = [
ShapeData(
swp.shape,
swp.absolute_left,
swp.absolute_top,
slide,
)
for swp in shapes_with_positions
]
# Sort by visual position and assign stable IDs in one step
sorted_shapes = sort_shapes_by_position(shape_data_list)
for idx, shape_data in enumerate(sorted_shapes):
shape_data.shape_id = f"shape-{idx}"
# Detect overlaps using the stable shape IDs
if len(sorted_shapes) > 1:
detect_overlaps(sorted_shapes)
# Filter for issues only if requested (after overlap detection)
if issues_only:
sorted_shapes = [sd for sd in sorted_shapes if sd.has_any_issues]
if not sorted_shapes:
continue
# Create slide inventory using the stable shape IDs
inventory[f"slide-{slide_idx}"] = {
shape_data.shape_id: shape_data for shape_data in sorted_shapes
}
return inventory
def get_inventory_as_dict(pptx_path: Path, issues_only: bool = False) -> InventoryDict:
"""Extract text inventory and return as JSON-serializable dictionaries.
This is a convenience wrapper around extract_text_inventory that returns
dictionaries instead of ShapeData objects, useful for testing and direct
JSON serialization.
Args:
pptx_path: Path to the PowerPoint file
issues_only: If True, only include shapes that have overflow or overlap issues
Returns:
Nested dictionary with all data serialized for JSON
"""
inventory = extract_text_inventory(pptx_path, issues_only=issues_only)
# Convert ShapeData objects to dictionaries
dict_inventory: InventoryDict = {}
for slide_key, shapes in inventory.items():
dict_inventory[slide_key] = {
shape_key: shape_data.to_dict() for shape_key, shape_data in shapes.items()
}
return dict_inventory
def save_inventory(inventory: InventoryData, output_path: Path) -> None:
"""Save inventory to JSON file with proper formatting.
Converts ShapeData objects to dictionaries for JSON serialization.
"""
# Convert ShapeData objects to dictionaries
json_inventory: InventoryDict = {}
for slide_key, shapes in inventory.items():
json_inventory[slide_key] = {
shape_key: shape_data.to_dict() for shape_key, shape_data in shapes.items()
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(json_inventory, f, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Extract structured text content from PowerPoint presentations.
This module provides functionality to:
- Extract all text content from PowerPoint shapes
- Preserve paragraph formatting (alignment, bullets, fonts, spacing)
- Handle nested GroupShapes recursively with correct absolute positions
- Sort shapes by visual position on slides
- Filter out slide numbers and non-content placeholders
- Export to JSON with clean, structured data
Classes:
ParagraphData: Represents a text paragraph with formatting
ShapeData: Represents a shape with position and text content
Main Functions:
extract_text_inventory: Extract all text from a presentation
save_inventory: Save extracted data to JSON
Usage:
python inventory.py input.pptx output.json
"""
import argparse
import json
import platform
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw, ImageFont
from pptx import Presentation
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.shapes.base import BaseShape
# Type aliases for cleaner signatures
JsonValue = Union[str, int, float, bool, None]
ParagraphDict = Dict[str, JsonValue]
ShapeDict = Dict[
str, Union[str, float, bool, List[ParagraphDict], List[str], Dict[str, Any], None]
]
InventoryData = Dict[
str, Dict[str, "ShapeData"]
] # Dict of slide_id -> {shape_id -> ShapeData}
InventoryDict = Dict[str, Dict[str, ShapeDict]] # JSON-serializable inventory
def main():
"""Main entry point for command-line usage."""
parser = argparse.ArgumentParser(
description="Extract text inventory from PowerPoint with proper GroupShape support.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python inventory.py presentation.pptx inventory.json
Extracts text inventory with correct absolute positions for grouped shapes
python inventory.py presentation.pptx inventory.json --issues-only
Extracts only text shapes that have overflow or overlap issues
The output JSON includes:
- All text content organized by slide and shape
- Correct absolute positions for shapes in groups
- Visual position and size in inches
- Paragraph properties and formatting
- Issue detection: text overflow and shape overlaps
""",
)
parser.add_argument("input", help="Input PowerPoint file (.pptx)")
parser.add_argument("output", help="Output JSON file for inventory")
parser.add_argument(
"--issues-only",
action="store_true",
help="Include only text shapes that have overflow or overlap issues",
)
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}")
sys.exit(1)
if not input_path.suffix.lower() == ".pptx":
print("Error: Input must be a PowerPoint file (.pptx)")
sys.exit(1)
try:
print(f"Extracting text inventory from: {args.input}")
if args.issues_only:
print(
"Filtering to include only text shapes with issues (overflow/overlap)"
)
inventory = extract_text_inventory(input_path, issues_only=args.issues_only)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
save_inventory(inventory, output_path)
print(f"Output saved to: {args.output}")
# Report statistics
total_slides = len(inventory)
total_shapes = sum(len(shapes) for shapes in inventory.values())
if args.issues_only:
if total_shapes > 0:
print(
f"Found {total_shapes} text elements with issues in {total_slides} slides"
)
else:
print("No issues discovered")
else:
print(
f"Found text in {total_slides} slides with {total_shapes} text elements"
)
except Exception as e:
print(f"Error processing presentation: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
SERIF_HINTS = ("caslon", "georgia", "times", "serif", "lora", "playfair", "garamond", "baskerville")
_SANS_FALLBACKS = [
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
]
_SERIF_FALLBACKS = [
"/System/Library/Fonts/Supplemental/Georgia.ttf",
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
]
def load_measure_font(font_name: str, size: int):
"""Load a font for text measurement at the CORRECT SIZE.
Critical: PIL's bare load_default() is an ~11px bitmap font that ignores
the requested size, which silently disables overflow detection for any
font not installed on this machine. Always fall back to a sized system
font instead (serif-aware so metrics stay roughly comparable)."""
path = ShapeData.get_font_path(font_name) if font_name else None
if path:
try:
return ImageFont.truetype(path, size=size)
except Exception:
pass
serif = any(h in (font_name or "").lower() for h in SERIF_HINTS)
candidates = (_SERIF_FALLBACKS + _SANS_FALLBACKS) if serif else (_SANS_FALLBACKS + _SERIF_FALLBACKS)
for cand in candidates:
if Path(cand).exists():
try:
return ImageFont.truetype(cand, size=size)
except Exception:
continue
try:
return ImageFont.load_default(size=size) # Pillow >= 10.1
except TypeError:
return ImageFont.load_default()
@dataclass
class ShapeWithPosition:
"""A shape with its absolute position on the slide."""
shape: BaseShape
absolute_left: int # in EMUs
absolute_top: int # in EMUs
class ParagraphData:
"""Data structure for paragraph properties extracted from a PowerPoint paragraph."""
def __init__(self, paragraph: Any):
"""Initialize from a PowerPoint paragraph object.
Args:
paragraph: The PowerPoint paragraph object
"""
self.text: str = paragraph.text.strip()
self.bullet: bool = False
self.level: Optional[int] = None
self.alignment: Optional[str] = None
self.space_before: Optional[float] = None
self.space_after: Optional[float] = None
self.font_name: Optional[str] = None
self.font_size: Optional[float] = None
self.bold: Optional[bool] = None
self.italic: Optional[bool] = None
self.underline: Optional[bool] = None
self.color: Optional[str] = None
self.theme_color: Optional[str] = None
self.line_spacing: Optional[float] = None
# Check for bullet formatting
if (
hasattr(paragraph, "_p")
and paragraph._p is not None
and paragraph._p.pPr is not None
):
pPr = paragraph._p.pPr
ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
if (
pPr.find(f"{ns}buChar") is not None
or pPr.find(f"{ns}buAutoNum") is not None
):
self.bullet = True
if hasattr(paragraph, "level"):
self.level = paragraph.level
# Add alignment if not LEFT (default)
if hasattr(paragraph, "alignment") and paragraph.alignment is not None:
alignment_map = {
PP_ALIGN.CENTER: "CENTER",
PP_ALIGN.RIGHT: "RIGHT",
PP_ALIGN.JUSTIFY: "JUSTIFY",
}
if paragraph.alignment in alignment_map:
self.alignment = alignment_map[paragraph.alignment]
# Add spacing properties if set
if hasattr(paragraph, "space_before") and paragraph.space_before:
self.space_before = paragraph.space_before.pt
if hasattr(paragraph, "space_after") and paragraph.space_after:
self.space_after = paragraph.space_after.pt
# Extract font properties from first run
if paragraph.runs:
first_run = paragraph.runs[0]
if hasattr(first_run, "font"):
font = first_run.font
if font.name:
self.font_name = font.name
if font.size:
self.font_size = font.size.pt
if font.bold is not None:
self.bold = font.bold
if font.italic is not None:
self.italic = font.italic
if font.underline is not None:
self.underline = font.underline
# Handle color - both RGB and theme colors
try:
# Try RGB color first
if font.color.rgb:
self.color = str(font.color.rgb)
except (AttributeError, TypeError):
# Fall back to theme color
try:
if font.color.theme_color:
self.theme_color = font.color.theme_color.name
except (AttributeError, TypeError):
pass
# Add line spacing if set
if hasattr(paragraph, "line_spacing") and paragraph.line_spacing is not None:
if hasattr(paragraph.line_spacing, "pt"):
self.line_spacing = round(paragraph.line_spacing.pt, 2)
else:
# Multiplier - convert to points
font_size = self.font_size if self.font_size else 12.0
self.line_spacing = round(paragraph.line_spacing * font_size, 2)
def to_dict(self) -> ParagraphDict:
"""Convert to dictionary for JSON serialization, excluding None values."""
result: ParagraphDict = {"text": self.text}
# Add optional fields only if they have values
if self.bullet:
result["bullet"] = self.bullet
if self.level is not None:
result["level"] = self.level
if self.alignment:
result["alignment"] = self.alignment
if self.space_before is not None:
result["space_before"] = self.space_before
if self.space_after is not None:
result["space_after"] = self.space_after
if self.font_name:
result["font_name"] = self.font_name
if self.font_size is not None:
result["font_size"] = self.font_size
if self.bold is not None:
result["bold"] = self.bold
if self.italic is not None:
result["italic"] = self.italic
if self.underline is not None:
result["underline"] = self.underline
if self.color:
result["color"] = self.color
if self.theme_color:
result["theme_color"] = self.theme_color
if self.line_spacing is not None:
result["line_spacing"] = self.line_spacing
return result
class ShapeData:
"""Data structure for shape properties extracted from a PowerPoint shape."""
@staticmethod
def emu_to_inches(emu: int) -> float:
"""Convert EMUs (English Metric Units) to inches."""
return emu / 914400.0
@staticmethod
def inches_to_pixels(inches: float, dpi: int = 96) -> int:
"""Convert inches to pixels at given DPI."""
return int(inches * dpi)
@staticmethod
def get_font_path(font_name: str) -> Optional[str]:
"""Get the font file path for a given font name.
Args:
font_name: Name of the font (e.g., 'Arial', 'Calibri')
Returns:
Path to the font file, or None if not found
"""
system = platform.system()
# Common font file variations to try
font_variations = [
font_name,
font_name.lower(),
font_name.replace(" ", ""),
font_name.replace(" ", "-"),
]
# Define font directories and extensions by platform
if system == "Darwin": # macOS
font_dirs = [
"/System/Library/Fonts/",
"/Library/Fonts/",
"~/Library/Fonts/",
]
extensions = [".ttf", ".otf", ".ttc", ".dfont"]
else: # Linux
font_dirs = [
"/usr/share/fonts/truetype/",
"/usr/local/share/fonts/",
"~/.fonts/",
]
extensions = [".ttf", ".otf"]
# Try to find the font file
from pathlib import Path
for font_dir in font_dirs:
font_dir_path = Path(font_dir).expanduser()
if not font_dir_path.exists():
continue
# First try exact matches
for variant in font_variations:
for ext in extensions:
font_path = font_dir_path / f"{variant}{ext}"
if font_path.exists():
return str(font_path)
# Then try fuzzy matching - find files containing the font name
try:
for file_path in font_dir_path.iterdir():
if file_path.is_file():
file_name_lower = file_path.name.lower()
font_name_lower = font_name.lower().replace(" ", "")
if font_name_lower in file_name_lower and any(
file_name_lower.endswith(ext) for ext in extensions
):
return str(file_path)
except (OSError, PermissionError):
continue
return None
@staticmethod
def get_slide_dimensions(slide: Any) -> tuple[Optional[int], Optional[int]]:
"""Get slide dimensions from slide object.
Args:
slide: Slide object
Returns:
Tuple of (width_emu, height_emu) or (None, None) if not found
"""
try:
prs = slide.part.package.presentation_part.presentation
return prs.slide_width, prs.slide_height
except (AttributeError, TypeError):
return None, None
@staticmethod
def get_default_font_size(shape: BaseShape, slide_layout: Any) -> Optional[float]:
"""Extract default font size from slide layout for a placeholder shape.
Args:
shape: Placeholder shape
slide_layout: Slide layout containing the placeholder definition
Returns:
Default font size in points, or None if not found
"""
try:
if not hasattr(shape, "placeholder_format"):
return None
shape_type = shape.placeholder_format.type # type: ignore
for layout_placeholder in slide_layout.placeholders:
if layout_placeholder.placeholder_format.type == shape_type:
# Find first defRPr element with sz (size) attribute
for elem in layout_placeholder.element.iter():
if "defRPr" in elem.tag and (sz := elem.get("sz")):
return float(sz) / 100.0 # Convert EMUs to points
break
except Exception:
pass
return None
def __init__(
self,
shape: BaseShape,
absolute_left: Optional[int] = None,
absolute_top: Optional[int] = None,
slide: Optional[Any] = None,
):
"""Initialize from a PowerPoint shape object.
Args:
shape: The PowerPoint shape object (should be pre-validated)
absolute_left: Absolute left position in EMUs (for shapes in groups)
absolute_top: Absolute top position in EMUs (for shapes in groups)
slide: Optional slide object to get dimensions and layout information
"""
self.shape = shape # Store reference to original shape
self.shape_id: str = "" # Will be set after sorting
# Get slide dimensions from slide object
self.slide_width_emu, self.slide_height_emu = (
self.get_slide_dimensions(slide) if slide else (None, None)
)
# Get placeholder type if applicable
self.placeholder_type: Optional[str] = None
self.default_font_size: Optional[float] = None
if hasattr(shape, "is_placeholder") and shape.is_placeholder: # type: ignore
if shape.placeholder_format and shape.placeholder_format.type: # type: ignore
self.placeholder_type = (
str(shape.placeholder_format.type).split(".")[-1].split(" ")[0] # type: ignore
)
# Get default font size from layout
if slide and hasattr(slide, "slide_layout"):
self.default_font_size = self.get_default_font_size(
shape, slide.slide_layout
)
# Get position information
# Use absolute positions if provided (for shapes in groups), otherwise use shape's position
left_emu = (
absolute_left
if absolute_left is not None
else (shape.left if hasattr(shape, "left") else 0)
)
top_emu = (
absolute_top
if absolute_top is not None
else (shape.top if hasattr(shape, "top") else 0)
)
self.left: float = round(self.emu_to_inches(left_emu), 2) # type: ignore
self.top: float = round(self.emu_to_inches(top_emu), 2) # type: ignore
self.width: float = round(
self.emu_to_inches(shape.width if hasattr(shape, "width") else 0),
2, # type: ignore
)
self.height: float = round(
self.emu_to_inches(shape.height if hasattr(shape, "height") else 0),
2, # type: ignore
)
# Store EMU positions for overflow calculations
self.left_emu = left_emu
self.top_emu = top_emu
self.width_emu = shape.width if hasattr(shape, "width") else 0
self.height_emu = shape.height if hasattr(shape, "height") else 0
# Calculate overflow status
self.frame_overflow_bottom: Optional[float] = None
self.slide_overflow_right: Optional[float] = None
self.slide_overflow_bottom: Optional[float] = None
self.overlapping_shapes: Dict[
str, float
] = {} # Dict of shape_id -> overlap area in sq inches
self.warnings: List[str] = []
self._estimate_frame_overflow()
self._calculate_slide_overflow()
self._detect_bullet_issues()
@property
def vertical_anchor(self) -> Optional[str]:
"""Vertical anchor of the text frame (TOP|MIDDLE|BOTTOM).
Returns None when the anchor is the default top (or unreadable) so the
inspect output stays uncluttered — mirrors how alignment is only shown
when it is not the LEFT default."""
if not self.shape or not getattr(self.shape, "has_text_frame", False):
return None
try:
va = self.shape.text_frame.vertical_anchor
except Exception:
return None
return {MSO_ANCHOR.MIDDLE: "MIDDLE", MSO_ANCHOR.BOTTOM: "BOTTOM"}.get(va)
@property
def paragraphs(self) -> List[ParagraphData]:
"""Calculate paragraphs from the shape's text frame."""
if not self.shape or not hasattr(self.shape, "text_frame"):
return []
paragraphs = []
for paragraph in self.shape.text_frame.paragraphs: # type: ignore
if paragraph.text.strip():
paragraphs.append(ParagraphData(paragraph))
return paragraphs
def _get_default_font_size(self) -> int:
"""Get default font size from theme text styles or use conservative default."""
try:
if not (
hasattr(self.shape, "part") and hasattr(self.shape.part, "slide_layout")
):
return 14
slide_master = self.shape.part.slide_layout.slide_master # type: ignore
if not hasattr(slide_master, "element"):
return 14
# Determine theme style based on placeholder type
style_name = "bodyStyle" # Default
if self.placeholder_type and "TITLE" in self.placeholder_type:
style_name = "titleStyle"
# Find font size in theme styles
for child in slide_master.element.iter():
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == style_name:
for elem in child.iter():
if "sz" in elem.attrib:
return int(elem.attrib["sz"]) // 100
except Exception:
pass
return 14 # Conservative default for body text
def _get_usable_dimensions(self, text_frame) -> Tuple[int, int]:
"""Get usable width and height in pixels after accounting for margins."""
# Default PowerPoint margins in inches
margins = {"top": 0.05, "bottom": 0.05, "left": 0.1, "right": 0.1}
# Override with actual margins if set (0 is a real value — explicit
# zero insets must not fall back to the defaults)
if getattr(text_frame, "margin_top", None) is not None:
margins["top"] = self.emu_to_inches(text_frame.margin_top)
if getattr(text_frame, "margin_bottom", None) is not None:
margins["bottom"] = self.emu_to_inches(text_frame.margin_bottom)
if getattr(text_frame, "margin_left", None) is not None:
margins["left"] = self.emu_to_inches(text_frame.margin_left)
if getattr(text_frame, "margin_right", None) is not None:
margins["right"] = self.emu_to_inches(text_frame.margin_right)
# Calculate usable area
usable_width = self.width - margins["left"] - margins["right"]
usable_height = self.height - margins["top"] - margins["bottom"]
# Convert to pixels
return (
self.inches_to_pixels(usable_width),
self.inches_to_pixels(usable_height),
)
def _wrap_text_line(self, line: str, max_width_px: int, draw, font) -> List[str]:
"""Wrap a single line of text to fit within max_width_px."""
if not line:
return [""]
# Use textlength for efficient width calculation
if draw.textlength(line, font=font) <= max_width_px:
return [line]
# Need to wrap - split into words
wrapped = []
words = line.split(" ")
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
if draw.textlength(test_line, font=font) <= max_width_px:
current_line = test_line
else:
if current_line:
wrapped.append(current_line)
current_line = word
if current_line:
wrapped.append(current_line)
return wrapped
def _estimate_frame_overflow(self) -> None:
"""Estimate if text overflows the shape bounds using PIL text measurement."""
if not self.shape or not hasattr(self.shape, "text_frame"):
return
text_frame = self.shape.text_frame # type: ignore
if not text_frame or not text_frame.paragraphs:
return
# Get usable dimensions after accounting for margins
usable_width_px, usable_height_px = self._get_usable_dimensions(text_frame)
if usable_width_px <= 0 or usable_height_px <= 0:
return
# Set up PIL for text measurement
dummy_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(dummy_img)
# Get default font size from placeholder or use conservative estimate
default_font_size = self._get_default_font_size()
# Calculate total height of all paragraphs
total_height_px = 0
for para_idx, paragraph in enumerate(text_frame.paragraphs):
if not paragraph.text.strip():
continue
para_data = ParagraphData(paragraph)
# Load font for this paragraph (sized fallback when not installed —
# a bare load_default() ignores size and breaks overflow detection)
font_name = para_data.font_name or "Arial"
font_size = int(para_data.font_size or default_font_size)
font = load_measure_font(font_name, font_size)
# Wrap all lines in this paragraph
all_wrapped_lines = []
for line in paragraph.text.split("\n"):
wrapped = self._wrap_text_line(line, usable_width_px, draw, font)
all_wrapped_lines.extend(wrapped)
if all_wrapped_lines:
# Calculate line height
if para_data.line_spacing:
# Custom line spacing explicitly set
line_height_px = para_data.line_spacing * 96 / 72
else:
# PowerPoint default single spacing (1.0x font size)
line_height_px = font_size * 96 / 72
# Add space_before (except first paragraph)
if para_idx > 0 and para_data.space_before:
total_height_px += para_data.space_before * 96 / 72
# Add paragraph text height
total_height_px += len(all_wrapped_lines) * line_height_px
# Add space_after
if para_data.space_after:
total_height_px += para_data.space_after * 96 / 72
# Check for overflow (ignore negligible overflows <= 0.05")
if total_height_px > usable_height_px:
overflow_px = total_height_px - usable_height_px
overflow_inches = round(overflow_px / 96.0, 2)
if overflow_inches > 0.05: # Only report significant overflows
self.frame_overflow_bottom = overflow_inches
def _calculate_slide_overflow(self) -> None:
"""Calculate if shape overflows the slide boundaries."""
if self.slide_width_emu is None or self.slide_height_emu is None:
return
# Check right overflow (ignore negligible overflows <= 0.01")
right_edge_emu = self.left_emu + self.width_emu
if right_edge_emu > self.slide_width_emu:
overflow_emu = right_edge_emu - self.slide_width_emu
overflow_inches = round(self.emu_to_inches(overflow_emu), 2)
if overflow_inches > 0.01: # Only report significant overflows
self.slide_overflow_right = overflow_inches
# Check bottom overflow (ignore negligible overflows <= 0.01")
bottom_edge_emu = self.top_emu + self.height_emu
if bottom_edge_emu > self.slide_height_emu:
overflow_emu = bottom_edge_emu - self.slide_height_emu
overflow_inches = round(self.emu_to_inches(overflow_emu), 2)
if overflow_inches > 0.01: # Only report significant overflows
self.slide_overflow_bottom = overflow_inches
def _detect_bullet_issues(self) -> None:
"""Detect bullet point formatting issues in paragraphs."""
if not self.shape or not hasattr(self.shape, "text_frame"):
return
text_frame = self.shape.text_frame # type: ignore
if not text_frame or not text_frame.paragraphs:
return
# Common bullet symbols that indicate manual bullets
bullet_symbols = ["•", "●", "○"]
for paragraph in text_frame.paragraphs:
text = paragraph.text.strip()
# Check for manual bullet symbols
if text and any(text.startswith(symbol + " ") for symbol in bullet_symbols):
self.warnings.append(
"manual_bullet_symbol: use proper bullet formatting"
)
break
@property
def has_any_issues(self) -> bool:
"""Check if shape has any issues (overflow, overlap, or warnings)."""
return (
self.frame_overflow_bottom is not None
or self.slide_overflow_right is not None
or self.slide_overflow_bottom is not None
or len(self.overlapping_shapes) > 0
or len(self.warnings) > 0
)
def to_dict(self) -> ShapeDict:
"""Convert to dictionary for JSON serialization."""
result: ShapeDict = {
"left": self.left,
"top": self.top,
"width": self.width,
"height": self.height,
}
# Add optional fields if present
if self.placeholder_type:
result["placeholder_type"] = self.placeholder_type
if self.default_font_size:
result["default_font_size"] = self.default_font_size
# Add overflow information only if there is overflow
overflow_data = {}
# Add frame overflow if present
if self.frame_overflow_bottom is not None:
overflow_data["frame"] = {"overflow_bottom": self.frame_overflow_bottom}
# Add slide overflow if present
slide_overflow = {}
if self.slide_overflow_right is not None:
slide_overflow["overflow_right"] = self.slide_overflow_right
if self.slide_overflow_bottom is not None:
slide_overflow["overflow_bottom"] = self.slide_overflow_bottom
if slide_overflow:
overflow_data["slide"] = slide_overflow
# Only add overflow field if there is overflow
if overflow_data:
result["overflow"] = overflow_data
# Add overlap field if there are overlapping shapes
if self.overlapping_shapes:
result["overlap"] = {"overlapping_shapes": self.overlapping_shapes}
# Add warnings field if there are warnings
if self.warnings:
result["warnings"] = self.warnings
# Vertical anchor — only when it differs from the default top
anchor = self.vertical_anchor
if anchor:
result["anchor"] = anchor
# Add paragraphs after placeholder_type
result["paragraphs"] = [para.to_dict() for para in self.paragraphs]
return result
def is_valid_shape(shape: BaseShape) -> bool:
"""Check if a shape contains meaningful text content."""
# Must have a text frame with content
if not hasattr(shape, "text_frame") or not shape.text_frame: # type: ignore
return False
text = shape.text_frame.text.strip() # type: ignore
if not text:
return False
# Skip slide numbers and numeric footers
if hasattr(shape, "is_placeholder") and shape.is_placeholder: # type: ignore
if shape.placeholder_format and shape.placeholder_format.type: # type: ignore
placeholder_type = (
str(shape.placeholder_format.type).split(".")[-1].split(" ")[0] # type: ignore
)
if placeholder_type == "SLIDE_NUMBER":
return False
if placeholder_type == "FOOTER" and text.isdigit():
return False
return True
def collect_shapes_with_absolute_positions(
shape: BaseShape, parent_left: int = 0, parent_top: int = 0
) -> List[ShapeWithPosition]:
"""Recursively collect all shapes with valid text, calculating absolute positions.
For shapes within groups, their positions are relative to the group.
This function calculates the absolute position on the slide by accumulating
parent group offsets.
Args:
shape: The shape to process
parent_left: Accumulated left offset from parent groups (in EMUs)
parent_top: Accumulated top offset from parent groups (in EMUs)
Returns:
List of ShapeWithPosition objects with absolute positions
"""
if hasattr(shape, "shapes"): # GroupShape
result = []
# Get this group's position
group_left = shape.left if hasattr(shape, "left") else 0
group_top = shape.top if hasattr(shape, "top") else 0
# Calculate absolute position for this group
abs_group_left = parent_left + group_left
abs_group_top = parent_top + group_top
# Process children with accumulated offsets
for child in shape.shapes: # type: ignore
result.extend(
collect_shapes_with_absolute_positions(
child, abs_group_left, abs_group_top
)
)
return result
# Regular shape - check if it has valid text
if is_valid_shape(shape):
# Calculate absolute position
shape_left = shape.left if hasattr(shape, "left") else 0
shape_top = shape.top if hasattr(shape, "top") else 0
return [
ShapeWithPosition(
shape=shape,
absolute_left=parent_left + shape_left,
absolute_top=parent_top + shape_top,
)
]
return []
def sort_shapes_by_position(shapes: List[ShapeData]) -> List[ShapeData]:
"""Sort shapes by visual position (top-to-bottom, left-to-right).
Shapes within 0.5 inches vertically are considered on the same row.
"""
if not shapes:
return shapes
# Sort by top position first
shapes = sorted(shapes, key=lambda s: (s.top, s.left))
# Group shapes by row (within 0.5 inches vertically)
result = []
row = [shapes[0]]
row_top = shapes[0].top
for shape in shapes[1:]:
if abs(shape.top - row_top) <= 0.5:
row.append(shape)
else:
# Sort current row by left position and add to result
result.extend(sorted(row, key=lambda s: s.left))
row = [shape]
row_top = shape.top
# Don't forget the last row
result.extend(sorted(row, key=lambda s: s.left))
return result
def calculate_overlap(
rect1: Tuple[float, float, float, float],
rect2: Tuple[float, float, float, float],
tolerance: float = 0.05,
) -> Tuple[bool, float]:
"""Calculate if and how much two rectangles overlap.
Args:
rect1: (left, top, width, height) of first rectangle in inches
rect2: (left, top, width, height) of second rectangle in inches
tolerance: Minimum overlap in inches to consider as overlapping (default: 0.05")
Returns:
Tuple of (overlaps, overlap_area) where:
- overlaps: True if rectangles overlap by more than tolerance
- overlap_area: Area of overlap in square inches
"""
left1, top1, w1, h1 = rect1
left2, top2, w2, h2 = rect2
# Calculate overlap dimensions
overlap_width = min(left1 + w1, left2 + w2) - max(left1, left2)
overlap_height = min(top1 + h1, top2 + h2) - max(top1, top2)
# Check if there's meaningful overlap (more than tolerance)
if overlap_width > tolerance and overlap_height > tolerance:
# Calculate overlap area in square inches
overlap_area = overlap_width * overlap_height
return True, round(overlap_area, 2)
return False, 0
def detect_overlaps(shapes: List[ShapeData]) -> None:
"""Detect overlapping shapes and update their overlapping_shapes dictionaries.
This function requires each ShapeData to have its shape_id already set.
It modifies the shapes in-place, adding shape IDs with overlap areas in square inches.
Args:
shapes: List of ShapeData objects with shape_id attributes set
"""
n = len(shapes)
# Compare each pair of shapes
for i in range(n):
for j in range(i + 1, n):
shape1 = shapes[i]
shape2 = shapes[j]
# Ensure shape IDs are set
assert shape1.shape_id, f"Shape at index {i} has no shape_id"
assert shape2.shape_id, f"Shape at index {j} has no shape_id"
rect1 = (shape1.left, shape1.top, shape1.width, shape1.height)
rect2 = (shape2.left, shape2.top, shape2.width, shape2.height)
overlaps, overlap_area = calculate_overlap(rect1, rect2)
if overlaps:
# Add shape IDs with overlap area in square inches
shape1.overlapping_shapes[shape2.shape_id] = overlap_area
shape2.overlapping_shapes[shape1.shape_id] = overlap_area
def cluster_1d(items, eps):
"""Greedy 1-D clustering of edge coordinates.
items: iterable of (key, coord) pairs (key identifies who owns the edge).
Sorts by coord and starts a new cluster wherever the gap to the previous
coord exceeds eps. Returns a list of clusters, each a list of (key, coord).
Single-linkage drift is irrelevant here: real gridlines are far tighter
than eps, so a populated cluster stays tight."""
out = []
cur = []
last = None
for key, coord in sorted(items, key=lambda kc: kc[1]):
if last is not None and coord - last > eps:
out.append(cur)
cur = []
cur.append((key, coord))
last = coord
if cur:
out.append(cur)
return out
def extract_text_inventory(
pptx_path: Path, prs: Optional[Any] = None, issues_only: bool = False
) -> InventoryData:
"""Extract text content from all slides in a PowerPoint presentation.
Args:
pptx_path: Path to the PowerPoint file
prs: Optional Presentation object to use. If not provided, will load from pptx_path.
issues_only: If True, only include shapes that have overflow or overlap issues
Returns a nested dictionary: {slide-N: {shape-N: ShapeData}}
Shapes are sorted by visual position (top-to-bottom, left-to-right).
The ShapeData objects contain the full shape information and can be
converted to dictionaries for JSON serialization using to_dict().
"""
if prs is None:
prs = Presentation(str(pptx_path))
inventory: InventoryData = {}
for slide_idx, slide in enumerate(prs.slides):
# Collect all valid shapes from this slide with absolute positions
shapes_with_positions = []
for shape in slide.shapes: # type: ignore
shapes_with_positions.extend(collect_shapes_with_absolute_positions(shape))
if not shapes_with_positions:
continue
# Convert to ShapeData with absolute positions and slide reference
shape_data_list = [
ShapeData(
swp.shape,
swp.absolute_left,
swp.absolute_top,
slide,
)
for swp in shapes_with_positions
]
# Sort by visual position and assign stable IDs in one step
sorted_shapes = sort_shapes_by_position(shape_data_list)
for idx, shape_data in enumerate(sorted_shapes):
shape_data.shape_id = f"shape-{idx}"
# Detect overlaps using the stable shape IDs
if len(sorted_shapes) > 1:
detect_overlaps(sorted_shapes)
# Filter for issues only if requested (after overlap detection)
if issues_only:
sorted_shapes = [sd for sd in sorted_shapes if sd.has_any_issues]
if not sorted_shapes:
continue
# Create slide inventory using the stable shape IDs
inventory[f"slide-{slide_idx}"] = {
shape_data.shape_id: shape_data for shape_data in sorted_shapes
}
return inventory
def get_inventory_as_dict(pptx_path: Path, issues_only: bool = False) -> InventoryDict:
"""Extract text inventory and return as JSON-serializable dictionaries.
This is a convenience wrapper around extract_text_inventory that returns
dictionaries instead of ShapeData objects, useful for testing and direct
JSON serialization.
Args:
pptx_path: Path to the PowerPoint file
issues_only: If True, only include shapes that have overflow or overlap issues
Returns:
Nested dictionary with all data serialized for JSON
"""
inventory = extract_text_inventory(pptx_path, issues_only=issues_only)
# Convert ShapeData objects to dictionaries
dict_inventory: InventoryDict = {}
for slide_key, shapes in inventory.items():
dict_inventory[slide_key] = {
shape_key: shape_data.to_dict() for shape_key, shape_data in shapes.items()
}
return dict_inventory
def save_inventory(inventory: InventoryData, output_path: Path) -> None:
"""Save inventory to JSON file with proper formatting.
Converts ShapeData objects to dictionaries for JSON serialization.
"""
# Convert ShapeData objects to dictionaries
json_inventory: InventoryDict = {}
for slide_key, shapes in inventory.items():
json_inventory[slide_key] = {
shape_key: shape_data.to_dict() for shape_key, shape_data in shapes.items()
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(json_inventory, f, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()
scripts/merge_decks.py
python
#!/usr/bin/env python3
"""Merge slides from one PowerPoint deck into another.
`rearrange.py` only reorders/duplicates slides WITHIN a single file. This script
fills the gap: it copies slides from a source deck (e.g. a reusable module) into
a base deck, importing each slide's shapes, slide-level background, and embedded
images, and attaching them to a layout in the base deck so they inherit the base
deck's master (footer, theme).
It is built for the common case where base deck and modules share a theme, so
attaching imported slides to a base layout re-brands them consistently (e.g. the
base deck's footer) while their own explicit shapes carry the content.
Usage:
# Append every slide of a module to the end of the base deck.
python merge_decks.py base.pptx output.pptx --add module.pptx
# Insert specific source slides (1-based) at position 12 (0-based) in base.
python merge_decks.py base.pptx output.pptx --add module.pptx --slides 1,2,3 --at 12
# Pin imported slides to a specific base layout (see --list-layouts).
python merge_decks.py base.pptx output.pptx --add module.pptx --layout 5
# Inspect base layouts to choose one.
python merge_decks.py base.pptx --list-layouts
Notes:
- Source slide numbers are 1-based; --at is a 0-based index into the base
deck's slide list (0 = before the first slide).
- Images and external hyperlinks are carried over. Charts, embedded OLE
objects, and other rare relationship types are NOT copied — the script
warns if it sees one so you can handle that slide manually.
- Imported slides inherit the base deck's master/footer by design. If a
source slide set its own slide-level background, that background is kept.
"""
import argparse
import copy
import sys
import tempfile
from pathlib import Path
from pptx import Presentation
from pptx.oxml.ns import qn
IMAGE_RELTYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
HYPERLINK_RELTYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
# Relationship roles that the base layout/master already provides; ignore them.
STRUCTURAL_RELTYPES = {
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
}
# r: attributes that may reference a relationship inside shape XML.
EMBED_ATTRS = [qn("r:embed"), qn("r:link"), qn("r:id"), qn("r:pict")]
def list_layouts(prs):
print(f"{len(prs.slide_layouts)} layouts on the base deck's primary master:")
for i, layout in enumerate(prs.slide_layouts):
shape_count = len(layout.shapes)
print(f" [{i:>2}] {layout.name} ({shape_count} shapes)")
def pick_default_layout(prs):
"""Prefer a 'blank'-style layout; otherwise the one with the fewest shapes."""
layouts = list(prs.slide_layouts)
by_name = [l for l in layouts if "blank" in l.name.lower()]
if by_name:
return by_name[0]
return min(layouts, key=lambda l: len(l.shapes))
def strip_layout_shapes(slide):
"""Remove the placeholder shapes the layout seeded onto a fresh slide, so only
imported content (plus inherited layout/master visuals) remains."""
spTree = slide.shapes._spTree
for shape in list(slide.shapes):
spTree.remove(shape.element)
def copy_background(src_slide, dest_slide):
"""Carry over a slide-level <p:bg> if the source set one."""
src_cSld = src_slide._element.find(qn("p:cSld"))
dest_cSld = dest_slide._element.find(qn("p:cSld"))
if src_cSld is None or dest_cSld is None:
return
src_bg = src_cSld.find(qn("p:bg"))
if src_bg is None:
return
# Remove any existing bg on the destination, then insert as first child of cSld.
existing = dest_cSld.find(qn("p:bg"))
if existing is not None:
dest_cSld.remove(existing)
dest_cSld.insert(0, copy.deepcopy(src_bg))
def import_relationships(src_slide, dest_slide, warnings):
"""Recreate the source slide's image and hyperlink relationships on the
destination slide. Returns {old_rId: new_rId} for rewriting shape XML."""
rid_map = {}
tmp_files = []
for rId, rel in src_slide.part.rels.items():
if rel.reltype in STRUCTURAL_RELTYPES:
continue
if rel.reltype == IMAGE_RELTYPE:
image_part = rel.target_part
ext = image_part.partname.ext # e.g. 'png'
tmp = tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False)
tmp.write(image_part.blob)
tmp.close()
tmp_files.append(tmp.name)
_, new_rid = dest_slide.part.get_or_add_image_part(tmp.name)
rid_map[rId] = new_rid
elif rel.reltype == HYPERLINK_RELTYPE and rel.is_external:
new_rid = dest_slide.part.relate_to(
rel.target_ref, rel.reltype, is_external=True
)
rid_map[rId] = new_rid
else:
warnings.append(
f"unsupported relationship {rel.reltype.split('/')[-1]} "
f"(rId {rId}) was not copied — review this slide manually"
)
return rid_map, tmp_files
def rewrite_rel_ids(element, rid_map):
"""Point every r:embed / r:link / r:id in the copied XML at its new rId."""
for el in element.iter():
for attr in EMBED_ATTRS:
old = el.get(attr)
if old is not None and old in rid_map:
el.set(attr, rid_map[old])
def copy_slide(src_slide, dest_prs, dest_layout, warnings):
"""Create a new slide in dest_prs from src_slide's content."""
new_slide = dest_prs.slides.add_slide(dest_layout)
strip_layout_shapes(new_slide)
copy_background(src_slide, new_slide)
rid_map, tmp_files = import_relationships(src_slide, new_slide, warnings)
# Deep-copy every shape from the source spTree (skip the group's own
# nvGrpSpPr / grpSpPr bookkeeping elements).
src_spTree = src_slide.shapes._spTree
dest_spTree = new_slide.shapes._spTree
skip = {qn("p:nvGrpSpPr"), qn("p:grpSpPr")}
for child in list(src_spTree):
if child.tag in skip:
continue
new_el = copy.deepcopy(child)
rewrite_rel_ids(new_el, rid_map)
dest_spTree.append(new_el)
for path in tmp_files:
try:
Path(path).unlink()
except OSError:
pass
return new_slide
def move_slide_to(dest_prs, from_index, to_index):
"""Move the slide currently at from_index to to_index in the slide list."""
sldIdLst = dest_prs.slides._sldIdLst
elements = list(sldIdLst)
el = elements[from_index]
sldIdLst.remove(el)
sldIdLst.insert(to_index, el)
def merge(base_path, output_path, module_path, slide_nums, at_index, layout_index):
dest_prs = Presentation(str(base_path))
src_prs = Presentation(str(module_path))
total_src = len(src_prs.slides)
if slide_nums:
for n in slide_nums:
if n < 1 or n > total_src:
sys.exit(f"Error: source slide {n} out of range (1-{total_src})")
chosen = slide_nums
else:
chosen = list(range(1, total_src + 1))
if layout_index is not None:
if layout_index < 0 or layout_index >= len(dest_prs.slide_layouts):
sys.exit(f"Error: layout {layout_index} out of range "
f"(0-{len(dest_prs.slide_layouts) - 1}). Use --list-layouts.")
dest_layout = dest_prs.slide_layouts[layout_index]
else:
dest_layout = pick_default_layout(dest_prs)
base_count = len(dest_prs.slides)
if at_index is None:
at_index = base_count
if at_index < 0 or at_index > base_count:
sys.exit(f"Error: --at {at_index} out of range (0-{base_count})")
all_warnings = []
print(f"Merging {len(chosen)} slide(s) from {module_path.name} into {base_path.name}")
print(f" attaching to base layout: '{dest_layout.name}'")
# add_slide always appends; we copy in order, then move the appended block
# into position so source order is preserved at the insertion point.
for offset, n in enumerate(chosen):
warnings = []
copy_slide(src_prs.slides[n - 1], dest_prs, dest_layout, warnings)
appended_index = len(dest_prs.slides) - 1
move_slide_to(dest_prs, appended_index, at_index + offset)
tag = f" [src {n}] -> base position {at_index + offset}"
if warnings:
tag += " ⚠ " + "; ".join(warnings)
all_warnings.extend(warnings)
print(tag)
output_path.parent.mkdir(parents=True, exist_ok=True)
dest_prs.save(str(output_path))
print(f"\nSaved: {output_path} ({len(dest_prs.slides)} slides total)")
if all_warnings:
print(f"\n{len(all_warnings)} warning(s) — review the flagged slides in PowerPoint.")
def main():
parser = argparse.ArgumentParser(
description="Merge slides from a module deck into a base deck.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("base", help="Base .pptx (the working deck)")
parser.add_argument("output", nargs="?", help="Output .pptx (omit for --list-layouts)")
parser.add_argument("--add", help="Source module .pptx to merge in")
parser.add_argument("--slides", help="Comma-separated source slide numbers (1-based); default all")
parser.add_argument("--at", type=int, help="0-based insert position in base; default append")
parser.add_argument("--layout", type=int, help="Base layout index to attach imported slides to")
parser.add_argument("--list-layouts", action="store_true", help="List base layouts and exit")
args = parser.parse_args()
base_path = Path(args.base)
if not base_path.exists():
sys.exit(f"Error: base file not found: {base_path}")
if args.list_layouts:
list_layouts(Presentation(str(base_path)))
return
if not args.add:
sys.exit("Error: --add <module.pptx> is required (or use --list-layouts)")
module_path = Path(args.add)
if not module_path.exists():
sys.exit(f"Error: module file not found: {module_path}")
if not args.output:
sys.exit("Error: output .pptx path required")
slide_nums = None
if args.slides:
try:
slide_nums = [int(x.strip()) for x in args.slides.split(",")]
except ValueError:
sys.exit("Error: --slides must be comma-separated integers (e.g. 1,2,3)")
merge(base_path, Path(args.output), module_path, slide_nums, args.at, args.layout)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Merge slides from one PowerPoint deck into another.
`rearrange.py` only reorders/duplicates slides WITHIN a single file. This script
fills the gap: it copies slides from a source deck (e.g. a reusable module) into
a base deck, importing each slide's shapes, slide-level background, and embedded
images, and attaching them to a layout in the base deck so they inherit the base
deck's master (footer, theme).
It is built for the common case where base deck and modules share a theme, so
attaching imported slides to a base layout re-brands them consistently (e.g. the
base deck's footer) while their own explicit shapes carry the content.
Usage:
# Append every slide of a module to the end of the base deck.
python merge_decks.py base.pptx output.pptx --add module.pptx
# Insert specific source slides (1-based) at position 12 (0-based) in base.
python merge_decks.py base.pptx output.pptx --add module.pptx --slides 1,2,3 --at 12
# Pin imported slides to a specific base layout (see --list-layouts).
python merge_decks.py base.pptx output.pptx --add module.pptx --layout 5
# Inspect base layouts to choose one.
python merge_decks.py base.pptx --list-layouts
Notes:
- Source slide numbers are 1-based; --at is a 0-based index into the base
deck's slide list (0 = before the first slide).
- Images and external hyperlinks are carried over. Charts, embedded OLE
objects, and other rare relationship types are NOT copied — the script
warns if it sees one so you can handle that slide manually.
- Imported slides inherit the base deck's master/footer by design. If a
source slide set its own slide-level background, that background is kept.
"""
import argparse
import copy
import sys
import tempfile
from pathlib import Path
from pptx import Presentation
from pptx.oxml.ns import qn
IMAGE_RELTYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
HYPERLINK_RELTYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
# Relationship roles that the base layout/master already provides; ignore them.
STRUCTURAL_RELTYPES = {
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide",
}
# r: attributes that may reference a relationship inside shape XML.
EMBED_ATTRS = [qn("r:embed"), qn("r:link"), qn("r:id"), qn("r:pict")]
def list_layouts(prs):
print(f"{len(prs.slide_layouts)} layouts on the base deck's primary master:")
for i, layout in enumerate(prs.slide_layouts):
shape_count = len(layout.shapes)
print(f" [{i:>2}] {layout.name} ({shape_count} shapes)")
def pick_default_layout(prs):
"""Prefer a 'blank'-style layout; otherwise the one with the fewest shapes."""
layouts = list(prs.slide_layouts)
by_name = [l for l in layouts if "blank" in l.name.lower()]
if by_name:
return by_name[0]
return min(layouts, key=lambda l: len(l.shapes))
def strip_layout_shapes(slide):
"""Remove the placeholder shapes the layout seeded onto a fresh slide, so only
imported content (plus inherited layout/master visuals) remains."""
spTree = slide.shapes._spTree
for shape in list(slide.shapes):
spTree.remove(shape.element)
def copy_background(src_slide, dest_slide):
"""Carry over a slide-level <p:bg> if the source set one."""
src_cSld = src_slide._element.find(qn("p:cSld"))
dest_cSld = dest_slide._element.find(qn("p:cSld"))
if src_cSld is None or dest_cSld is None:
return
src_bg = src_cSld.find(qn("p:bg"))
if src_bg is None:
return
# Remove any existing bg on the destination, then insert as first child of cSld.
existing = dest_cSld.find(qn("p:bg"))
if existing is not None:
dest_cSld.remove(existing)
dest_cSld.insert(0, copy.deepcopy(src_bg))
def import_relationships(src_slide, dest_slide, warnings):
"""Recreate the source slide's image and hyperlink relationships on the
destination slide. Returns {old_rId: new_rId} for rewriting shape XML."""
rid_map = {}
tmp_files = []
for rId, rel in src_slide.part.rels.items():
if rel.reltype in STRUCTURAL_RELTYPES:
continue
if rel.reltype == IMAGE_RELTYPE:
image_part = rel.target_part
ext = image_part.partname.ext # e.g. 'png'
tmp = tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False)
tmp.write(image_part.blob)
tmp.close()
tmp_files.append(tmp.name)
_, new_rid = dest_slide.part.get_or_add_image_part(tmp.name)
rid_map[rId] = new_rid
elif rel.reltype == HYPERLINK_RELTYPE and rel.is_external:
new_rid = dest_slide.part.relate_to(
rel.target_ref, rel.reltype, is_external=True
)
rid_map[rId] = new_rid
else:
warnings.append(
f"unsupported relationship {rel.reltype.split('/')[-1]} "
f"(rId {rId}) was not copied — review this slide manually"
)
return rid_map, tmp_files
def rewrite_rel_ids(element, rid_map):
"""Point every r:embed / r:link / r:id in the copied XML at its new rId."""
for el in element.iter():
for attr in EMBED_ATTRS:
old = el.get(attr)
if old is not None and old in rid_map:
el.set(attr, rid_map[old])
def copy_slide(src_slide, dest_prs, dest_layout, warnings):
"""Create a new slide in dest_prs from src_slide's content."""
new_slide = dest_prs.slides.add_slide(dest_layout)
strip_layout_shapes(new_slide)
copy_background(src_slide, new_slide)
rid_map, tmp_files = import_relationships(src_slide, new_slide, warnings)
# Deep-copy every shape from the source spTree (skip the group's own
# nvGrpSpPr / grpSpPr bookkeeping elements).
src_spTree = src_slide.shapes._spTree
dest_spTree = new_slide.shapes._spTree
skip = {qn("p:nvGrpSpPr"), qn("p:grpSpPr")}
for child in list(src_spTree):
if child.tag in skip:
continue
new_el = copy.deepcopy(child)
rewrite_rel_ids(new_el, rid_map)
dest_spTree.append(new_el)
for path in tmp_files:
try:
Path(path).unlink()
except OSError:
pass
return new_slide
def move_slide_to(dest_prs, from_index, to_index):
"""Move the slide currently at from_index to to_index in the slide list."""
sldIdLst = dest_prs.slides._sldIdLst
elements = list(sldIdLst)
el = elements[from_index]
sldIdLst.remove(el)
sldIdLst.insert(to_index, el)
def merge(base_path, output_path, module_path, slide_nums, at_index, layout_index):
dest_prs = Presentation(str(base_path))
src_prs = Presentation(str(module_path))
total_src = len(src_prs.slides)
if slide_nums:
for n in slide_nums:
if n < 1 or n > total_src:
sys.exit(f"Error: source slide {n} out of range (1-{total_src})")
chosen = slide_nums
else:
chosen = list(range(1, total_src + 1))
if layout_index is not None:
if layout_index < 0 or layout_index >= len(dest_prs.slide_layouts):
sys.exit(f"Error: layout {layout_index} out of range "
f"(0-{len(dest_prs.slide_layouts) - 1}). Use --list-layouts.")
dest_layout = dest_prs.slide_layouts[layout_index]
else:
dest_layout = pick_default_layout(dest_prs)
base_count = len(dest_prs.slides)
if at_index is None:
at_index = base_count
if at_index < 0 or at_index > base_count:
sys.exit(f"Error: --at {at_index} out of range (0-{base_count})")
all_warnings = []
print(f"Merging {len(chosen)} slide(s) from {module_path.name} into {base_path.name}")
print(f" attaching to base layout: '{dest_layout.name}'")
# add_slide always appends; we copy in order, then move the appended block
# into position so source order is preserved at the insertion point.
for offset, n in enumerate(chosen):
warnings = []
copy_slide(src_prs.slides[n - 1], dest_prs, dest_layout, warnings)
appended_index = len(dest_prs.slides) - 1
move_slide_to(dest_prs, appended_index, at_index + offset)
tag = f" [src {n}] -> base position {at_index + offset}"
if warnings:
tag += " ⚠ " + "; ".join(warnings)
all_warnings.extend(warnings)
print(tag)
output_path.parent.mkdir(parents=True, exist_ok=True)
dest_prs.save(str(output_path))
print(f"\nSaved: {output_path} ({len(dest_prs.slides)} slides total)")
if all_warnings:
print(f"\n{len(all_warnings)} warning(s) — review the flagged slides in PowerPoint.")
def main():
parser = argparse.ArgumentParser(
description="Merge slides from a module deck into a base deck.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("base", help="Base .pptx (the working deck)")
parser.add_argument("output", nargs="?", help="Output .pptx (omit for --list-layouts)")
parser.add_argument("--add", help="Source module .pptx to merge in")
parser.add_argument("--slides", help="Comma-separated source slide numbers (1-based); default all")
parser.add_argument("--at", type=int, help="0-based insert position in base; default append")
parser.add_argument("--layout", type=int, help="Base layout index to attach imported slides to")
parser.add_argument("--list-layouts", action="store_true", help="List base layouts and exit")
args = parser.parse_args()
base_path = Path(args.base)
if not base_path.exists():
sys.exit(f"Error: base file not found: {base_path}")
if args.list_layouts:
list_layouts(Presentation(str(base_path)))
return
if not args.add:
sys.exit("Error: --add <module.pptx> is required (or use --list-layouts)")
module_path = Path(args.add)
if not module_path.exists():
sys.exit(f"Error: module file not found: {module_path}")
if not args.output:
sys.exit("Error: output .pptx path required")
slide_nums = None
if args.slides:
try:
slide_nums = [int(x.strip()) for x in args.slides.split(",")]
except ValueError:
sys.exit("Error: --slides must be comma-separated integers (e.g. 1,2,3)")
merge(base_path, Path(args.output), module_path, slide_nums, args.at, args.layout)
if __name__ == "__main__":
main()
scripts/rearrange.py
python
#!/usr/bin/env python3
"""
Rearrange PowerPoint slides based on a sequence of indices.
Usage:
python rearrange.py template.pptx output.pptx 0,34,34,50,52
This will create output.pptx using slides from template.pptx in the specified order.
Slides can be repeated (e.g., 34 appears twice).
"""
import argparse
import shutil
import sys
from copy import deepcopy
from pathlib import Path
from pptx import Presentation
def main():
parser = argparse.ArgumentParser(
description="Rearrange PowerPoint slides based on a sequence of indices.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python rearrange.py template.pptx output.pptx 0,34,34,50,52
Creates output.pptx using slides 0, 34 (twice), 50, and 52 from template.pptx
python rearrange.py template.pptx output.pptx 5,3,1,2,4
Creates output.pptx with slides reordered as specified
Note: Slide indices are 0-based (first slide is 0, second is 1, etc.)
""",
)
parser.add_argument("template", help="Path to template PPTX file")
parser.add_argument("output", help="Path for output PPTX file")
parser.add_argument(
"sequence", help="Comma-separated sequence of slide indices (0-based)"
)
args = parser.parse_args()
# Parse the slide sequence
try:
slide_sequence = [int(x.strip()) for x in args.sequence.split(",")]
except ValueError:
print(
"Error: Invalid sequence format. Use comma-separated integers (e.g., 0,34,34,50,52)"
)
sys.exit(1)
# Check template exists
template_path = Path(args.template)
if not template_path.exists():
print(f"Error: Template file not found: {args.template}")
sys.exit(1)
# Create output directory if needed
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
rearrange_presentation(template_path, output_path, slide_sequence)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
except Exception as e:
print(f"Error processing presentation: {e}")
sys.exit(1)
def duplicate_slide(pres, index):
"""Duplicate a slide in the presentation."""
source = pres.slides[index]
# Use source's layout to preserve formatting
new_slide = pres.slides.add_slide(source.slide_layout)
# Collect all image and media relationships from the source slide
image_rels = {}
for rel_id, rel in source.part.rels.items():
if "image" in rel.reltype or "media" in rel.reltype:
image_rels[rel_id] = rel
# CRITICAL: Clear placeholder shapes to avoid duplicates
for shape in new_slide.shapes:
sp = shape.element
sp.getparent().remove(sp)
# Copy all shapes from source
for shape in source.shapes:
el = shape.element
new_el = deepcopy(el)
new_slide.shapes._spTree.insert_element_before(new_el, "p:extLst")
# Handle picture shapes - need to update the blip reference
# Look for all blip elements (they can be in pic or other contexts)
# Using the element's own xpath method without namespaces argument
blips = new_el.xpath(".//a:blip[@r:embed]")
for blip in blips:
old_rId = blip.get(
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
)
if old_rId in image_rels:
# Create a new relationship in the destination slide for this image
old_rel = image_rels[old_rId]
# get_or_add returns the rId directly, or adds and returns new rId
new_rId = new_slide.part.rels.get_or_add(
old_rel.reltype, old_rel._target
)
# Update the blip's embed reference to use the new relationship ID
blip.set(
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed",
new_rId,
)
# Copy any additional image/media relationships that might be referenced elsewhere
for rel_id, rel in image_rels.items():
try:
new_slide.part.rels.get_or_add(rel.reltype, rel._target)
except Exception:
pass # Relationship might already exist
return new_slide
def delete_slide(pres, index):
"""Delete a slide from the presentation."""
rId = pres.slides._sldIdLst[index].rId
pres.part.drop_rel(rId)
del pres.slides._sldIdLst[index]
def reorder_slides(pres, slide_index, target_index):
"""Move a slide from one position to another."""
slides = pres.slides._sldIdLst
# Remove slide element from current position
slide_element = slides[slide_index]
slides.remove(slide_element)
# Insert at target position
slides.insert(target_index, slide_element)
def rearrange_presentation(template_path, output_path, slide_sequence):
"""
Create a new presentation with slides from template in specified order.
Args:
template_path: Path to template PPTX file
output_path: Path for output PPTX file
slide_sequence: List of slide indices (0-based) to include
"""
# Copy template to preserve dimensions and theme
if template_path != output_path:
shutil.copy2(template_path, output_path)
prs = Presentation(output_path)
else:
prs = Presentation(template_path)
total_slides = len(prs.slides)
# Validate indices
for idx in slide_sequence:
if idx < 0 or idx >= total_slides:
raise ValueError(f"Slide index {idx} out of range (0-{total_slides - 1})")
# Track original slides and their duplicates
slide_map = [] # List of actual slide indices for final presentation
duplicated = {} # Track duplicates: original_idx -> [duplicate_indices]
# Step 1: DUPLICATE repeated slides
print(f"Processing {len(slide_sequence)} slides from template...")
for i, template_idx in enumerate(slide_sequence):
if template_idx in duplicated and duplicated[template_idx]:
# Already duplicated this slide, use the duplicate
slide_map.append(duplicated[template_idx].pop(0))
print(f" [{i}] Using duplicate of slide {template_idx}")
elif slide_sequence.count(template_idx) > 1 and template_idx not in duplicated:
# First occurrence of a repeated slide - create duplicates
slide_map.append(template_idx)
duplicates = []
count = slide_sequence.count(template_idx) - 1
print(
f" [{i}] Using original slide {template_idx}, creating {count} duplicate(s)"
)
for _ in range(count):
duplicate_slide(prs, template_idx)
duplicates.append(len(prs.slides) - 1)
duplicated[template_idx] = duplicates
else:
# Unique slide or first occurrence already handled, use original
slide_map.append(template_idx)
print(f" [{i}] Using original slide {template_idx}")
# Step 2: DELETE unwanted slides (work backwards)
slides_to_keep = set(slide_map)
print(f"\nDeleting {len(prs.slides) - len(slides_to_keep)} unused slides...")
for i in range(len(prs.slides) - 1, -1, -1):
if i not in slides_to_keep:
delete_slide(prs, i)
# Update slide_map indices after deletion
slide_map = [idx - 1 if idx > i else idx for idx in slide_map]
# Step 3: REORDER to final sequence
print(f"Reordering {len(slide_map)} slides to final sequence...")
for target_pos in range(len(slide_map)):
# Find which slide should be at target_pos
current_pos = slide_map[target_pos]
if current_pos != target_pos:
reorder_slides(prs, current_pos, target_pos)
# Update slide_map: the move shifts other slides
for i in range(len(slide_map)):
if slide_map[i] > current_pos and slide_map[i] <= target_pos:
slide_map[i] -= 1
elif slide_map[i] < current_pos and slide_map[i] >= target_pos:
slide_map[i] += 1
slide_map[target_pos] = target_pos
# Save the presentation
prs.save(output_path)
print(f"\nSaved rearranged presentation to: {output_path}")
print(f"Final presentation has {len(prs.slides)} slides")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Rearrange PowerPoint slides based on a sequence of indices.
Usage:
python rearrange.py template.pptx output.pptx 0,34,34,50,52
This will create output.pptx using slides from template.pptx in the specified order.
Slides can be repeated (e.g., 34 appears twice).
"""
import argparse
import shutil
import sys
from copy import deepcopy
from pathlib import Path
from pptx import Presentation
def main():
parser = argparse.ArgumentParser(
description="Rearrange PowerPoint slides based on a sequence of indices.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python rearrange.py template.pptx output.pptx 0,34,34,50,52
Creates output.pptx using slides 0, 34 (twice), 50, and 52 from template.pptx
python rearrange.py template.pptx output.pptx 5,3,1,2,4
Creates output.pptx with slides reordered as specified
Note: Slide indices are 0-based (first slide is 0, second is 1, etc.)
""",
)
parser.add_argument("template", help="Path to template PPTX file")
parser.add_argument("output", help="Path for output PPTX file")
parser.add_argument(
"sequence", help="Comma-separated sequence of slide indices (0-based)"
)
args = parser.parse_args()
# Parse the slide sequence
try:
slide_sequence = [int(x.strip()) for x in args.sequence.split(",")]
except ValueError:
print(
"Error: Invalid sequence format. Use comma-separated integers (e.g., 0,34,34,50,52)"
)
sys.exit(1)
# Check template exists
template_path = Path(args.template)
if not template_path.exists():
print(f"Error: Template file not found: {args.template}")
sys.exit(1)
# Create output directory if needed
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
rearrange_presentation(template_path, output_path, slide_sequence)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
except Exception as e:
print(f"Error processing presentation: {e}")
sys.exit(1)
def duplicate_slide(pres, index):
"""Duplicate a slide in the presentation."""
source = pres.slides[index]
# Use source's layout to preserve formatting
new_slide = pres.slides.add_slide(source.slide_layout)
# Collect all image and media relationships from the source slide
image_rels = {}
for rel_id, rel in source.part.rels.items():
if "image" in rel.reltype or "media" in rel.reltype:
image_rels[rel_id] = rel
# CRITICAL: Clear placeholder shapes to avoid duplicates
for shape in new_slide.shapes:
sp = shape.element
sp.getparent().remove(sp)
# Copy all shapes from source
for shape in source.shapes:
el = shape.element
new_el = deepcopy(el)
new_slide.shapes._spTree.insert_element_before(new_el, "p:extLst")
# Handle picture shapes - need to update the blip reference
# Look for all blip elements (they can be in pic or other contexts)
# Using the element's own xpath method without namespaces argument
blips = new_el.xpath(".//a:blip[@r:embed]")
for blip in blips:
old_rId = blip.get(
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
)
if old_rId in image_rels:
# Create a new relationship in the destination slide for this image
old_rel = image_rels[old_rId]
# get_or_add returns the rId directly, or adds and returns new rId
new_rId = new_slide.part.rels.get_or_add(
old_rel.reltype, old_rel._target
)
# Update the blip's embed reference to use the new relationship ID
blip.set(
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed",
new_rId,
)
# Copy any additional image/media relationships that might be referenced elsewhere
for rel_id, rel in image_rels.items():
try:
new_slide.part.rels.get_or_add(rel.reltype, rel._target)
except Exception:
pass # Relationship might already exist
return new_slide
def delete_slide(pres, index):
"""Delete a slide from the presentation."""
rId = pres.slides._sldIdLst[index].rId
pres.part.drop_rel(rId)
del pres.slides._sldIdLst[index]
def reorder_slides(pres, slide_index, target_index):
"""Move a slide from one position to another."""
slides = pres.slides._sldIdLst
# Remove slide element from current position
slide_element = slides[slide_index]
slides.remove(slide_element)
# Insert at target position
slides.insert(target_index, slide_element)
def rearrange_presentation(template_path, output_path, slide_sequence):
"""
Create a new presentation with slides from template in specified order.
Args:
template_path: Path to template PPTX file
output_path: Path for output PPTX file
slide_sequence: List of slide indices (0-based) to include
"""
# Copy template to preserve dimensions and theme
if template_path != output_path:
shutil.copy2(template_path, output_path)
prs = Presentation(output_path)
else:
prs = Presentation(template_path)
total_slides = len(prs.slides)
# Validate indices
for idx in slide_sequence:
if idx < 0 or idx >= total_slides:
raise ValueError(f"Slide index {idx} out of range (0-{total_slides - 1})")
# Track original slides and their duplicates
slide_map = [] # List of actual slide indices for final presentation
duplicated = {} # Track duplicates: original_idx -> [duplicate_indices]
# Step 1: DUPLICATE repeated slides
print(f"Processing {len(slide_sequence)} slides from template...")
for i, template_idx in enumerate(slide_sequence):
if template_idx in duplicated and duplicated[template_idx]:
# Already duplicated this slide, use the duplicate
slide_map.append(duplicated[template_idx].pop(0))
print(f" [{i}] Using duplicate of slide {template_idx}")
elif slide_sequence.count(template_idx) > 1 and template_idx not in duplicated:
# First occurrence of a repeated slide - create duplicates
slide_map.append(template_idx)
duplicates = []
count = slide_sequence.count(template_idx) - 1
print(
f" [{i}] Using original slide {template_idx}, creating {count} duplicate(s)"
)
for _ in range(count):
duplicate_slide(prs, template_idx)
duplicates.append(len(prs.slides) - 1)
duplicated[template_idx] = duplicates
else:
# Unique slide or first occurrence already handled, use original
slide_map.append(template_idx)
print(f" [{i}] Using original slide {template_idx}")
# Step 2: DELETE unwanted slides (work backwards)
slides_to_keep = set(slide_map)
print(f"\nDeleting {len(prs.slides) - len(slides_to_keep)} unused slides...")
for i in range(len(prs.slides) - 1, -1, -1):
if i not in slides_to_keep:
delete_slide(prs, i)
# Update slide_map indices after deletion
slide_map = [idx - 1 if idx > i else idx for idx in slide_map]
# Step 3: REORDER to final sequence
print(f"Reordering {len(slide_map)} slides to final sequence...")
for target_pos in range(len(slide_map)):
# Find which slide should be at target_pos
current_pos = slide_map[target_pos]
if current_pos != target_pos:
reorder_slides(prs, current_pos, target_pos)
# Update slide_map: the move shifts other slides
for i in range(len(slide_map)):
if slide_map[i] > current_pos and slide_map[i] <= target_pos:
slide_map[i] -= 1
elif slide_map[i] < current_pos and slide_map[i] >= target_pos:
slide_map[i] += 1
slide_map[target_pos] = target_pos
# Save the presentation
prs.save(output_path)
print(f"\nSaved rearranged presentation to: {output_path}")
print(f"Final presentation has {len(prs.slides)} slides")
if __name__ == "__main__":
main()
scripts/replace.py
python
#!/usr/bin/env python3
"""Apply text replacements to PowerPoint presentation.
Usage:
python replace.py <input.pptx> <replacements.json> <output.pptx>
The replacements JSON should have the structure output by inventory.py.
ALL text shapes identified by inventory.py will have their text cleared
unless "paragraphs" is specified in the replacements for that shape.
"""
import json
import sys
from pathlib import Path
from typing import Any, Dict, List
from inventory import InventoryData, extract_text_inventory
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.dml import MSO_THEME_COLOR
from pptx.enum.text import PP_ALIGN
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Pt
def clear_paragraph_bullets(paragraph):
"""Clear bullet formatting from a paragraph."""
pPr = paragraph._element.get_or_add_pPr()
# Remove existing bullet elements
for child in list(pPr):
if (
child.tag.endswith("buChar")
or child.tag.endswith("buNone")
or child.tag.endswith("buAutoNum")
or child.tag.endswith("buFont")
):
pPr.remove(child)
return pPr
def apply_paragraph_properties(paragraph, para_data: Dict[str, Any]):
"""Apply formatting properties to a paragraph."""
# Get the text but don't set it on paragraph directly yet
text = para_data.get("text", "")
# Get or create paragraph properties
pPr = clear_paragraph_bullets(paragraph)
# Handle bullet formatting (bullet: True = "•", bullet: "number" = 1. 2. 3.)
if para_data.get("bullet", False):
level = para_data.get("level", 0)
paragraph.level = level
# Calculate font-proportional indentation
font_size = para_data.get("font_size", 18.0)
level_indent_emu = int((font_size * (1.6 + level * 1.6)) * 12700)
hanging_indent_emu = int(-font_size * 0.8 * 12700)
# Set indentation
pPr.attrib["marL"] = str(level_indent_emu)
pPr.attrib["indent"] = str(hanging_indent_emu)
if para_data["bullet"] == "number":
buNum = OxmlElement("a:buAutoNum")
buNum.set("type", "arabicPeriod")
pPr.append(buNum)
else:
buChar = OxmlElement("a:buChar")
buChar.set("char", "•")
pPr.append(buChar)
# Default to left alignment for bullets if not specified
if "alignment" not in para_data:
paragraph.alignment = PP_ALIGN.LEFT
else:
# Remove indentation for non-bullet text
pPr.attrib["marL"] = "0"
pPr.attrib["indent"] = "0"
# Add buNone element
buNone = OxmlElement("a:buNone")
pPr.insert(0, buNone)
# Apply alignment
if "alignment" in para_data:
alignment_map = {
"LEFT": PP_ALIGN.LEFT,
"CENTER": PP_ALIGN.CENTER,
"RIGHT": PP_ALIGN.RIGHT,
"JUSTIFY": PP_ALIGN.JUSTIFY,
}
if para_data["alignment"] in alignment_map:
paragraph.alignment = alignment_map[para_data["alignment"]]
# Apply spacing
if "space_before" in para_data:
paragraph.space_before = Pt(para_data["space_before"])
if "space_after" in para_data:
paragraph.space_after = Pt(para_data["space_after"])
if "line_spacing" in para_data:
paragraph.line_spacing = Pt(para_data["line_spacing"])
# Apply run-level formatting
if not paragraph.runs:
run = paragraph.add_run()
run.text = text
else:
run = paragraph.runs[0]
run.text = text
# Apply font properties
apply_font_properties(run, para_data)
def apply_font_properties(run, para_data: Dict[str, Any]):
"""Apply font properties to a text run."""
if "bold" in para_data:
run.font.bold = para_data["bold"]
if "italic" in para_data:
run.font.italic = para_data["italic"]
if "underline" in para_data:
run.font.underline = para_data["underline"]
if "font_size" in para_data:
run.font.size = Pt(para_data["font_size"])
if "font_name" in para_data:
run.font.name = para_data["font_name"]
if "link" in para_data:
# a URL string sets the hyperlink; null/"" removes it
run.hyperlink.address = para_data["link"] or None
# Apply color - prefer RGB, fall back to theme_color
if "color" in para_data:
color_hex = para_data["color"].lstrip("#")
if len(color_hex) == 6:
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
run.font.color.rgb = RGBColor(r, g, b)
elif "theme_color" in para_data:
# Get theme color by name (e.g., "DARK_1", "ACCENT_1")
theme_name = para_data["theme_color"]
try:
run.font.color.theme_color = getattr(MSO_THEME_COLOR, theme_name)
except AttributeError:
print(f" WARNING: Unknown theme color name '{theme_name}'")
def detect_frame_overflow(inventory: InventoryData) -> Dict[str, Dict[str, float]]:
"""Detect text overflow in shapes (text exceeding shape bounds).
Returns dict of slide_key -> shape_key -> overflow_inches.
Only includes shapes that have text overflow.
"""
overflow_map = {}
for slide_key, shapes_dict in inventory.items():
for shape_key, shape_data in shapes_dict.items():
# Check for frame overflow (text exceeding shape bounds)
if shape_data.frame_overflow_bottom is not None:
if slide_key not in overflow_map:
overflow_map[slide_key] = {}
overflow_map[slide_key][shape_key] = shape_data.frame_overflow_bottom
return overflow_map
def validate_replacements(inventory: InventoryData, replacements: Dict) -> List[str]:
"""Validate that all shapes in replacements exist in inventory.
Returns list of error messages.
"""
errors = []
for slide_key, shapes_data in replacements.items():
if not slide_key.startswith("slide-"):
continue
# Check if slide exists
if slide_key not in inventory:
errors.append(f"Slide '{slide_key}' not found in inventory")
continue
# Check each shape
for shape_key in shapes_data.keys():
if shape_key not in inventory[slide_key]:
# Find shapes without replacements defined and show their content
unused_with_content = []
for k in inventory[slide_key].keys():
if k not in shapes_data:
shape_data = inventory[slide_key][k]
# Get text from paragraphs as preview
paragraphs = shape_data.paragraphs
if paragraphs and paragraphs[0].text:
first_text = paragraphs[0].text[:50]
if len(paragraphs[0].text) > 50:
first_text += "..."
unused_with_content.append(f"{k} ('{first_text}')")
else:
unused_with_content.append(k)
errors.append(
f"Shape '{shape_key}' not found on '{slide_key}'. "
f"Shapes without replacements: {', '.join(sorted(unused_with_content)) if unused_with_content else 'none'}"
)
return errors
def check_duplicate_keys(pairs):
"""Check for duplicate keys when loading JSON."""
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"Duplicate key found in JSON: '{key}'")
result[key] = value
return result
def apply_replacements(pptx_file: str, json_file: str, output_file: str):
"""Apply text replacements from JSON to PowerPoint presentation."""
# Load presentation
prs = Presentation(pptx_file)
# Get inventory of all text shapes (returns ShapeData objects)
# Pass prs to use same Presentation instance
inventory = extract_text_inventory(Path(pptx_file), prs)
# Detect text overflow in original presentation
original_overflow = detect_frame_overflow(inventory)
# Load replacement data with duplicate key detection
with open(json_file, "r") as f:
replacements = json.load(f, object_pairs_hook=check_duplicate_keys)
# Validate replacements
errors = validate_replacements(inventory, replacements)
if errors:
print("ERROR: Invalid shapes in replacement JSON:")
for error in errors:
print(f" - {error}")
print("\nPlease check the inventory and update your replacement JSON.")
print(
"You can regenerate the inventory with: python inventory.py <input.pptx> <output.json>"
)
raise ValueError(f"Found {len(errors)} validation error(s)")
# Track statistics
shapes_processed = 0
shapes_cleared = 0
shapes_replaced = 0
# Process each slide from inventory
for slide_key, shapes_dict in inventory.items():
if not slide_key.startswith("slide-"):
continue
slide_index = int(slide_key.split("-")[1])
if slide_index >= len(prs.slides):
print(f"Warning: Slide {slide_index} not found")
continue
# Process each shape from inventory
for shape_key, shape_data in shapes_dict.items():
shapes_processed += 1
# Get the shape directly from ShapeData
shape = shape_data.shape
if not shape:
print(f"Warning: {shape_key} has no shape reference")
continue
# ShapeData already validates text_frame in __init__
text_frame = shape.text_frame # type: ignore
text_frame.clear() # type: ignore
shapes_cleared += 1
# Check for replacement paragraphs
replacement_shape_data = replacements.get(slide_key, {}).get(shape_key, {})
if "paragraphs" not in replacement_shape_data:
continue
shapes_replaced += 1
# Add replacement paragraphs
for i, para_data in enumerate(replacement_shape_data["paragraphs"]):
if i == 0:
p = text_frame.paragraphs[0] # type: ignore
else:
p = text_frame.add_paragraph() # type: ignore
apply_paragraph_properties(p, para_data)
# Check for issues after replacements
# Save to a temporary file and reload to avoid modifying the presentation during inventory
# (extract_text_inventory accesses font.color which adds empty <a:solidFill/> elements)
import tempfile
with tempfile.NamedTemporaryFile(suffix=".pptx", delete=False) as tmp:
tmp_path = Path(tmp.name)
prs.save(str(tmp_path))
try:
updated_inventory = extract_text_inventory(tmp_path)
updated_overflow = detect_frame_overflow(updated_inventory)
finally:
tmp_path.unlink() # Clean up temp file
# Check if any text overflow got worse
overflow_errors = []
for slide_key, shape_overflows in updated_overflow.items():
for shape_key, new_overflow in shape_overflows.items():
# Get original overflow (0 if there was no overflow before)
original = original_overflow.get(slide_key, {}).get(shape_key, 0.0)
# Error if overflow increased
if new_overflow > original + 0.01: # Small tolerance for rounding
increase = new_overflow - original
overflow_errors.append(
f'{slide_key}/{shape_key}: overflow worsened by {increase:.2f}" '
f'(was {original:.2f}", now {new_overflow:.2f}")'
)
# Collect warnings from updated shapes
warnings = []
for slide_key, shapes_dict in updated_inventory.items():
for shape_key, shape_data in shapes_dict.items():
if shape_data.warnings:
for warning in shape_data.warnings:
warnings.append(f"{slide_key}/{shape_key}: {warning}")
# Fail if there are any issues
if overflow_errors or warnings:
print("\nERROR: Issues detected in replacement output:")
if overflow_errors:
print("\nText overflow worsened:")
for error in overflow_errors:
print(f" - {error}")
if warnings:
print("\nFormatting warnings:")
for warning in warnings:
print(f" - {warning}")
print("\nPlease fix these issues before saving.")
raise ValueError(
f"Found {len(overflow_errors)} overflow error(s) and {len(warnings)} warning(s)"
)
# Save the presentation
prs.save(output_file)
# Report results
print(f"Saved updated presentation to: {output_file}")
print(f"Processed {len(prs.slides)} slides")
print(f" - Shapes processed: {shapes_processed}")
print(f" - Shapes cleared: {shapes_cleared}")
print(f" - Shapes replaced: {shapes_replaced}")
def main():
"""Main entry point for command-line usage."""
if len(sys.argv) != 4:
print(__doc__)
sys.exit(1)
input_pptx = Path(sys.argv[1])
replacements_json = Path(sys.argv[2])
output_pptx = Path(sys.argv[3])
if not input_pptx.exists():
print(f"Error: Input file '{input_pptx}' not found")
sys.exit(1)
if not replacements_json.exists():
print(f"Error: Replacements JSON file '{replacements_json}' not found")
sys.exit(1)
try:
apply_replacements(str(input_pptx), str(replacements_json), str(output_pptx))
except Exception as e:
print(f"Error applying replacements: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Apply text replacements to PowerPoint presentation.
Usage:
python replace.py <input.pptx> <replacements.json> <output.pptx>
The replacements JSON should have the structure output by inventory.py.
ALL text shapes identified by inventory.py will have their text cleared
unless "paragraphs" is specified in the replacements for that shape.
"""
import json
import sys
from pathlib import Path
from typing import Any, Dict, List
from inventory import InventoryData, extract_text_inventory
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.dml import MSO_THEME_COLOR
from pptx.enum.text import PP_ALIGN
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Pt
def clear_paragraph_bullets(paragraph):
"""Clear bullet formatting from a paragraph."""
pPr = paragraph._element.get_or_add_pPr()
# Remove existing bullet elements
for child in list(pPr):
if (
child.tag.endswith("buChar")
or child.tag.endswith("buNone")
or child.tag.endswith("buAutoNum")
or child.tag.endswith("buFont")
):
pPr.remove(child)
return pPr
def apply_paragraph_properties(paragraph, para_data: Dict[str, Any]):
"""Apply formatting properties to a paragraph."""
# Get the text but don't set it on paragraph directly yet
text = para_data.get("text", "")
# Get or create paragraph properties
pPr = clear_paragraph_bullets(paragraph)
# Handle bullet formatting (bullet: True = "•", bullet: "number" = 1. 2. 3.)
if para_data.get("bullet", False):
level = para_data.get("level", 0)
paragraph.level = level
# Calculate font-proportional indentation
font_size = para_data.get("font_size", 18.0)
level_indent_emu = int((font_size * (1.6 + level * 1.6)) * 12700)
hanging_indent_emu = int(-font_size * 0.8 * 12700)
# Set indentation
pPr.attrib["marL"] = str(level_indent_emu)
pPr.attrib["indent"] = str(hanging_indent_emu)
if para_data["bullet"] == "number":
buNum = OxmlElement("a:buAutoNum")
buNum.set("type", "arabicPeriod")
pPr.append(buNum)
else:
buChar = OxmlElement("a:buChar")
buChar.set("char", "•")
pPr.append(buChar)
# Default to left alignment for bullets if not specified
if "alignment" not in para_data:
paragraph.alignment = PP_ALIGN.LEFT
else:
# Remove indentation for non-bullet text
pPr.attrib["marL"] = "0"
pPr.attrib["indent"] = "0"
# Add buNone element
buNone = OxmlElement("a:buNone")
pPr.insert(0, buNone)
# Apply alignment
if "alignment" in para_data:
alignment_map = {
"LEFT": PP_ALIGN.LEFT,
"CENTER": PP_ALIGN.CENTER,
"RIGHT": PP_ALIGN.RIGHT,
"JUSTIFY": PP_ALIGN.JUSTIFY,
}
if para_data["alignment"] in alignment_map:
paragraph.alignment = alignment_map[para_data["alignment"]]
# Apply spacing
if "space_before" in para_data:
paragraph.space_before = Pt(para_data["space_before"])
if "space_after" in para_data:
paragraph.space_after = Pt(para_data["space_after"])
if "line_spacing" in para_data:
paragraph.line_spacing = Pt(para_data["line_spacing"])
# Apply run-level formatting
if not paragraph.runs:
run = paragraph.add_run()
run.text = text
else:
run = paragraph.runs[0]
run.text = text
# Apply font properties
apply_font_properties(run, para_data)
def apply_font_properties(run, para_data: Dict[str, Any]):
"""Apply font properties to a text run."""
if "bold" in para_data:
run.font.bold = para_data["bold"]
if "italic" in para_data:
run.font.italic = para_data["italic"]
if "underline" in para_data:
run.font.underline = para_data["underline"]
if "font_size" in para_data:
run.font.size = Pt(para_data["font_size"])
if "font_name" in para_data:
run.font.name = para_data["font_name"]
if "link" in para_data:
# a URL string sets the hyperlink; null/"" removes it
run.hyperlink.address = para_data["link"] or None
# Apply color - prefer RGB, fall back to theme_color
if "color" in para_data:
color_hex = para_data["color"].lstrip("#")
if len(color_hex) == 6:
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
run.font.color.rgb = RGBColor(r, g, b)
elif "theme_color" in para_data:
# Get theme color by name (e.g., "DARK_1", "ACCENT_1")
theme_name = para_data["theme_color"]
try:
run.font.color.theme_color = getattr(MSO_THEME_COLOR, theme_name)
except AttributeError:
print(f" WARNING: Unknown theme color name '{theme_name}'")
def detect_frame_overflow(inventory: InventoryData) -> Dict[str, Dict[str, float]]:
"""Detect text overflow in shapes (text exceeding shape bounds).
Returns dict of slide_key -> shape_key -> overflow_inches.
Only includes shapes that have text overflow.
"""
overflow_map = {}
for slide_key, shapes_dict in inventory.items():
for shape_key, shape_data in shapes_dict.items():
# Check for frame overflow (text exceeding shape bounds)
if shape_data.frame_overflow_bottom is not None:
if slide_key not in overflow_map:
overflow_map[slide_key] = {}
overflow_map[slide_key][shape_key] = shape_data.frame_overflow_bottom
return overflow_map
def validate_replacements(inventory: InventoryData, replacements: Dict) -> List[str]:
"""Validate that all shapes in replacements exist in inventory.
Returns list of error messages.
"""
errors = []
for slide_key, shapes_data in replacements.items():
if not slide_key.startswith("slide-"):
continue
# Check if slide exists
if slide_key not in inventory:
errors.append(f"Slide '{slide_key}' not found in inventory")
continue
# Check each shape
for shape_key in shapes_data.keys():
if shape_key not in inventory[slide_key]:
# Find shapes without replacements defined and show their content
unused_with_content = []
for k in inventory[slide_key].keys():
if k not in shapes_data:
shape_data = inventory[slide_key][k]
# Get text from paragraphs as preview
paragraphs = shape_data.paragraphs
if paragraphs and paragraphs[0].text:
first_text = paragraphs[0].text[:50]
if len(paragraphs[0].text) > 50:
first_text += "..."
unused_with_content.append(f"{k} ('{first_text}')")
else:
unused_with_content.append(k)
errors.append(
f"Shape '{shape_key}' not found on '{slide_key}'. "
f"Shapes without replacements: {', '.join(sorted(unused_with_content)) if unused_with_content else 'none'}"
)
return errors
def check_duplicate_keys(pairs):
"""Check for duplicate keys when loading JSON."""
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"Duplicate key found in JSON: '{key}'")
result[key] = value
return result
def apply_replacements(pptx_file: str, json_file: str, output_file: str):
"""Apply text replacements from JSON to PowerPoint presentation."""
# Load presentation
prs = Presentation(pptx_file)
# Get inventory of all text shapes (returns ShapeData objects)
# Pass prs to use same Presentation instance
inventory = extract_text_inventory(Path(pptx_file), prs)
# Detect text overflow in original presentation
original_overflow = detect_frame_overflow(inventory)
# Load replacement data with duplicate key detection
with open(json_file, "r") as f:
replacements = json.load(f, object_pairs_hook=check_duplicate_keys)
# Validate replacements
errors = validate_replacements(inventory, replacements)
if errors:
print("ERROR: Invalid shapes in replacement JSON:")
for error in errors:
print(f" - {error}")
print("\nPlease check the inventory and update your replacement JSON.")
print(
"You can regenerate the inventory with: python inventory.py <input.pptx> <output.json>"
)
raise ValueError(f"Found {len(errors)} validation error(s)")
# Track statistics
shapes_processed = 0
shapes_cleared = 0
shapes_replaced = 0
# Process each slide from inventory
for slide_key, shapes_dict in inventory.items():
if not slide_key.startswith("slide-"):
continue
slide_index = int(slide_key.split("-")[1])
if slide_index >= len(prs.slides):
print(f"Warning: Slide {slide_index} not found")
continue
# Process each shape from inventory
for shape_key, shape_data in shapes_dict.items():
shapes_processed += 1
# Get the shape directly from ShapeData
shape = shape_data.shape
if not shape:
print(f"Warning: {shape_key} has no shape reference")
continue
# ShapeData already validates text_frame in __init__
text_frame = shape.text_frame # type: ignore
text_frame.clear() # type: ignore
shapes_cleared += 1
# Check for replacement paragraphs
replacement_shape_data = replacements.get(slide_key, {}).get(shape_key, {})
if "paragraphs" not in replacement_shape_data:
continue
shapes_replaced += 1
# Add replacement paragraphs
for i, para_data in enumerate(replacement_shape_data["paragraphs"]):
if i == 0:
p = text_frame.paragraphs[0] # type: ignore
else:
p = text_frame.add_paragraph() # type: ignore
apply_paragraph_properties(p, para_data)
# Check for issues after replacements
# Save to a temporary file and reload to avoid modifying the presentation during inventory
# (extract_text_inventory accesses font.color which adds empty <a:solidFill/> elements)
import tempfile
with tempfile.NamedTemporaryFile(suffix=".pptx", delete=False) as tmp:
tmp_path = Path(tmp.name)
prs.save(str(tmp_path))
try:
updated_inventory = extract_text_inventory(tmp_path)
updated_overflow = detect_frame_overflow(updated_inventory)
finally:
tmp_path.unlink() # Clean up temp file
# Check if any text overflow got worse
overflow_errors = []
for slide_key, shape_overflows in updated_overflow.items():
for shape_key, new_overflow in shape_overflows.items():
# Get original overflow (0 if there was no overflow before)
original = original_overflow.get(slide_key, {}).get(shape_key, 0.0)
# Error if overflow increased
if new_overflow > original + 0.01: # Small tolerance for rounding
increase = new_overflow - original
overflow_errors.append(
f'{slide_key}/{shape_key}: overflow worsened by {increase:.2f}" '
f'(was {original:.2f}", now {new_overflow:.2f}")'
)
# Collect warnings from updated shapes
warnings = []
for slide_key, shapes_dict in updated_inventory.items():
for shape_key, shape_data in shapes_dict.items():
if shape_data.warnings:
for warning in shape_data.warnings:
warnings.append(f"{slide_key}/{shape_key}: {warning}")
# Fail if there are any issues
if overflow_errors or warnings:
print("\nERROR: Issues detected in replacement output:")
if overflow_errors:
print("\nText overflow worsened:")
for error in overflow_errors:
print(f" - {error}")
if warnings:
print("\nFormatting warnings:")
for warning in warnings:
print(f" - {warning}")
print("\nPlease fix these issues before saving.")
raise ValueError(
f"Found {len(overflow_errors)} overflow error(s) and {len(warnings)} warning(s)"
)
# Save the presentation
prs.save(output_file)
# Report results
print(f"Saved updated presentation to: {output_file}")
print(f"Processed {len(prs.slides)} slides")
print(f" - Shapes processed: {shapes_processed}")
print(f" - Shapes cleared: {shapes_cleared}")
print(f" - Shapes replaced: {shapes_replaced}")
def main():
"""Main entry point for command-line usage."""
if len(sys.argv) != 4:
print(__doc__)
sys.exit(1)
input_pptx = Path(sys.argv[1])
replacements_json = Path(sys.argv[2])
output_pptx = Path(sys.argv[3])
if not input_pptx.exists():
print(f"Error: Input file '{input_pptx}' not found")
sys.exit(1)
if not replacements_json.exists():
print(f"Error: Replacements JSON file '{replacements_json}' not found")
sys.exit(1)
try:
apply_replacements(str(input_pptx), str(replacements_json), str(output_pptx))
except Exception as e:
print(f"Error applying replacements: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
scripts/thumbnail.py
python
#!/usr/bin/env python3
"""
Create thumbnail grids from PowerPoint presentation slides.
Creates a grid layout of slide thumbnails with configurable columns (max 6).
Each grid contains up to cols×(cols+1) images. For presentations with more
slides, multiple numbered grid files are created automatically.
The program outputs the names of all files created.
Output:
- Single grid: {prefix}.jpg (if slides fit in one grid)
- Multiple grids: {prefix}-1.jpg, {prefix}-2.jpg, etc.
Grid limits by column count:
- 3 cols: max 12 slides per grid (3×4)
- 4 cols: max 20 slides per grid (4×5)
- 5 cols: max 30 slides per grid (5×6) [default]
- 6 cols: max 42 slides per grid (6×7)
Usage:
python thumbnail.py input.pptx [output_prefix] [--cols N] [--outline-placeholders]
Examples:
python thumbnail.py presentation.pptx
# Creates: thumbnails.jpg (using default prefix)
# Outputs:
# Created 1 grid(s):
# - thumbnails.jpg
python thumbnail.py large-deck.pptx grid --cols 4
# Creates: grid-1.jpg, grid-2.jpg, grid-3.jpg
# Outputs:
# Created 3 grid(s):
# - grid-1.jpg
# - grid-2.jpg
# - grid-3.jpg
python thumbnail.py template.pptx analysis --outline-placeholders
# Creates thumbnail grids with red outlines around text placeholders
"""
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
from inventory import extract_text_inventory
from PIL import Image, ImageDraw, ImageFont
from pptx import Presentation
# Constants
THUMBNAIL_WIDTH = 300 # Fixed thumbnail width in pixels
CONVERSION_DPI = 100 # DPI for PDF to image conversion
MAX_COLS = 6 # Maximum number of columns
DEFAULT_COLS = 5 # Default number of columns
JPEG_QUALITY = 95 # JPEG compression quality
# Grid layout constants
GRID_PADDING = 20 # Padding between thumbnails
BORDER_WIDTH = 2 # Border width around thumbnails
FONT_SIZE_RATIO = 0.12 # Font size as fraction of thumbnail width
LABEL_PADDING_RATIO = 0.4 # Label padding as fraction of font size
def main():
parser = argparse.ArgumentParser(
description="Create thumbnail grids from PowerPoint slides."
)
parser.add_argument("input", help="Input PowerPoint file (.pptx)")
parser.add_argument(
"output_prefix",
nargs="?",
default="thumbnails",
help="Output prefix for image files (default: thumbnails, will create prefix.jpg or prefix-N.jpg)",
)
parser.add_argument(
"--cols",
type=int,
default=DEFAULT_COLS,
help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})",
)
parser.add_argument(
"--outline-placeholders",
action="store_true",
help="Outline text placeholders with a colored border",
)
args = parser.parse_args()
# Validate columns
cols = min(args.cols, MAX_COLS)
if args.cols > MAX_COLS:
print(f"Warning: Columns limited to {MAX_COLS} (requested {args.cols})")
# Validate input
input_path = Path(args.input)
if not input_path.exists() or input_path.suffix.lower() != ".pptx":
print(f"Error: Invalid PowerPoint file: {args.input}")
sys.exit(1)
# Construct output path (always JPG)
output_path = Path(f"{args.output_prefix}.jpg")
print(f"Processing: {args.input}")
try:
with tempfile.TemporaryDirectory() as temp_dir:
# Get placeholder regions if outlining is enabled
placeholder_regions = None
slide_dimensions = None
if args.outline_placeholders:
print("Extracting placeholder regions...")
placeholder_regions, slide_dimensions = get_placeholder_regions(
input_path
)
if placeholder_regions:
print(f"Found placeholders on {len(placeholder_regions)} slides")
# Convert slides to images
slide_images = convert_to_images(input_path, Path(temp_dir), CONVERSION_DPI)
if not slide_images:
print("Error: No slides found")
sys.exit(1)
print(f"Found {len(slide_images)} slides")
# Create grids (max cols×(cols+1) images per grid)
grid_files = create_grids(
slide_images,
cols,
THUMBNAIL_WIDTH,
output_path,
placeholder_regions,
slide_dimensions,
)
# Print saved files
print(f"Created {len(grid_files)} grid(s):")
for grid_file in grid_files:
print(f" - {grid_file}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
def create_hidden_slide_placeholder(size):
"""Create placeholder image for hidden slides."""
img = Image.new("RGB", size, color="#F0F0F0")
draw = ImageDraw.Draw(img)
line_width = max(5, min(size) // 100)
draw.line([(0, 0), size], fill="#CCCCCC", width=line_width)
draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width)
return img
def get_placeholder_regions(pptx_path):
"""Extract ALL text regions from the presentation.
Returns a tuple of (placeholder_regions, slide_dimensions).
text_regions is a dict mapping slide indices to lists of text regions.
Each region is a dict with 'left', 'top', 'width', 'height' in inches.
slide_dimensions is a tuple of (width_inches, height_inches).
"""
prs = Presentation(str(pptx_path))
inventory = extract_text_inventory(pptx_path, prs)
placeholder_regions = {}
# Get actual slide dimensions in inches (EMU to inches conversion)
slide_width_inches = (prs.slide_width or 9144000) / 914400.0
slide_height_inches = (prs.slide_height or 5143500) / 914400.0
for slide_key, shapes in inventory.items():
# Extract slide index from "slide-N" format
slide_idx = int(slide_key.split("-")[1])
regions = []
for shape_key, shape_data in shapes.items():
# The inventory only contains shapes with text, so all shapes should be highlighted
regions.append(
{
"left": shape_data.left,
"top": shape_data.top,
"width": shape_data.width,
"height": shape_data.height,
}
)
if regions:
placeholder_regions[slide_idx] = regions
return placeholder_regions, (slide_width_inches, slide_height_inches)
def convert_to_images(pptx_path, temp_dir, dpi):
"""Convert PowerPoint to images via PDF, handling hidden slides."""
# Detect hidden slides
print("Analyzing presentation...")
prs = Presentation(str(pptx_path))
total_slides = len(prs.slides)
# Find hidden slides (1-based indexing for display)
hidden_slides = {
idx + 1
for idx, slide in enumerate(prs.slides)
if slide.element.get("show") == "0"
}
print(f"Total slides: {total_slides}")
if hidden_slides:
print(f"Hidden slides: {sorted(hidden_slides)}")
pdf_path = temp_dir / f"{pptx_path.stem}.pdf"
# Convert to PDF
print("Converting to PDF...")
result = subprocess.run(
[
"soffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(temp_dir),
str(pptx_path),
],
capture_output=True,
text=True,
)
if result.returncode != 0 or not pdf_path.exists():
raise RuntimeError("PDF conversion failed")
# Convert PDF to images
print(f"Converting to images at {dpi} DPI...")
result = subprocess.run(
["pdftoppm", "-jpeg", "-r", str(dpi), str(pdf_path), str(temp_dir / "slide")],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError("Image conversion failed")
visible_images = sorted(temp_dir.glob("slide-*.jpg"))
# Create full list with placeholders for hidden slides
all_images = []
visible_idx = 0
# Get placeholder dimensions from first visible slide
if visible_images:
with Image.open(visible_images[0]) as img:
placeholder_size = img.size
else:
placeholder_size = (1920, 1080)
for slide_num in range(1, total_slides + 1):
if slide_num in hidden_slides:
# Create placeholder image for hidden slide
placeholder_path = temp_dir / f"hidden-{slide_num:03d}.jpg"
placeholder_img = create_hidden_slide_placeholder(placeholder_size)
placeholder_img.save(placeholder_path, "JPEG")
all_images.append(placeholder_path)
else:
# Use the actual visible slide image
if visible_idx < len(visible_images):
all_images.append(visible_images[visible_idx])
visible_idx += 1
return all_images
def create_grids(
image_paths,
cols,
width,
output_path,
placeholder_regions=None,
slide_dimensions=None,
):
"""Create multiple thumbnail grids from slide images, max cols×(cols+1) images per grid."""
# Maximum images per grid is cols × (cols + 1) for better proportions
max_images_per_grid = cols * (cols + 1)
grid_files = []
print(
f"Creating grids with {cols} columns (max {max_images_per_grid} images per grid)"
)
# Split images into chunks
for chunk_idx, start_idx in enumerate(
range(0, len(image_paths), max_images_per_grid)
):
end_idx = min(start_idx + max_images_per_grid, len(image_paths))
chunk_images = image_paths[start_idx:end_idx]
# Create grid for this chunk
grid = create_grid(
chunk_images, cols, width, start_idx, placeholder_regions, slide_dimensions
)
# Generate output filename
if len(image_paths) <= max_images_per_grid:
# Single grid - use base filename without suffix
grid_filename = output_path
else:
# Multiple grids - insert index before extension with dash
stem = output_path.stem
suffix = output_path.suffix
grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}"
# Save grid
grid_filename.parent.mkdir(parents=True, exist_ok=True)
grid.save(str(grid_filename), quality=JPEG_QUALITY)
grid_files.append(str(grid_filename))
return grid_files
def create_grid(
image_paths,
cols,
width,
start_slide_num=0,
placeholder_regions=None,
slide_dimensions=None,
):
"""Create thumbnail grid from slide images with optional placeholder outlining."""
font_size = int(width * FONT_SIZE_RATIO)
label_padding = int(font_size * LABEL_PADDING_RATIO)
# Get dimensions
with Image.open(image_paths[0]) as img:
aspect = img.height / img.width
height = int(width * aspect)
# Calculate grid size
rows = (len(image_paths) + cols - 1) // cols
grid_w = cols * width + (cols + 1) * GRID_PADDING
grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING
# Create grid
grid = Image.new("RGB", (grid_w, grid_h), "white")
draw = ImageDraw.Draw(grid)
# Load font with size based on thumbnail width
try:
# Use Pillow's default font with size
font = ImageFont.load_default(size=font_size)
except Exception:
# Fall back to basic default font if size parameter not supported
font = ImageFont.load_default()
# Place thumbnails
for i, img_path in enumerate(image_paths):
row, col = i // cols, i % cols
x = col * width + (col + 1) * GRID_PADDING
y_base = (
row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING
)
# Add label with actual slide number
label = f"{start_slide_num + i}"
bbox = draw.textbbox((0, 0), label, font=font)
text_w = bbox[2] - bbox[0]
draw.text(
(x + (width - text_w) // 2, y_base + label_padding),
label,
fill="black",
font=font,
)
# Add thumbnail below label with proportional spacing
y_thumbnail = y_base + label_padding + font_size + label_padding
with Image.open(img_path) as img:
# Get original dimensions before thumbnail
orig_w, orig_h = img.size
# Apply placeholder outlines if enabled
if placeholder_regions and (start_slide_num + i) in placeholder_regions:
# Convert to RGBA for transparency support
if img.mode != "RGBA":
img = img.convert("RGBA")
# Get the regions for this slide
regions = placeholder_regions[start_slide_num + i]
# Calculate scale factors using actual slide dimensions
if slide_dimensions:
slide_width_inches, slide_height_inches = slide_dimensions
else:
# Fallback: estimate from image size at CONVERSION_DPI
slide_width_inches = orig_w / CONVERSION_DPI
slide_height_inches = orig_h / CONVERSION_DPI
x_scale = orig_w / slide_width_inches
y_scale = orig_h / slide_height_inches
# Create a highlight overlay
overlay = Image.new("RGBA", img.size, (255, 255, 255, 0))
overlay_draw = ImageDraw.Draw(overlay)
# Highlight each placeholder region
for region in regions:
# Convert from inches to pixels in the original image
px_left = int(region["left"] * x_scale)
px_top = int(region["top"] * y_scale)
px_width = int(region["width"] * x_scale)
px_height = int(region["height"] * y_scale)
# Draw highlight outline with red color and thick stroke
# Using a bright red outline instead of fill
stroke_width = max(
5, min(orig_w, orig_h) // 150
) # Thicker proportional stroke width
overlay_draw.rectangle(
[(px_left, px_top), (px_left + px_width, px_top + px_height)],
outline=(255, 0, 0, 255), # Bright red, fully opaque
width=stroke_width,
)
# Composite the overlay onto the image using alpha blending
img = Image.alpha_composite(img, overlay)
# Convert back to RGB for JPEG saving
img = img.convert("RGB")
img.thumbnail((width, height), Image.Resampling.LANCZOS)
w, h = img.size
tx = x + (width - w) // 2
ty = y_thumbnail + (height - h) // 2
grid.paste(img, (tx, ty))
# Add border
if BORDER_WIDTH > 0:
draw.rectangle(
[
(tx - BORDER_WIDTH, ty - BORDER_WIDTH),
(tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1),
],
outline="gray",
width=BORDER_WIDTH,
)
return grid
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Create thumbnail grids from PowerPoint presentation slides.
Creates a grid layout of slide thumbnails with configurable columns (max 6).
Each grid contains up to cols×(cols+1) images. For presentations with more
slides, multiple numbered grid files are created automatically.
The program outputs the names of all files created.
Output:
- Single grid: {prefix}.jpg (if slides fit in one grid)
- Multiple grids: {prefix}-1.jpg, {prefix}-2.jpg, etc.
Grid limits by column count:
- 3 cols: max 12 slides per grid (3×4)
- 4 cols: max 20 slides per grid (4×5)
- 5 cols: max 30 slides per grid (5×6) [default]
- 6 cols: max 42 slides per grid (6×7)
Usage:
python thumbnail.py input.pptx [output_prefix] [--cols N] [--outline-placeholders]
Examples:
python thumbnail.py presentation.pptx
# Creates: thumbnails.jpg (using default prefix)
# Outputs:
# Created 1 grid(s):
# - thumbnails.jpg
python thumbnail.py large-deck.pptx grid --cols 4
# Creates: grid-1.jpg, grid-2.jpg, grid-3.jpg
# Outputs:
# Created 3 grid(s):
# - grid-1.jpg
# - grid-2.jpg
# - grid-3.jpg
python thumbnail.py template.pptx analysis --outline-placeholders
# Creates thumbnail grids with red outlines around text placeholders
"""
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
from inventory import extract_text_inventory
from PIL import Image, ImageDraw, ImageFont
from pptx import Presentation
# Constants
THUMBNAIL_WIDTH = 300 # Fixed thumbnail width in pixels
CONVERSION_DPI = 100 # DPI for PDF to image conversion
MAX_COLS = 6 # Maximum number of columns
DEFAULT_COLS = 5 # Default number of columns
JPEG_QUALITY = 95 # JPEG compression quality
# Grid layout constants
GRID_PADDING = 20 # Padding between thumbnails
BORDER_WIDTH = 2 # Border width around thumbnails
FONT_SIZE_RATIO = 0.12 # Font size as fraction of thumbnail width
LABEL_PADDING_RATIO = 0.4 # Label padding as fraction of font size
def main():
parser = argparse.ArgumentParser(
description="Create thumbnail grids from PowerPoint slides."
)
parser.add_argument("input", help="Input PowerPoint file (.pptx)")
parser.add_argument(
"output_prefix",
nargs="?",
default="thumbnails",
help="Output prefix for image files (default: thumbnails, will create prefix.jpg or prefix-N.jpg)",
)
parser.add_argument(
"--cols",
type=int,
default=DEFAULT_COLS,
help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})",
)
parser.add_argument(
"--outline-placeholders",
action="store_true",
help="Outline text placeholders with a colored border",
)
args = parser.parse_args()
# Validate columns
cols = min(args.cols, MAX_COLS)
if args.cols > MAX_COLS:
print(f"Warning: Columns limited to {MAX_COLS} (requested {args.cols})")
# Validate input
input_path = Path(args.input)
if not input_path.exists() or input_path.suffix.lower() != ".pptx":
print(f"Error: Invalid PowerPoint file: {args.input}")
sys.exit(1)
# Construct output path (always JPG)
output_path = Path(f"{args.output_prefix}.jpg")
print(f"Processing: {args.input}")
try:
with tempfile.TemporaryDirectory() as temp_dir:
# Get placeholder regions if outlining is enabled
placeholder_regions = None
slide_dimensions = None
if args.outline_placeholders:
print("Extracting placeholder regions...")
placeholder_regions, slide_dimensions = get_placeholder_regions(
input_path
)
if placeholder_regions:
print(f"Found placeholders on {len(placeholder_regions)} slides")
# Convert slides to images
slide_images = convert_to_images(input_path, Path(temp_dir), CONVERSION_DPI)
if not slide_images:
print("Error: No slides found")
sys.exit(1)
print(f"Found {len(slide_images)} slides")
# Create grids (max cols×(cols+1) images per grid)
grid_files = create_grids(
slide_images,
cols,
THUMBNAIL_WIDTH,
output_path,
placeholder_regions,
slide_dimensions,
)
# Print saved files
print(f"Created {len(grid_files)} grid(s):")
for grid_file in grid_files:
print(f" - {grid_file}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
def create_hidden_slide_placeholder(size):
"""Create placeholder image for hidden slides."""
img = Image.new("RGB", size, color="#F0F0F0")
draw = ImageDraw.Draw(img)
line_width = max(5, min(size) // 100)
draw.line([(0, 0), size], fill="#CCCCCC", width=line_width)
draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width)
return img
def get_placeholder_regions(pptx_path):
"""Extract ALL text regions from the presentation.
Returns a tuple of (placeholder_regions, slide_dimensions).
text_regions is a dict mapping slide indices to lists of text regions.
Each region is a dict with 'left', 'top', 'width', 'height' in inches.
slide_dimensions is a tuple of (width_inches, height_inches).
"""
prs = Presentation(str(pptx_path))
inventory = extract_text_inventory(pptx_path, prs)
placeholder_regions = {}
# Get actual slide dimensions in inches (EMU to inches conversion)
slide_width_inches = (prs.slide_width or 9144000) / 914400.0
slide_height_inches = (prs.slide_height or 5143500) / 914400.0
for slide_key, shapes in inventory.items():
# Extract slide index from "slide-N" format
slide_idx = int(slide_key.split("-")[1])
regions = []
for shape_key, shape_data in shapes.items():
# The inventory only contains shapes with text, so all shapes should be highlighted
regions.append(
{
"left": shape_data.left,
"top": shape_data.top,
"width": shape_data.width,
"height": shape_data.height,
}
)
if regions:
placeholder_regions[slide_idx] = regions
return placeholder_regions, (slide_width_inches, slide_height_inches)
def convert_to_images(pptx_path, temp_dir, dpi):
"""Convert PowerPoint to images via PDF, handling hidden slides."""
# Detect hidden slides
print("Analyzing presentation...")
prs = Presentation(str(pptx_path))
total_slides = len(prs.slides)
# Find hidden slides (1-based indexing for display)
hidden_slides = {
idx + 1
for idx, slide in enumerate(prs.slides)
if slide.element.get("show") == "0"
}
print(f"Total slides: {total_slides}")
if hidden_slides:
print(f"Hidden slides: {sorted(hidden_slides)}")
pdf_path = temp_dir / f"{pptx_path.stem}.pdf"
# Convert to PDF
print("Converting to PDF...")
result = subprocess.run(
[
"soffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(temp_dir),
str(pptx_path),
],
capture_output=True,
text=True,
)
if result.returncode != 0 or not pdf_path.exists():
raise RuntimeError("PDF conversion failed")
# Convert PDF to images
print(f"Converting to images at {dpi} DPI...")
result = subprocess.run(
["pdftoppm", "-jpeg", "-r", str(dpi), str(pdf_path), str(temp_dir / "slide")],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError("Image conversion failed")
visible_images = sorted(temp_dir.glob("slide-*.jpg"))
# Create full list with placeholders for hidden slides
all_images = []
visible_idx = 0
# Get placeholder dimensions from first visible slide
if visible_images:
with Image.open(visible_images[0]) as img:
placeholder_size = img.size
else:
placeholder_size = (1920, 1080)
for slide_num in range(1, total_slides + 1):
if slide_num in hidden_slides:
# Create placeholder image for hidden slide
placeholder_path = temp_dir / f"hidden-{slide_num:03d}.jpg"
placeholder_img = create_hidden_slide_placeholder(placeholder_size)
placeholder_img.save(placeholder_path, "JPEG")
all_images.append(placeholder_path)
else:
# Use the actual visible slide image
if visible_idx < len(visible_images):
all_images.append(visible_images[visible_idx])
visible_idx += 1
return all_images
def create_grids(
image_paths,
cols,
width,
output_path,
placeholder_regions=None,
slide_dimensions=None,
):
"""Create multiple thumbnail grids from slide images, max cols×(cols+1) images per grid."""
# Maximum images per grid is cols × (cols + 1) for better proportions
max_images_per_grid = cols * (cols + 1)
grid_files = []
print(
f"Creating grids with {cols} columns (max {max_images_per_grid} images per grid)"
)
# Split images into chunks
for chunk_idx, start_idx in enumerate(
range(0, len(image_paths), max_images_per_grid)
):
end_idx = min(start_idx + max_images_per_grid, len(image_paths))
chunk_images = image_paths[start_idx:end_idx]
# Create grid for this chunk
grid = create_grid(
chunk_images, cols, width, start_idx, placeholder_regions, slide_dimensions
)
# Generate output filename
if len(image_paths) <= max_images_per_grid:
# Single grid - use base filename without suffix
grid_filename = output_path
else:
# Multiple grids - insert index before extension with dash
stem = output_path.stem
suffix = output_path.suffix
grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}"
# Save grid
grid_filename.parent.mkdir(parents=True, exist_ok=True)
grid.save(str(grid_filename), quality=JPEG_QUALITY)
grid_files.append(str(grid_filename))
return grid_files
def create_grid(
image_paths,
cols,
width,
start_slide_num=0,
placeholder_regions=None,
slide_dimensions=None,
):
"""Create thumbnail grid from slide images with optional placeholder outlining."""
font_size = int(width * FONT_SIZE_RATIO)
label_padding = int(font_size * LABEL_PADDING_RATIO)
# Get dimensions
with Image.open(image_paths[0]) as img:
aspect = img.height / img.width
height = int(width * aspect)
# Calculate grid size
rows = (len(image_paths) + cols - 1) // cols
grid_w = cols * width + (cols + 1) * GRID_PADDING
grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING
# Create grid
grid = Image.new("RGB", (grid_w, grid_h), "white")
draw = ImageDraw.Draw(grid)
# Load font with size based on thumbnail width
try:
# Use Pillow's default font with size
font = ImageFont.load_default(size=font_size)
except Exception:
# Fall back to basic default font if size parameter not supported
font = ImageFont.load_default()
# Place thumbnails
for i, img_path in enumerate(image_paths):
row, col = i // cols, i % cols
x = col * width + (col + 1) * GRID_PADDING
y_base = (
row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING
)
# Add label with actual slide number
label = f"{start_slide_num + i}"
bbox = draw.textbbox((0, 0), label, font=font)
text_w = bbox[2] - bbox[0]
draw.text(
(x + (width - text_w) // 2, y_base + label_padding),
label,
fill="black",
font=font,
)
# Add thumbnail below label with proportional spacing
y_thumbnail = y_base + label_padding + font_size + label_padding
with Image.open(img_path) as img:
# Get original dimensions before thumbnail
orig_w, orig_h = img.size
# Apply placeholder outlines if enabled
if placeholder_regions and (start_slide_num + i) in placeholder_regions:
# Convert to RGBA for transparency support
if img.mode != "RGBA":
img = img.convert("RGBA")
# Get the regions for this slide
regions = placeholder_regions[start_slide_num + i]
# Calculate scale factors using actual slide dimensions
if slide_dimensions:
slide_width_inches, slide_height_inches = slide_dimensions
else:
# Fallback: estimate from image size at CONVERSION_DPI
slide_width_inches = orig_w / CONVERSION_DPI
slide_height_inches = orig_h / CONVERSION_DPI
x_scale = orig_w / slide_width_inches
y_scale = orig_h / slide_height_inches
# Create a highlight overlay
overlay = Image.new("RGBA", img.size, (255, 255, 255, 0))
overlay_draw = ImageDraw.Draw(overlay)
# Highlight each placeholder region
for region in regions:
# Convert from inches to pixels in the original image
px_left = int(region["left"] * x_scale)
px_top = int(region["top"] * y_scale)
px_width = int(region["width"] * x_scale)
px_height = int(region["height"] * y_scale)
# Draw highlight outline with red color and thick stroke
# Using a bright red outline instead of fill
stroke_width = max(
5, min(orig_w, orig_h) // 150
) # Thicker proportional stroke width
overlay_draw.rectangle(
[(px_left, px_top), (px_left + px_width, px_top + px_height)],
outline=(255, 0, 0, 255), # Bright red, fully opaque
width=stroke_width,
)
# Composite the overlay onto the image using alpha blending
img = Image.alpha_composite(img, overlay)
# Convert back to RGB for JPEG saving
img = img.convert("RGB")
img.thumbnail((width, height), Image.Resampling.LANCZOS)
w, h = img.size
tx = x + (width - w) // 2
ty = y_thumbnail + (height - h) // 2
grid.paste(img, (tx, ty))
# Add border
if BORDER_WIDTH > 0:
draw.rectangle(
[
(tx - BORDER_WIDTH, ty - BORDER_WIDTH),
(tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1),
],
outline="gray",
width=BORDER_WIDTH,
)
return grid
if __name__ == "__main__":
main()
Related
Other Operations skills
Email Ghostwriter
Draft professional email in a supplied writer or organization voice. Use when someone needs an outreach email, follow-up, reply, introduc...
Format Like Mckinsey
Review and improve spreadsheet structure and formatting so the main takeaway is immediately clear. Use after building or editing a spread...
Intercom API
Operate Intercom conversations and help center content.