
Bggg Creator Image2ppt
- 70 installs
- 553 repo stars
- Updated August 5, 2026
- binggandata/bggg-skills
bggg-creator-image2ppt is a Codex skill that converts images, HTML, or SVG designs into editable PPTX presentations.
About
This skill converts flat visual sources such as images, screenshots, HTML, or SVG into editable PowerPoint .pptx files. It first builds a structured JSON manifest of text boxes, shapes, and image components, then generates the PPTX with a bundled Python script. In Codex it pairs with the imagegen skill to rebuild backgrounds and components as separate layers. A developer uses it to turn a design mockup into an editable deck.
- Converts images, screenshots, HTML, or SVG into editable PPTX slides
- Builds a structured manifest, then generates PPTX with a Python script
- Uses the imagegen skill to rebuild backgrounds and components as separate layers
Bggg Creator Image2ppt by the numbers
- 70 all-time installs (skills.sh)
- Ranked #348 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bggg-creator-image2ppt capabilities & compatibility
- Capabilities
- presentations · image generation
- Use cases
- presentations · image generation
What bggg-creator-image2ppt says it does
把图片、截图、海报、PPT 页面截图、HTML 或 SVG 设计稿转换成可编辑 PPTX 的 Codex skill。
用这个 skill 把平面视觉稿转换成可编辑的 `.pptx`。
对于可编辑文字,直接写入 manifest 的 `text` 元素,不要把文字只做成图片。
npx skills add https://github.com/binggandata/bggg-skills --skill bggg-creator-image2pptAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 70 |
|---|---|
| repo stars | ★ 553 |
| Last updated | August 5, 2026 |
| Repository | binggandata/bggg-skills ↗ |
What it does
Convert an image, screenshot, HTML, or SVG design into an editable PPTX deck.
Who is it for?
Turning a screenshot, image, HTML, or SVG design into an editable PowerPoint deck.
When should I use this skill?
A user wants image2ppt, screenshot to PPT, or HTML/SVG converted to editable slides.
What you get
An editable .pptx with native text boxes, shapes, and image component layers.
- an editable .pptx file
- a manifest.json
By the numbers
- 5 manifest element kinds (background, image, text, shape, table)
Files
BGGG Creator Image2PPT
用这个 skill 把平面视觉稿转换成可编辑的 .pptx。核心设计是“先形成结构化 manifest,再由脚本生成 PPTX”。
- 二进制图片输入:Codex 负责识别版式、文字和组件;默认调用
imagegenskill 生成或清理背景、装饰、产品、图表等组件图片;文本尽量还原为 PowerPoint 文本框。 - HTML/SVG 输入:优先把代码结构解析为原生 PPT 元素;复杂节点再降级为图片组件。
- 所有中间图片、manifest、诊断和输出都必须放在本 skill 的
projects/YYYYMMDD_slug/目录里。
Codex 默认策略
在 Codex 中处理 PNG/JPEG/WebP 等二进制图片时,默认执行这个顺序:
1. 使用 Codex 视觉能力观察源图,列出幻灯片尺寸、背景、标题、正文、图标、照片、图表、装饰、阴影、遮罩等对象。 2. 对于可编辑文字,直接写入 manifest 的 text 元素,不要把文字只做成图片。 3. 对于背景、照片、图标、复杂插画、图表、纹理、遮挡后需要补全的背景,使用 imagegen skill 生成、清理或重建独立组件图片。 4. 把 imagegen 生成的图片复制到当前项目目录的 imagegen_assets/ 或 component_images/,不要让项目依赖 $CODEX_HOME 的临时输出。 5. 写 manifest.json,用 scripts/image2pptx.py build 生成 PPTX。 6. 验证 PPTX 能被 python-pptx 重新打开,并记录图层/文本/图片数量和已知限制。
如果用户明确要求“不重绘”“保持原图像素”,可以用原图裁切或全画布透明 PNG 作为组件图片;但默认仍要用 Codex imagegen 能力辅助背景清理、缺失区域补全和组件干净化。
项目目录约定
每次转换都创建独立项目目录:
bggg-creator-image2ppt/
└── projects/
└── YYYYMMDD_slug/
├── original_inputs/
├── component_images/
├── imagegen_assets/
├── diagnostics/
├── manifest.json
├── output.pptx
├── summary.json
└── process_notes.md初始化:
python3 bggg-creator-image2ppt/scripts/init_project.py pitch_deck \
--source /path/to/reference.png \
--date 20260504二进制图片转 PPTX 工作流
1. 初始化项目,把源图复制到 original_inputs/。 2. 识别页面结构:
- 画布比例和大致尺寸。
- 背景是纯色、渐变、照片还是复杂插画。
- 每段文字的内容、位置、字号、颜色、粗细、对齐方式。
- 组件图片的边界、层级和是否需要透明背景。
3. 默认用 imagegen 生成或编辑组件:
- 背景:完整画布、无文字、无前景组件。
- 照片/产品/人物/图标/复杂装饰:干净边缘,必要时透明背景。
- 图表:能原生重建就用形状和文本;复杂图表可先做成图片组件。
4. 把组件图片放入 component_images/ 或 imagegen_assets/。 5. 写 manifest,元素按从底到顶排序。 6. 运行:
python3 bggg-creator-image2ppt/scripts/image2pptx.py build \
--manifest bggg-creator-image2ppt/projects/YYYYMMDD_slug/manifest.json \
--output bggg-creator-image2ppt/projects/YYYYMMDD_slug/output.pptx \
--summary bggg-creator-image2ppt/projects/YYYYMMDD_slug/summary.json7. 写 process_notes.md,说明是否使用 imagegen、哪些对象是可编辑文本、哪些对象是图片 fallback。
HTML/SVG 转 PPTX 工作流
HTML/SVG 是代码形式的 PPT 时,优先走解析器:
python3 bggg-creator-image2ppt/scripts/html_svg_to_manifest.py input.svg \
--output bggg-creator-image2ppt/projects/YYYYMMDD_slug/manifest.json
python3 bggg-creator-image2ppt/scripts/html_svg_to_manifest.py input.html \
--output bggg-creator-image2ppt/projects/YYYYMMDD_slug/manifest.json解析原则:
- 原生还原
text、rect、ellipse、line、简单图片。 - HTML 里带绝对定位的
.slide/body元素最容易被准确转换。 - SVG 的复杂
path、滤镜、渐变、mask、clip-path、foreignObject 可写入 diagnostics,并用 imagegen 或外部渲染结果作为图片组件 fallback。 - 解析脚本生成 manifest 后,仍用
image2pptx.py build生成最终 PPTX。
Manifest 格式
manifest 是跨来源的中间层。坐标默认以源画布像素为单位,脚本会映射到 PowerPoint 尺寸。
{
"deck": {
"canvas_width": 1600,
"canvas_height": 900,
"slide_width_in": 13.333,
"name": "Example Deck"
},
"slides": [
{
"name": "Cover",
"elements": [
{
"kind": "background",
"fill": "#f7f4ec"
},
{
"kind": "image",
"name": "Hero Product",
"file": "component_images/product.png",
"x": 910,
"y": 170,
"w": 520,
"h": 520,
"fit": "contain"
},
{
"kind": "text",
"name": "Title",
"text": "Walk With Intention",
"x": 120,
"y": 180,
"w": 680,
"h": 150,
"font_size_px": 68,
"font_family": "Arial",
"bold": true,
"color": "#17120d",
"align": "left"
}
]
}
]
}常用元素:
background:fill纯色或file背景图。image:file/path/src、x、y、w、h、fit。fit支持stretch、contain、cover。text:text、x、y、w、h、font_size_px或font_size_pt、font_family、color、bold、italic、align。shape:shape为rect、roundRect、ellipse或line;支持fill、stroke、stroke_width_px。table: 简单表格 fallback,用rows数组生成原生 PPT 表格。
可编辑性优先级
1. 文本框:标题、正文、页码、标签、按钮文字都优先转成原生 PPT 文本。 2. 基础形状:矩形、圆角矩形、圆、线条优先转成 PPT 形状。 3. 图片组件:照片、插画、图标、纹理、复杂图表作为独立图片层。 4. 整页背景 fallback:无法拆干净时可保留一张底图,再把关键文字和组件覆盖为可编辑对象,并明确说明限制。
输出要求
交付时至少说明:
- 项目目录路径。
- PPTX 路径。
- manifest 和 summary 路径。
- 可编辑文本框数量、图片组件数量、形状数量。
- 哪些组件由 Codex imagegen 生成或清理。
- 哪些复杂对象降级为图片 fallback。
projects/*
!projects/.gitkeep
__pycache__/
*.pyc
{
"deck": {
"name": "Sample Image2PPT Deck",
"canvas_width": 1600,
"canvas_height": 900,
"slide_width_in": 13.333
},
"slides": [
{
"name": "Cover",
"elements": [
{
"kind": "background",
"fill": "#f6f0e6"
},
{
"kind": "shape",
"shape": "roundRect",
"name": "Accent Panel",
"x": 960,
"y": 140,
"w": 420,
"h": 520,
"fill": "#1f2933",
"stroke": "#1f2933"
},
{
"kind": "shape",
"shape": "line",
"name": "Divider",
"x1": 120,
"y1": 520,
"x2": 660,
"y2": 520,
"stroke": "#c5362e",
"stroke_width_px": 6
},
{
"kind": "text",
"name": "Title",
"text": "Image to Editable PPTX",
"x": 120,
"y": 170,
"w": 720,
"h": 160,
"font_size_px": 64,
"font_family": "Arial",
"bold": true,
"color": "#15110d",
"align": "left"
},
{
"kind": "text",
"name": "Subtitle",
"text": "Native text boxes, simple shapes, and image components",
"x": 124,
"y": 360,
"w": 780,
"h": 86,
"font_size_px": 30,
"font_family": "Arial",
"color": "#51483f"
}
]
}
]
}
{
"skill_name": "bggg-creator-image2ppt",
"evals": [
{
"id": 1,
"prompt": "把一个 16:9 PNG 幻灯片截图转成可编辑 PPTX。",
"expected_output": "创建 projects/YYYYMMDD_slug 项目目录,使用 Codex imagegen 生成或清理背景/组件图片,文本以 PPT 文本框写入 manifest,并生成 output.pptx 与 summary.json。",
"files": [],
"expectations": [
"使用 bggg-creator-image2ppt/SKILL.md 的二进制图片工作流",
"默认调用 Codex imagegen skill 生成或清理组件图片",
"所有中间图片放在 projects/YYYYMMDD_slug/component_images 或 imagegen_assets",
"PPTX 中包含可编辑文本框而不是整页纯图片"
]
},
{
"id": 2,
"prompt": "把一个包含绝对定位标题、图片和色块的 HTML 文件转成 PPTX。",
"expected_output": "运行 html_svg_to_manifest.py 生成 manifest,再运行 image2pptx.py build 生成可编辑 PPTX。",
"files": [],
"expectations": [
"HTML 文本转换为 text 元素",
"HTML 图片转换为 image 元素",
"背景色块转换为 shape 元素",
"生成 summary.json 并说明解析限制"
]
},
{
"id": 3,
"prompt": "把一个 SVG 信息图转成 PPTX,并保留简单文字、矩形、圆形和线条的可编辑性。",
"expected_output": "SVG 简单节点被解析成原生 PPT text/shape/image 元素,复杂 path 写入 diagnostics 并作为 fallback 待处理。",
"files": [],
"expectations": [
"运行 html_svg_to_manifest.py 解析 SVG",
"rect/circle/ellipse/line/text 被转换为 manifest 原生元素",
"复杂 SVG path 被记录为 diagnostics",
"最终 PPTX 可以被 python-pptx 重新打开"
]
}
]
}
MIT License
Copyright (c) 2026 BGGG
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
bggg-creator-image2ppt
中文 | English
bggg-creator-image2ppt is a Codex skill for converting images, screenshots, HTML, or SVG designs into editable PowerPoint .pptx files. It is useful for rebuilding slide screenshots, turning infographics into slides, converting AI-generated visuals into editable decks, compiling HTML/SVG slide-like pages into PPTX, and reconstructing flat designs as image components plus text boxes plus native PowerPoint shapes.
The skill is built around a structured manifest.json intermediate representation and a pure-Python PPTX builder. For bitmap inputs, it is designed to work with Codex imagegen by default: Codex first understands the slide layout, uses built-in image generation/editing to create or clean component assets, then the deterministic scripts assemble the final PPTX.
What It Can Do
- Rebuild PNG/JPEG/WebP bitmap slides into editable PPTX files.
- Convert titles, body text, labels, and formulas into editable PowerPoint text boxes.
- Convert rectangles, rounded rectangles, circles, lines, and arrows into native PowerPoint shapes.
- Keep photos, icons, complex illustrations, textures, and complex charts as separate image components.
- Parse simple HTML/SVG text, images, and basic shapes into a manifest before generating PPTX.
- Store each run under
projects/YYYYMMDD_slug/with source files, components, imagegen assets, manifest, PPTX, summary, and diagnostics.
Install
Copy this directory into your Codex skills directory:
mkdir -p ~/.codex/skills
cp -R bggg-creator-image2ppt ~/.codex/skills/Or clone the full bggg-skills repository and symlink the skill:
git clone https://github.com/binggandata/bggg-skills.git
mkdir -p ~/.codex/skills
ln -s "$PWD/bggg-skills/bggg-creator-image2ppt" ~/.codex/skills/bggg-creator-image2pptInstall runtime dependencies:
python3 -m pip install -r ~/.codex/skills/bggg-creator-image2ppt/scripts/requirements.txtThe main dependencies are python-pptx, Pillow, beautifulsoup4, and lxml.
Use In Codex
Example prompt:
Use bggg-creator-image2ppt to convert this slide image into a real pptx file.
Make text editable where possible, recreate shapes as native PowerPoint shapes,
and use imagegen for complex background or icon components when needed.HTML/SVG input:
Use bggg-creator-image2ppt to convert this HTML/SVG page into an editable PPTX.
Prefer native PPT text, rectangles, circles, and lines.CLI Usage
Initialize a project:
python3 bggg-creator-image2ppt/scripts/init_project.py cross_border_formula \
--source input.pngBuild PPTX from a manifest:
python3 bggg-creator-image2ppt/scripts/image2pptx.py build \
--manifest bggg-creator-image2ppt/projects/20260505_cross_border_formula/manifest.json \
--output bggg-creator-image2ppt/projects/20260505_cross_border_formula/output.pptx \
--summary bggg-creator-image2ppt/projects/20260505_cross_border_formula/summary.jsonConvert SVG or HTML to a manifest:
python3 bggg-creator-image2ppt/scripts/html_svg_to_manifest.py input.svg \
--output bggg-creator-image2ppt/projects/20260505_demo/manifest.json
python3 bggg-creator-image2ppt/scripts/html_svg_to_manifest.py input.html \
--output bggg-creator-image2ppt/projects/20260505_demo/manifest.jsonManifest Example
Elements are ordered from back to front.
{
"deck": {
"name": "Example Deck",
"canvas_width": 1600,
"canvas_height": 900,
"slide_width_in": 13.333
},
"slides": [
{
"name": "Cover",
"elements": [
{
"kind": "background",
"fill": "#f7f4ec"
},
{
"kind": "shape",
"shape": "roundRect",
"x": 120,
"y": 160,
"w": 620,
"h": 240,
"fill": "#ffffff",
"stroke": "#ffffff"
},
{
"kind": "text",
"text": "Image to Editable PPTX",
"x": 160,
"y": 210,
"w": 760,
"h": 110,
"font_size_px": 64,
"font_family": "Arial",
"bold": true,
"color": "#17120d"
},
{
"kind": "image",
"file": "component_images/icon.png",
"x": 1120,
"y": 260,
"w": 180,
"h": 180,
"fit": "contain"
}
]
}
]
}Project Output Structure
Each real run lives under:
bggg-creator-image2ppt/projects/YYYYMMDD_slug/
├── original_inputs/
├── component_images/
├── imagegen_assets/
├── diagnostics/
├── exports/
├── manifest.json
├── output.pptx
├── summary.json
└── process_notes.mdRuntime outputs are ignored by Git. The open-source repository keeps only projects/.gitkeep.
Notes
- Rebuilding an editable deck from a single bitmap is an approximate reconstruction, not lossless decompilation.
- For bitmap inputs, Codex handles visual analysis and calls
imagegento generate or clean components; the scripts themselves do not call model APIs. - The HTML/SVG parser covers common absolutely positioned layouts and basic SVG nodes. Complex paths, filters, gradients, masks, and clip paths should usually become image-component fallbacks.
python-pptxdoes not render previews directly. Inspect results in PowerPoint, Keynote, LibreOffice, or macOS Quick Look.
License
MIT
bggg-creator-image2ppt
中文 | English
bggg-creator-image2ppt 是一个 Codex skill,用来把图片、截图、HTML 或 SVG 设计稿转换成可编辑的 PowerPoint .pptx 文件。它适合 PPT 截图还原、信息图转幻灯片、AI 生图转可编辑页面、HTML/SVG 代码式页面转 PPTX,以及“把平面图片拆成组件图片 + 文本框 + 原生形状再重建”的工作流。
这个 skill 的核心是结构化 manifest.json 中间层和纯 Python PPTX 生成器。二进制图片输入时,它在 Codex 中默认配合 imagegen:先用 Codex 视觉理解页面,再用内置生图/编辑能力生成或清理背景、图标、照片、装饰等组件,最后由脚本拼成真正的 PPTX。
能做什么
- 把 PNG/JPEG/WebP 等二进制图片重建为可编辑 PPTX。
- 把标题、正文、标签、公式等文字还原为 PowerPoint 文本框。
- 把矩形、圆角矩形、圆、线条、箭头等还原为原生 PowerPoint 形状。
- 把照片、图标、复杂插画、纹理、复杂图表作为独立图片组件。
- 把 HTML/SVG 中的简单文本、图片和基础图形解析成 manifest,再生成 PPTX。
- 每次任务自动使用
projects/YYYYMMDD_slug/保存源文件、组件图、imagegen 资产、manifest、PPTX、摘要和诊断文件。
安装
把本目录复制到 Codex skills 目录:
mkdir -p ~/.codex/skills
cp -R bggg-creator-image2ppt ~/.codex/skills/也可以克隆整个 bggg-skills 仓库后复制或软链接:
git clone https://github.com/binggandata/bggg-skills.git
mkdir -p ~/.codex/skills
ln -s "$PWD/bggg-skills/bggg-creator-image2ppt" ~/.codex/skills/bggg-creator-image2ppt安装运行依赖:
python3 -m pip install -r ~/.codex/skills/bggg-creator-image2ppt/scripts/requirements.txt主要依赖是 python-pptx、Pillow、beautifulsoup4 和 lxml。
在 Codex 中使用
可以直接这样对 Codex 说:
使用 bggg-creator-image2ppt 把这张 PPT 图片转成真正的 pptx 文件。
文本要尽量变成可编辑文本框,形状尽量用 PPT 原生形状,
复杂图标和背景可以用 imagegen 生成组件后拼进去。HTML/SVG 输入:
使用 bggg-creator-image2ppt 把这个 HTML/SVG 页面转成可编辑 PPTX。
优先把文本、矩形、圆形和线条转换为原生 PPT 元素。命令行用法
初始化一个项目目录:
python3 bggg-creator-image2ppt/scripts/init_project.py cross_border_formula \
--source input.png从 manifest 生成 PPTX:
python3 bggg-creator-image2ppt/scripts/image2pptx.py build \
--manifest bggg-creator-image2ppt/projects/20260505_cross_border_formula/manifest.json \
--output bggg-creator-image2ppt/projects/20260505_cross_border_formula/output.pptx \
--summary bggg-creator-image2ppt/projects/20260505_cross_border_formula/summary.json把 SVG 或 HTML 转成 manifest:
python3 bggg-creator-image2ppt/scripts/html_svg_to_manifest.py input.svg \
--output bggg-creator-image2ppt/projects/20260505_demo/manifest.json
python3 bggg-creator-image2ppt/scripts/html_svg_to_manifest.py input.html \
--output bggg-creator-image2ppt/projects/20260505_demo/manifest.jsonManifest 示例
元素顺序是从底到顶。
{
"deck": {
"name": "Example Deck",
"canvas_width": 1600,
"canvas_height": 900,
"slide_width_in": 13.333
},
"slides": [
{
"name": "Cover",
"elements": [
{
"kind": "background",
"fill": "#f7f4ec"
},
{
"kind": "shape",
"shape": "roundRect",
"x": 120,
"y": 160,
"w": 620,
"h": 240,
"fill": "#ffffff",
"stroke": "#ffffff"
},
{
"kind": "text",
"text": "Image to Editable PPTX",
"x": 160,
"y": 210,
"w": 760,
"h": 110,
"font_size_px": 64,
"font_family": "PingFang SC",
"bold": true,
"color": "#17120d"
},
{
"kind": "image",
"file": "component_images/icon.png",
"x": 1120,
"y": 260,
"w": 180,
"h": 180,
"fit": "contain"
}
]
}
]
}项目输出结构
每次真实任务都会放在:
bggg-creator-image2ppt/projects/YYYYMMDD_slug/
├── original_inputs/
├── component_images/
├── imagegen_assets/
├── diagnostics/
├── exports/
├── manifest.json
├── output.pptx
├── summary.json
└── process_notes.md运行产物默认被 Git 忽略。开源仓库只保留 projects/.gitkeep。
注意事项
- 从单张图片还原可编辑 PPT 是近似重建,不是无损反编译。
- 二进制图片输入时,Codex 负责识别版式和调用
imagegen生成/清理组件;脚本本身不直接调用模型。 - HTML/SVG 解析器覆盖常见绝对定位页面和基础 SVG 节点;复杂 path、滤镜、渐变、mask、clip-path 等建议作为图片组件 fallback。
python-pptx不能直接渲染预览;可以用 PowerPoint、Keynote、LibreOffice 或 macOS Quick Look 检查结果。
License
MIT
Implementation Notes
This skill follows an IR-first architecture inspired by the local reference/image2ppt materials:
- Bitmap images are recognition problems: detect slide structure, OCR text, infer styles, split foreground/background assets, then rebuild the slide from native PPT objects and image components.
- HTML/SVG inputs are compiler problems: parse DOM/SVG nodes directly into manifest elements, preserving geometry and text wherever the source code exposes them.
- Codex image generation is a required companion for bitmap inputs. The scripts cannot call Codex model tools by themselves, so the skill workflow instructs Codex to generate or edit components first, save them in
projects/YYYYMMDD_slug/, and then run the deterministic PPTX builder. - The PPTX writer should be independent and reproducible: given
manifest.jsonand local assets, it should generate the same.pptxwithout needing Photoshop, PowerPoint, external repos, or network access.
Conversion Priorities
1. Native PowerPoint text boxes for all readable text. 2. Native shapes for rectangles, rounded rectangles, ellipses, and lines. 3. Tables for simple grid data. 4. Separate image components for photos, illustrations, icons, complex charts, shadows, textures, and anything produced by imagegen. 5. Full-slide image fallback only when the page cannot be safely decomposed; overlay important editable text and shapes on top.
Project Hygiene
Every run should keep all artifacts in the skill project folder:
projects/YYYYMMDD_slug/
original_inputs/
component_images/
imagegen_assets/
diagnostics/
exports/
manifest.json
output.pptx
summary.json
process_notes.mdimagegen_assets/ stores raw Codex-generated images. component_images/ stores final cropped/transparent images used by the manifest.
Known Limits
python-pptxdoes not render previews by itself. Use--render-pdfwhen LibreOffice is installed, or manually inspect the output in PowerPoint/Keynote/LibreOffice.- SVG paths, gradients, masks, filters, and complex transforms are not fully converted to native PPT shapes by the helper parser. Convert them to image components when fidelity matters.
- HTML flow layout is only partially recoverable without a browser layout engine. Absolute-positioned slide HTML converts best.
#!/usr/bin/env python3
"""Convert simple HTML/SVG slide-like files into a bggg-creator-image2ppt manifest."""
from __future__ import annotations
import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from html import unescape
from pathlib import Path
from typing import Any, Sequence
from bs4 import BeautifulSoup
DEFAULT_CANVAS_WIDTH = 1600
DEFAULT_CANVAS_HEIGHT = 900
CSS_NAMED_COLORS = {
"black",
"white",
"red",
"green",
"blue",
"yellow",
"gray",
"grey",
"silver",
"navy",
"orange",
"purple",
}
COLOR_TOKEN_RE = re.compile(r"(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|\b[a-zA-Z]+\b)")
def strip_namespace(tag: str) -> str:
return tag.rsplit("}", 1)[-1].lower()
def write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def parse_style(value: str | None) -> dict[str, str]:
if not value:
return {}
result: dict[str, str] = {}
for chunk in value.split(";"):
if ":" not in chunk:
continue
key, raw = chunk.split(":", 1)
result[key.strip().lower()] = raw.strip()
return result
def merge_svg_style(attrs: dict[str, str]) -> dict[str, str]:
style = parse_style(attrs.get("style"))
for key in [
"fill",
"stroke",
"stroke-width",
"font-size",
"font-family",
"font-weight",
"font-style",
"text-anchor",
"opacity",
]:
if attrs.get(key) is not None:
style[key] = attrs[key]
return style
def number(value: Any, default: float = 0.0) -> float:
if value is None:
return default
text = str(value).strip()
if not text:
return default
if text.endswith("%"):
return default
match = re.match(r"[-+]?\d*\.?\d+", text)
if not match:
return default
return float(match.group(0))
def css_length(style: dict[str, str], key: str, default: float = 0.0) -> float:
return number(style.get(key), default)
def css_color(value: str | None, default: str | None = None) -> str | None:
if not value:
return default
text = value.strip()
lowered = text.lower()
if lowered in {"none", "transparent"}:
return None
if lowered in CSS_NAMED_COLORS or lowered.startswith("rgb") or text.startswith("#"):
return text
match = COLOR_TOKEN_RE.search(text)
if match:
token = match.group(1)
if token.lower() in CSS_NAMED_COLORS or token.lower().startswith("rgb") or token.startswith("#"):
return token
return default
def asset_reference(ref: str | None, input_dir: Path) -> str | None:
if not ref:
return None
if ref.startswith(("data:", "http://", "https://", "#")):
return ref
path = Path(ref).expanduser()
if not path.is_absolute():
path = input_dir / path
return str(path.resolve())
def infer_svg_canvas(root: ET.Element) -> tuple[float, float]:
width = number(root.attrib.get("width"), 0)
height = number(root.attrib.get("height"), 0)
viewbox = root.attrib.get("viewBox") or root.attrib.get("viewbox")
if (width <= 0 or height <= 0) and viewbox:
parts = [number(part) for part in re.split(r"[\s,]+", viewbox.strip()) if part]
if len(parts) == 4:
width = width or parts[2]
height = height or parts[3]
return width or DEFAULT_CANVAS_WIDTH, height or DEFAULT_CANVAS_HEIGHT
def svg_text_content(element: ET.Element) -> str:
pieces: list[str] = []
if element.text:
pieces.append(element.text)
for child in element:
if child.text:
pieces.append(child.text)
if child.tail:
pieces.append(child.tail)
return unescape("".join(pieces).strip())
def svg_href(attrs: dict[str, str]) -> str | None:
return attrs.get("href") or attrs.get("{http://www.w3.org/1999/xlink}href") or attrs.get("xlink:href")
def convert_svg(path: Path, *, slide_name: str | None = None) -> dict[str, Any]:
tree = ET.parse(path)
root = tree.getroot()
canvas_width, canvas_height = infer_svg_canvas(root)
elements: list[dict[str, Any]] = []
diagnostics: list[str] = []
for index, node in enumerate(root.iter()):
tag = strip_namespace(node.tag)
if tag == "svg":
continue
attrs = dict(node.attrib)
style = merge_svg_style(attrs)
if attrs.get("transform"):
diagnostics.append(f"element #{index} <{tag}> has transform and may need manual adjustment")
base = {"z": index, "name": attrs.get("id") or f"{tag}_{index}"}
if tag == "rect":
rx = number(attrs.get("rx"), 0)
elements.append(
{
**base,
"kind": "shape",
"shape": "roundRect" if rx > 0 else "rect",
"x": number(attrs.get("x")),
"y": number(attrs.get("y")),
"w": number(attrs.get("width")),
"h": number(attrs.get("height")),
"fill": css_color(style.get("fill")),
"stroke": css_color(style.get("stroke")),
"stroke_width_px": number(style.get("stroke-width"), 1),
}
)
elif tag == "circle":
radius = number(attrs.get("r"))
elements.append(
{
**base,
"kind": "shape",
"shape": "ellipse",
"x": number(attrs.get("cx")) - radius,
"y": number(attrs.get("cy")) - radius,
"w": radius * 2,
"h": radius * 2,
"fill": css_color(style.get("fill")),
"stroke": css_color(style.get("stroke")),
"stroke_width_px": number(style.get("stroke-width"), 1),
}
)
elif tag == "ellipse":
rx = number(attrs.get("rx"))
ry = number(attrs.get("ry"))
elements.append(
{
**base,
"kind": "shape",
"shape": "ellipse",
"x": number(attrs.get("cx")) - rx,
"y": number(attrs.get("cy")) - ry,
"w": rx * 2,
"h": ry * 2,
"fill": css_color(style.get("fill")),
"stroke": css_color(style.get("stroke")),
"stroke_width_px": number(style.get("stroke-width"), 1),
}
)
elif tag == "line":
elements.append(
{
**base,
"kind": "shape",
"shape": "line",
"x1": number(attrs.get("x1")),
"y1": number(attrs.get("y1")),
"x2": number(attrs.get("x2")),
"y2": number(attrs.get("y2")),
"stroke": css_color(style.get("stroke"), "#111111"),
"stroke_width_px": number(style.get("stroke-width"), 1),
}
)
elif tag == "text":
text = svg_text_content(node)
if text:
font_size = number(style.get("font-size"), 32)
anchor = style.get("text-anchor", "start")
x = number(attrs.get("x"))
estimated_width = max(font_size * 0.62 * len(text), font_size * 2)
if anchor == "middle":
x -= estimated_width / 2
align = "center"
elif anchor == "end":
x -= estimated_width
align = "right"
else:
align = "left"
elements.append(
{
**base,
"kind": "text",
"text": text,
"x": x,
"y": number(attrs.get("y")) - font_size,
"w": estimated_width,
"h": font_size * 1.35,
"font_size_px": font_size,
"font_family": style.get("font-family", "Arial").strip("\"'"),
"bold": style.get("font-weight") in {"bold", "700", "800", "900"},
"italic": style.get("font-style") == "italic",
"color": css_color(style.get("fill"), "#111111"),
"align": align,
}
)
elif tag == "image":
href = asset_reference(svg_href(attrs), path.parent)
if href:
elements.append(
{
**base,
"kind": "image",
"file": href,
"x": number(attrs.get("x")),
"y": number(attrs.get("y")),
"w": number(attrs.get("width")),
"h": number(attrs.get("height")),
"fit": "stretch",
}
)
elif tag in {"path", "polygon", "polyline", "g", "defs", "lineargradient", "radialgradient", "filter", "mask", "clippath"}:
if tag not in {"g", "defs"}:
diagnostics.append(f"unsupported SVG <{tag}> at element #{index}; use imagegen/raster fallback if visually important")
return {
"deck": {
"name": slide_name or path.stem,
"canvas_width": canvas_width,
"canvas_height": canvas_height,
"slide_width_in": 13.333,
},
"slides": [{"name": slide_name or path.stem, "elements": elements}],
"diagnostics": diagnostics,
}
def find_slide_root(soup: BeautifulSoup) -> Any:
return soup.select_one(".slide") or soup.select_one("[data-slide]") or soup.body or soup
def html_canvas(root: Any) -> tuple[float, float]:
style = parse_style(root.get("style") if hasattr(root, "get") else None)
width = css_length(style, "width", 0)
height = css_length(style, "height", 0)
return width or DEFAULT_CANVAS_WIDTH, height or DEFAULT_CANVAS_HEIGHT
def text_of_tag(tag: Any) -> str:
own_text = tag.get_text(" ", strip=True)
return re.sub(r"\s+", " ", own_text)
def looks_like_text_node(tag: Any, text: str) -> bool:
if not text:
return False
if tag.name in {"script", "style", "svg"}:
return False
return tag.name in {"p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "button", "a", "label", "li"} or bool(
parse_style(tag.get("style")).get("font-size")
)
def convert_html(path: Path, *, slide_name: str | None = None) -> dict[str, Any]:
soup = BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")
root = find_slide_root(soup)
canvas_width, canvas_height = html_canvas(root)
elements: list[dict[str, Any]] = []
diagnostics: list[str] = []
for index, tag in enumerate(root.find_all(True)):
if tag.name in {"script", "style", "meta", "link"}:
continue
style = parse_style(tag.get("style"))
position = style.get("position")
left = css_length(style, "left", None) if position == "absolute" else css_length(style, "left", 0)
top = css_length(style, "top", None) if position == "absolute" else css_length(style, "top", 0)
width = css_length(style, "width", 0)
height = css_length(style, "height", 0)
z_index = int(number(style.get("z-index"), index))
base = {"z": z_index, "name": tag.get("id") or " ".join(tag.get("class", [])) or f"{tag.name}_{index}"}
if tag.name == "img":
src = asset_reference(tag.get("src"), path.parent)
if not src:
continue
elements.append(
{
**base,
"kind": "image",
"file": src,
"x": left or number(tag.get("x"), 0),
"y": top or number(tag.get("y"), 0),
"w": width or number(tag.get("width"), 0),
"h": height or number(tag.get("height"), 0),
"fit": style.get("object-fit", "stretch"),
}
)
continue
background = css_color(style.get("background-color") or style.get("background"))
border_color = None
border_match = re.search(r"(#[0-9a-fA-F]{3,6}|rgb\([^)]+\))", style.get("border", ""))
if border_match:
border_color = border_match.group(1)
text = text_of_tag(tag)
if background and not text and width and height:
radius = css_length(style, "border-radius", 0)
elements.append(
{
**base,
"kind": "shape",
"shape": "roundRect" if radius > 0 else "rect",
"x": left or 0,
"y": top or 0,
"w": width,
"h": height,
"fill": background,
"stroke": border_color,
}
)
continue
if looks_like_text_node(tag, text):
font_size = css_length(style, "font-size", 28)
inferred_width = width or min(canvas_width - (left or 0), max(font_size * 0.6 * len(text), font_size * 4))
inferred_height = height or font_size * 1.4 * max(1, text.count(" ") // 8 + 1)
elements.append(
{
**base,
"kind": "text",
"text": text,
"x": left or 0,
"y": top or 0,
"w": inferred_width,
"h": inferred_height,
"font_size_px": font_size,
"font_family": style.get("font-family", "Arial").split(",")[0].strip("\"' "),
"bold": style.get("font-weight") in {"bold", "700", "800", "900"},
"italic": style.get("font-style") == "italic",
"color": css_color(style.get("color"), "#111111"),
"align": style.get("text-align", "left"),
}
)
elif style and position != "absolute":
diagnostics.append(f"HTML element <{tag.name}> #{index} is not absolutely positioned; layout may need manual review")
return {
"deck": {
"name": slide_name or path.stem,
"canvas_width": canvas_width,
"canvas_height": canvas_height,
"slide_width_in": 13.333,
},
"slides": [{"name": slide_name or path.stem, "elements": elements}],
"diagnostics": diagnostics,
}
def convert(path: Path, *, slide_name: str | None = None) -> dict[str, Any]:
suffix = path.suffix.lower()
if suffix == ".svg":
return convert_svg(path, slide_name=slide_name)
if suffix in {".html", ".htm"}:
return convert_html(path, slide_name=slide_name)
raise ValueError(f"unsupported input type: {path.suffix}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Convert simple HTML/SVG files to a bggg-creator-image2ppt manifest.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("input", help="input .html/.htm/.svg file")
parser.add_argument("--output", "-o", help="manifest JSON path; defaults to stdout")
parser.add_argument("--slide-name", help="slide name override")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
input_path = Path(args.input).expanduser().resolve()
if not input_path.exists():
raise SystemExit(f"input not found: {input_path}")
manifest = convert(input_path, slide_name=args.slide_name)
if args.output:
write_json(Path(args.output).expanduser().resolve(), manifest)
else:
json.dump(manifest, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Build editable PPTX files from a bggg-creator-image2ppt manifest."""
from __future__ import annotations
import argparse
import json
import math
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Sequence
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.dml import MSO_THEME_COLOR
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml.ns import qn
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Emu, Inches, Pt
DEFAULT_SLIDE_WIDTH_IN = 13.333
TRANSPARENT_VALUES = {"none", "transparent", "rgba(0,0,0,0)"}
NAMED_COLORS = {
"black": "#000000",
"white": "#ffffff",
"red": "#ff0000",
"green": "#008000",
"blue": "#0000ff",
"yellow": "#ffff00",
"gray": "#808080",
"grey": "#808080",
"silver": "#c0c0c0",
"navy": "#000080",
"orange": "#ffa500",
"purple": "#800080",
}
@dataclass(frozen=True)
class SlideScale:
canvas_width: float
canvas_height: float
slide_width_in: float
slide_height_in: float
@property
def x_ratio(self) -> float:
return self.slide_width_in / self.canvas_width
@property
def y_ratio(self) -> float:
return self.slide_height_in / self.canvas_height
def x(self, value: float | int | None) -> Emu:
return Inches((float(value or 0)) * self.x_ratio)
def y(self, value: float | int | None) -> Emu:
return Inches((float(value or 0)) * self.y_ratio)
def w(self, value: float | int | None) -> Emu:
return Inches((float(value or 0)) * self.x_ratio)
def h(self, value: float | int | None) -> Emu:
return Inches((float(value or 0)) * self.y_ratio)
def px_font_to_pt(self, value: float | int) -> float:
# Use the average axis ratio so text remains visually close when aspect ratio is adjusted.
return float(value) * ((self.slide_width_in / self.canvas_width) + (self.slide_height_in / self.canvas_height)) * 36
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def resolve_path(path_value: str | None, base_dir: Path) -> Path | None:
if not path_value:
return None
path = Path(path_value).expanduser()
if not path.is_absolute():
path = base_dir / path
return path.resolve()
def parse_color(value: Any, default: str | None = None) -> RGBColor | None:
if value is None:
value = default
if value is None:
return None
if isinstance(value, (list, tuple)) and len(value) >= 3:
return RGBColor(int(value[0]), int(value[1]), int(value[2]))
text = str(value).strip()
if not text or text.lower() in TRANSPARENT_VALUES:
return None
lowered = text.lower().replace(" ", "")
if lowered in TRANSPARENT_VALUES:
return None
if text.lower() in NAMED_COLORS:
text = NAMED_COLORS[text.lower()]
if (text.startswith("rgb(") or text.startswith("rgba(")) and text.endswith(")"):
body = text[text.find("(") + 1 : -1]
parts = [part.strip() for part in body.split(",")]
if len(parts) >= 3:
return RGBColor(int(float(parts[0])), int(float(parts[1])), int(float(parts[2])))
if text.startswith("#"):
text = text[1:]
if len(text) == 3:
text = "".join(char * 2 for char in text)
if len(text) != 6:
raise ValueError(f"unsupported color value: {value!r}")
return RGBColor(int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
def shape_type(name: str | None) -> MSO_AUTO_SHAPE_TYPE:
key = (name or "rect").strip().lower()
mapping = {
"rect": MSO_AUTO_SHAPE_TYPE.RECTANGLE,
"rectangle": MSO_AUTO_SHAPE_TYPE.RECTANGLE,
"roundrect": MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
"roundedrectangle": MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
"rounded_rect": MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
"ellipse": MSO_AUTO_SHAPE_TYPE.OVAL,
"oval": MSO_AUTO_SHAPE_TYPE.OVAL,
"circle": MSO_AUTO_SHAPE_TYPE.OVAL,
"rightarrow": MSO_AUTO_SHAPE_TYPE.RIGHT_ARROW,
"right_arrow": MSO_AUTO_SHAPE_TYPE.RIGHT_ARROW,
"notchedrightarrow": MSO_AUTO_SHAPE_TYPE.NOTCHED_RIGHT_ARROW,
"notched_right_arrow": MSO_AUTO_SHAPE_TYPE.NOTCHED_RIGHT_ARROW,
"chevron": MSO_AUTO_SHAPE_TYPE.CHEVRON,
"uparrow": MSO_AUTO_SHAPE_TYPE.UP_ARROW,
"up_arrow": MSO_AUTO_SHAPE_TYPE.UP_ARROW,
"heart": MSO_AUTO_SHAPE_TYPE.HEART,
}
return mapping.get(key, MSO_AUTO_SHAPE_TYPE.RECTANGLE)
def align_value(value: str | None) -> PP_ALIGN:
key = (value or "left").strip().lower()
mapping = {
"left": PP_ALIGN.LEFT,
"center": PP_ALIGN.CENTER,
"middle": PP_ALIGN.CENTER,
"right": PP_ALIGN.RIGHT,
"justify": PP_ALIGN.JUSTIFY,
}
return mapping.get(key, PP_ALIGN.LEFT)
def vertical_anchor(value: str | None) -> MSO_ANCHOR:
key = (value or "top").strip().lower()
mapping = {
"top": MSO_ANCHOR.TOP,
"middle": MSO_ANCHOR.MIDDLE,
"center": MSO_ANCHOR.MIDDLE,
"bottom": MSO_ANCHOR.BOTTOM,
}
return mapping.get(key, MSO_ANCHOR.TOP)
def apply_typeface(run: Any, font_name: str) -> None:
run.font.name = font_name
r_pr = run._r.get_or_add_rPr()
for tag in ("a:latin", "a:ea", "a:cs"):
node = r_pr.find(qn(tag))
if node is None:
node = OxmlElement(tag)
r_pr.append(node)
node.set("typeface", font_name)
def image_size(path: Path) -> tuple[int, int]:
with Image.open(path) as image:
return image.size
def element_kind(element: dict[str, Any]) -> str:
return str(element.get("kind") or element.get("type") or "image").strip().lower()
def sorted_elements(elements: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
elements,
key=lambda item: (
int(item.get("z", item.get("z_index", item.get("order", 0))) or 0),
int(item.get("_source_index", 0)),
),
)
def normalize_slides(manifest: dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(manifest.get("slides"), list):
slides = manifest["slides"]
else:
elements = manifest.get("elements") or manifest.get("layers") or []
slides = [{"name": manifest.get("name") or "Slide 1", "elements": elements}]
normalized: list[dict[str, Any]] = []
for slide_index, slide in enumerate(slides, start=1):
if not isinstance(slide, dict):
raise ValueError(f"slide #{slide_index} must be an object")
elements = slide.get("elements") or slide.get("layers") or []
for element_index, element in enumerate(elements):
if isinstance(element, dict):
element.setdefault("_source_index", element_index)
normalized.append(
{
**slide,
"name": slide.get("name") or f"Slide {slide_index}",
"elements": elements,
}
)
return normalized
def scale_for_slide(manifest: dict[str, Any], slide_data: dict[str, Any]) -> SlideScale:
deck = manifest.get("deck") or manifest.get("canvas") or {}
canvas_width = float(
slide_data.get("canvas_width")
or slide_data.get("width")
or deck.get("canvas_width")
or deck.get("width")
or 1600
)
canvas_height = float(
slide_data.get("canvas_height")
or slide_data.get("height")
or deck.get("canvas_height")
or deck.get("height")
or 900
)
slide_width_in = float(
slide_data.get("slide_width_in")
or deck.get("slide_width_in")
or deck.get("width_in")
or DEFAULT_SLIDE_WIDTH_IN
)
slide_height_in = float(
slide_data.get("slide_height_in")
or deck.get("slide_height_in")
or deck.get("height_in")
or (slide_width_in * canvas_height / canvas_width)
)
if canvas_width <= 0 or canvas_height <= 0:
raise ValueError("canvas dimensions must be positive")
return SlideScale(canvas_width, canvas_height, slide_width_in, slide_height_in)
def set_slide_size(presentation: Presentation, scale: SlideScale) -> None:
presentation.slide_width = Inches(scale.slide_width_in)
presentation.slide_height = Inches(scale.slide_height_in)
def add_background(slide: Any, element: dict[str, Any], scale: SlideScale, base_dir: Path) -> str:
file_path = resolve_path(element.get("file") or element.get("path") or element.get("src"), base_dir)
if file_path:
if not file_path.exists():
raise FileNotFoundError(f"background image not found: {file_path}")
add_image(slide, {**element, "x": 0, "y": 0, "w": scale.canvas_width, "h": scale.canvas_height, "fit": element.get("fit", "cover")}, scale, base_dir)
return "image-background"
fill_color = parse_color(element.get("fill") or element.get("color") or element.get("background"), "#ffffff")
if fill_color:
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = fill_color
return "solid-background"
return "empty-background"
def add_image(slide: Any, element: dict[str, Any], scale: SlideScale, base_dir: Path) -> None:
file_path = resolve_path(element.get("file") or element.get("path") or element.get("src"), base_dir)
if not file_path or not file_path.exists():
raise FileNotFoundError(f"image not found for element {element.get('name') or element.get('id')}: {file_path}")
x_px = float(element.get("x", 0))
y_px = float(element.get("y", 0))
width_px = element.get("w", element.get("width"))
height_px = element.get("h", element.get("height"))
source_width, source_height = image_size(file_path)
if width_px is None and height_px is None:
width_px, height_px = source_width, source_height
elif width_px is None:
width_px = float(height_px) * source_width / source_height
elif height_px is None:
height_px = float(width_px) * source_height / source_width
width_px = float(width_px)
height_px = float(height_px)
fit = str(element.get("fit") or "stretch").lower()
if fit == "contain":
image_aspect = source_width / source_height
box_aspect = width_px / height_px
if image_aspect > box_aspect:
final_width = width_px
final_height = width_px / image_aspect
final_x = x_px
final_y = y_px + (height_px - final_height) / 2
else:
final_height = height_px
final_width = height_px * image_aspect
final_x = x_px + (width_px - final_width) / 2
final_y = y_px
slide.shapes.add_picture(
str(file_path),
scale.x(final_x),
scale.y(final_y),
width=scale.w(final_width),
height=scale.h(final_height),
)
return
picture = slide.shapes.add_picture(
str(file_path),
scale.x(x_px),
scale.y(y_px),
width=scale.w(width_px),
height=scale.h(height_px),
)
if fit == "cover":
image_aspect = source_width / source_height
box_aspect = width_px / height_px
if image_aspect > box_aspect:
crop = max(0.0, min(0.45, 1 - box_aspect / image_aspect)) / 2
picture.crop_left = crop
picture.crop_right = crop
elif image_aspect < box_aspect:
crop = max(0.0, min(0.45, 1 - image_aspect / box_aspect)) / 2
picture.crop_top = crop
picture.crop_bottom = crop
def add_text(slide: Any, element: dict[str, Any], scale: SlideScale) -> None:
x_px = float(element.get("x", 0))
y_px = float(element.get("y", 0))
width_px = float(element.get("w", element.get("width", scale.canvas_width - x_px)))
height_px = float(element.get("h", element.get("height", 80)))
shape = slide.shapes.add_textbox(scale.x(x_px), scale.y(y_px), scale.w(width_px), scale.h(height_px))
shape.name = str(element.get("name") or element.get("id") or "Text")
text_frame = shape.text_frame
text_frame.clear()
text_frame.word_wrap = bool(element.get("wrap", True))
text_frame.margin_left = Emu(0)
text_frame.margin_right = Emu(0)
text_frame.margin_top = Emu(0)
text_frame.margin_bottom = Emu(0)
text_frame.vertical_anchor = vertical_anchor(element.get("valign") or element.get("vertical_align"))
raw_text = str(element.get("text", ""))
lines = raw_text.splitlines() or [""]
font_size_pt = element.get("font_size_pt")
if font_size_pt is None:
font_px = element.get("font_size_px", element.get("font_size", 32))
font_size_pt = scale.px_font_to_pt(float(font_px))
color = parse_color(element.get("color"), "#111111")
for index, line in enumerate(lines):
paragraph = text_frame.paragraphs[0] if index == 0 else text_frame.add_paragraph()
paragraph.alignment = align_value(element.get("align") or element.get("text_align"))
paragraph.space_after = Pt(0)
paragraph.space_before = Pt(0)
if "line_spacing" in element:
paragraph.line_spacing = float(element["line_spacing"])
run = paragraph.add_run()
run.text = line
font = run.font
apply_typeface(run, str(element.get("font_family") or element.get("font") or "Arial"))
font.size = Pt(float(font_size_pt))
font.bold = bool(element.get("bold", False))
font.italic = bool(element.get("italic", False))
if color:
font.color.rgb = color
else:
font.color.theme_color = MSO_THEME_COLOR.TEXT_1
def add_shape(slide: Any, element: dict[str, Any], scale: SlideScale) -> None:
shape_name = str(element.get("shape") or element.get("kind") or element.get("type") or "rect")
if shape_name.lower() == "line":
x1 = float(element.get("x1", element.get("x", 0)))
y1 = float(element.get("y1", element.get("y", 0)))
x2 = float(element.get("x2", x1 + float(element.get("w", element.get("width", 0)))))
y2 = float(element.get("y2", y1 + float(element.get("h", element.get("height", 0)))))
connector = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, scale.x(x1), scale.y(y1), scale.x(x2), scale.y(y2))
connector.name = str(element.get("name") or element.get("id") or "Line")
stroke = parse_color(element.get("stroke") or element.get("color"), "#111111")
if stroke:
connector.line.color.rgb = stroke
connector.line.width = scale.w(float(element.get("stroke_width_px", element.get("stroke_width", 1))))
return
x_px = float(element.get("x", 0))
y_px = float(element.get("y", 0))
width_px = float(element.get("w", element.get("width", 100)))
height_px = float(element.get("h", element.get("height", 100)))
shape = slide.shapes.add_shape(
shape_type(shape_name),
scale.x(x_px),
scale.y(y_px),
scale.w(width_px),
scale.h(height_px),
)
shape.name = str(element.get("name") or element.get("id") or shape_name)
fill_color = parse_color(element.get("fill") or element.get("background"))
if fill_color:
shape.fill.solid()
shape.fill.fore_color.rgb = fill_color
else:
shape.fill.background()
stroke_value = element.get("stroke", element.get("border_color"))
stroke_color = parse_color(stroke_value) if stroke_value is not None else None
if stroke_color:
shape.line.color.rgb = stroke_color
shape.line.width = scale.w(float(element.get("stroke_width_px", element.get("stroke_width", 1))))
else:
shape.line.fill.background()
def add_table(slide: Any, element: dict[str, Any], scale: SlideScale) -> None:
rows = element.get("rows") or element.get("data") or []
if not rows:
return
row_count = len(rows)
col_count = max(len(row) if isinstance(row, list) else 1 for row in rows)
x_px = float(element.get("x", 0))
y_px = float(element.get("y", 0))
width_px = float(element.get("w", element.get("width", scale.canvas_width - x_px)))
height_px = float(element.get("h", element.get("height", row_count * 44)))
frame = slide.shapes.add_table(
row_count,
col_count,
scale.x(x_px),
scale.y(y_px),
scale.w(width_px),
scale.h(height_px),
)
frame.name = str(element.get("name") or "Table")
table = frame.table
font_size_pt = element.get("font_size_pt")
if font_size_pt is None:
font_size_pt = scale.px_font_to_pt(float(element.get("font_size_px", element.get("font_size", 22))))
text_color = parse_color(element.get("color"), "#111111")
fill_color = parse_color(element.get("fill"))
for row_index, row in enumerate(rows):
values = row if isinstance(row, list) else [row]
for col_index in range(col_count):
cell = table.cell(row_index, col_index)
cell.text = str(values[col_index]) if col_index < len(values) else ""
if fill_color:
cell.fill.solid()
cell.fill.fore_color.rgb = fill_color
for paragraph in cell.text_frame.paragraphs:
paragraph.alignment = align_value(element.get("align"))
for run in paragraph.runs:
run.font.size = Pt(float(font_size_pt))
apply_typeface(run, str(element.get("font_family") or "Arial"))
if text_color:
run.font.color.rgb = text_color
def add_element(slide: Any, element: dict[str, Any], scale: SlideScale, base_dir: Path, counters: dict[str, int]) -> None:
kind = element_kind(element)
if kind == "background":
add_background(slide, element, scale, base_dir)
counters["background"] += 1
elif kind == "image":
add_image(slide, element, scale, base_dir)
counters["image"] += 1
elif kind == "text":
add_text(slide, element, scale)
counters["text"] += 1
elif kind in {"shape", "rect", "rectangle", "roundrect", "ellipse", "circle", "line"}:
add_shape(slide, element, scale)
counters["shape"] += 1
elif kind == "table":
add_table(slide, element, scale)
counters["table"] += 1
else:
raise ValueError(f"unsupported element kind: {kind}")
def build_pptx(manifest_path: Path, output_path: Path | None = None, summary_path: Path | None = None) -> dict[str, Any]:
manifest = load_json(manifest_path)
base_dir = manifest_path.parent
slides_data = normalize_slides(manifest)
if not slides_data:
raise ValueError("manifest contains no slides")
first_scale = scale_for_slide(manifest, slides_data[0])
presentation = Presentation()
set_slide_size(presentation, first_scale)
blank_layout = presentation.slide_layouts[6]
counters = {
"slides": 0,
"background": 0,
"image": 0,
"text": 0,
"shape": 0,
"table": 0,
"unsupported": 0,
}
slide_summaries: list[dict[str, Any]] = []
for slide_data in slides_data:
scale = scale_for_slide(manifest, slide_data)
if not math.isclose(scale.slide_width_in, first_scale.slide_width_in) or not math.isclose(
scale.slide_height_in, first_scale.slide_height_in
):
raise ValueError("all slides in one PPTX must use the same slide size")
slide = presentation.slides.add_slide(blank_layout)
elements = sorted_elements(slide_data.get("elements") or [])
slide_counters = {"elements": len(elements), "image": 0, "text": 0, "shape": 0, "table": 0, "background": 0}
for element in elements:
before = counters.copy()
add_element(slide, element, scale, base_dir, counters)
for key in slide_counters:
if key != "elements":
slide_counters[key] += counters[key] - before[key]
counters["slides"] += 1
slide_summaries.append({"name": slide_data.get("name"), **slide_counters})
deck = manifest.get("deck") or {}
props = presentation.core_properties
output_stem = output_path.stem if output_path else "image2ppt"
props.title = str(deck.get("name") or manifest.get("name") or output_stem)
props.subject = "Generated by bggg-creator-image2ppt"
output_value = output_path or resolve_path(manifest.get("output") or "output.pptx", base_dir)
assert output_value is not None
output_value.parent.mkdir(parents=True, exist_ok=True)
presentation.save(output_value)
summary = {
"manifest": str(manifest_path.resolve()),
"output": str(output_value.resolve()),
"slide_width_in": first_scale.slide_width_in,
"slide_height_in": first_scale.slide_height_in,
"canvas_width": first_scale.canvas_width,
"canvas_height": first_scale.canvas_height,
"counts": counters,
"slides": slide_summaries,
}
if summary_path:
write_json(summary_path, summary)
return summary
def maybe_render_pdf(output_path: Path, out_dir: Path) -> str | None:
executable = shutil.which("soffice") or shutil.which("libreoffice")
if not executable:
return None
out_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
[executable, "--headless", "--convert-to", "pdf", "--outdir", str(out_dir), str(output_path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
pdf = out_dir / f"{output_path.stem}.pdf"
return str(pdf) if pdf.exists() else None
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Build editable PPTX from a bggg-creator-image2ppt manifest.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", help="build PPTX from manifest")
build.add_argument("--manifest", required=True, help="manifest JSON path")
build.add_argument("--output", help="output PPTX path; defaults to manifest output or output.pptx")
build.add_argument("--summary", help="summary JSON path")
build.add_argument("--render-pdf", action="store_true", help="also render a PDF preview with LibreOffice if available")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "build":
manifest_path = Path(args.manifest).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve() if args.output else None
summary_path = Path(args.summary).expanduser().resolve() if args.summary else None
summary = build_pptx(manifest_path, output_path, summary_path)
if args.render_pdf:
pdf = maybe_render_pdf(Path(summary["output"]), Path(summary["output"]).parent / "diagnostics")
summary["pdf_preview"] = pdf
if summary_path:
write_json(summary_path, summary)
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
raise SystemExit(f"unknown command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Create a bggg-creator-image2ppt project folder for one conversion run."""
from __future__ import annotations
import argparse
import json
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Sequence
SKILL_ROOT = Path(__file__).resolve().parent.parent
PROJECTS_ROOT = SKILL_ROOT / "projects"
def slugify(value: str) -> str:
text = value.strip().lower()
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "_", text)
text = re.sub(r"_+", "_", text).strip("_")
return text or "image2ppt"
def unique_project_dir(date_prefix: str, slug: str) -> Path:
base = PROJECTS_ROOT / f"{date_prefix}_{slug}"
if not base.exists():
return base
index = 2
while True:
candidate = PROJECTS_ROOT / f"{date_prefix}_{slug}_{index}"
if not candidate.exists():
return candidate
index += 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Initialize a project folder under bggg-creator-image2ppt/projects.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("slug", help="short project name, e.g. investor_cover")
parser.add_argument(
"--source",
action="append",
default=[],
help="optional source file to copy into original_inputs/; repeat for multiple files",
)
parser.add_argument("--date", help="YYYYMMDD override; defaults to local current date")
parser.add_argument("--force-dir", help="explicit project directory name under projects/")
return parser
def copy_sources(project_dir: Path, sources: list[str]) -> list[str]:
copied: list[str] = []
inputs_dir = project_dir / "original_inputs"
for item in sources:
source = Path(item).expanduser().resolve()
if not source.exists():
raise SystemExit(f"source not found: {source}")
target = inputs_dir / source.name
if target.exists():
stem = target.stem
suffix = target.suffix
index = 2
while True:
candidate = inputs_dir / f"{stem}_{index}{suffix}"
if not candidate.exists():
target = candidate
break
index += 1
shutil.copy2(source, target)
copied.append(str(target))
return copied
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
date_prefix = args.date or datetime.now().strftime("%Y%m%d")
project_dir = (
PROJECTS_ROOT / args.force_dir
if args.force_dir
else unique_project_dir(date_prefix, slugify(args.slug))
)
subdirs = [
"original_inputs",
"component_images",
"imagegen_assets",
"diagnostics",
"exports",
]
project_dir.mkdir(parents=True, exist_ok=True)
for item in subdirs:
(project_dir / item).mkdir(parents=True, exist_ok=True)
copied_sources = copy_sources(project_dir, args.source)
result = {
"project_dir": str(project_dir),
"original_inputs": str(project_dir / "original_inputs"),
"component_images": str(project_dir / "component_images"),
"imagegen_assets": str(project_dir / "imagegen_assets"),
"diagnostics": str(project_dir / "diagnostics"),
"exports": str(project_dir / "exports"),
"sources": copied_sources,
"manifest": str(project_dir / "manifest.json"),
"output": str(project_dir / "output.pptx"),
"summary": str(project_dir / "summary.json"),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
python-pptx>=0.6.23
Pillow>=10.0.0
beautifulsoup4>=4.12.0
lxml>=4.9.0
Related skills
FAQ
How does it handle HTML or SVG input?
It parses the code into native PPT elements first and downgrades complex nodes to image components.
Where does it store outputs?
In a per-conversion project directory under projects/YYYYMMDD_slug/.