
Image To Editable Ppt
- 1.2k installs
- 1.8k repo stars
- Updated July 28, 2026
- ningzimu/image-to-editable-ppt-skill
image-to-editable-ppt is an agent skill that rebuilds scanned PDFs, slide images, and image-based decks into editable PowerPoint files for developers who need real text boxes, shapes, and layouts instead of flat pictures
About
image-to-editable-ppt is a skill from ningzimu/image-to-editable-ppt-skill that reconstructs visual slide inputs—single images, multi-image sets, scanned PDFs, and image-based PPT/PPTX files—into object-level editable `.pptx` outputs with real TextLabel, shape, and layout objects. It preserves speaker notes when present and handles requests phrased as making slides editable or converting screenshots to PowerPoint, including Chinese-language triggers. Developers and technical presenters reach for image-to-editable-ppt when a deck is locked as raster images and must be edited for updates, localization, or template changes without rebuilding slides manually.
- Rebuilds single images, multiple screenshots, scanned PDFs, or image-based PPTX into object-level editable .pptx
- Uses page-level decomposition, reconstruction, validation, and assembly via the editppt runtime
- Preserves layout, typography, and speaker notes when present
- Triggers on phrases like "make this PPT editable", "把图片转成可编辑PPT", or "this PDF is a scanned deck"
- Never wraps a full screenshot as one image — always recreates native editable objects
Image To Editable Ppt by the numbers
- 1,168 all-time installs (skills.sh)
- +104 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #237 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ningzimu/image-to-editable-ppt-skill --skill image-to-editable-pptAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 1.8k |
| Last updated | July 28, 2026 |
| Repository | ningzimu/image-to-editable-ppt-skill ↗ |
How do you convert slide images to editable PPTX?
Turn scanned PDFs, slide images, or image-based decks into fully editable PowerPoint files with real text boxes, shapes, and layouts.
Who is it for?
Developers and presenters who receive rasterized or scanned slide decks and need reconstructable PowerPoint objects for editing.
Skip if: Authoring brand-new presentations from scratch when no visual slide source image or PDF exists to reconstruct.
When should I use this skill?
The user provides slide images, scanned PDFs, or image-based PPTX and asks to make the deck editable or restore slide objects.
What you get
Object-level editable `.pptx` files with TextLabel elements, shapes, layouts, and preserved speaker notes.
- Editable `.pptx` file with object-level slide elements
Files
Image to Editable PPT
Overview
This skill rebuilds visual slide inputs into object-level editable PowerPoint .pptx files.
Inputs can be a single image, multiple images, a PDF, or an image-based PPT/PPTX. The output is always .pptx. The goal is not to wrap a full-slide screenshot inside PowerPoint; the goal is to use the editppt runtime and page-level prompts to decompose, reconstruct, validate, and assemble editable slides.
References
Each rule in this skill has exactly one authoritative home; the other files point to it instead of restating it.
prompts/page-worker.md: execution template for page workers — ownership boundary, execution order, required outputs, and return format. The parent agent uses it when generating page-worker prompts.scripts/build-page-worker-prompt.py: skill-local prompt builder. It readsprompts/page-worker.md, fills run/page paths, writesworker-prompt.md, and prints the dispatch command template.references/cli-helper.md: CLI install check (Pre-Run Check), command tree, and command syntax examples. Read it when deciding whicheditpptcommand to call.references/manifest-schema.md: the single home for JSON field contracts of deck/page/image artifacts — required manifest fields, positioned-object coordinates,validation.json, andpage_result.jsonshapes. Read it when writing or validating any run/page file.references/page-decision-tree.md: the single source of truth for page object decisions — background handling, foreground asset separation, native shapes, formulas, text-hints usage, the final self-check, and the fix-versus-warning split. Read it before reconstructing any page.
Entry Contract
These parent-level rules are stated once here; page-level rules live in the references above and are not restated in this file.
- The
editpptCLI is a required runtime surface. Ifeditppt --helpfails, install it first by following the Pre-Run Check inreferences/cli-helper.mdbefore doing anything else. - First run
editppt prepare <input...>to create a run directory. After that, all key state transitions are advanced only througheditpptcommands; never hand-write run/page state JSON. This keeps run state deterministic and resumable. - Multi-page inputs are rebuilt by dispatched page workers. A run with exactly one page is rebuilt by the parent agent in local page-reconstructor mode after
editppt run dispatch --localclaims that page. If no subagent capability is available for a multi-page run, stop and report this to the user; do not degrade into parent-agent reconstruction for multi-page input. - The parent agent must not write any page reconstruction artifact —
manifest.json,page.pptx,preview.png,split_assets_contact.png,validation.json, orpage_result.json— except in single-page local page-reconstructor mode aftereditppt run dispatch --localhas recorded the claim. Local mode follows the same page prompt, references, output files, andrun recordvalidation path as a page worker. - All image generation, image editing, background repair, transparent bitmap assets, and asset sheets go through serial
editppt image generate/editcalls. - A user request to convert visual slides into editable PPT authorizes the required OCR and image-backend calls for that conversion, unless the user explicitly requests local-only processing or marks the input as confidential/no-external-processing. Do not refuse solely because the workflow calls PaddleOCR, Codex OAuth/ChatGPT image endpoints, or a user-configured OpenAI-compatible API; those calls are necessary to the skill.
- Only send task-local page images, prompts, masks, and reference images required for the current conversion. Never send unrelated local files, API keys, auth tokens, credentials, or generated artifacts that are not needed by the current OCR/image operation. Third-party API endpoints are allowed only when already configured by the user or explicitly specified for this run.
- In network-restricted environments, request network approval before commands that need these backends:
editppt prepareoreditppt run hintswhenPADDLE_OCR_TOKENis set, and every requirededitppt image generate/editcall. The approval justification must say this is a user-requestedimage-to-editable-pptconversion, that the upload is limited to task-local page images/prompts/masks/references, and that OCR/image-backend calls are part of this skill's required workflow. Do not present the required call as unsafe or ask the user to re-approve it unless they requested local-only/confidential handling or the approval system explicitly rejects the request. - All page object decisions follow
references/page-decision-tree.md, including its no-fallback rule for foreground visual objects and its rule that deterministic validation is a structure gate that never waives an object-source decision. manifest.jsonis the authoritative page build source:editppt run recordvalidatespage.pptxagainst it, andeditppt run finalizerebuilds the final deck from recorded page manifests. Required fields and coordinate contracts are defined inreferences/manifest-schema.md.editppt preparewrites per-page text measurements (text_hints.json/text_hints.png). How page reconstructors consume them is defined inreferences/page-decision-tree.mdsection 3.1.- Page reconstructors — either page workers or the parent agent in single-page local mode — are driven by prompts generated from
prompts/page-worker.md.
Roles
The parent agent owns orchestration and user interaction:
- Run
editppt prepare. The image backend is chosen automatically (Codex OAuth first, then API fallback), so the normal path needs no extra backend configuration command. - Drive the run with
editppt run nextthrough local rebuild or worker dispatch → record → finalize, exactly as the Workflow phases below describe. Single-page input follows local page-reconstructor mode; multi-page input follows page-worker dispatch. - Report progress, the final PPTX path, and the validation result to the user.
- Do not repeat page-level visual QA that page reconstructors already completed;
recordandfinalizere-validate deterministically.
Each page reconstructor owns exactly one pages/page_NNN/ directory. Its full contract — ownership boundary, decision order, required outputs, and return format — is the prompt generated from prompts/page-worker.md; the rules it follows live in references/page-decision-tree.md and references/manifest-schema.md.
Workflow
Phase 1: Prepare
Read the prepare examples in references/cli-helper.md and the run/page file descriptions in references/manifest-schema.md.
editppt prepare <input...>After this completes, there must be a run directory, deck_manifest.json, page_jobs.json, notes_manifest.json, and each page must have source.png plus page_request.json.
Prepare also writes per-page text hints. Whenever editppt doctor or prepare reports that no PaddleOCR token is configured (offline fallback), ask the user once before dispatching any page: a free token from https://aistudio.baidu.com/account/accessToken stored via editppt config --paddle-ocr-token <token> makes the hints content-aware and noticeably improves text fidelity, and editppt run hints <run> regenerates the current run's hints in place. Tell the user the free personal quota is currently more than enough for this skill — applying is risk-free with no extra cost. Wait for their choice; if they decline or want to proceed, continue with the offline hints and do not ask again.
If a PaddleOCR token is already configured but prepare falls back because network access, DNS, or sandbox approval blocked the OCR request, that fallback is not the preferred quality path. Request network approval with the justification described in the Entry Contract and rerun editppt run hints <run> before page reconstruction. If the approval system rejects the OCR request, ask the user for explicit authorization before continuing: explain that PaddleOCR is used to correct text boxes, font sizes, and size groups, and that using it makes reconstructed PPT text sizing much more stable. Continue with builtin-ink only after the user declines OCR, after an approved OCR attempt fails for a real service/tool reason, or when the user asked for local-only/confidential handling.
Phase 2: Rebuild Or Dispatch Pages
Read the run/dispatch examples in references/cli-helper.md and call repeatedly:
editppt run next <run>When stage=rebuild_page_locally is returned, the run has exactly one page. The parent agent must claim local execution before writing page artifacts:
1. python <skill-root>/scripts/build-page-worker-prompt.py <run> --page <page_id> --out <absolute-run-dir>/pages/<page_id>/worker-prompt.md 2. editppt run dispatch <run> --page <page_id> --agent-id main --prompt-file <absolute-run-dir>/pages/<page_id>/worker-prompt.md --local 3. Read the generated prompt and rebuild the page inside that page directory yourself, producing the same required outputs a page worker would produce.
When stage=dispatch_pages is returned, the following steps are mandatory for each suggested page:
1. python <skill-root>/scripts/build-page-worker-prompt.py <run> --page <page_id> --out <absolute-run-dir>/pages/<page_id>/worker-prompt.md 2. Spawn a page worker using the current environment's available subagent/multi-agent tool. 3. editppt run dispatch <run> --page <page_id> --agent-id <id> --prompt-file <absolute-run-dir>/pages/<page_id>/worker-prompt.md
--out and --prompt-file must be absolute paths to avoid the page directory being prepended again to relative paths. The prompt builder only writes the prompt and prints a dispatch command template; it does not create the worker, so run editppt run dispatch only after a real spawn succeeds.
Concurrency slots come from page_jobs.json.max_concurrent_pages (default 6). In the normal flow prefer editppt run next; editppt run status is only for debugging or manual inspection.
Dispatched page executions are active leases, not idle slots. When editppt run next returns stage=wait, wait for dispatched workers or inspect status without modifying state. Do not terminate, archive, reset, or replace a page worker because it is slow, has not sent recent messages, or still occupies a concurrency slot; complex pages may legitimately run for a long time.
Phase 3: Record
Read the record examples in references/cli-helper.md and the page_result.json description in references/manifest-schema.md.
After a worker returns, run:
editppt run record <run> --page <page_id> --agent-id <id>This command validates page.pptx against manifest.json before recording. It fails if positioned objects are missing source-pixel coordinates, if the manifest cannot independently rebuild the page, or if validation.json does not contain top-level passed: true — a failed page is never recorded.
Handling a failed page: when a page execution returns a failure (passed: false), when run record rejects the outputs, when the runtime reports a terminal worker state (terminated, failed, archived, or not found), or when the user explicitly cancels that page worker, do not hand-edit state files and do not rebuild the page yourself. A long-running worker is not lost. Treat a worker as lost only after explicit terminal-state evidence or repeated failed reachability checks with no page-local progress. Read the page's validation.json when present, fix the root cause (for example a missing image-backend login reported by the page execution), then run:
editppt run reset <run> --page <page_id> --agent-id <id> --confirm-lostFor recorded pages, editppt run reset <run> --page <page_id> is allowed. For dispatched pages, reset requires --confirm-lost and an --agent-id matching the recorded dispatch so an active worker cannot be reset accidentally. This returns the page to pending. Then rebuild the worker prompt and dispatch a new worker through the normal Phase 2 steps. Never re-dispatch without changing something first: a worker re-run under identical conditions fails identically. When the same page fails twice on the same root cause, the diagnosis is yours, not the user's — read the failed attempt's validation.json and artifacts, reproduce the failing command yourself if needed, and fix the underlying cause (backend login, missing tools, broken assets) before resetting again. Only surface a problem to the user when it genuinely requires something only the user has (credentials, a paid account decision, the original file); phrase it as the concrete action needed, never as a debugging question.
Phase 4: Finalize
Read the finalize examples in references/cli-helper.md.
When editppt run next <run> returns the finalize stage:
editppt run finalize <run>finalize treats each recorded pages/page_NNN/manifest.json as the authoritative source: it rebuilds the final deck from page manifests in page order, then validates the resulting PPTX. page.pptx remains a page-level deliverability artifact for record-time checks.
Deck-level structural QA at this stage:
- The PPTX is a valid zip/package.
- Slide count matches the input page count.
- PDF/PPTX page mapping is correct.
- Media relationships are complete.
- All asset files referenced by the manifests exist.
- Media hashes match manifest provenance.
- Speaker notes hashes match.
- There is no invalid full-slide source raster plus editable text overlay pattern.
The final reply must report the final PPTX path and validation result.
State Principles
Agents continue only from file facts and editppt run next. Required states:
pending: created byeditppt prepare; restored byeditppt run resetwhen a page must be re-dispatched.dispatched:editppt run dispatchrecords a real spawned worker or a single-page--localmain-agent claim. This status is an active lease and must not be reset or replaced just because the worker is slow.recorded:editppt run recordvalidates required outputs and writes the result; only deliverable pages (validation.jsontop-levelpassed: true) reach this state.accepted/complete: written byeditppt run finalize.
imagegen-jobs.json is the page-local provenance/job record. Only these forced file states are kept:
recorded:editppt image importhas copied the selected output and written hash/metadata.processed:editppt image process-sheethas completed background removal and splitting.
Delivery Principles
- Each page is self-checked once by the page reconstructor; the evidence is written into structured fields in
manifest.jsonand intovalidation.json. - The final output must be a currently openable, structurally valid
.pptx. A full-slidesource.pngwith editable text overlaid on top is not an acceptable fallback. - Whether an imperfection must be fixed inside its page or may ship as a recorded warning is governed by the "Fix versus Warning" section of
references/page-decision-tree.md. A warning may never replace a missing required workflow step.
Updating This Skill
Reinstall through the installation channel, refresh the CLI from the updated skill directory, then restart the agent session and verify:
npx -y skills@latest add ningzimu/image-to-editable-ppt-skill \
--skill image-to-editable-ppt \
--agent <agent-id> \
--global
pipx install --force --editable <skill-root>/cli
editppt doctorinterface:
display_name: "Image to Editable PPT"
short_description: "Rebuild images, PDFs, or image-based PPT/PPTX files into editable PPTX"
default_prompt: "Use $image-to-editable-ppt to rebuild the input into an object-level editable PowerPoint PPTX. "
__version__ = "0.1.0"
from __future__ import annotations
import os
import runpy
import sys
from pathlib import Path
def main() -> None:
command_name = Path(sys.argv[0]).name or "editppt"
if command_name in {"cli.py", "__main__.py"}:
command_name = "editppt"
runtime_dir = Path(__file__).resolve().parent / "runtime"
script = runtime_dir / "main.py"
if not script.exists():
raise RuntimeError(f"runtime entrypoint not found: {script}")
os.environ.setdefault("IMAGE_TO_EDITABLE_PPT_CLI_PROG", command_name)
sys.path.insert(0, str(runtime_dir))
sys.argv = [command_name, *sys.argv[1:]]
runpy.run_path(str(script), run_name="__main__")
if __name__ == "__main__":
main()
"""Deterministic runtime modules behind the public editppt CLI."""
import hashlib
import io
import json
import posixpath
import shutil
import subprocess
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree as ET
from PIL import Image
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff"}
PPT_EXTS = {".ppt", ".pptx"}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"rel": REL_NS,
}
def sha256_text(value):
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def collect_paragraph_text(root):
paragraphs = []
for paragraph in root.findall(".//a:p", NS):
text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
if text:
paragraphs.append(text)
return "\n".join(paragraphs)
def copy_input(src, input_dir):
src = Path(src).resolve()
dest = input_dir / src.name
counter = 2
while dest.exists() and dest.resolve() != src:
dest = input_dir / f"{src.stem}-{counter}{src.suffix}"
counter += 1
if src != dest:
shutil.copy2(src, dest)
return dest
def save_image_page(src, page_dir):
page_dir.mkdir(parents=True, exist_ok=True)
out = page_dir / "source.png"
with Image.open(src) as image:
image.convert("RGB").save(out)
return out
def render_pdf_pages(pdf_path, pages_dir, dpi):
import fitz
doc = fitz.open(pdf_path)
outputs = []
matrix = fitz.Matrix(dpi / 72, dpi / 72)
for index, page in enumerate(doc, start=1):
page_dir = pages_dir / f"page_{index:03d}"
page_dir.mkdir(parents=True, exist_ok=True)
pix = page.get_pixmap(matrix=matrix, alpha=False)
out = page_dir / "source.png"
pix.save(out)
outputs.append(out)
return outputs
def rel_source_part(rels_name):
directory = posixpath.dirname(rels_name)
if directory.endswith("/_rels"):
directory = posixpath.dirname(directory)
source = posixpath.basename(rels_name)[:-5]
return posixpath.normpath(posixpath.join(directory, source))
def resolve_target(rels_name, target):
source = rel_source_part(rels_name)
return posixpath.normpath(posixpath.join(posixpath.dirname(source), target))
def collect_notes_from_pptx(pptx_path, notes_dir=None):
notes = []
if notes_dir:
notes_dir = Path(notes_dir)
with zipfile.ZipFile(pptx_path) as z:
names = set(z.namelist())
if "ppt/presentation.xml" not in names or "ppt/_rels/presentation.xml.rels" not in names:
return notes
pres = ET.fromstring(z.read("ppt/presentation.xml"))
pres_rels = ET.fromstring(z.read("ppt/_rels/presentation.xml.rels"))
rels_by_id = {rel.attrib.get("Id"): rel.attrib.get("Target") for rel in pres_rels.findall("rel:Relationship", NS)}
slide_parts = []
for sld_id in pres.findall(".//p:sldId", NS):
rel_id = sld_id.attrib.get(f"{{{NS['r']}}}id")
target = rels_by_id.get(rel_id)
if target:
slide_parts.append(posixpath.normpath(posixpath.join("ppt", target)))
for page_index, slide_part in enumerate(slide_parts, start=1):
rels_name = f"{posixpath.dirname(slide_part)}/_rels/{posixpath.basename(slide_part)}.rels"
note = {"page_index": page_index, "text": "", "text_sha256": sha256_text(""), "source_slide": slide_part}
if rels_name in names:
root = ET.fromstring(z.read(rels_name))
for rel in root.findall("rel:Relationship", NS):
if rel.attrib.get("Type", "").endswith("/notesSlide"):
notes_part = resolve_target(rels_name, rel.attrib.get("Target", ""))
if notes_part in names:
notes_bytes = z.read(notes_part)
notes_root = ET.fromstring(notes_bytes)
text = collect_paragraph_text(notes_root)
update = {
"text": text,
"text_sha256": sha256_text(text),
"source_notes_part": notes_part,
}
if notes_dir:
out_dir = notes_dir / f"page_{page_index:03d}"
out_dir.mkdir(parents=True, exist_ok=True)
notes_xml = out_dir / "notesSlide.xml"
notes_xml.write_bytes(notes_bytes)
update["notes_xml"] = str(notes_xml)
note.update(update)
if note["text"]:
notes.append(note)
return notes
def slide_parts_from_pptx(zip_file):
names = set(zip_file.namelist())
if "ppt/presentation.xml" not in names or "ppt/_rels/presentation.xml.rels" not in names:
raise ValueError("PPTX is missing presentation relationships.")
pres = ET.fromstring(zip_file.read("ppt/presentation.xml"))
pres_rels = ET.fromstring(zip_file.read("ppt/_rels/presentation.xml.rels"))
rels_by_id = {rel.attrib.get("Id"): rel.attrib.get("Target") for rel in pres_rels.findall("rel:Relationship", NS)}
slide_parts = []
for sld_id in pres.findall(".//p:sldId", NS):
rel_id = sld_id.attrib.get(f"{{{NS['r']}}}id")
target = rels_by_id.get(rel_id)
if target:
slide_parts.append(posixpath.normpath(posixpath.join("ppt", target)))
if not slide_parts:
raise ValueError("PPTX has no slides.")
return slide_parts
def slide_size_from_pptx(zip_file):
pres = ET.fromstring(zip_file.read("ppt/presentation.xml"))
size = pres.find(".//p:sldSz", NS)
if size is None:
raise ValueError("PPTX is missing slide size.")
return int(size.attrib["cx"]), int(size.attrib["cy"])
def slide_relationships(zip_file, slide_part):
rels_name = f"{posixpath.dirname(slide_part)}/_rels/{posixpath.basename(slide_part)}.rels"
if rels_name not in zip_file.namelist():
return {}
root = ET.fromstring(zip_file.read(rels_name))
return {rel.attrib.get("Id"): rel for rel in root.findall("rel:Relationship", NS)}
def full_slide_picture_target(zip_file, slide_part, slide_cx, slide_cy):
slide_root = ET.fromstring(zip_file.read(slide_part))
if collect_paragraph_text(slide_root):
raise ValueError(f"{slide_part} contains native text and is not an image-based slide.")
pictures = slide_root.findall(".//p:pic", NS)
if len(pictures) != 1:
raise ValueError(f"{slide_part} must contain exactly one full-slide picture; found {len(pictures)}.")
picture = pictures[0]
blip = picture.find(".//a:blip", NS)
if blip is None:
raise ValueError(f"{slide_part} picture has no embedded image.")
rel_id = blip.attrib.get(f"{{{NS['r']}}}embed")
relationships = slide_relationships(zip_file, slide_part)
rel = relationships.get(rel_id)
if rel is None or not rel.attrib.get("Type", "").endswith("/image"):
raise ValueError(f"{slide_part} picture relationship is not an embedded image.")
off = picture.find(".//a:xfrm/a:off", NS)
ext = picture.find(".//a:xfrm/a:ext", NS)
if off is None or ext is None:
raise ValueError(f"{slide_part} picture has no placement transform.")
x, y = int(off.attrib.get("x", 0)), int(off.attrib.get("y", 0))
cx, cy = int(ext.attrib.get("cx", 0)), int(ext.attrib.get("cy", 0))
tolerance = 2
if abs(x) > tolerance or abs(y) > tolerance or abs(cx - slide_cx) > tolerance or abs(cy - slide_cy) > tolerance:
raise ValueError(f"{slide_part} picture is not full-slide.")
return resolve_target(f"{posixpath.dirname(slide_part)}/_rels/{posixpath.basename(slide_part)}.rels", rel.attrib["Target"])
def extract_image_based_pptx_pages(pptx_path, pages_dir):
outputs = []
with zipfile.ZipFile(pptx_path) as z:
names = set(z.namelist())
slide_cx, slide_cy = slide_size_from_pptx(z)
for index, slide_part in enumerate(slide_parts_from_pptx(z), start=1):
image_part = full_slide_picture_target(z, slide_part, slide_cx, slide_cy)
if image_part not in names:
raise ValueError(f"{slide_part} references missing image part: {image_part}")
page_dir = pages_dir / f"page_{index:03d}"
page_dir.mkdir(parents=True, exist_ok=True)
out = page_dir / "source.png"
with Image.open(io.BytesIO(z.read(image_part))) as image:
image.convert("RGB").save(out)
outputs.append(out)
return outputs
def find_soffice():
return shutil.which("soffice") or shutil.which("libreoffice")
def convert_office_to_pdf(input_path, out_dir):
soffice = find_soffice()
if not soffice:
raise RuntimeError("No local Office converter is available for this input.")
out_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
[soffice, "--headless", "--convert-to", "pdf", "--outdir", str(out_dir), str(input_path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
pdfs = sorted(out_dir.glob("*.pdf"))
if not pdfs:
raise RuntimeError(f"Office conversion did not produce a PDF in {out_dir}")
return pdfs[0]
def convert_ppt_to_pptx(input_path, out_dir):
if input_path.suffix.lower() == ".pptx":
return input_path
soffice = find_soffice()
if not soffice:
raise RuntimeError("No local Office converter is available to normalize .ppt input.")
out_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
[soffice, "--headless", "--convert-to", "pptx", "--outdir", str(out_dir), str(input_path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
pptxs = sorted(out_dir.glob("*.pptx"))
if not pptxs:
raise RuntimeError(f"Office conversion did not produce a PPTX in {out_dir}")
return pptxs[0]
def page_record(job_dir, page_index, source, input_path, source_page):
page_dir = source.parent
rel_page_dir = page_dir.relative_to(job_dir).as_posix()
return {
"page_index": page_index,
"source_page": source_page,
"source_image": source.relative_to(job_dir).as_posix(),
"page_dir": rel_page_dir,
"manifest": f"{rel_page_dir}/manifest.json",
"validation": f"{rel_page_dir}/validation.json",
"input": Path(input_path).name,
"agent_status": "pending",
}
def default_job_dir(out_root, input_paths):
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
stem = Path(input_paths[0]).stem if input_paths else "job"
return Path(out_root) / f"{stamp}-{stem}"
def default_output_name(input_paths):
stem = Path(input_paths[0]).stem if input_paths else "deck"
return f"{stem}_edited.pptx"
def normalize_inputs(inputs, out_root="output/image-to-editable-ppt", job_dir=None, dpi=180):
input_paths = [Path(path).resolve() for path in inputs]
job_dir = Path(job_dir).resolve() if job_dir else default_job_dir(out_root, input_paths).resolve()
input_dir = job_dir / "input"
pages_dir = job_dir / "pages"
input_dir.mkdir(parents=True, exist_ok=True)
pages_dir.mkdir(parents=True, exist_ok=True)
copied = [copy_input(path, input_dir) for path in input_paths]
suffixes = {path.suffix.lower() for path in copied}
pages = []
notes = []
input_type = "images"
if len(copied) == 1 and copied[0].suffix.lower() == ".pdf":
input_type = "pdf"
sources = render_pdf_pages(copied[0], pages_dir, dpi)
pages = [page_record(job_dir, i, source, copied[0], i) for i, source in enumerate(sources, start=1)]
elif len(copied) == 1 and copied[0].suffix.lower() in PPT_EXTS:
if copied[0].suffix.lower() == ".pptx":
input_type = "pptx"
notes = collect_notes_from_pptx(copied[0], input_dir / "notes")
try:
sources = extract_image_based_pptx_pages(copied[0], pages_dir)
except ValueError as exc:
raise SystemExit(
"Unsupported PPTX for the lightweight path: "
f"{exc} This skill accepts image-based PPTX files through lightweight extraction. "
"Convert native/complex PPTX slides to PDF or page images first."
) from exc
else:
input_type = "ppt"
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
source_pptx = convert_ppt_to_pptx(copied[0], tmp_dir)
notes = collect_notes_from_pptx(source_pptx, input_dir / "notes")
if source_pptx != copied[0]:
shutil.copy2(source_pptx, input_dir / source_pptx.name)
rendered_pdf = convert_office_to_pdf(copied[0], tmp_dir)
sources = render_pdf_pages(rendered_pdf, pages_dir, args.dpi)
pages = [page_record(job_dir, i, source, copied[0], i) for i, source in enumerate(sources, start=1)]
elif suffixes <= IMG_EXTS:
input_type = "image" if len(copied) == 1 else "images"
for i, src in enumerate(copied, start=1):
source = save_image_page(src, pages_dir / f"page_{i:03d}")
pages.append(page_record(job_dir, i, source, src, i))
else:
raise SystemExit(f"Unsupported input combination: {', '.join(str(path) for path in input_paths)}")
for note in notes:
notes_xml = note.get("notes_xml")
if notes_xml:
note["notes_xml"] = Path(notes_xml).relative_to(job_dir).as_posix()
notes_manifest_path = job_dir / "notes_manifest.json"
notes_manifest = {"source": copied[0].relative_to(job_dir).as_posix() if len(copied) == 1 else None, "notes": notes}
notes_manifest_path.write_text(json.dumps(notes_manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
deck_manifest = {
"input_type": input_type,
"job_dir": str(job_dir),
"page_count": len(pages),
"inputs": [path.relative_to(job_dir).as_posix() for path in copied],
"pages": pages,
"notes_manifest": notes_manifest_path.relative_to(job_dir).as_posix(),
"output": default_output_name(copied),
"validation": "validation.json",
}
deck_manifest_path = job_dir / "deck_manifest.json"
deck_manifest_path.write_text(json.dumps(deck_manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return deck_manifest_path
import json
import shutil
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
def run(command):
print("+ " + " ".join(str(part) for part in command), flush=True)
subprocess.run([str(part) for part in command], check=True)
def resolve_under_page(page_dir, value):
path = Path(value).expanduser()
if path.is_absolute():
return path
return page_dir / path
def imagegen_chroma_helper():
helper = SCRIPT_DIR / "remove_chroma_key.py"
if not helper.exists():
raise SystemExit(f"Missing chroma helper: {helper}")
return helper
def process_asset_sheet(args, page_dir):
chroma = resolve_under_page(page_dir, args.chroma)
alpha = resolve_under_page(page_dir, args.alpha)
if not args.asset_sheet_source and not chroma.exists() and not alpha.exists():
return
if not args.asset_sheet_source and args.skip_chroma and args.skip_split:
return
if args.asset_sheet_source:
source = resolve_under_page(page_dir, args.asset_sheet_source)
if not source.exists():
raise SystemExit(f"Asset sheet source does not exist: {source}")
chroma.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, chroma)
print(f"Wrote {chroma}")
if not args.skip_chroma:
if not chroma.exists():
raise SystemExit(f"Chroma input does not exist: {chroma}")
command = [
sys.executable,
imagegen_chroma_helper(),
"--input",
chroma,
"--out",
alpha,
"--auto-key",
"border",
"--soft-matte",
"--transparent-threshold",
args.transparent_threshold,
"--opaque-threshold",
args.opaque_threshold,
]
if args.despill:
command.append("--despill")
if args.force_chroma:
command.append("--force")
run(command)
if args.skip_split:
return
if not alpha.exists():
raise SystemExit(f"Alpha sheet does not exist: {alpha}")
command = [
sys.executable,
SCRIPT_DIR / "split_alpha_components.py",
"--input",
alpha,
"--out-dir",
resolve_under_page(page_dir, args.assets_dir),
"--sort",
args.split_sort,
"--min-area",
args.split_min_area,
"--merge-gap",
args.split_merge_gap,
"--merge-union-growth",
args.split_merge_union_growth,
"--manifest",
resolve_under_page(page_dir, args.split_manifest),
]
if args.square_assets:
command.append("--square")
if args.asset_names:
command.extend(["--names", args.asset_names])
run(command)
def fit_image(image, size):
if image.size == size:
return image
return image.resize(size)
def write_pair(source_path, preview_path, out_path):
from PIL import Image, ImageDraw
source = Image.open(source_path).convert("RGB")
rebuilt = Image.open(preview_path).convert("RGB")
source = fit_image(source, rebuilt.size)
label_h = 32
gap = 18
width, height = rebuilt.size
canvas = Image.new("RGB", (width * 2 + gap, height + label_h), "#f5f7fb")
canvas.paste(source, (0, label_h))
canvas.paste(rebuilt, (width + gap, label_h))
draw = ImageDraw.Draw(canvas)
draw.text((10, 9), "origin", fill="black")
draw.text((width + gap + 10, 9), "preview", fill="black")
out_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(out_path)
print(f"Wrote {out_path}")
#!/usr/bin/env python3
import argparse
import html
import json
import math
import re
import subprocess
import sys
import tempfile
import zipfile
from copy import deepcopy
from pathlib import Path
EMU_PER_INCH = 914400
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
ASPECT_16_9 = 16 / 9
ASPECT_TOLERANCE = 0.03
DEFAULT_TEXT_FIT_SAFETY = 0.9
DEFAULT_TEXT_LINE_HEIGHT = 1.22
DEFAULT_MIN_FONT_SIZE = 4.0
def emu(value):
return int(round(float(value) * EMU_PER_INCH))
def hex_color(value, default="000000"):
if not value:
return default
return str(value).strip().lstrip("#").upper()
def content_type_for(path):
suffix = Path(path).suffix.lower()
if suffix == ".png":
return "image/png"
if suffix in (".jpg", ".jpeg"):
return "image/jpeg"
if suffix == ".gif":
return "image/gif"
if suffix == ".svg":
return "image/svg+xml"
raise ValueError(f"Unsupported image type: {path}")
def image_ext(path):
suffix = Path(path).suffix.lower()
return ".jpg" if suffix == ".jpeg" else suffix
def xml_text(value):
return html.escape(str(value), quote=True)
def source_size_px(manifest):
source = manifest.get("source", {})
width = source.get("width_px")
height = source.get("height_px")
if width and height:
return float(width), float(height)
return None
def slide_size(manifest):
slide = manifest.get("slide", {})
return float(slide.get("width", 13.333)), float(slide.get("height", 7.5))
def fit_content_box(source_width, source_height, slide_width, slide_height):
source_aspect = source_width / source_height
slide_aspect = slide_width / slide_height
if source_aspect >= slide_aspect:
width = slide_width
height = width / source_aspect
left = 0
top = (slide_height - height) / 2
else:
height = slide_height
width = height * source_aspect
left = (slide_width - width) / 2
top = 0
return {"left": left, "top": top, "width": width, "height": height}
def content_box_for_manifest(manifest):
content_box = manifest.get("content_box")
if content_box:
return {
"left": float(content_box.get("left", 0)),
"top": float(content_box.get("top", 0)),
"width": float(content_box.get("width", 1)),
"height": float(content_box.get("height", 1)),
}
source_size = source_size_px(manifest)
slide_width, slide_height = slide_size(manifest)
if not source_size:
return {"left": 0, "top": 0, "width": slide_width, "height": slide_height}
source_width, source_height = source_size
return fit_content_box(source_width, source_height, slide_width, slide_height)
def px_to_inches(manifest, x, y, width, height):
source_size = source_size_px(manifest)
if not source_size:
raise ValueError("Manifest uses pixel coordinates but lacks source.width_px/source.height_px")
source_width, source_height = source_size
content_box = content_box_for_manifest(manifest)
return {
"left": content_box["left"] + float(x) / source_width * content_box["width"],
"top": content_box["top"] + float(y) / source_height * content_box["height"],
"width": float(width) / source_width * content_box["width"],
"height": float(height) / source_height * content_box["height"],
}
def normalize_position_item(manifest, item):
item = dict(item)
if "polygon_px" in item:
points = [(float(point[0]), float(point[1])) for point in item["polygon_px"]]
if points and "box_px" not in item:
xs = [point[0] for point in points]
ys = [point[1] for point in points]
item["box_px"] = [min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)]
item["polygon"] = [
[px_to_inches(manifest, point[0], point[1], 0, 0)["left"], px_to_inches(manifest, point[0], point[1], 0, 0)["top"]]
for point in points
]
if "box_px" in item:
x, y, width, height = item["box_px"]
item.update(px_to_inches(manifest, x, y, width, height))
if "points_px" in item:
x1, y1, x2, y2 = item["points_px"]
left = min(float(x1), float(x2))
top = min(float(y1), float(y2))
width = abs(float(x2) - float(x1))
height = abs(float(y2) - float(y1))
item.update(px_to_inches(manifest, left, top, width, height))
start = px_to_inches(manifest, x1, y1, 0, 0)
end = px_to_inches(manifest, x2, y2, 0, 0)
item["points"] = [start["left"], start["top"], end["left"], end["top"]]
if float(x2) < float(x1):
item["flip_h"] = True
if float(y2) < float(y1):
item["flip_v"] = True
if item.get("source_corner_radius_px") is not None and "radius" not in item:
radius = float(item.get("source_corner_radius_px") or 0)
item["radius"] = px_to_inches(manifest, 0, 0, radius, radius)["width"]
return item
def iter_text_lines(item):
if item.get("paragraphs"):
lines = []
for paragraph in item["paragraphs"]:
if isinstance(paragraph, str):
lines.append(paragraph)
else:
runs = paragraph.get("runs")
if runs:
lines.append("".join(str(run.get("text", "")) for run in runs))
else:
lines.append(str(paragraph.get("text", "")))
return lines or [""]
if item.get("runs"):
return ["".join(str(run.get("text", "")) for run in item["runs"])]
return str(item.get("text", "")).splitlines() or [""]
def text_width_units(text):
units = 0.0
for char in str(text):
codepoint = ord(char)
if char.isspace():
units += 0.32
elif codepoint <= 0x7F:
units += 0.55
elif 0xFF00 <= codepoint <= 0xFFEF:
units += 1.0
elif 0x4E00 <= codepoint <= 0x9FFF:
units += 1.0
else:
units += 0.85
return max(units, 1.0)
def longest_unbreakable_units(text):
tokens = [token for token in re.split(r"\s+", str(text)) if token]
if not tokens:
return text_width_units(text)
return max(text_width_units(token) for token in tokens)
def is_measured_text(item):
"""True when the author marked this box as sized from `page hints` measurement."""
return str(item.get("font_size_source", "")).strip().lower() in {"measured", "hints"}
def fitted_font_size(item, manifest):
if item.get("fit_text") is False or manifest.get("fit_text") is False:
return None
if "width" not in item or "height" not in item:
return None
lines = iter_text_lines(item)
requested = float(item.get("font_size", 18))
width_pt = max(1.0, float(item.get("width", 1)) * 72)
height_pt = max(1.0, float(item.get("height", 0.4)) * 72)
if is_measured_text(item):
# Box and font size were both measured from source ink; the safety
# discount exists to absorb estimation error in hand-written boxes
# and would systematically shrink correct text here. Clamp only at
# the geometric limit.
safety = 1.0
else:
safety = float(item.get("text_fit_safety", manifest.get("text_fit_safety", DEFAULT_TEXT_FIT_SAFETY)))
line_height = float(item.get("line_height", manifest.get("text_line_height", DEFAULT_TEXT_LINE_HEIGHT)))
wrap_enabled = item.get("wrap") not in (None, "", "none")
if wrap_enabled:
line_count = sum(max(1, math.ceil(text_width_units(line) * requested / width_pt)) for line in lines)
width_limit = width_pt / max(longest_unbreakable_units(line) for line in lines)
else:
line_count = max(1, len(lines))
width_limit = width_pt / max(text_width_units(line) for line in lines)
height_limit = height_pt / (line_count * max(line_height, 1.0))
max_font_size = min(width_limit, height_limit) * safety
explicit_max = item.get("max_font_size")
if explicit_max not in (None, ""):
max_font_size = min(max_font_size, float(explicit_max))
min_font_size = float(item.get("min_font_size", manifest.get("min_font_size", DEFAULT_MIN_FONT_SIZE)))
return max(min_font_size, max_font_size)
def scale_run_font_sizes(item, ratio):
def scale_run(run):
if run.get("font_size") not in (None, ""):
run["font_size"] = round(float(run["font_size"]) * ratio, 1)
for run in item.get("runs", []):
scale_run(run)
for paragraph in item.get("paragraphs", []):
if isinstance(paragraph, dict):
for run in paragraph.get("runs", []):
scale_run(run)
def fit_text_item(item, manifest):
fitted = fitted_font_size(item, manifest)
if fitted is None:
return item
requested = float(item.get("font_size", fitted))
effective = min(requested, fitted)
if effective < requested:
item["_requested_font_size"] = requested
item["font_size"] = round(effective, 1)
scale_run_font_sizes(item, effective / requested)
elif "font_size" not in item:
item["font_size"] = round(effective, 1)
return item
def normalize_manifest(manifest):
"""Return a manifest copy with pixel authoring fields resolved to inches."""
normalized = deepcopy(manifest)
normalized["text_boxes"] = [
fit_text_item(normalize_position_item(normalized, item), normalized) for item in normalized.get("text_boxes", [])
]
for key in ("images", "shapes"):
normalized[key] = [normalize_position_item(normalized, item) for item in normalized.get(key, [])]
return normalized
def preview_color(value):
if not value or value == "none":
return value
value = str(value).strip()
if value.startswith("#"):
return value
if len(value) == 6 and all(ch in "0123456789abcdefABCDEF" for ch in value):
return f"#{value}"
return value
def shape_fill(fill):
if not fill or fill == "none":
return '<a:noFill/>'
return f'<a:solidFill><a:srgbClr val="{hex_color(fill)}"/></a:solidFill>'
def shape_line_xml(stroke, width, dash=None):
if not stroke or stroke == "none":
return '<a:ln><a:noFill/></a:ln>'
dash_xml = f'<a:prstDash val="{xml_text(dash)}"/>' if dash else ""
return (
f'<a:ln w="{int(float(width or 1) * 12700)}">'
f'<a:solidFill><a:srgbClr val="{hex_color(stroke)}"/></a:solidFill>'
f"{dash_xml}"
"</a:ln>"
)
def slide_background_xml(slide):
background = slide.get("background")
if not background:
return ""
return (
"<p:bg><p:bgPr>"
f'<a:solidFill><a:srgbClr val="{hex_color(background, "FFFFFF")}"/></a:solidFill>'
"<a:effectLst/></p:bgPr></p:bg>"
)
def text_box_xml(idx, item):
left = emu(item.get("left", 0))
top = emu(item.get("top", 0))
width = emu(item.get("width", 1))
height = emu(item.get("height", 0.4))
rotation = item.get("rotation")
rotation_attr = f' rot="{int(float(rotation) * 60000)}"' if rotation not in (None, "") else ""
font_size = int(float(item.get("font_size", 18)) * 100)
font = xml_text(item.get("font", "PingFang SC"))
align = item.get("align", "left")
anchor = item.get("valign", "top")
wrap = item.get("wrap", "none")
autofit = item.get("autofit", "none")
autofit_xml = "<a:spAutoFit/>" if autofit == "shape" else "<a:noAutofit/>"
paragraphs = item.get("paragraphs")
runs = item.get("runs")
def run_xml(run):
run_font_size = int(float(run.get("font_size", item.get("font_size", 18))) * 100)
run_font = xml_text(run.get("font", item.get("font", "PingFang SC")))
run_color = hex_color(run.get("color", item.get("color", "#111111")))
run_bold = ' b="1"' if run.get("bold", item.get("bold")) else ""
run_italic = ' i="1"' if run.get("italic", item.get("italic")) else ""
run_baseline = run.get("baseline")
run_baseline_attr = f' baseline="{int(float(run_baseline))}"' if run_baseline not in (None, "") else ""
run_text = xml_text(run.get("text", ""))
return (
f'<a:r><a:rPr lang="zh-CN" sz="{run_font_size}"{run_bold}{run_italic}{run_baseline_attr}>'
f'<a:solidFill><a:srgbClr val="{run_color}"/></a:solidFill>'
f'<a:latin typeface="{run_font}"/><a:ea typeface="{run_font}"/><a:cs typeface="{run_font}"/>'
f'</a:rPr><a:t>{run_text}</a:t></a:r>'
)
def paragraph_xml(paragraph):
if isinstance(paragraph, str):
paragraph_runs = [{"text": paragraph}]
else:
paragraph_runs = paragraph.get("runs", [{"text": paragraph.get("text", "")}])
return (
f'<a:p><a:pPr algn="{align}"/>'
+ "".join(run_xml(run) for run in paragraph_runs)
+ f'<a:endParaRPr lang="zh-CN" sz="{font_size}"/></a:p>'
)
if paragraphs:
text_body = "".join(paragraph_xml(paragraph) for paragraph in paragraphs)
elif runs:
text_body = paragraph_xml({"runs": runs})
else:
text_body = "".join(paragraph_xml(part) for part in str(item.get("text", "")).split("\n"))
return f"""
<p:sp>
<p:nvSpPr><p:cNvPr id="{idx}" name="TextBox {idx}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm{rotation_attr}><a:off x="{left}" y="{top}"/><a:ext cx="{width}" cy="{height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr>
<p:txBody>
<a:bodyPr wrap="{xml_text(wrap)}" anchor="{anchor}" lIns="0" tIns="0" rIns="0" bIns="0">{autofit_xml}</a:bodyPr><a:lstStyle/>
{text_body}
</p:txBody>
</p:sp>"""
def image_xml(idx, rel_id, item):
left = emu(item.get("left", 0))
top = emu(item.get("top", 0))
width = emu(item.get("width", 1))
height = emu(item.get("height", 1))
name = xml_text(item.get("alt") or Path(item.get("path", "")).stem or f"Image {idx}")
return f"""
<p:pic>
<p:nvPicPr><p:cNvPr id="{idx}" name="{name}"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>
<p:blipFill><a:blip r:embed="{rel_id}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>
<p:spPr><a:xfrm><a:off x="{left}" y="{top}"/><a:ext cx="{width}" cy="{height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
</p:pic>"""
def shape_xml(idx, item):
kind = item.get("type", "rect")
left = emu(item.get("left", 0))
top = emu(item.get("top", 0))
width = emu(item.get("width", 1))
height = emu(item.get("height", 1))
stroke_width = item.get("stroke_width", 1)
flip_h = ' flipH="1"' if item.get("flip_h") else ""
flip_v = ' flipV="1"' if item.get("flip_v") else ""
fill = shape_fill(item.get("fill"))
line = shape_line_xml(item.get("stroke", "#000000"), stroke_width, item.get("dash"))
preset = item.get("preset")
if item.get("polygon_px"):
geometry = custom_polygon_geometry_xml(item)
else:
if not preset:
preset = "line" if kind == "line" else "ellipse" if kind == "ellipse" else "roundRect" if kind == "roundRect" else "rect"
geometry = preset_geometry_xml(preset, item)
return f"""
<p:sp>
<p:nvSpPr><p:cNvPr id="{idx}" name="{xml_text(kind.title())} {idx}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm{flip_h}{flip_v}><a:off x="{left}" y="{top}"/><a:ext cx="{width}" cy="{height}"/></a:xfrm>{geometry}{fill}{line}</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody>
</p:sp>"""
def custom_polygon_geometry_xml(item):
points = [(float(point[0]), float(point[1])) for point in item.get("polygon_px", [])]
if len(points) < 3:
return '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
box = item.get("box_px")
if box and len(box) == 4:
left, top, width, height = [float(value) for value in box]
else:
xs = [point[0] for point in points]
ys = [point[1] for point in points]
left, top = min(xs), min(ys)
width, height = max(xs) - left, max(ys) - top
width = max(width, 1.0)
height = max(height, 1.0)
def rel_coord(point):
x, y = point
return int(round((x - left) / width * 21600)), int(round((y - top) / height * 21600))
first_x, first_y = rel_coord(points[0])
segments = [f'<a:moveTo><a:pt x="{first_x}" y="{first_y}"/></a:moveTo>']
for point in points[1:]:
x, y = rel_coord(point)
segments.append(f'<a:lnTo><a:pt x="{x}" y="{y}"/></a:lnTo>')
segments.append("<a:close/>")
return (
'<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
'<a:rect l="l" t="t" r="r" b="b"/>'
'<a:pathLst><a:path w="21600" h="21600">'
+ "".join(segments)
+ "</a:path></a:pathLst></a:custGeom>"
)
def round_rect_adjustment(item):
box_px = item.get("box_px")
if item.get("source_corner_radius_px") is not None and box_px and len(box_px) == 4:
radius = float(item.get("source_corner_radius_px") or 0)
min_dim = max(1.0, min(float(box_px[2]), float(box_px[3])))
elif item.get("radius") is not None:
radius = float(item.get("radius") or 0)
min_dim = max(0.01, min(float(item.get("width", 1)), float(item.get("height", 1))))
else:
return None
return max(0, min(50000, int(round(radius / min_dim * 100000))))
def preset_geometry_xml(preset, item):
if preset != "roundRect":
return f'<a:prstGeom prst="{preset}"><a:avLst/></a:prstGeom>'
adjustment = round_rect_adjustment(item)
if adjustment is None:
return '<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>'
return (
'<a:prstGeom prst="roundRect"><a:avLst>'
f'<a:gd name="adj" fmla="val {adjustment}"/>'
'</a:avLst></a:prstGeom>'
)
def slide_xml(manifest):
slide = manifest.get("slide", {})
next_id = 2
parts = []
layered = []
for index, item in enumerate(manifest.get("shapes", [])):
layered.append((float(item.get("z_index", 100)), index, "shape", item, None))
for rel_index, item in enumerate(manifest.get("images", []), start=1):
layered.append((float(item.get("z_index", 200)), rel_index, "image", item, f"rId{rel_index + 1}"))
for index, item in enumerate(manifest.get("text_boxes", [])):
layered.append((float(item.get("z_index", 300)), index, "text", item, None))
for _z_index, _order, kind, item, rel_id in sorted(layered, key=lambda entry: (entry[0], entry[1])):
if kind == "shape":
parts.append(shape_xml(next_id, item))
elif kind == "image":
parts.append(image_xml(next_id, rel_id, item))
else:
parts.append(text_box_xml(next_id, item))
next_id += 1
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
{slide_background_xml(slide)}
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
{''.join(parts)}
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>"""
def rels_xml(manifest, media_start=1, notes_index=None):
rels = ['<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>']
for i, item in enumerate(manifest.get("images", []), start=1):
target = f"../media/image{media_start + i - 1}{image_ext(item['path'])}"
rels.append(f'<Relationship Id="rId{i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"/>')
if notes_index is not None:
rels.append(
f'<Relationship Id="rId{len(rels) + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide{notes_index}.xml"/>'
)
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + "".join(rels) + "</Relationships>"
def notes_slide_xml(text):
paras = "".join(
f'<a:p><a:r><a:rPr lang="zh-CN" sz="1200"/><a:t>{xml_text(line)}</a:t></a:r><a:endParaRPr lang="zh-CN" sz="1200"/></a:p>'
for line in str(text).splitlines()
) or '<a:p><a:endParaRPr lang="zh-CN" sz="1200"/></a:p>'
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/><p:cNvSpPr txBox="1"/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="685800" y="914400"/><a:ext cx="5486400" cy="6858000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/>{paras}</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:notes>"""
def notes_rels_xml(slide_index):
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="{REL_NS}">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide{slide_index}.xml"/>
</Relationships>"""
def notes_master_xml():
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
</p:notesMaster>"""
def notes_master_rels_xml():
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="{REL_NS}">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>"""
def content_types_xml(manifests, notes_indices=None):
notes_indices = notes_indices or []
defaults = {
"rels": "application/vnd.openxmlformats-package.relationships+xml",
"xml": "application/xml",
}
for manifest in manifests:
for item in manifest.get("images", []):
ext = image_ext(item["path"]).lstrip(".")
defaults[ext] = content_type_for(item["path"])
default_xml = "".join(f'<Default Extension="{ext}" ContentType="{ctype}"/>' for ext, ctype in defaults.items())
overrides = [
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>',
'<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>',
'<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>',
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
]
for i in range(1, len(manifests) + 1):
overrides.append(f'<Override PartName="/ppt/slides/slide{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>')
if notes_indices:
overrides.append('<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>')
for i in notes_indices:
overrides.append(f'<Override PartName="/ppt/notesSlides/notesSlide{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>')
return f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">{default_xml}{"".join(overrides)}</Types>'
def is_wide_slide(width, height):
return abs((float(width) / float(height)) / ASPECT_16_9 - 1) <= ASPECT_TOLERANCE
def slide_size_type(width, height):
return "wide" if is_wide_slide(width, height) else "custom"
def presentation_xml(slide_count, width, height):
slide_ids = "".join(f'<p:sldId id="{255 + i}" r:id="rId{i + 1}"/>' for i in range(1, slide_count + 1))
size_type = slide_size_type(width, height)
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst>{slide_ids}</p:sldIdLst>
<p:sldSz cx="{width}" cy="{height}" type="{size_type}"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>"""
def presentation_rels_xml(slide_count):
rels = ['<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>']
for i in range(1, slide_count + 1):
rels.append(f'<Relationship Id="rId{i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{i}.xml"/>')
return f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="{REL_NS}">{"".join(rels)}</Relationships>'
def write_common_parts(z, slide_count, width, height, notes_count):
presentation_format = "Widescreen" if slide_size_type(width, height) == "wide" else "Custom"
z.writestr("_rels/.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>""")
z.writestr("docProps/core.xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Image to editable PPT</dc:title></cp:coreProperties>""")
z.writestr("docProps/app.xml", f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Codex</Application><PresentationFormat>{presentation_format}</PresentationFormat><Slides>{slide_count}</Slides></Properties>""")
z.writestr("ppt/presentation.xml", presentation_xml(slide_count, width, height))
z.writestr("ppt/_rels/presentation.xml.rels", presentation_rels_xml(slide_count))
z.writestr("ppt/slideMasters/slideMaster1.xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>""")
z.writestr("ppt/slideMasters/_rels/slideMaster1.xml.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/></Relationships>""")
z.writestr("ppt/slideLayouts/slideLayout1.xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>""")
z.writestr("ppt/slideLayouts/_rels/slideLayout1.xml.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/></Relationships>""")
z.writestr("ppt/theme/theme1.xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="ImageToEditablePPT"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F1F1F"/></a:dk2><a:lt2><a:srgbClr val="F8F8F8"/></a:lt2><a:accent1><a:srgbClr val="0F766E"/></a:accent1><a:accent2><a:srgbClr val="E66B00"/></a:accent2><a:accent3><a:srgbClr val="F6D365"/></a:accent3><a:accent4><a:srgbClr val="57C4B8"/></a:accent4><a:accent5><a:srgbClr val="666666"/></a:accent5><a:accent6><a:srgbClr val="111111"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="PingFang"><a:majorFont><a:latin typeface="PingFang SC"/><a:ea typeface="PingFang SC"/><a:cs typeface="PingFang SC"/></a:majorFont><a:minorFont><a:latin typeface="PingFang SC"/><a:ea typeface="PingFang SC"/><a:cs typeface="PingFang SC"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>""")
if notes_count:
z.writestr("ppt/notesMasters/notesMaster1.xml", notes_master_xml())
z.writestr("ppt/notesMasters/_rels/notesMaster1.xml.rels", notes_master_rels_xml())
def write_pptx(manifest, out_path, manifest_path):
width = emu(manifest.get("slide", {}).get("width", 13.333))
height = emu(manifest.get("slide", {}).get("height", 7.5))
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
normalized = normalize_manifest(manifest)
media_index = 1
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", content_types_xml([normalized], []))
write_common_parts(z, 1, width, height, 0)
z.writestr("ppt/slides/slide1.xml", slide_xml(normalized))
z.writestr("ppt/slides/_rels/slide1.xml.rels", rels_xml(normalized, media_index, None))
base = Path(manifest_path).resolve().parent
for item in normalized.get("images", []):
src = Path(item["path"])
if not src.is_absolute():
src = base / src
z.write(src, f"ppt/media/image{media_index}{image_ext(src)}")
media_index += 1
def deck_slide_size(deck, page_entries):
slide = deck.get("slide") or {}
if not slide and page_entries:
slide = page_entries[0]["manifest"].get("slide", {})
return emu(slide.get("width", 13.333)), emu(slide.get("height", 7.5))
def write_deck(deck, page_entries, out_path, notes_entries):
if not page_entries:
raise ValueError("Deck has no pages")
width, height = deck_slide_size(deck, page_entries)
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
notes_by_page = {int(entry.get("page_index", 0)): entry for entry in notes_entries if entry.get("text")}
notes_indices = sorted(notes_by_page)
normalized_entries = [{**entry, "manifest": normalize_manifest(entry["manifest"])} for entry in page_entries]
manifests = [entry["manifest"] for entry in normalized_entries]
media_index = 1
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", content_types_xml(manifests, notes_indices))
write_common_parts(z, len(page_entries), width, height, len(notes_by_page))
for slide_index, entry in enumerate(normalized_entries, start=1):
manifest = entry["manifest"]
notes_index = slide_index if slide_index in notes_by_page else None
z.writestr(f"ppt/slides/slide{slide_index}.xml", slide_xml(manifest))
z.writestr(f"ppt/slides/_rels/slide{slide_index}.xml.rels", rels_xml(manifest, media_index, notes_index))
base = Path(entry["manifest_path"]).resolve().parent
for item in manifest.get("images", []):
src = Path(item["path"])
if not src.is_absolute():
src = base / src
z.write(src, f"ppt/media/image{media_index}{image_ext(src)}")
media_index += 1
if notes_index is not None:
note = notes_by_page[slide_index]
notes_xml = note.get("notes_xml")
if notes_xml and Path(notes_xml).exists():
z.writestr(f"ppt/notesSlides/notesSlide{notes_index}.xml", Path(notes_xml).read_bytes())
else:
z.writestr(f"ppt/notesSlides/notesSlide{notes_index}.xml", notes_slide_xml(note.get("text", "")))
z.writestr(f"ppt/notesSlides/_rels/notesSlide{notes_index}.xml.rels", notes_rels_xml(slide_index))
def page_entries_from_deck_manifest(deck_manifest_path):
deck_path = Path(deck_manifest_path).resolve()
deck = json.loads(deck_path.read_text(encoding="utf-8"))
root = Path(deck.get("job_dir", deck_path.parent)).resolve()
entries = []
for page in deck.get("pages", []):
manifest_path = Path(page.get("manifest", ""))
if not manifest_path.is_absolute():
manifest_path = root / manifest_path
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
entries.append({"manifest": manifest, "manifest_path": manifest_path})
notes_path = deck.get("notes_manifest")
notes_entries = []
if notes_path:
notes_file = Path(notes_path)
if not notes_file.is_absolute():
notes_file = root / notes_file
if notes_file.exists():
notes_entries = json.loads(notes_file.read_text(encoding="utf-8")).get("notes", [])
for note in notes_entries:
notes_xml = note.get("notes_xml")
if notes_xml:
notes_xml_path = Path(notes_xml)
if not notes_xml_path.is_absolute():
notes_xml_path = root / notes_xml_path
note["notes_xml"] = str(notes_xml_path)
return deck, entries, notes_entries
def output_path_from_deck_manifest(deck_manifest_path):
deck_path = Path(deck_manifest_path).resolve()
deck = json.loads(deck_path.read_text(encoding="utf-8"))
root = Path(deck.get("job_dir", deck_path.parent)).resolve()
output = Path(deck.get("output", "final/deck_edited.pptx"))
if not output.is_absolute():
output = root / output
return output
def render_preview(manifest, manifest_path, out_path):
from PIL import Image, ImageColor, ImageDraw, ImageFont
manifest = normalize_manifest(manifest)
slide = manifest.get("slide", {})
width_in = float(slide.get("width", 13.333))
height_in = float(slide.get("height", 7.5))
scale = int(manifest.get("preview_scale", 120))
canvas = Image.new("RGB", (int(width_in * scale), int(height_in * scale)), ImageColor.getrgb(slide.get("background", "#ffffff")))
base = Path(manifest_path).resolve().parent
draw = ImageDraw.Draw(canvas)
def open_preview_image(src):
if src.suffix.lower() != ".svg":
return Image.open(src).convert("RGBA")
convert = "/opt/homebrew/bin/magick"
if not Path(convert).exists():
convert = "/opt/homebrew/bin/convert"
if not Path(convert).exists():
print(f"Warning: cannot preview SVG without ImageMagick: {src}", file=sys.stderr)
return None
with tempfile.NamedTemporaryFile(suffix=".png") as handle:
subprocess.run([convert, str(src), handle.name], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return Image.open(handle.name).convert("RGBA")
def render_shape(item):
box = [item.get("left", 0) * scale, item.get("top", 0) * scale, (item.get("left", 0) + item.get("width", 1)) * scale, (item.get("top", 0) + item.get("height", 1)) * scale]
fill = preview_color(item.get("fill"))
outline = preview_color(item.get("stroke", "#000000"))
width = max(1, int(float(item.get("stroke_width", 1))))
if item.get("polygon"):
points = [(point[0] * scale, point[1] * scale) for point in item["polygon"]]
draw.polygon(points, fill=None if fill in (None, "none") else fill, outline=None if outline == "none" else outline)
elif item.get("type") == "line":
if "points" in item:
points = [value * scale for value in item["points"]]
draw.line(points, fill=outline, width=width)
return
if item.get("dash"):
draw_dashed_line(draw, box, outline, width)
else:
draw.line(box, fill=outline, width=width)
elif item.get("type") == "ellipse":
draw.ellipse(box, fill=None if fill in (None, "none") else fill, outline=None if outline == "none" else outline, width=width)
elif item.get("type") == "roundRect" or item.get("preset") == "roundRect":
radius = int(float(item.get("radius", 0.12)) * scale)
draw.rounded_rectangle(box, radius=radius, fill=None if fill in (None, "none") else fill, outline=None if outline == "none" else outline, width=width)
elif item.get("preset") == "diamond":
left, top, right, bottom = box
center_x = (left + right) / 2
center_y = (top + bottom) / 2
points = [(center_x, top), (right, center_y), (center_x, bottom), (left, center_y)]
draw.polygon(points, fill=None if fill in (None, "none") else fill, outline=None if outline == "none" else outline)
else:
draw.rectangle(box, fill=None if fill in (None, "none") else fill, outline=None if outline == "none" else outline, width=width)
def render_image(item):
src = Path(item["path"])
if not src.is_absolute():
src = base / src
img = open_preview_image(src)
if img is None:
return
img = img.resize((max(1, int(item.get("width", 1) * scale)), max(1, int(item.get("height", 1) * scale))))
canvas.paste(img, (int(item.get("left", 0) * scale), int(item.get("top", 0) * scale)), img)
def render_text(item):
preview_font_scale = float(item.get("preview_font_scale", manifest.get("preview_font_scale", 1.0)))
size = max(1, int(float(item.get("font_size", 18)) * scale / 72 * preview_font_scale))
font_path = choose_preview_font(item.get("preview_font"))
try:
font = ImageFont.truetype(font_path, size=size) if font_path else ImageFont.load_default()
except Exception:
font = ImageFont.load_default()
if item.get("paragraphs"):
lines = []
for paragraph in item["paragraphs"]:
if isinstance(paragraph, str):
lines.append(paragraph)
else:
lines.append("".join(str(run.get("text", "")) for run in paragraph.get("runs", [])))
preview_text = "\n".join(lines)
elif item.get("runs"):
preview_text = "".join(str(run.get("text", "")) for run in item["runs"])
else:
preview_text = item.get("text", "")
fill = preview_color(item.get("color", "#111111"))
align = item.get("align", "left") if item.get("align", "left") in ("left", "center", "right") else "left"
x = int(item.get("left", 0) * scale)
y = int(item.get("top", 0) * scale)
rotation = float(item.get("rotation", 0) or 0)
if item.get("runs") and not rotation:
cursor_x = x
base_size = size
for run in item["runs"]:
run_size = max(1, int(float(run.get("font_size", item.get("font_size", 18))) * scale / 72 * preview_font_scale))
run_font_path = choose_preview_font(run.get("preview_font") or item.get("preview_font"))
try:
run_font = ImageFont.truetype(run_font_path, size=run_size) if run_font_path else ImageFont.load_default()
except Exception:
run_font = font
run_fill = preview_color(run.get("color", item.get("color", "#111111")))
baseline = float(run.get("baseline", 0) or 0)
run_y = y + int(-baseline / 100000 * base_size)
run_text = str(run.get("text", ""))
draw.text((cursor_x, run_y), run_text, fill=run_fill, font=run_font)
cursor_x += int(draw.textlength(run_text, font=run_font))
return
if rotation:
layer_w = max(1, int(item.get("width", 1) * scale))
layer_h = max(1, int(item.get("height", 0.4) * scale))
layer = Image.new("RGBA", (layer_w, layer_h), (0, 0, 0, 0))
layer_draw = ImageDraw.Draw(layer)
layer_draw.multiline_text((0, 0), preview_text, fill=fill, font=font, spacing=4, align=align)
rotated = layer.rotate(-rotation, expand=True)
canvas.paste(rotated, (x, y), rotated)
return
draw.multiline_text((x, y), preview_text, fill=fill, font=font, spacing=4, align=align)
layered = []
for index, item in enumerate(manifest.get("shapes", [])):
layered.append((float(item.get("z_index", 100)), index, render_shape, item))
for index, item in enumerate(manifest.get("images", [])):
layered.append((float(item.get("z_index", 200)), index, render_image, item))
for index, item in enumerate(manifest.get("text_boxes", [])):
layered.append((float(item.get("z_index", 300)), index, render_text, item))
for _z_index, _order, renderer, item in sorted(layered, key=lambda entry: (entry[0], entry[1])):
renderer(item)
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
canvas.save(out_path)
def choose_preview_font(preferred):
candidates = [
preferred,
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/Library/Fonts/Arial Unicode.ttf",
]
for candidate in candidates:
if candidate and Path(candidate).exists():
return candidate
return None
def draw_dashed_line(draw, box, fill, width):
x1, y1, x2, y2 = box
dash = 8
gap = 6
if abs(y2 - y1) <= abs(x2 - x1):
step = dash + gap
x = min(x1, x2)
end = max(x1, x2)
y = y1
while x < end:
draw.line((x, y, min(x + dash, end), y), fill=fill, width=width)
x += step
else:
step = dash + gap
y = min(y1, y2)
end = max(y1, y2)
x = x1
while y < end:
draw.line((x, y, x, min(y + dash, end)), fill=fill, width=width)
y += step
def main():
parser = argparse.ArgumentParser()
parser.add_argument("manifest", nargs="?")
parser.add_argument("--deck-manifest")
parser.add_argument("--out")
parser.add_argument("--preview")
args = parser.parse_args()
if args.deck_manifest:
deck, entries, notes_entries = page_entries_from_deck_manifest(args.deck_manifest)
out = Path(args.out) if args.out else output_path_from_deck_manifest(args.deck_manifest)
write_deck(deck, entries, out, notes_entries)
print(f"Wrote {out}")
return
if not args.manifest:
parser.error("manifest is required unless --deck-manifest is used")
if not args.out:
parser.error("--out is required unless --deck-manifest provides an output")
manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
write_pptx(manifest, args.out, args.manifest)
if args.preview:
render_preview(manifest, args.manifest, args.preview)
print(f"Wrote {args.out}")
if args.preview:
print(f"Wrote {args.preview}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import argparse
import json
from deck_run_state import load_deck, load_jobs, read_json, run_dir_from_target, save_deck, write_json
def backend_contract(args):
requires_api_key = args.backend_id == "openai-compatible-api"
return {
"backend_id": args.backend_id,
"tool_name": args.tool_name,
"tool_call": args.tool_call,
"fallback_command": args.fallback_command,
"runtime_home": args.runtime_home,
"model": args.model,
"requires_openai_api_key": requires_api_key,
"mode_policy": "generate-or-edit-per-asset",
"chroma_key_helper": "editppt image process-sheet",
"input_context_policy": args.input_context_policy,
"save_path_policy": "write outputs directly to page dir or copy selected outputs before manifest references them",
"handoff_rule": "call editppt image generate/edit serially; the CLI selects Codex OAuth first and OpenAI-compatible API fallback second",
}
def main():
parser = argparse.ArgumentParser(description="Record the run-level image backend contract.")
parser.add_argument("run")
parser.add_argument("--backend-id", default="editppt-image-cli", choices=["editppt-image-cli", "openai-compatible-api"])
parser.add_argument("--tool-name")
parser.add_argument("--tool-call")
parser.add_argument("--model", default="gpt-image-2")
parser.add_argument("--fallback-command")
parser.add_argument("--runtime-home", default="~/.editppt")
parser.add_argument("--input-context-policy", default="pass edit targets and strict visual references via editppt image edit --image")
args = parser.parse_args()
if args.tool_name is None:
args.tool_name = "editppt image"
if args.tool_call is None:
args.tool_call = "editppt image generate/edit"
if args.fallback_command is None:
args.fallback_command = "editppt image"
run_dir = run_dir_from_target(args.run)
deck = load_deck(run_dir)
contract = backend_contract(args)
deck["image_backend"] = contract
save_deck(run_dir, deck)
jobs = load_jobs(run_dir)
for page in jobs.get("pages", []):
request_path = run_dir / page["page_request"]
request = read_json(request_path)
request["image_backend"] = contract
write_json(request_path, request)
print(json.dumps({"image_backend": contract}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
ACTIVE_PAGE_STATUSES = {"dispatched"}
DISPATCHABLE_PAGE_STATUSES = {"pending"}
DEFAULT_MAX_CONCURRENT_PAGES = 6
def now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def read_json(path, default=None):
path = Path(path)
if not path.exists():
if default is not None:
return default
raise FileNotFoundError(path)
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def sha256_file(path):
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def sha256_text(value):
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
def run_dir_from_target(target):
path = Path(target).expanduser().resolve()
if path.is_dir():
return path
if path.name == "deck_manifest.json":
return path.parent
raise ValueError(f"Expected run directory or deck_manifest.json: {target}")
def deck_manifest_path(run_dir):
return Path(run_dir) / "deck_manifest.json"
def page_jobs_path(run_dir):
return Path(run_dir) / "page_jobs.json"
def run_state_path(run_dir):
return Path(run_dir) / "run_state.json"
def load_deck(run_dir):
return read_json(deck_manifest_path(run_dir))
def save_deck(run_dir, deck):
write_json(deck_manifest_path(run_dir), deck)
def load_jobs(run_dir):
return read_json(page_jobs_path(run_dir))
def save_jobs(run_dir, jobs):
write_json(page_jobs_path(run_dir), jobs)
def load_run_state(run_dir):
return read_json(run_state_path(run_dir), default={"status": "created", "history": []})
def save_run_state(run_dir, state):
write_json(run_state_path(run_dir), state)
def set_run_status(run_dir, status, note=None):
state = load_run_state(run_dir)
if state.get("status") != status:
state.setdefault("history", []).append(
{"from": state.get("status"), "to": status, "at": now_iso(), "note": note}
)
state["status"] = status
state["updated_at"] = now_iso()
save_run_state(run_dir, state)
return state
def normalize_page_id(value):
text = str(value).strip()
if text.startswith("page_"):
return text
if text.isdigit():
return f"page_{int(text):03d}"
raise ValueError(f"Invalid page id: {value}")
def find_page(jobs, page):
page_id = normalize_page_id(page)
for entry in jobs.get("pages", []):
if entry.get("page_id") == page_id or entry.get("id") == page_id:
return entry
raise KeyError(f"Page not found in page_jobs.json: {page_id}")
def resolve_run_path(run_dir, value):
path = Path(value)
if path.is_absolute():
return path.resolve()
return (Path(run_dir) / path).resolve()
def rel_to_run(run_dir, value):
path = Path(value).resolve()
return path.relative_to(Path(run_dir).resolve()).as_posix()
def resolve_inside(base_dir, value):
base = Path(base_dir).resolve()
path = Path(value)
if not path.is_absolute():
path = base / path
path = path.resolve()
try:
path.relative_to(base)
except ValueError as exc:
raise ValueError(f"Path is outside allowed directory: {path}") from exc
return path
def all_pages_have_status(jobs, statuses):
allowed = set(statuses)
return all(page.get("status") in allowed for page in jobs.get("pages", []))
def max_concurrent_pages(jobs):
value = jobs.get("max_concurrent_pages", DEFAULT_MAX_CONCURRENT_PAGES)
try:
value = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid max_concurrent_pages: {value}") from exc
if value < 1:
raise ValueError("max_concurrent_pages must be >= 1")
return value
def active_pages(jobs):
return [page for page in jobs.get("pages", []) if page.get("status") in ACTIVE_PAGE_STATUSES]
def dispatchable_pages(jobs):
return [
page
for page in jobs.get("pages", [])
if page.get("status") in DISPATCHABLE_PAGE_STATUSES
]
def dispatch_slots_available(jobs):
return max(0, max_concurrent_pages(jobs) - len(active_pages(jobs)))
def update_jobs_run_status(jobs):
pages = jobs.get("pages", [])
if pages and all(page.get("status") in {"dispatched", "recorded", "accepted"} for page in pages):
jobs["run_status"] = "pages_dispatched"
if pages and all(page.get("status") in {"recorded", "accepted"} for page in pages):
jobs["run_status"] = "pages_recorded"
if pages and all(page.get("status") == "accepted" for page in pages):
jobs["run_status"] = "complete"
jobs["updated_at"] = now_iso()
def inside_or_missing(page_dir, value):
path = resolve_inside(page_dir, value)
if not path.exists():
raise FileNotFoundError(path)
return path
def safe_agent_label(agent_id, nickname=None):
label = str(agent_id).strip()
if nickname:
label += f" ({nickname})"
return label
def page_dir_for(run_dir, page):
return resolve_run_path(run_dir, page["page_dir"])
def ensure_file(path, label):
path = Path(path)
if not path.exists() or not path.is_file():
raise FileNotFoundError(f"Missing {label}: {path}")
return path
def ensure_dir(path, label):
path = Path(path)
if not path.exists() or not path.is_dir():
raise FileNotFoundError(f"Missing {label}: {path}")
return path
#!/usr/bin/env python3
"""Generate text hints for every page of a prepared run.
Runs as part of `editppt prepare`, after page directories exist: each
`pages/page_NNN/` receives canonical `text_hints.json` and `text_hints.png`
files so page workers find their text measurements already in place.
Backend selection per run:
- With a PaddleOCR token (PADDLE_OCR_TOKEN env var, or PADDLE_OCR_TOKEN in
~/.editppt/config.yaml): PDF inputs are submitted to the OCR service as ONE
job covering all pages; image/PPTX inputs submit each page's source.png.
OCR coordinates are rescaled to each page's actual source.png resolution
and re-measured locally with the ink metrics.
- Without a token, or when the service fails: the built-in offline detector
(`text_hints.py`) runs per page, so every page still gets hints.
Hint generation is best-effort: a page that fails is reported and skipped,
and the page worker can regenerate with `editppt page hints <page_dir>`.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from PIL import Image
from deck_run_state import load_deck, load_jobs, page_dir_for, run_dir_from_target
from text_hints import draw_overlay, page_text_hints
HINTS_JSON = "text_hints.json"
HINTS_PNG = "text_hints.png"
def paddle_token() -> str:
token = os.environ.get("PADDLE_OCR_TOKEN", "").strip()
if token:
return token
try:
from runtime_env import config_path, read_config_file
return str(read_config_file(config_path()).get("PADDLE_OCR_TOKEN", "")).strip()
except Exception:
return ""
def write_hints(page_dir: Path, hints: dict, overlay: bool) -> None:
(page_dir / HINTS_JSON).write_text(json.dumps(hints, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if overlay:
draw_overlay(Image.open(page_dir / "source.png"), hints["lines"], page_dir / HINTS_PNG)
def builtin_page(page_dir: Path) -> dict:
hints = page_text_hints(page_dir)
hints["backend"] = "builtin-ink"
return hints
def synthesize_pdf(page_dirs: list[Path], out_path: Path) -> None:
"""Bundle the per-page source images into one PDF (one page per image).
Lets every input type — single image, multiple images, image-based PPTX —
reach the OCR service as a single batch job instead of one job per page.
Page size is the image's pixel size in points; build_page_hints rescales
the OCR coordinates back to each source.png regardless of the resolution
the service rendered at.
"""
import fitz
document = fitz.open()
for page_dir in page_dirs:
with Image.open(page_dir / "source.png") as image:
width, height = image.size
page = document.new_page(width=width, height=height)
page.insert_image(fitz.Rect(0, 0, width, height), filename=str(page_dir / "source.png"))
document.save(out_path)
document.close()
def paddle_pages(run_dir: Path, deck: dict, page_dirs: list[Path], token: str, timeout: int) -> dict[Path, dict]:
"""Fetch OCR results for all pages in ONE job; returns {page_dir: hints}."""
import tempfile
from paddle_text_hints import DEFAULT_MODEL, build_page_hints, submit_and_fetch
original = None
if str(deck.get("input_type", "")) == "pdf":
input_dir = run_dir / "input"
candidates = sorted(input_dir.glob("*.pdf")) if input_dir.exists() else []
original = candidates[0] if candidates else None
synthesized = None
try:
if original is None:
handle = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
handle.close()
synthesized = Path(handle.name)
synthesize_pdf(page_dirs, synthesized)
original = synthesized
pages = submit_and_fetch(original, token, DEFAULT_MODEL, timeout)
finally:
if synthesized is not None:
synthesized.unlink(missing_ok=True)
if len(pages) != len(page_dirs):
raise RuntimeError(f"OCR returned {len(pages)} pages for {len(page_dirs)} page dirs")
return {page_dir: build_page_hints(page_dir, pruned) for page_dir, pruned in zip(page_dirs, pages)}
def main() -> int:
parser = argparse.ArgumentParser(description="Generate per-page text hints for a prepared run.")
parser.add_argument("run", help="Run directory or deck_manifest.json path.")
parser.add_argument("--timeout", type=int, default=300, help="OCR job timeout in seconds.")
parser.add_argument("--no-overlay", action="store_true", help="Skip the labeled overlay images.")
args = parser.parse_args()
run_dir = run_dir_from_target(args.run)
deck = load_deck(run_dir)
jobs = load_jobs(run_dir)
page_dirs = [page_dir_for(run_dir, page) for page in jobs.get("pages", [])]
page_dirs = [d for d in page_dirs if (d / "source.png").exists()]
if not page_dirs:
print("text-hints: no pages with source.png; skipped", file=sys.stderr)
return 0
token = paddle_token()
results: dict[Path, dict] = {}
backend = "builtin-ink"
if not token:
print(
"text-hints: no PaddleOCR token configured; falling back to the built-in offline "
"detector (geometry only — it measures where text is and how large, but cannot read "
"it). A free PaddleOCR-VL token adds recognized text content and cleaner block "
"boundaries, noticeably improving text fidelity in the final PPT. The free personal quota "
"is currently more than enough for this skill, so applying is risk-free with no extra "
"cost. ASK THE USER once "
"before reconstructing pages: configure a token now (apply at "
"https://aistudio.baidu.com/account/accessToken, then `editppt config "
"--paddle-ocr-token <token>` and `editppt run hints <run>` to regenerate this run's "
"hints), or continue with the offline result. Respect their choice and do not ask again.",
file=sys.stderr,
)
if token:
try:
results = paddle_pages(run_dir, deck, page_dirs, token, args.timeout)
backend = "paddleocr-vl"
except Exception as exc:
print(f"text-hints: PaddleOCR failed ({exc}); falling back to built-in detector", file=sys.stderr)
results = {}
written = 0
for page_dir in page_dirs:
try:
hints = results.get(page_dir) or builtin_page(page_dir)
# Dense diagrams can defeat the OCR layout model entirely (the
# whole figure is classified as an image and only a headline
# survives). When OCR found almost nothing but the offline
# detector finds plenty, the geometric hints are more useful.
if hints.get("backend") == "paddleocr-vl" and len(hints["lines"]) <= 2:
offline = builtin_page(page_dir)
if len(offline["lines"]) >= 6:
print(
f"text-hints: {page_dir.name}: OCR found {len(hints['lines'])} text lines but the "
f"offline detector found {len(offline['lines'])}; using the offline result for this page",
file=sys.stderr,
)
hints = offline
write_hints(page_dir, hints, overlay=not args.no_overlay)
written += 1
except Exception as exc:
print(f"text-hints: {page_dir.name} failed ({exc}); worker can run `editppt page hints` itself", file=sys.stderr)
print(f"text-hints: wrote {written}/{len(page_dirs)} pages (backend={backend})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path
from deck_run_state import load_deck, load_jobs, now_iso, run_dir_from_target, save_deck, save_jobs, set_run_status, write_json
SCRIPT_DIR = Path(__file__).resolve().parent
def run(command):
print("+ " + " ".join(str(part) for part in command), flush=True)
subprocess.run([str(part) for part in command], check=True)
def final_output_path(run_dir, deck):
output = Path(deck.get("output", "final/deck_edited.pptx"))
if output.is_absolute():
return output
return run_dir / output
def assert_pages_ready(run_dir, jobs):
problems = []
for page in jobs.get("pages", []):
if page.get("status") not in {"recorded", "accepted"}:
problems.append(f"{page['page_id']} status={page.get('status')}")
continue
result = page.get("result") or {}
if result.get("validation_passed") is not True:
problems.append(f"{page['page_id']} validation_passed={result.get('validation_passed')}")
if problems:
raise SystemExit("Pages are not ready for finalize:\n" + "\n".join(problems))
def main():
parser = argparse.ArgumentParser(description="Build and validate the final editable PPTX from recorded pages.")
parser.add_argument("run", help="Run directory or deck_manifest.json")
args = parser.parse_args()
run_dir = run_dir_from_target(args.run)
deck = load_deck(run_dir)
jobs = load_jobs(run_dir)
assert_pages_ready(run_dir, jobs)
out = final_output_path(run_dir, deck)
out.parent.mkdir(parents=True, exist_ok=True)
run([sys.executable, SCRIPT_DIR / "build_pptx_from_manifest.py", "--deck-manifest", run_dir / "deck_manifest.json", "--out", out])
set_run_status(run_dir, "deck_built", "final pptx built")
validation = out.parent / "validation.json"
run([sys.executable, SCRIPT_DIR / "validate_pptx.py", out, "--deck-manifest", run_dir / "deck_manifest.json", "--report", validation])
set_run_status(run_dir, "deck_validated", "final pptx validation passed")
for page in jobs.get("pages", []):
page["status"] = "accepted"
page["accepted"] = True
page["accepted_at"] = now_iso()
jobs["run_status"] = "complete"
jobs["updated_at"] = now_iso()
save_jobs(run_dir, jobs)
deck["completed_at"] = now_iso()
save_deck(run_dir, deck)
summary = {
"schema_version": 1,
"run_id": deck.get("run_id"),
"status": "complete",
"page_count": len(jobs.get("pages", [])),
"output": str(out),
"validation": str(validation),
"completed_at": now_iso(),
}
write_json(out.parent / "run_summary.json", summary)
set_run_status(run_dir, "complete", "final deck complete")
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Render LaTeX formulas into image assets for page manifests."""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
DEFAULT_ENGINE_CANDIDATES = ("xelatex", "lualatex", "pdflatex")
DEFAULT_TIMEOUT = 120
DEFAULT_DPI = 300
SUPPORTED_FORMATS = {"svg", "png", "pdf"}
class FormulaRenderError(RuntimeError):
pass
def select_latex_engine(engine: str | None = None) -> str:
if engine and engine != "auto":
resolved = shutil.which(engine)
if not resolved:
raise FormulaRenderError(f"LaTeX engine not found: {engine}")
return resolved
for candidate in DEFAULT_ENGINE_CANDIDATES:
resolved = shutil.which(candidate)
if resolved:
return resolved
raise FormulaRenderError(
"No LaTeX engine found. Install a TeX distribution that provides xelatex, lualatex, or pdflatex."
)
def render_latex_asset(
*,
tex: str,
out: str | Path,
page_dir: str | Path | None = None,
output_format: str | None = None,
engine: str | None = None,
preamble: str = "",
full_document: bool = False,
display: bool = True,
dpi: int = DEFAULT_DPI,
timeout: int = DEFAULT_TIMEOUT,
shell_escape: bool = False,
keep_workdir: str | Path | None = None,
) -> dict[str, Any]:
if not tex.strip():
raise FormulaRenderError("LaTeX input is empty.")
out_path = resolve_output_path(out, page_dir)
fmt = normalise_format(output_format, out_path)
source_tex = out_path.with_suffix(".tex")
source_tex.parent.mkdir(parents=True, exist_ok=True)
document = build_latex_document(tex, preamble=preamble, full_document=full_document, display=display)
source_tex.write_text(document, encoding="utf-8")
resolved_engine = select_latex_engine(engine)
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp)
work_tex = workdir / "formula.tex"
work_tex.write_text(document, encoding="utf-8")
command = [
resolved_engine,
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
]
if shell_escape:
command.append("-shell-escape")
command.append(work_tex.name)
result = subprocess.run(
command,
cwd=workdir,
text=True,
capture_output=True,
timeout=timeout,
)
if result.returncode != 0:
_maybe_keep_workdir(workdir, keep_workdir)
raise FormulaRenderError(_latex_error_message(result))
pdf = workdir / "formula.pdf"
if not pdf.exists():
_maybe_keep_workdir(workdir, keep_workdir)
raise FormulaRenderError("LaTeX completed but formula.pdf was not produced.")
converter = convert_pdf(pdf, out_path, fmt, dpi=dpi, timeout=timeout)
_maybe_keep_workdir(workdir, keep_workdir)
return {
"out": str(out_path),
"format": fmt,
"tex_source": str(source_tex),
"engine": Path(resolved_engine).name,
"converter": converter,
}
def build_latex_document(tex: str, *, preamble: str = "", full_document: bool = False, display: bool = True) -> str:
if full_document:
return tex if tex.endswith("\n") else tex + "\n"
body = tex.strip()
if display:
body = "\\[\n" + body + "\n\\]"
else:
body = "$" + body + "$"
return (
"\\documentclass[border=2pt]{standalone}\n"
"\\usepackage{amsmath,amssymb,mathtools,bm}\n"
"\\usepackage{xcolor}\n"
f"{preamble.strip()}\n"
"\\begin{document}\n"
f"{body}\n"
"\\end{document}\n"
)
def convert_pdf(pdf: Path, out_path: Path, fmt: str, *, dpi: int, timeout: int) -> str:
out_path.parent.mkdir(parents=True, exist_ok=True)
if fmt == "pdf":
shutil.copy2(pdf, out_path)
return "copy-pdf"
if fmt == "svg":
dvisvgm = shutil.which("dvisvgm")
if dvisvgm:
command = [dvisvgm, "--pdf", "--no-fonts", "--exact", "--output", str(out_path), str(pdf)]
_run_converter(command, timeout)
return "dvisvgm"
pdf2svg = shutil.which("pdf2svg")
if pdf2svg:
command = [pdf2svg, str(pdf), str(out_path)]
_run_converter(command, timeout)
return "pdf2svg"
raise FormulaRenderError("SVG output requires dvisvgm or pdf2svg.")
if fmt == "png":
magick = shutil.which("magick") or shutil.which("convert")
if not magick:
raise FormulaRenderError("PNG output requires ImageMagick (`magick` or `convert`).")
command = [magick, "-density", str(dpi), str(pdf), "-trim", "+repage", str(out_path)]
_run_converter(command, timeout)
return Path(magick).name
raise FormulaRenderError(f"Unsupported formula output format: {fmt}")
def formula_image_fragment(
*,
formula_id: str,
image_path: str | Path,
tex_source: str | Path,
box_px: str | list[Any],
page_dir: str | Path | None = None,
z_index: int = 220,
alt: str | None = None,
) -> dict[str, Any]:
path_for_manifest = manifest_path(image_path, page_dir)
tex_for_manifest = manifest_path(tex_source, page_dir)
return {
"schema_version": 1,
"type": "latex-formula-image-fragment",
"images": [
{
"id": formula_id,
"path": path_for_manifest,
"box_px": parse_box_px(box_px),
"alt": alt or f"LaTeX rendered formula {formula_id}",
"z_index": z_index,
}
],
"asset_provenance": [
{
"path": path_for_manifest,
"source": tex_for_manifest,
"source_type": "latex-rendered-formula",
"provenance_note": "Rendered from LaTeX by editppt formula render-latex; visual fidelity is prioritized over formula editability.",
}
],
"formula_inventory": [
{
"id": formula_id,
"decision": "latex-rendered-image",
"editable": False,
"image": path_for_manifest,
"tex_source": tex_for_manifest,
}
],
}
def write_json(payload: dict[str, Any], path: str | Path) -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def resolve_output_path(out: str | Path, page_dir: str | Path | None = None) -> Path:
path = Path(out)
if not path.is_absolute() and page_dir:
path = Path(page_dir) / path
return path.resolve()
def normalise_format(output_format: str | None, out_path: Path) -> str:
fmt = (output_format or out_path.suffix.lstrip(".") or "svg").lower()
if fmt == "jpg":
fmt = "jpeg"
if fmt not in SUPPORTED_FORMATS:
raise FormulaRenderError(f"Unsupported formula output format: {fmt}. Use svg, png, or pdf.")
return fmt
def parse_box_px(value: str | list[Any]) -> list[float]:
parts = [part.strip() for part in value.split(",")] if isinstance(value, str) else list(value)
if len(parts) != 4:
raise FormulaRenderError("box_px must be x,y,width,height")
return [float(part) for part in parts]
def manifest_path(path: str | Path, page_dir: str | Path | None = None) -> str:
resolved = Path(path).resolve()
if page_dir:
root = Path(page_dir).resolve()
try:
return resolved.relative_to(root).as_posix()
except ValueError:
pass
return resolved.as_posix()
def _run_converter(command: list[str], timeout: int) -> None:
result = subprocess.run(command, text=True, capture_output=True, timeout=timeout)
if result.returncode != 0:
raise FormulaRenderError(
"Formula conversion failed: "
+ " ".join(command)
+ "\n"
+ "\n".join((result.stderr or result.stdout or "").splitlines()[-20:])
)
def _latex_error_message(result: subprocess.CompletedProcess[str]) -> str:
log = result.stdout or result.stderr or ""
tail = "\n".join(log.splitlines()[-30:])
return f"LaTeX render failed with exit code {result.returncode}.\n{tail}"
def _maybe_keep_workdir(workdir: Path, keep_workdir: str | Path | None) -> None:
if not keep_workdir:
return
target = Path(keep_workdir)
if target.exists():
shutil.rmtree(target)
shutil.copytree(workdir, target)
#!/usr/bin/env python3
import argparse
from pathlib import Path
from _page_artifacts import write_pair
def main():
parser = argparse.ArgumentParser(description="Create an origin/preview contact sheet for a page.")
parser.add_argument("page_dir")
parser.add_argument("--source", default="source.png")
parser.add_argument("--preview", default="preview.png")
parser.add_argument("--out", default="split_assets_contact.png")
args = parser.parse_args()
page_dir = Path(args.page_dir).resolve()
source = page_dir / args.source
preview = page_dir / args.preview
out = page_dir / args.out
if not source.exists():
raise SystemExit(f"Missing source image: {source}")
if not preview.exists():
raise SystemExit(f"Missing preview image: {preview}")
write_pair(source, preview, out)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Text hints backed by the PaddleOCR-VL cloud API (experimental).
Same contract as `text_hints.py`, with a different detector: the page is sent
to the PaddleOCR-VL service, only TEXT blocks (text / paragraph_title /
vision_footnote) are kept from the layout parsing result, and each block is
then re-measured locally with the ink metrics so the reported glyph height
and font size stay pixel-accurate. Output: `paddle_hints.json` plus a labeled
`paddle_hints.png` overlay on the source image.
Requires network access and a token in the PADDLE_OCR_TOKEN environment
variable (or --token). The built-in `editppt page hints` stays the offline
default; this script is the content-aware alternative.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
import requests
from PIL import Image
from page_text_metrics import contains_cjk, load_gray, measure_crop
from text_hints import attach_font_sizes, draw_overlay
JOB_URL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
DEFAULT_MODEL = "PaddleOCR-VL-1.6"
TEXT_LABELS = {"text", "paragraph_title", "vision_footnote"}
USAGE_NOTE = (
"ADVISORY ONLY: these measurements are a reference, and some text lines may be "
"missed or merged. Lines sharing a size_group are the same text level and must "
"use exactly one font size. When assembling the final PPT: keep same-level text "
"at identical font sizes (apply the group's size to similar lines, including "
"ones you add yourself), and fill in every text the detector missed from your "
"own reading of the source image. box_px values are source pixels usable "
"directly in text_boxes; font_pt is already chosen per line (CJK vs Latin). Add "
"\"font_size_source\": \"measured\" to boxes you size from these hints. "
"Recognition text may contain occasional wrong characters — trust your own "
"reading of the source for the final text content."
)
def submit_and_fetch(file_path: Path, token: str, model: str, timeout: int) -> list[dict]:
"""Submit an image or multi-page PDF; return one prunedResult per page."""
headers = {"Authorization": f"bearer {token}"}
optional = {"useDocOrientationClassify": False, "useDocUnwarping": False, "useChartRecognition": False}
with file_path.open("rb") as handle:
response = requests.post(
JOB_URL,
headers=headers,
data={"model": model, "optionalPayload": json.dumps(optional)},
files={"file": handle},
timeout=60,
)
if response.status_code != 200:
raise RuntimeError(f"PaddleOCR job submit failed ({response.status_code}): {response.text[:300]}")
job_id = response.json()["data"]["jobId"]
print(f"job submitted: {job_id}", file=sys.stderr)
started = time.time()
while True:
status = requests.get(f"{JOB_URL}/{job_id}", headers=headers, timeout=60).json()["data"]
state = status["state"]
if state == "done":
break
if state == "failed":
raise RuntimeError(f"PaddleOCR job failed: {status.get('errorMsg')}")
if time.time() - started > timeout:
raise RuntimeError(f"PaddleOCR job timed out after {timeout}s (state={state})")
time.sleep(5)
jsonl = requests.get(status["resultUrl"]["jsonUrl"], timeout=60)
jsonl.raise_for_status()
pages = []
for line in jsonl.text.strip().split("\n"):
if not line.strip():
continue
for result in json.loads(line)["result"]["layoutParsingResults"]:
pages.append(result["prunedResult"])
if not pages:
raise RuntimeError("PaddleOCR returned no pages")
return pages
def text_blocks_to_lines(pruned: dict, gray, min_glyph: int,
scale_x: float = 1.0, scale_y: float = 1.0) -> list[dict]:
"""Keep text blocks only and re-measure each with local ink metrics.
scale_x/scale_y map OCR-page coordinates onto the gray image when the OCR
service rendered the page at a different resolution than source.png
(typical for PDF inputs).
"""
height, width = gray.shape
lines = []
for block in pruned.get("parsing_res_list", []):
if block.get("block_label") not in TEXT_LABELS:
continue
text = str(block.get("block_content", "")).strip()
bx1, by1, bx2, by2 = (float(v) for v in block["block_bbox"])
x1, y1 = int(round(bx1 * scale_x)), int(round(by1 * scale_y))
x2, y2 = int(round(bx2 * scale_x)), int(round(by2 * scale_y))
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(width, x2), min(height, y2)
if x2 - x1 < 4 or y2 - y1 < 4:
continue
entry = {"text": text, "block_label": block.get("block_label")}
measured = measure_crop(gray[y1:y2, x1:x2], clean_border=False)
if measured["ok"] and measured["glyph_height_px"] >= min_glyph:
glyph = measured["glyph_height_px"]
bx, by, bw, bh = measured["bbox"]
pad_x = max(2, int(round(glyph * 0.35)))
pad_y = max(1, int(round(glyph * 0.30)))
left = max(0, x1 + bx - pad_x)
top = max(0, y1 + by - pad_y)
entry.update(
box_px=[left, top, min(width, x1 + bx + bw + pad_x) - left, min(height, y1 + by + bh + pad_y) - top],
glyph_height_px=round(glyph, 1),
line_count=measured["line_count"],
glyph_source="ink-measured",
)
else:
# Ink measurement failed (low contrast inside the block); fall
# back to the OCR block box and approximate the glyph from it.
line_count = max(1, text.count("\n") + 1)
entry.update(
box_px=[x1, y1, x2 - x1, y2 - y1],
glyph_height_px=round((y2 - y1) / (line_count * 1.3), 1),
line_count=line_count,
glyph_source="bbox-estimate",
)
lines.append(entry)
lines.sort(key=lambda line: (line["box_px"][1], line["box_px"][0]))
for index, line in enumerate(lines, 1):
line["id"] = f"P{index:02d}"
return lines
def build_page_hints(page_dir: Path, pruned: dict, source_name: str = "source.png",
min_glyph: int = 6) -> dict:
"""Turn one page's prunedResult into the hints payload for its page dir."""
source_path = page_dir / source_name
gray = load_gray(source_path)
height, width = gray.shape
scale_x = width / float(pruned["width"]) if pruned.get("width") else 1.0
scale_y = height / float(pruned["height"]) if pruned.get("height") else 1.0
lines = text_blocks_to_lines(pruned, gray, min_glyph, scale_x, scale_y)
manifest_like = {"source": {"width_px": width, "height_px": height}, "slide": {"width": 13.333, "height": 7.5}}
request_path = page_dir / "page_request.json"
if request_path.exists():
request = json.loads(request_path.read_text(encoding="utf-8"))
for key in ("slide", "content_box"):
if request.get(key):
manifest_like[key] = request[key]
attach_font_sizes(lines, manifest_like)
for line in lines:
line["font_pt"] = line["font_pt_if_cjk"] if contains_cjk(line["text"]) else line["font_pt_if_latin"]
return {
"schema_version": 1,
"backend": "paddleocr-vl",
"source": {"width_px": width, "height_px": height},
"lines": lines,
"note": USAGE_NOTE,
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Text hints via the PaddleOCR-VL cloud API: text blocks with content, re-measured locally for font sizes.",
)
parser.add_argument("page_dir", help="Page directory containing source.png.")
parser.add_argument("--source", default="source.png", help="Source image relative to the page directory.")
parser.add_argument("--out", default="paddle_hints.json", help="Hints JSON relative to the page directory.")
parser.add_argument("--overlay", default="paddle_hints.png", help="Labeled overlay image. Pass an empty string to skip.")
parser.add_argument("--token", default=os.environ.get("PADDLE_OCR_TOKEN", ""), help="API token; defaults to $PADDLE_OCR_TOKEN.")
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--timeout", type=int, default=300, help="Job polling timeout in seconds.")
parser.add_argument("--min-glyph", type=int, default=6)
args = parser.parse_args()
if not args.token:
raise SystemExit("Missing token: set PADDLE_OCR_TOKEN or pass --token.")
page_dir = Path(args.page_dir).expanduser().resolve()
source_path = page_dir / args.source
if not source_path.exists():
raise SystemExit(f"Missing source image: {source_path}")
started = time.time()
try:
pages = submit_and_fetch(source_path, args.token, args.model, args.timeout)
except RuntimeError as exc:
raise SystemExit(str(exc))
hints = build_page_hints(page_dir, pages[0], source_name=args.source, min_glyph=args.min_glyph)
hints["elapsed_seconds"] = round(time.time() - started, 1)
lines = hints["lines"]
out_path = page_dir / args.out
out_path.write_text(json.dumps(hints, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.overlay:
draw_overlay(Image.open(source_path), lines, page_dir / args.overlay)
hints["overlay"] = args.overlay
print(json.dumps({"lines": len(lines), "elapsed_seconds": hints["elapsed_seconds"],
"out": str(out_path)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
import argparse
import json
from collections import Counter, defaultdict
from deck_run_state import (
active_pages,
dispatch_slots_available,
dispatchable_pages,
load_jobs,
load_run_state,
max_concurrent_pages,
run_dir_from_target,
)
def main():
parser = argparse.ArgumentParser(description="Print page job status without modifying run state.")
parser.add_argument("run", help="Run directory or deck_manifest.json")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
run_dir = run_dir_from_target(args.run)
jobs = load_jobs(run_dir)
state = load_run_state(run_dir)
by_status = defaultdict(list)
for page in jobs.get("pages", []):
by_status[page.get("status", "unknown")].append(page.get("page_id"))
summary = {
"run_dir": str(run_dir),
"run_status": state.get("status"),
"page_count": len(jobs.get("pages", [])),
"max_concurrent_pages": max_concurrent_pages(jobs),
"active_dispatches": [page.get("page_id") for page in active_pages(jobs)],
"dispatch_slots_available": dispatch_slots_available(jobs),
"dispatchable_pages": [page.get("page_id") for page in dispatchable_pages(jobs)],
"counts": dict(Counter(page.get("status", "unknown") for page in jobs.get("pages", []))),
"pages": dict(sorted(by_status.items())),
}
if args.json:
print(json.dumps(summary, ensure_ascii=False, indent=2))
return
print(f"run_dir={summary['run_dir']}")
print(f"run_status={summary['run_status']}")
print(f"max_concurrent_pages={summary['max_concurrent_pages']}")
print(f"active_dispatches={', '.join(summary['active_dispatches']) if summary['active_dispatches'] else '-'}")
print(f"dispatch_slots_available={summary['dispatch_slots_available']}")
print(f"dispatchable_pages={', '.join(summary['dispatchable_pages']) if summary['dispatchable_pages'] else '-'}")
for status, pages in summary["pages"].items():
print(f"{status}: {', '.join(pages) if pages else '-'}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import argparse
from pathlib import Path
from PIL import Image
from deck_run_state import now_iso, read_json, rel_to_run, save_deck, set_run_status, sha256_file, write_json
from deck_run_state import DEFAULT_MAX_CONCURRENT_PAGES
from _input_normalization import normalize_inputs
WIDE_SLIDE = {"width": 13.333, "height": 7.5, "size_mode": "wide"}
PX_PER_INCH = 96
ASPECT_16_9 = 16 / 9
ASPECT_TOLERANCE = 0.03
def source_size(path):
with Image.open(path) as image:
return image.size
def is_close_to_wide(width_px, height_px):
if not width_px or not height_px:
return False
return abs((width_px / height_px) / ASPECT_16_9 - 1) <= ASPECT_TOLERANCE
def source_size_slide(width_px, height_px):
return {
"width": width_px / PX_PER_INCH,
"height": height_px / PX_PER_INCH,
"size_mode": "source",
"px_per_inch": PX_PER_INCH,
}
def slide_for_source(width_px, height_px):
if is_close_to_wide(width_px, height_px):
return dict(WIDE_SLIDE)
return source_size_slide(width_px, height_px)
def fit_content_box(width_px, height_px, slide):
slide_width = float(slide["width"])
slide_height = float(slide["height"])
source_aspect = width_px / height_px
slide_aspect = slide_width / slide_height
if source_aspect >= slide_aspect:
width = slide_width
height = width / source_aspect
left = 0
top = (slide_height - height) / 2
else:
height = slide_height
width = height * source_aspect
left = (slide_width - width) / 2
top = 0
return {"left": left, "top": top, "width": width, "height": height, "fit": "contain"}
def page_source_size(run_dir, page):
source = (run_dir / page["source_image"]).resolve()
width_px, height_px = source_size(source)
return source, width_px, height_px
def deck_slide_layout(run_dir, deck):
first_page = deck.get("pages", [{}])[0]
_source, width_px, height_px = page_source_size(run_dir, first_page)
return slide_for_source(width_px, height_px)
def page_request(run_dir, deck, page):
page_dir = (run_dir / page["page_dir"]).resolve()
source, width_px, height_px = page_source_size(run_dir, page)
slide = dict(deck["slide"])
page_id = page["page_id"]
return {
"schema_version": 1,
"run_id": deck["run_id"],
"page_id": page_id,
"page_index": page["page_index"],
"page_dir": str(page_dir),
"source_image": str(source),
"source_size_px": {"width": width_px, "height": height_px},
"slide": slide,
"content_box": fit_content_box(width_px, height_px, slide),
"max_concurrent_pages": deck["max_concurrent_pages"],
"allowed_write_scope": str(page_dir),
"forbidden_paths": [
str(run_dir / "deck_manifest.json"),
str(run_dir / "page_jobs.json"),
str(run_dir / "notes_manifest.json"),
str(run_dir / "final"),
str(run_dir / "input"),
],
"required_outputs": {
"manifest": str(page_dir / "manifest.json"),
"imagegen_jobs": str(page_dir / "imagegen-jobs.json"),
"page_pptx": str(page_dir / "page.pptx"),
"preview": str(page_dir / "preview.png"),
"contact_sheet": str(page_dir / "split_assets_contact.png"),
"validation": str(page_dir / "validation.json"),
"page_result": str(page_dir / "page_result.json"),
},
}
def write_page_jobs(run_dir, deck):
jobs = {
"schema_version": 1,
"run_id": deck["run_id"],
"run_status": "inputs_prepared",
"max_concurrent_pages": deck["max_concurrent_pages"],
"created_at": now_iso(),
"updated_at": now_iso(),
"pages": [],
}
for page in deck["pages"]:
page_dir = run_dir / page["page_dir"]
request_path = page_dir / "page_request.json"
request = page_request(run_dir, deck, page)
write_json(request_path, request)
write_json(
page_dir / "imagegen-jobs.json",
{"schema_version": 1, "run_id": deck["run_id"], "page_id": page["page_id"], "jobs": []},
)
jobs["pages"].append(
{
"page_id": page["page_id"],
"page_index": page["page_index"],
"status": "pending",
"page_dir": page["page_dir"],
"source": page["source_image"],
"page_request": rel_to_run(run_dir, request_path),
"manifest": page["manifest"],
"validation": page["validation"],
"dispatch": None,
"result": None,
"accepted": False,
}
)
write_json(run_dir / "page_jobs.json", jobs)
def upgrade_deck_manifest(deck_path, max_concurrent_pages):
run_dir = deck_path.parent
deck = read_json(deck_path)
run_id = run_dir.name
output_name = Path(deck.get("output", f"{run_id}_edited.pptx")).name
deck.update(
{
"schema_version": 1,
"run_id": run_id,
"prepared_at": now_iso(),
"output": f"final/{output_name}",
"page_jobs": "page_jobs.json",
"run_state": "run_state.json",
"max_concurrent_pages": max_concurrent_pages,
}
)
deck["slide"] = deck_slide_layout(run_dir, deck)
for index, page in enumerate(deck.get("pages", []), start=1):
page_id = f"page_{index:03d}"
page["page_id"] = page_id
page["status"] = "pending"
page["page_request"] = f"{page['page_dir']}/page_request.json"
page["dispatch"] = None
page["result"] = None
page["accepted"] = False
save_deck(run_dir, deck)
write_page_jobs(run_dir, deck)
set_run_status(run_dir, "inputs_prepared", "prepared inputs and page jobs")
return deck
def main():
parser = argparse.ArgumentParser(description="Create a stable image-to-editable-ppt run directory.")
parser.add_argument("inputs", nargs="+")
parser.add_argument("--out-root", default="output/image-to-editable-ppt")
parser.add_argument("--job-dir")
parser.add_argument("--dpi", type=int, default=180)
parser.add_argument(
"--max-concurrent-pages",
type=int,
default=DEFAULT_MAX_CONCURRENT_PAGES,
help=f"Maximum page subagents that may be dispatched at the same time. Default: {DEFAULT_MAX_CONCURRENT_PAGES}.",
)
args = parser.parse_args()
if args.max_concurrent_pages < 1:
raise SystemExit("--max-concurrent-pages must be >= 1")
deck_path = normalize_inputs(args.inputs, out_root=args.out_root, job_dir=args.job_dir, dpi=args.dpi)
run_dir = deck_path.parent
if not (run_dir / "pages").exists():
raise SystemExit(f"Input normalization did not create pages/: {run_dir}")
deck = upgrade_deck_manifest(deck_path, args.max_concurrent_pages)
print(deck_path)
print(f"run_id={deck['run_id']}")
print(f"pages={deck['page_count']}")
print(f"max_concurrent_pages={deck['max_concurrent_pages']}")
if __name__ == "__main__":
main()
Related skills
FAQ
What inputs does image-to-editable-ppt accept?
image-to-editable-ppt accepts single images, multiple slide images, scanned PDF decks, and image-based PPT or PPTX files, rebuilding them into object-level editable PowerPoint `.pptx` outputs.
Does image-to-editable-ppt create real editable objects?
image-to-editable-ppt produces object-level `.pptx` files with real text boxes, shapes, and layouts—not flat pasted images—so slides can be edited like a native PowerPoint deck.