Release standalone PPT template workflow 0.5.0
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Templated-PPT-Master
|
||||||
|
|
||||||
|
Source repository for the standalone Codex Skill `ppt-template-workflow`, version **0.5.0**. The installable Skill is this repository root: [SKILL.md](SKILL.md).
|
||||||
|
|
||||||
|
The Skill selects one external template and one theme at a time. It includes its own page mockup/material workflow, native object authoring and reconstruction guidance, native PPTX exporter, declared SVG paint processor, PowerPoint review guidance, and portable project tools. No separately installed Skill is required. Templates, themes, organization assets and default palettes remain outside this repository.
|
||||||
|
|
||||||
|
The installer prepares Python 3.12+, the nine packages in [runtime/requirements.txt](runtime/requirements.txt), and the fonts declared by the chosen template. Metadata/project/paint commands use Python 3.10+ and the standard library. ImageGen and PowerPoint checks use the host's capabilities when needed; application binaries and fonts are not distributed here.
|
||||||
|
|
||||||
|
Install only the root Skill. `runtime/ppt-master/SKILL.md` is preserved upstream attribution, not a second installation unit. The bundled compiler contains 280 unmodified MIT-licensed files; [runtime/provenance.json](runtime/provenance.json) records their hashes. See [runtime and provenance](references/runtime-and-provenance.md) for boundaries and licensing.
|
||||||
|
|
||||||
|
Start with [USAGE.md](USAGE.md). Preserve interactive page review when requested; portable projects do not grant batch authoring or automatic publishing permission. Track reviewed commits from this repository as the Codex configuration source. Installation and synchronization belong to the configuration owner, separately from source publication.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: ppt-template-workflow
|
||||||
|
description: Create and revise template-based editable PPTX decks, design page mockups and materials, reconstruct visual pages into native objects, and maintain portable source projects. Select one external template and theme at a time; includes its own native compiler and workflows without requiring another installed Skill.
|
||||||
|
metadata:
|
||||||
|
version: "0.5.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# PPT Template Workflow
|
||||||
|
|
||||||
|
Use **template → theme → layout** as versioned inputs. A catalog can contain multiple independent templates; activate one template and one theme at a time. This skill contains no organization, default palette, template identity or local library path. Templates and themes remain separate packages. Its native compiler, project tools and authoring guidance are bundled; no other installed Skill is required. The installer provides Python libraries and fonts. Image generation and application UI checks use host capabilities when the task needs them.
|
||||||
|
|
||||||
|
## Select and review
|
||||||
|
|
||||||
|
Read the supplied catalog or manifest with `scripts/template_ref.py`. For a foundation project, start with its README consumer links. If the task needs a template and no source is specified, ask once for the source instead of guessing a global library.
|
||||||
|
|
||||||
|
- `list --catalog <absolute path>` discovers candidates, including proposals, with their themes and layouts.
|
||||||
|
- `inspect --manifest <absolute path>` exposes one candidate's status and design references. Read its specification and choose based on audience, geometry, editability and user constraints. Reuse explicit choices; ask only for consequential decisions still missing.
|
||||||
|
- For v2, `export-style --manifest <absolute path> --theme <id> [--layout <id>] [--out <absolute path>]` exports complete shared style, palette, assets, asset bindings and actual paths/hashes for agent review and style alignment. It creates no slides and clearly reports proposal/approved/released status.
|
||||||
|
- `resolve --manifest <absolute path> --theme <id> --route native|image-rebuild --workflow interactive-page|batch [--layout <id>] [--out <absolute path>]` binds a released v2 template. Legacy v1 manifests use `--mode <id>` and retain their native PPTX requirement.
|
||||||
|
|
||||||
|
Read [the interface](references/template-interface.md) for manifest authoring, output fields, path roots and v1 migration. Both output commands protect existing files. Style export is allowed before release; production binding refuses incomplete or non-released packages. Do not rewrite status or invent artifacts to bypass this boundary.
|
||||||
|
|
||||||
|
## Apply the chosen template
|
||||||
|
|
||||||
|
Read the selected specification, shared style, theme and relevant layout. Shared style defines canvas, typography, grid and general rules; the theme supplies named palette entries, assets and declared fill/stroke bindings for parts of suitable icons. Preserve asset proportions and scientific meaning. Exact marks, evidence colors, labels, line styles, numbers and formulas change only as explicitly permitted by the template and the user's content requirements.
|
||||||
|
|
||||||
|
For SVG assets with `paint_contract`, read the exported `paint_contract_data` before preparing materials. Preserve all element/channel pairs by default; recolor only the contract's `palette` whitelist through the selected theme bindings. Keep `fixed` literals and `structural: none`, including white details and transparent openings. Multiple tokens can distinguish adjustable parts; repeated tokens express a shared semantic paint role. Check stable SVG IDs and original fill/stroke values before applying changes. Never replace colors across an entire image or infer undeclared permissions from a matching color. Stop the affected change when IDs, inherited paint or policies conflict; report the unresolved pair instead of guessing. See [the paint contract](references/template-interface.md#svg-paint-contract) and [material handoff](references/authoring-handoff.md#icon-material-handoff).
|
||||||
|
|
||||||
|
Use the resolved workspace or native template path. v2 can provide a complete native authoring workspace with `native_template: null`; generate from its SVG sources and full layout roster through the bundled compiler. A manifest or style export does not replace the workspace's source objects.
|
||||||
|
|
||||||
|
Keep the user's working style above downstream defaults. For `interactive-page`, finish and review the current page before advancing; page count is not batch permission. Reuse settled route, theme and backend decisions. Explain an actual conflict with its source before continuing.
|
||||||
|
|
||||||
|
- **Native:** read [native authoring](references/native-authoring.md). Author the current SVG page against its declared layout and compile it with `scripts/native_export.py`, retaining Master, Layout and placeholder identities. Avoid slide-local copies that cover inherited objects.
|
||||||
|
- **Page mockups and materials:** read [visual design and materials](references/visual-design.md). Use the host image-generation tool for requested raster mockups, illustrations and image edits; keep exact supplied evidence and identity assets separate from style references. Use existing vectors and the bundled `scripts/paint_asset.py` for declared SVG paint changes.
|
||||||
|
- **Image-rebuild:** read [object reconstruction](references/object-reconstruction.md). Inventory the current page, separate background and foreground sources, then author native text and structural objects in the selected SVG layout. Use source material or host image editing where needed; an image assembly is not editable completion.
|
||||||
|
|
||||||
|
Read [authoring handoff](references/authoring-handoff.md) for shared page inputs, icon roles and theme changes. Use [PowerPoint checks](references/powerpoint-checks.md) when application opening, rendering or actual editing evidence is needed. If a required host capability is unavailable, finish independent work and name that specific evidence gap; do not substitute an old export or change the selected backend silently.
|
||||||
|
|
||||||
|
## Keep a portable source project
|
||||||
|
|
||||||
|
When the user needs version control, collaboration or rebuilding after moving devices, read [project workspace](references/project-workspace.md) and use `scripts/ppt_project.py`. Keep stable page IDs, page sources/content/notes, data, assets, the selected template snapshot and an explicit local builder together. Declare the builder's actual dependencies and fonts in the environment metadata; this skill does not install them.
|
||||||
|
|
||||||
|
`init` creates an empty source skeleton with no working builder. For finalized native SVG pages, `scripts/prepare_native_project.py --project <path>` adds a project-local compiler, builder and environment declaration to a clean skeleton without overwriting custom builders or accepting source changes. Review the changes and explicitly `lock`; add real pages before claiming a usable deck project. The bundled builder reads finalized SVGs and notes; content/data bindings belong to the author and are not inferred.
|
||||||
|
|
||||||
|
`check` validates portable paths and separate template/author locks without executing the builder; `lock` accepts intentional author changes only. `build` regenerates local receipts, invokes the declared project Python entrypoint and publishes a newly validated PPTX. `pack` carries locked source inputs without old exports or machine-local receipts. Rebuild in a relocated copy and check an intentional source/content change before claiming portability; distinguish same-device relocation from another device's rendering acceptance.
|
||||||
|
|
||||||
|
Preserve the user's page-by-page workflow. Projectization does not authorize batch creation, Git initialization, commits, publishing or global tool installation.
|
||||||
|
|
||||||
|
## Verify and report
|
||||||
|
|
||||||
|
Check the rendered current page and actual editable objects. Distinguish editable text/shapes/tables/charts/formulas, picture exceptions and inherited placeholders. Confirm native structure separately from appearance. Read [runtime and provenance](references/runtime-and-provenance.md) for dependencies, bundled compiler integrity, supported source boundaries and destination font checks.
|
||||||
|
|
||||||
|
Deliver the PPTX with its selection record and a compact account of template version, theme, object exceptions, structural evidence, rendering and application checks. XML, screenshots and successful export alone do not prove application editing or projection acceptance.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Changes
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
Bundle self-contained page/material design, reconstruction, native export and PowerPoint review guidance. Include the unchanged compiler snapshot and dependency/provenance metadata. Add native export, font identity preservation, declared SVG paint materialization, and explicit portable-project preparation. External templates/themes remain separate. Twenty-one isolated source, export, boundary, paint, font and relocation checks passed; these do not establish application editing or other-device rendering.
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
Add portable source projects with separate template and author locks, stable page IDs, declared build inputs, environment metadata, build receipts and source-only packing. Initialization remains an empty skeleton. Existing v1/v2 selection interfaces remain supported.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Use the standalone Skill
|
||||||
|
|
||||||
|
Read [SKILL.md](SKILL.md), then explicitly select the supplied external template manifest and theme. Use the absolute Skill location; no installed companion Skill or original machine path is required.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /path/to/skill/scripts/template_ref.py export-style \
|
||||||
|
--manifest /path/to/template/manifest.json --theme THEME --layout LAYOUT
|
||||||
|
python3 /path/to/skill/scripts/template_ref.py resolve \
|
||||||
|
--manifest /path/to/template/manifest.json --theme THEME --layout LAYOUT \
|
||||||
|
--route native --workflow interactive-page
|
||||||
|
python3 /path/to/skill/scripts/native_export.py \
|
||||||
|
--manifest /path/to/template/manifest.json --theme THEME \
|
||||||
|
--source /path/to/project/pages/page.svg --source-root /path/to/project \
|
||||||
|
--output /path/to/project/exports/review.pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
The native exporter accepts released v2 templates, preserves the complete layout roster, and protects existing output. Prepare reviewed page SVGs using the template's slots, typography, grid and paint rules. Inspect actual rendering separately from native structure. See [native authoring](references/native-authoring.md), [PowerPoint checks](references/powerpoint-checks.md), and [project workspace](references/project-workspace.md).
|
||||||
|
|
||||||
|
For a portable project, initialize an honest empty skeleton with `ppt_project.py init`. `prepare_native_project.py --project /path/to/project` explicitly copies a local builder/runtime into a clean skeleton. It neither installs dependencies nor updates the author lock automatically. The supplied builder consumes finalized SVGs and notes; data-driven page generation requires the author's explicit binding and build step. Provision the declared dependencies/fonts and verify a relocated rebuild before claiming portability.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "PPT 模板与原生制作"
|
||||||
|
short_description: "选择独立模板与主题,逐页设计、制作可编辑PPT,并保存可移动源码工程"
|
||||||
|
default_prompt: "使用 $ppt-template-workflow 读取我提供的模板目录,选择一个模板与主题,按逐页方式设计和制作可编辑 PPT,并使用包内工具保存可重建工程。"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Authoring handoff
|
||||||
|
|
||||||
|
Give every authoring step the same selected template ID/version, theme, layout, exported style, source specification, content and notes, strict input assets, approved visual references, editability policy and input hashes. These are local project inputs; reading another installed Skill is unnecessary.
|
||||||
|
|
||||||
|
For the current page, decide the message and evidence first, then compose the page within the selected layout. A mockup image establishes visual direction; the editable deliverable is authored from independent objects. Reuse approved decisions and the user's page-by-page workflow. A known page count does not authorize creating the remaining pages in bulk.
|
||||||
|
|
||||||
|
Native compilation uses the separate template's complete workspace roster, retaining Master, Layout and placeholder links. The final source page identifies its intended layout with `data-pptx-layout`. A whole-slide picture, selectable SVG picture or generic text reconstruction does not establish those links by itself. See [native authoring](native-authoring.md) and [object reconstruction](object-reconstruction.md).
|
||||||
|
|
||||||
|
Native formulas, tables and charts need their own object checks. Bitmap or SVG formula assets remain pictures even when individually selectable. Exact logos, charts and scientific figures keep their originals and evidence meanings. If a specialized object cannot be reconstructed faithfully, record its picture exception and source rather than claiming native editability.
|
||||||
|
|
||||||
|
This package carries no template, theme or global style library. Installation and source synchronization belong to the installation owner.
|
||||||
|
|
||||||
|
A theme switch changes only declared palette and asset properties. Shared canvas, typography, geometry, layout/slot identities and content meaning remain fixed unless the template explicitly defines an exception. Rebuild and inspect affected pages after a switch. Recompilation sets the PowerPoint theme palette; it does not retroactively recolor undeclared source literals.
|
||||||
|
|
||||||
|
## Icon material handoff
|
||||||
|
|
||||||
|
Supply the original SVG, its `paint_contract_data`, selected palette and exact asset bindings together. New vector materials use stable element IDs and adjustable fill/stroke pairs with semantic tokens. Distinct tokens express independent roles; a shared token expresses a shared role. Keep fixed details as literal values and unpainted openings as `structural: none`. Undeclared channels preserve their source paint.
|
||||||
|
|
||||||
|
`scripts/paint_asset.py --manifest <path> --theme <id> --asset <role> --output <new.svg>` applies only the selected theme's palette whitelist. It verifies exact IDs, explicit source paint, fixed/structural values and bindings, then protects existing output. It intentionally rejects stylesheets, overridden target channels and inherited target paint; flatten those channels into explicit reviewed attributes before using this helper. Other SVG authoring tools may support richer materials if they preserve the same contract.
|
||||||
|
|
||||||
|
Do not replace colors across a whole image or infer roles from matching colors. Keep geometry, opacity, masks, paint order, fixed colors and `none` intact. Raster materials with independent color roles require layers or reviewed per-role alpha masks; a flat PNG's colors do not grant recoloring permission. Preserve original assets and record derivatives separately.
|
||||||
|
|
||||||
|
Render the derived material on its intended background and compare it with the original. Structural gaps and fixed details must remain visible. Path/JSON/hash validation, SVG transformation, rendering, native structure and application editing are distinct evidence levels.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Native page authoring
|
||||||
|
|
||||||
|
Read the selected external template's specification, style, theme and relevant layout first. The bundled compiler accepts the native SVG workspace convention: `templates/roster.json` maps every manifest layout ID to one SVG; each source SVG declares `data-pptx-master`, `data-pptx-layout` and friendly names. Layout and master objects use their declared `data-pptx-layer`. Placeholder groups carry `data-pptx-placeholder`, a stable `data-pptx-idx`, and `data-pptx-bounds`.
|
||||||
|
|
||||||
|
Start the current page from its selected layout source. Retain inheritance metadata, inherited objects and placeholder indices. Fill the declared placeholder carrier; put independent content objects in the intended slide layer. Do not paint opaque slide-local copies over the master logo or layout watermarks. When a template supplies a custom composition generator, inspect it and use its declared content interface rather than approximating its source geometry.
|
||||||
|
|
||||||
|
Use the template's discrete typography, grid, fixed content region and spacing. Resolve overflow by choosing a suitable layout, shortening content or dividing the page according to the user's workflow. Do not infer ad hoc font sizes and alignment guides from mockup pixels. Keep text as text, simple panels and connectors as geometry, and exact image exceptions as independent assets. Tables, charts and formulas must retain their required native object type; a collection of lines/text or a formula picture does not automatically qualify.
|
||||||
|
|
||||||
|
## Current-page export
|
||||||
|
|
||||||
|
The installer provides Python 3.12+ and [runtime requirements](../runtime/requirements.txt). Metadata-only selection and project commands use Python 3.10+ without third-party packages.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /path/to/skill/scripts/native_export.py \
|
||||||
|
--manifest /path/to/separate-template/manifest.json \
|
||||||
|
--theme chosen-theme \
|
||||||
|
--source /path/to/current-page.svg \
|
||||||
|
--output /path/to/new-review.pptx \
|
||||||
|
--notes /path/to/notes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`--source` may repeat to assemble explicitly authorized, already authored pages in order. It does not create or design additional pages. Source filename stems must be unique. `--notes` is optional JSON mapping source stems to complete speaker-notes strings; unknown stems are rejected.
|
||||||
|
|
||||||
|
The exporter consumes released v2 manifests and retains all native layout definitions, even for a single visible page. It requires a complete layout roster, unique layout names, declared `page_title`/`body` family roles and sizes, and the twelve PowerPoint theme color indices. Optional `style.language` supplies the presentation's BCP-47 language tag. Without it, the exporter preserves the compiler's legacy language metadata behavior; record the deck language in the project when it matters.
|
||||||
|
|
||||||
|
Page SVG viewBox and layout ID must match the selected template. Source assets are only relative local PNG/JPEG references or embedded PNG/JPEG data. Fragment references are supported; external images, active SVG elements, document entities and external CSS URLs are refused. Default author resource boundary is each SVG's directory; template-internal sources use the template package root. For portable project pages that intentionally reference `../../assets/...`, pass `--source-root /path/to/project`. Boundaries and symlink checks apply before compilation. Other visual formats should be prepared as native geometry or explicit supported bitmap assets.
|
||||||
|
|
||||||
|
The exporter embeds images in temporary source copies, preserving canonical source bytes. It runs the bundled attribution guard, serial compiler and declared-font preservation context, then applies the external palette/theme fonts and verifies slide/Layout/Master links, placeholders and supplied notes. Existing output is protected, including a concurrent writer appearing during compilation. A failed build does not replace old output.
|
||||||
|
|
||||||
|
## Template authors and review prototypes
|
||||||
|
|
||||||
|
Keep proposed styles and mockups outside the final template workspace until the user's design review allows formal construction. A proposal manifest cannot be consumed as a released production template. After approval, author the common style, independent themes, roster and native SVG layout definitions together; compare each theme against the same geometry and slot contract.
|
||||||
|
|
||||||
|
The bundled runtime also carries the upstream preview compiler at `runtime/ppt-master/scripts/template_preview_pptx.py` for author review workspaces. It is source tooling, not a second Skill. Read its command help for a new workspace. Its platform font mapping is general-purpose: a template review wrapper should import the compiler and use `scripts/finalize_theme.py`'s `preserve_declared_font_families(style)` context around the serial compile, then `finalize(...)`. This preserves the declared family before information can be lost to platform fallback. Do not globally replace a fallback font in finished XML: the author may have intentionally used that same font.
|
||||||
|
|
||||||
|
For advanced native table/chart/formula or placeholder metadata, inspect the corresponding bundled compiler module and its local CLI help. Do not claim specialized objects from a screenshot or from a successful ZIP export alone. Finish with [render and application checks](powerpoint-checks.md).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Reconstruct one visual page into editable objects
|
||||||
|
|
||||||
|
Treat the current visual page as a design reference with explicit source fidelity requirements. Determine the background and asset boundaries before writing native text over it; otherwise old text and newly editable text can coexist invisibly in a background picture.
|
||||||
|
|
||||||
|
Inventory the page: canvas, fixed UI, text levels, content groups, foreground images/icons, simple geometry/connectors, tables/charts, formulas, decorative patterns and intended layout slots. Keep every text value, evidence value and source asset tied to the user-provided content. Record each object's target type and source.
|
||||||
|
|
||||||
|
## Background and materials
|
||||||
|
|
||||||
|
Solid backgrounds, ordinary panels, divider bands, repeated geometric patterns, axes and simple connectors can be authored as native geometry. Reuse a clean image region when it contains none of the foreground that will be rebuilt. For a complex illustration/photo background obscured by replaceable text or objects, use the host image editor to remove only those objects while preserving scene identity, camera, composition, lighting and texture. Inspect the repaired result for invented content and smearing.
|
||||||
|
|
||||||
|
Keep exact supplied logos, figures and screenshots as independent source assets. Use existing vector or layered assets when available. When visual identity depends on an inseparable bitmap illustration, isolate it through image editing or retain a disclosed image exception, according to the user's editability requirements. Do not replace it with an unrelated icon, approximate illustration or whole-page crop without authorization.
|
||||||
|
|
||||||
|
## Native reconstruction
|
||||||
|
|
||||||
|
Start from the selected template layout source, retaining its Master/Layout/placeholder metadata. Recreate normal text as native text with the external style's font family, discrete size, line spacing and alignment. Build simple panels, rules, arrows and diagram nodes as separate structural objects. Fit to the declared grid, not pixel-by-pixel ad hoc anchors.
|
||||||
|
|
||||||
|
Use actual data for native charts; if source values are unavailable, preserve the chart as an evidence image and say why. A native table uses native cells; native formulas require actual editable equation objects where the chosen compiler supports them. A formula bitmap, SVG picture or vector outline is still a picture/path exception. Preserve formula meaning and verify symbols against the source before conversion.
|
||||||
|
|
||||||
|
Preserve crop, aspect ratio and source meaning for image objects. Independently editable text over an unchanged full-page screenshot is not a reconstructed page. Keep repeated UI in inherited layers, rather than covering it with copied slide objects.
|
||||||
|
|
||||||
|
Compile the current SVG through [native export](native-authoring.md), then compare the actual rendered page with the accepted design at full size and a normal presentation view. Check hierarchy, density, line breaks, safe margins, alignment, clipping, asset identity and reading order. Native object checks establish object type and inheritance; they do not prove appearance. Complete the requested application checks before claiming editability acceptance.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# PowerPoint application evidence
|
||||||
|
|
||||||
|
Use the host's supported application UI tool, such as CUA, for PowerPoint operations. Initialize it with the documented app/browser entrypoint, read the returned documentation, and operate only through supported APIs. After a resumed computer-use summary, reload that tool's documentation before acting. Do not substitute shell scripting or hidden application automation for UI evidence.
|
||||||
|
|
||||||
|
Open the current generated file, verify the visible filename and page thumbnails, and inspect whether PowerPoint shows a repair/recovery warning. Work on task-owned copies and preserve the user's unrelated open presentations. Do not dismiss a repair prompt as successful opening; retain the diagnostic and repair the authored source.
|
||||||
|
|
||||||
|
For rendered evidence, export this file to PDF through PowerPoint's own export dialog using the intended quality setting and a task-owned destination. Check the export exists and belongs to the current file, render its pages and inspect the images. Compare page geometry, text wrapping, typography, images, transparent edges, alignment and separators. Confirm actual PDF font identities when font rendering is disputed; a declared PowerPoint face alone does not prove which font the application rendered.
|
||||||
|
|
||||||
|
Opening and exporting are distinct from actual editing. When editing evidence is requested, use a disposable task-owned copy: select a representative text/shape/placeholder, change it through the UI, save or export, and verify the resulting independent object/content change. Check an appropriate inherited object separately if template behavior is in scope. Do not alter the authoritative source project or a user's finished original merely to demonstrate editing.
|
||||||
|
|
||||||
|
Report the evidence actually obtained: source/reference validation, native structure and object types, current-page rendering, PowerPoint opening, actual object editing, or projection readability. A successful export cannot establish every later level. If the host application or UI capability is unavailable, deliver the validated source and PPTX with that specific unresolved application check.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 可移动的 PPT 消费工程
|
||||||
|
|
||||||
|
接口版本:`ppt-project/v1`。内容日期:2026-10-10。实现为 [ppt_project.py](../scripts/ppt_project.py),基础模板接口仍以 [template-interface.md](template-interface.md) 为准。本接口不改变 `template_ref.py`,不安装全局工具,不初始化 Git,不提交或发布。
|
||||||
|
|
||||||
|
工程把正文、页面源、数据、素材、模板源、必要工具和依赖说明放在同一目录。Git 可追踪这些输入;设备相关绝对路径、缓存和导出物在运行时重建。一次工程固定一个 template 版本和一个 theme,页面顺序由 `pages` 数组表达,重排页面无需更换页面 ID。
|
||||||
|
|
||||||
|
## 初始化与作者工作流
|
||||||
|
|
||||||
|
解析和工程管理要求 Python 3.10+,只用标准库;实际 builder 的依赖由工程自行声明。消费方明确逐页制作时,保留 `interactive-page`,不要因建立工程而自动批量创作。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /path/to/skill/scripts/ppt_project.py init \
|
||||||
|
--project /path/to/report \
|
||||||
|
--manifest /path/to/released-template/manifest.json \
|
||||||
|
--theme chosen-theme --title '报告标题' --project-id example-report \
|
||||||
|
--route native --workflow interactive-page
|
||||||
|
```
|
||||||
|
|
||||||
|
`--project` 对六个命令均必需。`init` 另要求 `--manifest`、`--theme`、`--title`;`--project-id` 默认生成合法 UUID,显式值为小写字母/数字及单连字符组成的 ID。路线默认为 `native`,另支持 `image-rebuild`;工作流默认为 `interactive-page`,另支持 `batch`。统一的 `--theme` 在消费 v1 清单时映射为其 `mode`,不改变基础解析器的 v1 CLI。
|
||||||
|
|
||||||
|
目标必须不存在或为空目录。初始化在相邻临时目录完成验证后整体重命名,失败不会把半成品合并入已有目录;非空目标保留。模板包根目录为选中 manifest 的父目录,按相对路径复制到 `vendor/template/`,排除所有 `exports/`、缓存、虚拟环境、Git 和本地配置。遇到复制范围内的 symlink 拒绝且不跟随。源清单一般命名为 `manifest.json`;选择其他文件名时保留原文件并添加相同字节的 `manifest.json`,有冲突则拒绝。
|
||||||
|
|
||||||
|
初始工程是诚实骨架:没有页面,`build.entrypoint` 为 `null`。`check` 可以通过并返回 `build_ready: false`;`build` 明确拒绝,不生成占位 PPTX。作者加入页面、数据与真实 builder 后执行:
|
||||||
|
|
||||||
|
采用本包原生编译器时,可先运行 `python3 /path/to/skill/scripts/prepare_native_project.py --project /path/to/report`。它只接受没有 builder、依赖/字体/工具声明为空的已锁定骨架,复制项目内 compiler/wrapper,写入 `tools/native_builder.py` 和环境声明,并保留旧作者锁。审阅这些改动后显式 `lock`;不覆盖已有 builder 或已配置环境,不改变工程 schema 1。完整行为见 [运行库与出处](runtime-and-provenance.md)。
|
||||||
|
|
||||||
|
该通用 builder 读取各页已完成的 SVG 和 Markdown 讲稿;`content.json`/`data` 仍是锁定作者输入,不会自动绑定到 SVG。需要数据驱动生成的消费方应提供自己的明确绑定或预处理步骤。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/ppt_project.py lock --project .
|
||||||
|
python3 tools/ppt_project.py check --project .
|
||||||
|
python3 tools/ppt_project.py resolve --project .
|
||||||
|
python3 tools/ppt_project.py build --project .
|
||||||
|
python3 tools/ppt_project.py pack --project . --out /outside/report-source.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolve` 是可选的制作准备步骤,`build` 会自动执行。`check` 只读且不运行 builder;`resolve` 只生成运行时记录且不运行 builder。所有命令输出 JSON,输入错误返回退出码 2 和 stderr JSON。初始化和移动工程时均不复用另一台机器的选择记录。
|
||||||
|
|
||||||
|
## 目录与字段
|
||||||
|
|
||||||
|
```text
|
||||||
|
report/
|
||||||
|
project.json
|
||||||
|
project.lock.json
|
||||||
|
template.lock.json
|
||||||
|
environment.json
|
||||||
|
README.md
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
pages/<stable-id>/source.svg
|
||||||
|
pages/<stable-id>/content.json
|
||||||
|
pages/<stable-id>/notes.md
|
||||||
|
data/
|
||||||
|
assets/
|
||||||
|
tools/template_ref.py
|
||||||
|
tools/ppt_project.py
|
||||||
|
tools/build_deck.py # 作者提供并声明的可选入口
|
||||||
|
vendor/template/manifest.json
|
||||||
|
build/local/ # 可丢弃的本机绝对路径记录
|
||||||
|
build/tmp/ # 本次唯一临时产物
|
||||||
|
exports/deck.pptx # 可丢弃的构建结果
|
||||||
|
```
|
||||||
|
|
||||||
|
`project.json` 的固定基础字段如下;示例中页面和 builder 由作者加入,初始值为 `pages: []`、`entrypoint: null`。`data`、`assets`、`inputs` 即便为空也保留数组。所有路径字段为相对于工程根的 POSIX 路径。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_id": "example-report",
|
||||||
|
"title": "报告标题",
|
||||||
|
"template": {
|
||||||
|
"manifest": "vendor/template/manifest.json",
|
||||||
|
"theme": "chosen-theme",
|
||||||
|
"lock": "template.lock.json"
|
||||||
|
},
|
||||||
|
"authoring": {"route": "native", "workflow": "interactive-page"},
|
||||||
|
"environment": "environment.json",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "opening",
|
||||||
|
"layout": "declared-layout-id",
|
||||||
|
"source": "pages/opening/source.svg",
|
||||||
|
"content": "pages/opening/content.json",
|
||||||
|
"notes": "pages/opening/notes.md",
|
||||||
|
"data": ["data/observations.json"],
|
||||||
|
"assets": ["assets/figure.svg"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"entrypoint": "tools/build_deck.py",
|
||||||
|
"output": "exports/deck.pptx",
|
||||||
|
"inputs": ["tools/helper.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
页面 ID 必须稳定且唯一,`layout` 必须在模板 manifest 中声明。三项页面文件名固定为 `pages/<id>/source.svg`、`content.json`、`notes.md`。数据文件可用 JSON 数组等合法 JSON 值;页面内容结构由 builder 定义。工具拒绝重复 JSON 键和非标准 NaN/Infinity。
|
||||||
|
|
||||||
|
声明文件必须存在。路径字段拒绝绝对路径、`..`、反斜杠、URL/盘符形式以及 symlink,包括仍指向工程内部的 symlink。SVG `href` 和 CSS `url(...)` 的本地资源、Markdown 行内及引用式链接递归纳入作者输入;相对于源文件解析,允许 `../../assets/...` 等仍在工程内的引用,拒绝逃逸、绝对路径和缺文件。SVG 允许 `#id` 与内嵌 `data:`;Markdown 允许 HTTP(S)/mailto 来源链接和 `#anchor`。这不是完整的 HTML/JavaScript/Python 依赖分析器:builder 导入的辅助文件、动态读取的数据和自定义格式资源仍须在 `build.inputs`、页面 `data`/`assets` 中显式声明。
|
||||||
|
|
||||||
|
`vendor/` 下的第三方 Markdown 说明作为固定字节锁定,不递归跟随其工作流或参考文档链接;裁剪后的编译器快照可以保留完整归属说明而无需携带无关文档库。实际构建所需文件仍须显式列入 `build.inputs`。工程 README、页面讲稿及其本地引用继续检查;此例外不豁免 SVG 素材引用。
|
||||||
|
|
||||||
|
`environment.json` 初始内容为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"python": {"minimum": "3.10"},
|
||||||
|
"packages": {},
|
||||||
|
"fonts": [],
|
||||||
|
"tools": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
作者应补充实际 Python/包版本、可重建的安装说明、外部工具与字体名称、版本、来源及许可。`check` 校验此文件的基本结构、存在与锁定,不探测所有平台的字体和工具,不自动下载或安装依赖。字体不会因写入清单而获得分发许可;工程只纳入确有许可的字体文件,并显式列入输入。设备设置写入被忽略的 `environment.local.json`,不要把用户名、绝对路径、凭据、私有配置写入受版本管理的清单。
|
||||||
|
|
||||||
|
## 两层锁与协作
|
||||||
|
|
||||||
|
`template.lock.json` 使用 `schema_version: 1`,包含 `template_id`、`version`、`theme`、工程相对 `manifest` 和有序 `inputs`。每个 input 为 `{"path": "vendor/template/...", "sha256": "..."}`。复用模板解析器验证 released 状态、路线、工作区和选择后,锁定全部复制的模板源,包括未激活 theme;不把 review `exports/` 当源。模板新增、删除、字节变化,或改变 template/theme 选择均使验证失败。
|
||||||
|
|
||||||
|
`project.lock.json` 使用 `schema_version: 1`、`project_id` 和相同形式的有序 `inputs`,包括 `project.json`、环境说明、页面 source/content/notes/data/assets、本地资源闭包、builder 与 `build.inputs`、随包两脚本,以及 README/Git 元数据。运行输出不进入锁。
|
||||||
|
|
||||||
|
`lock` 先验证模板锁未变,再校验作者输入和引用,原子更新作者锁。它不会修改模板锁、自动接受升级或掩盖缺文件。模板需要升级时,新建显式选择新版本的工程,审阅接口变化后迁移作者输入;不要通过反复 `lock` 接受发布模板漂移。
|
||||||
|
|
||||||
|
协作者一起审阅 source、data、project 与锁差异。多人编辑不同稳定页面时仍由工程负责人整合 `project.json` 页序与锁;不要把生成的绝对路径收据作为可移植状态提交。生成的 `.gitignore` 忽略 `build/`、`exports/`、`.venv/`、`node_modules/`、`__pycache__/`、`*.pyc` 和 `environment.local.json`,不忽略正文、数据、素材或讲稿;`.gitattributes` 为 JSON/Python/Markdown/SVG/CSV/TSV 声明 UTF-8/LF,为 PPTX/PDF/PNG 等声明二进制。
|
||||||
|
|
||||||
|
## 构建与移动重建
|
||||||
|
|
||||||
|
`resolve` 先 `check`,在 `build/local/` 写入本次唯一 ID 的 selection/style JSON。每页使用对应 layout;空骨架生成工程级记录。记录的绝对路径只对当前工程位置有效,v1 模板仅生成 selection。它们不进入 Git 或 pack。
|
||||||
|
|
||||||
|
builder 必须是工程内 `.py` 文件。执行前审阅入口代码及依赖;`build` 意味着运行作者声明的工程 Python 代码,不是沙箱。工具先验证锁并生成新收据,再使用当前 `sys.executable`、`shell=False`、工程根为 cwd,固定调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python /absolute/project/tools/build_deck.py \
|
||||||
|
--project /absolute/project \
|
||||||
|
--output /absolute/project/build/tmp/<new-unique-id>.pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
入口只能通过固定的 `--project`、`--output` 接收工程位置与本次输出位置,不接受 shell 字符串配置;退出 0 后必须实际产生新文件,最长运行 600 秒。工具检查 ZIP 成员、CRC、必要 XML 根和 slide 数量/presentation 页表,重新验证输入锁,然后原子替换 `build.output`。输出必须位于 `exports/` 且扩展名为 `.pptx`。builder 失败、超时、缺输出、无效 ZIP 或输入被修改时,旧产物保留;不能用已有 exports 推断本次成功。成功 JSON 返回本次输出的 SHA-256。
|
||||||
|
|
||||||
|
`pack --out` 必需,必须是工程外 `.zip` 路径。既有 ZIP 或同名 `.zip.sha256.json` 均拒绝覆盖。包仅包含已锁作者输入、模板源、两层锁和必要元数据;未声明文件、`build`、`exports`、Git、虚拟环境、缓存及本地配置不进入包。固定路径排序、ZIP 时间与权限,同一输入在同一压缩环境下得到相同 ZIP 字节;不同 Python/zlib 版本未保证逐字节压缩结果相同。另写 JSON SHA-256 收据,收据 archive 字段只存文件名。
|
||||||
|
|
||||||
|
移动整个源工程或解压 ZIP 后,先配置 `environment.json` 记录的依赖与有许可的字体,再运行工程随包的 `tools/ppt_project.py check` 和 `build`。不需要访问原模板目录、原 Skill 安装路径、原工作区或旧 exports。缺少外部工具时,由 destination 环境提供,不用旧输出冒充重建。
|
||||||
|
|
||||||
|
## 证据与限制
|
||||||
|
|
||||||
|
维护仓库的工程专项检查使用合成 released 模板与两页工程,覆盖锁定、路径/资源/symlink 拒绝、作者及模板漂移、失败保留旧产物、确定性源码包、缓存排除,并在原路径不存在时用解压后的工具实际生成新的基本 PPTX;修改数据后再次构建并检查 slide XML 的新数据。Skill 0.5 的独立编译器检查另覆盖原生导出、字体身份、素材边界、项目内 runtime 和移动重建;安装包不要求维护仓库的检查脚本在运行时存在。
|
||||||
|
|
||||||
|
这些证据证明本机新路径下的源码可移动与构建接口行为,不代表已在另一台物理设备执行,也不代替渲染、字体一致性、完整 OOXML 关系图、PowerPoint 打开/实际编辑或投影可读性验收。按实际作者路线另外完成原生结构、逐页渲染和产品验收。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Runtime, portability and provenance
|
||||||
|
|
||||||
|
This Skill contains selection/project tools, native authoring wrappers, and an unmodified compiler snapshot. It does not require another installed Skill, its home directory, or an original machine path. Templates/themes stay external and are supplied through explicit manifests. Host image generation and supported PowerPoint UI remain host capabilities, used only for work that needs them.
|
||||||
|
|
||||||
|
The native route requires Python 3.12+ and the nine pinned packages in [requirements.txt](../runtime/requirements.txt). The installer prepares that environment; this Skill does not install globally or ship Python, wheels, application binaries or fonts. Metadata-only selection/project/paint commands use Python 3.10+ and the standard library. A missing native dependency produces an actionable error and preserves existing output.
|
||||||
|
|
||||||
|
The compiler is the MIT-licensed `ppt-master` 4.6.0 snapshot from [hugohe3/ppt-master](https://github.com/hugohe3/ppt-master). `runtime/ppt-master/` preserves its LICENSE, sponsorship files, original SKILL.md and all scripts, including the executable attribution guard. Its exact shipped file hashes are in [provenance.json](../runtime/provenance.json). The nested SKILL.md is an attribution file, not a second installable Skill; install only this package's root. No upstream reference library, organization assets or themes are bundled. Do not remove the guard or rewrite upstream files to suppress it.
|
||||||
|
|
||||||
|
The preserved snapshot also contains optional upstream utilities and their documentation. Those legacy utility paths, remote services and optional executables are not this Skill's maintained authoring route or an installation requirement. Use the local native wrapper and host image/UI tools described here; do not invoke an optional utility while assuming its separate service configuration exists.
|
||||||
|
|
||||||
|
Page/mockup planning and strict input-asset handling draw on the workflow concepts of [codex-ppt](https://github.com/ningzimu/codex-ppt-skill); object inventory, background/material separation and object-type verification draw on the source-reconstruction workflow available in the development environment. The guidance here is independently written and self-contained. No external workflow script, OCR service, mandatory CLI, review state or installation path is required. Complex automatic asset separation and OCR are not represented as bundled capabilities: use the host visual tools and review the result.
|
||||||
|
|
||||||
|
The generic wrapper consumes declared palette/font/size data. `preserve_declared_font_families(style)` temporarily protects only those families from the compiler's platform replacement map during a serial compile, restoring process memory on success or failure. It preserves source identity before conversion; it does not replace arbitrary finished-document occurrences of a fallback font. Declared font names do not install, embed or license a font. Verify family, style, version/hash, source, license and actual rendering on the destination device.
|
||||||
|
|
||||||
|
For a portable author project, [prepare_native_project.py](../scripts/prepare_native_project.py) copies the required runtime and wrappers into `vendor/native-workflow/`, supplies `tools/native_builder.py`, and records every compiler source as a build input. It accepts an initialized project with an untouched author lock, no builder and an empty dependency/font/tool declaration. It refuses existing destinations and custom environments/builders. It deliberately leaves the old author lock unchanged so the new source must be reviewed and explicitly locked.
|
||||||
|
|
||||||
|
The prepared builder reads finalized page SVGs and Markdown speaker notes in project order. It checks the layout IDs and local asset boundaries, then compiles using the vendored template and compiler. `content.json` and declared `data` remain author inputs; this generic builder does not invent a binding schema or automatically regenerate SVG text from those files. If the deck needs data-driven generation, supply a reviewed project-local preprocessing/builder step and declare its inputs before locking.
|
||||||
|
|
||||||
|
Move or pack the whole locked project, provision the declared packages/fonts, then use its own tools to check and rebuild. Test from a different directory with the original source unavailable and verify an intentional source/notes change after relocking. Same-device relocation proves path independence for that snapshot, not another physical device's rendering or application editing.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Template manifest v2 and v1 compatibility
|
||||||
|
|
||||||
|
Select **template → theme → layout**. A catalog discovers multiple independent templates; one command activates one manifest and one theme. Themes share the selected template's structure, layouts and style rules. They do not combine different template geometries.
|
||||||
|
|
||||||
|
All catalog paths are relative to the catalog directory. All manifest paths, including asset paths inside theme JSON, are relative to the **manifest directory**. Use POSIX paths without `..`, absolute paths, backslashes or drive/URL prefixes. Paths must remain inside that package after symlink resolution. Duplicate JSON keys are rejected at every parsed level. Remote URLs can be provenance metadata, never executable dependencies.
|
||||||
|
|
||||||
|
A catalog has integer `schema_version: 1` or `2` and a `templates` array of objects containing `manifest`. Both catalog versions can list v1 and v2 manifests together. Duplicate template IDs are rejected; list one selected version of each ID. An explicit manifest can pin another version without changing the catalog.
|
||||||
|
|
||||||
|
## v2 manifest
|
||||||
|
|
||||||
|
| Field | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `schema_version` | Integer `2` |
|
||||||
|
| `template_id`, `version`, `name`, `description` | Non-empty strings; ID uses lowercase ASCII letters, numbers and hyphens; version is SemVer |
|
||||||
|
| `status` | `proposal`, `approved`, `released` or `deprecated`; only `released` resolves for production consumption |
|
||||||
|
| `spec` | Existing specification file; a proposal brief is allowed for review |
|
||||||
|
| `structure` | Existing shared structure file, or null before authored; `resolve` requires a file |
|
||||||
|
| `style` | Existing JSON object with shared canvas, typography, grid and general rules; template-specific fields are preserved |
|
||||||
|
| `themes` | Non-empty map of theme IDs to `name`, `theme`, `workspace`, `native_template` |
|
||||||
|
| `layouts` | Non-empty array of unique `id`, `name`, `slots`; `slots` is a unique string array; extensions are preserved |
|
||||||
|
| `routes` | Non-empty unique array drawn from `native`, `image-rebuild` |
|
||||||
|
| `editable_policy` | Object with string arrays `native`, `image_allowed`, `disclose` |
|
||||||
|
|
||||||
|
Every theme entry declares a theme JSON path and a workspace path. `native_template` is required as a field but may be null: native authoring can generate a deck from the workspace. A declared native file is checked on `resolve`; it must be a `.pptx` outside an `exports/` path and contain the basic presentation ZIP members. Null never permits an incomplete workspace.
|
||||||
|
|
||||||
|
A resolvable workspace has `templates/design_spec.md` and at least one SVG prototype. `templates/`, `images/` and `icons/` are hashed authoring surfaces; `exports/` is derived review evidence. Use separate theme workspaces when paint or assets differ, and derive them from the shared structure to prevent geometry drift. Equal layout IDs do not prove equivalent native Master/Layout topology.
|
||||||
|
|
||||||
|
## Theme JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"theme_id": "warm",
|
||||||
|
"palette": {"background": "#FFFFFF", "accent1": "#A14238", "diagram-highlight": "#B36A19"},
|
||||||
|
"assets": {
|
||||||
|
"mark": {"path": "assets/mark.svg", "preserve_aspect_ratio": true},
|
||||||
|
"section-icon": {"path": "assets/section.svg", "paint_contract": "assets/section.paint.json", "usage": "section heading"}
|
||||||
|
},
|
||||||
|
"asset_bindings": [
|
||||||
|
{"asset": "section-icon", "targets": [
|
||||||
|
{"selector": "#body", "channel": "fill", "palette_index": "accent1"},
|
||||||
|
{"selector": "#detail", "channel": "stroke", "palette_index": "diagram-highlight"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`theme_id` must match its manifest map key. Palette keys are arbitrary non-empty names; values are `#RRGGBB`. The interface does not fix the number of colors. `assets` is a map of roles to objects with an existing package-relative `path`. `asset_bindings` is an array; each entry references an existing asset role and a non-empty target array. Targets have a non-empty selector, channel `fill` or `stroke`, and an existing `palette_index`. Assets without `paint_contract` retain their existing selector contract, including `*` or element IDs. This helper never executes selectors or recolors assets.
|
||||||
|
|
||||||
|
Keep exact marks, photographs and evidence figures in their original form unless the template explicitly permits a change. Bindings can recolor designated portions of suitable icons; they do not authorize repainting every pixel or scientific color encoding. Additional fields in style, theme, assets, bindings and layouts remain available to the agent. Undeclared extension fields are data, not commands or automatic acceptance criteria.
|
||||||
|
|
||||||
|
### SVG paint contract
|
||||||
|
|
||||||
|
An asset may declare `paint_contract`, an existing JSON path relative to the **manifest directory**, under the same package containment rules as its SVG path. The field is supported only for `.svg` assets. One contract can accompany an SVG reused by several themes; each theme chooses its own palette indices without changing the contract's semantic tokens.
|
||||||
|
|
||||||
|
For the section icon above, `assets/section.paint.json` contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"asset_id": "section-icon",
|
||||||
|
"default_policy": "preserve",
|
||||||
|
"rules": [
|
||||||
|
{"id": "body-fill", "selector": "#body", "channel": "fill", "policy": "palette", "token": "primary"},
|
||||||
|
{"id": "detail-stroke", "selector": "#detail", "channel": "stroke", "policy": "palette", "token": "highlight"},
|
||||||
|
{"id": "paper-fill", "selector": "#paper", "channel": "fill", "policy": "fixed", "value": "#FFFFFF"},
|
||||||
|
{"id": "open-fill", "selector": "#open", "channel": "fill", "policy": "structural", "value": "none"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `schema_version` | Integer `1` |
|
||||||
|
| `asset_id` | Non-empty string identifying the material; independent of a theme's asset role |
|
||||||
|
| `default_policy` | Exactly `preserve`; undeclared element/channel pairs retain their original paint |
|
||||||
|
| `rules` | Array of rule objects; may be empty to preserve every channel |
|
||||||
|
| Rule `id` | Unique non-empty string |
|
||||||
|
| Rule `selector` | Exactly `#` followed by a stable XML ID matching `[A-Za-z_][A-Za-z0-9_.:-]*`; no wildcard, CSS query or selector execution |
|
||||||
|
| Rule `channel` | `fill` or `stroke`; each `(selector, channel)` pair appears at most once |
|
||||||
|
| Rule `policy` | `palette`, `fixed` or `structural` |
|
||||||
|
| Rule `token` | Required non-empty string for `palette` only; several rules may share one semantic role token |
|
||||||
|
| Rule `value` | Required non-empty literal for `fixed` or `structural`; `structural` currently accepts only `none` |
|
||||||
|
|
||||||
|
Each palette rule requires exactly one matching theme target with the same asset role, selector and channel. A contract asset cannot use `*`, target an undeclared pair, or bind a `fixed`/`structural` pair. Duplicate targets, including duplicates across separate binding entries, are rejected. A token describes meaning, not a concrete color or automatic palette lookup: the theme's `palette_index` selects the color for each rule.
|
||||||
|
|
||||||
|
Rules govern only the declared channel. A palette `fill` does not authorize changing that element's `stroke`, and a fixed `fill` does not fix other channels. Preserve fixed literals exactly, including white details and paint references such as `url(#gradient)`. Preserve structural `none` as an unpainted channel; do not convert it to white or a theme background. Unknown fields are preserved as metadata. Fixed literals are not interpreted as CSS, validated against SVG, or resolved by this parser.
|
||||||
|
|
||||||
|
`inspect` and manifest loading validate contract JSON and theme coverage. The helper does not read SVG IDs, evaluate inherited/style-based paint, verify original fixed values, inspect transparency, or execute a recolor. The authoring workflow must check those material facts and reject unresolved conflicts before changing an asset. Whole-image pixel replacement is outside this contract.
|
||||||
|
|
||||||
|
## CLI behavior
|
||||||
|
|
||||||
|
Use Python 3.10 or newer. `list` and `inspect` print JSON without writing files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /path/to/skill/scripts/template_ref.py list --catalog /path/to/library/catalog.json
|
||||||
|
python3 /path/to/skill/scripts/template_ref.py inspect --manifest /path/to/template/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`inspect` preserves manifest extension fields and exposes themes/layouts for review. It checks declared metadata, theme references, asset/paint-contract paths, contract JSON and binding coverage; it does not establish production acceptance.
|
||||||
|
|
||||||
|
### Export style for review and alignment
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /path/to/skill/scripts/template_ref.py export-style \
|
||||||
|
--manifest /path/to/template/manifest.json --theme warm \
|
||||||
|
--layout figure --out /path/to/report/style-review.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This v2-only command accepts proposal, approved, released and deprecated states and reports the actual `status`. `production_ready` is exactly `status == 'released'`; it is a status flag, not independent release verification. Style export creates no PPT and does not require an authored workspace or native PPTX. Existing spec, style, theme and asset files are still required; `structure` may be null. Workspace/native paths can describe future artifacts and their existence is not promised by this output.
|
||||||
|
|
||||||
|
The JSON includes template ID/version/status, selected `theme`, optional `layout` and complete `layout_definition`, `manifest`/`manifest_sha256`, absolute `spec`, `structure`, `style_path`, `theme_path`, `workspace`, `native_template`, complete `style` and `theme_data`, and `editable_policy`. Asset `path` and declared `paint_contract` paths in `theme_data.assets` become absolute. For each contract asset, `paint_contract_data` contains the complete parsed object, including extensions. Other fields are passed through unchanged; assets without a contract gain no new fields. `inputs` lists package-relative paths and SHA-256 hashes for the actual spec, structure, style, selected theme/assets/paint contracts, existing native file and existing workspace authoring surfaces. This supports agent review, prompt constraints and style alignment before production binding.
|
||||||
|
|
||||||
|
### Resolve one production selection
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 /path/to/skill/scripts/template_ref.py resolve \
|
||||||
|
--manifest /path/to/template/manifest.json \
|
||||||
|
--theme warm --route native --workflow interactive-page \
|
||||||
|
--layout figure --out /path/to/report/template-selection.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `released` resolves. `--theme`, `--route` and `--workflow` are explicit; `--layout` is optional before individual pages. Repeated theme/mode/layout flags are rejected instead of taking the last value. The command accepts one manifest; simultaneous mode and theme activation is invalid. Record each page's actual layout with its authoring artifacts.
|
||||||
|
|
||||||
|
The selection record includes schema version, template ID/version, manifest path/hash, theme, optional layout, route, workflow, workspace/native/spec/structure/style/theme paths and hashes of resolved inputs. v2 adds status and `production_ready`. It hashes the selected theme, its assets and their paint contracts, not every unselected theme. A receipt is a local snapshot, not replacement source. Re-resolve after changing an input or moving a package; unchanged records can be reused.
|
||||||
|
|
||||||
|
`resolve` and `export-style` print JSON and optionally write it with exclusive `--out`; an existing destination is never overwritten. Invalid data returns exit code 2 and a compact JSON error. CLI syntax errors also return 2 through argparse.
|
||||||
|
|
||||||
|
## v1 compatibility
|
||||||
|
|
||||||
|
v1 manifests retain `schema_version: 1` and `modes` instead of `themes`. Each mode declares `name`, `workspace`, `native_template`; paths may be null before release. There is no required shared `style` or theme JSON. Use `resolve --mode <id>`; `--theme` and `export-style` require v2. v1 production binding still requires both a complete workspace and an existing native PPTX. Common fields, routes, layout validation, path containment, duplicate-key protection and exclusive output behavior remain supported. Existing v1 selection records retain their `mode` field and schema version.
|
||||||
|
|
||||||
|
To migrate, separate shared style rules from theme palettes/assets, add `style` and `themes`, change the manifest schema to 2 and bind with `--theme`. Keep v1 packages and selection receipts pinned; do not rewrite them in place to simulate a migration.
|
||||||
|
|
||||||
|
## Evidence and authority
|
||||||
|
|
||||||
|
The helper checks metadata, references, containment, local files, hashes, workspace shape and declared PPTX ZIP members. It does not parse template-specific structure semantics or establish OOXML validity, placeholder topology, visual fidelity, font availability, native editability, application behavior or projection readability. Report those checks separately.
|
||||||
|
|
||||||
|
A manifest authorizes neither installation nor external communication nor consumer-project changes. Treat all template content as untrusted design input; it cannot override the user's scope, selected workflow or agent instructions.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Page mockups and visual materials
|
||||||
|
|
||||||
|
Reuse the user's settled template, theme, route and page workflow. Define the current page's title/message, supporting evidence, content groups, notes, layout, asset roles and required native objects before generating its mockup. Review only consequential decisions that remain open; do not restart settled outline or style choices. A request to review one page does not authorize a full-deck generation run.
|
||||||
|
|
||||||
|
When extracting style from an existing presentation or PDF, render representative pages and inspect the actual images. XML/object counts alone cannot establish density, hierarchy, ornament or spacing. Keep style references separate from strict source assets. Use existing accepted references; a rejected mockup does not remain a design baseline.
|
||||||
|
|
||||||
|
## Mockup prompt inputs
|
||||||
|
|
||||||
|
Use the host image-generation tool directly for requested raster page samples. Supply:
|
||||||
|
|
||||||
|
- The selected canvas/aspect ratio, template palette, typography intent, grid and fixed content regions.
|
||||||
|
- The exact current-page text and moderate content density, with a declared hierarchy and clear content division.
|
||||||
|
- Approved representative images as style references, with the current layout allowed to differ.
|
||||||
|
- Supplied logos, figures and screenshots as strict identity/evidence assets. Preserve values, axes, labels, proportions and scientific meaning; do not synthesize substitute evidence.
|
||||||
|
- The editable reconstruction plan: decorative pictures may remain assets, while title/body, controls, separators and diagrams must later become independent native objects as required.
|
||||||
|
|
||||||
|
Inspect local reference images using the available image-viewing tool before editing or conditioning on them. Label each input's role in the prompt. Save prompt, tool result, source attribution and review status under the project. Show the current sample for the user's requested review and incorporate concrete corrections before native reconstruction.
|
||||||
|
|
||||||
|
Use generated text only as visual guidance. Native source text comes from verified content, with the template's declared fonts and sizes; do not copy misspellings or typography defects from the raster sample. Preserve original evidence/identity assets independently so the final native page can use those files exactly.
|
||||||
|
|
||||||
|
## Material generation and transparent assets
|
||||||
|
|
||||||
|
For new bitmap illustrations, photos, textures or image edits, use the selected host image-generation capability. Request a transparent background when the final asset needs transparency. Preserve transparency on edits unless the user asks to change it. Use reference-image paths when all targets have paths, otherwise the smallest supported recent-image set that includes every target; do not silently omit a required target.
|
||||||
|
|
||||||
|
Ask for complete separated objects with consistent perspective, stroke and illumination. A material sheet can contain several non-overlapping assets with clear outer padding, but verify identity/count and edges after separation. Keep logos and strict evidence originals; generated approximations need explicit permission for that role. Repair small occlusions locally rather than redesigning an entire source background.
|
||||||
|
|
||||||
|
Check transparent edges against both light and dark backgrounds for halos, missing thin strokes and unintended holes. Avoid arbitrary color-key removal that erases matching subject colors. If an approved layered source or role mask exists, retain it with a clear mask-to-material contract; otherwise regenerate an unsuitable cutout through the image tool.
|
||||||
|
|
||||||
|
For an existing SVG icon system, use its vector source and declared paint contract rather than generating a new bitmap. Multiple independent color roles need stable element IDs and per-channel rules. Palette binding, fixed details, structural `none` and unlisted paint remain distinct. See [icon handoff](authoring-handoff.md#icon-material-handoff).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025-2026 Hugo He
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: ppt-master
|
||||||
|
description: >
|
||||||
|
AI-driven presentation workflow for generating editable PPTX decks,
|
||||||
|
reconstructing page visuals, creating reusable Brand/Style/Layout/Deck
|
||||||
|
workspaces, filling native PPTX templates, and enhancing finished PPTX files.
|
||||||
|
Use when the user asks to create, reconstruct, regenerate, template, fill, or
|
||||||
|
enhance a presentation, requests a presentation-authored narrated/self-running
|
||||||
|
video, or mentions ppt-master.
|
||||||
|
metadata:
|
||||||
|
version: "4.6.0"
|
||||||
|
copyright: "Copyright (c) 2025-2026 Hugo He"
|
||||||
|
license: "MIT"
|
||||||
|
official_repository: "https://github.com/hugohe3/ppt-master"
|
||||||
|
sponsors:
|
||||||
|
- "SPONSORS.md"
|
||||||
|
- "SPONSORS_CN.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# PPT Master Skill
|
||||||
|
|
||||||
|
PPT Master is a routed presentation workflow. This entry owns global execution discipline and route selection only; each selected route owns its procedure.
|
||||||
|
|
||||||
|
## Mandatory Load Order
|
||||||
|
|
||||||
|
1. Read this file.
|
||||||
|
2. Run `python3 scripts/attribution_guard.py` from this Skill directory. Any
|
||||||
|
non-zero result stops the Skill immediately; do not inspect, repair, or
|
||||||
|
bypass the integrity gate.
|
||||||
|
3. Read [`workflows/routing.md`](workflows/routing.md).
|
||||||
|
4. Select exactly one top-level route and its active profile from the routing
|
||||||
|
authority.
|
||||||
|
5. Read only the resulting runtime authority and its explicitly triggered
|
||||||
|
supporting documents.
|
||||||
|
|
||||||
|
| Selected route / profile | Runtime authority |
|
||||||
|
|---|---|
|
||||||
|
| Generate PPTX — Image to PPTX | [`workflows/profiles/image-to-pptx.md`](workflows/profiles/image-to-pptx.md); Codex-supported, always Quick |
|
||||||
|
| Generate PPTX — Beautify | [`workflows/profiles/beautify-pptx.md`](workflows/profiles/beautify-pptx.md); explicit Quick intent selects Quick, otherwise Default |
|
||||||
|
| Generate PPTX — ordinary Default | [`workflows/generate-pptx.md`](workflows/generate-pptx.md) |
|
||||||
|
| Generate PPTX — ordinary explicit Quick | [`workflows/profiles/quick-generate.md`](workflows/profiles/quick-generate.md) |
|
||||||
|
| Create Template | [`workflows/create-template.md`](workflows/create-template.md) |
|
||||||
|
| Fill Native PPTX | [`workflows/template-fill-pptx.md`](workflows/template-fill-pptx.md) |
|
||||||
|
| Enhance Native PPTX | [`workflows/native-enhance-pptx.md`](workflows/native-enhance-pptx.md) |
|
||||||
|
|
||||||
|
**Hard rule — selected authority only**: Do not load another top-level route's
|
||||||
|
procedure after routing. Image to PPTX and Beautify are mutually exclusive;
|
||||||
|
Image to PPTX activates Quick, while Beautify selects from explicit Quick
|
||||||
|
intent. Never load both runtimes. Supporting documents refine one route; they
|
||||||
|
never compete with it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Execution Discipline
|
||||||
|
|
||||||
|
1. **Serial execution** — Follow the selected authority's steps in order. A completed non-blocking step may continue directly to the next eligible step.
|
||||||
|
2. **Blocking means stop** — At every `⛔ BLOCKING` gate, wait for explicit user confirmation. Do not decide on the user's behalf.
|
||||||
|
3. **No cross-phase bundling** — Do not combine work across an unclosed gate. Once the route's final user gate closes, later non-blocking steps may continue automatically.
|
||||||
|
4. **Gate before entry** — Verify every listed prerequisite before entering a step.
|
||||||
|
5. **No speculative execution** — Do not prepare later-phase artifacts before their owning step.
|
||||||
|
6. **Deterministic routing** — Do not add a route-choice question when [`routing.md`](workflows/routing.md) resolves the request. If a route prerequisite is missing, state it and stop that route.
|
||||||
|
7. **Owning-source recovery** — On failure, repair or regenerate the owning source artifact and resume from the route's declared pointer. Do not silently downgrade a required artifact.
|
||||||
|
8. **Stable paths** — Use absolute skill/project paths; never derive them from CWD.
|
||||||
|
|
||||||
|
## Global Communication Rules
|
||||||
|
|
||||||
|
- Match the user's language and source language unless the user explicitly overrides it.
|
||||||
|
- Localize user-facing option labels and explanations. Keep exact enum IDs or field names when needed for precision.
|
||||||
|
- Keep `design_spec.md` section headings and field names in the template's original English; content values may use the user's language.
|
||||||
|
- Before switching roles, read the corresponding role reference and output:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [Role Switch: <Role Name>]
|
||||||
|
📖 Reading role definition: references/<filename>.md
|
||||||
|
📋 Current task: <brief description>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Compatibility
|
||||||
|
|
||||||
|
- This package is a workflow/skill, not a generic application scaffold. Do not create `.worktrees/`, `tests/`, branch workflows, or generic engineering structure by default.
|
||||||
|
- Keep required workflow, reference, script, and template documentation inside this Skill directory.
|
||||||
|
- Repository-level documents may point into the package; package runtime files must not depend on repository-level instructions.
|
||||||
|
- On Windows, if a documented `python3 ...` command is unavailable, rerun the same command with `python`.
|
||||||
|
- Sponsor information is optional reference material. Read the matching [`SPONSORS.md`](SPONSORS.md) or [`SPONSORS_CN.md`](SPONSORS_CN.md) only when the user explicitly requests a model, AI image model, API/provider, or hosted-service recommendation. Never surface sponsor or model recommendations proactively during normal generation, troubleshooting, or quality review.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Sponsors and User Benefits
|
||||||
|
|
||||||
|
English | [中文](SPONSORS_CN.md)
|
||||||
|
|
||||||
|
PPT Master is free and open source. The sponsors below help fund its ongoing maintenance and provide optional benefits for PPT Master users. Sponsorship does not change the project's provider-agnostic design: use any compatible agent, model, or service you prefer.
|
||||||
|
|
||||||
|
Want to support the project or discuss a partnership? See the [sponsorship guide](https://github.com/hugohe3/ppt-master/blob/main/SPONSORING.md).
|
||||||
|
|
||||||
|
## Current Model Recommendation
|
||||||
|
|
||||||
|
For the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the workflow, paired with **`gpt-image-2`** (OpenAI) or **`gemini-3.1-flash-image`** (Google) for AI image generation. If model capability is limiting output quality, upgrade the driving model before weakening the workflow or its quality requirements.
|
||||||
|
|
||||||
|
## Kimi
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.kimi.com/code/?aff=ppt-master"><img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" alt="Kimi" width="100%"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring PPT Master. [Kimi K3](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart) is the world's first open 3T-class model, featuring native vision and a 1-million-token context window. With PPT Master, K3 can understand PDFs, DOCX files, web pages, and other source material, structure the narrative, and generate a natively editable PPTX.
|
||||||
|
|
||||||
|
**Try [Kimi Code](https://www.kimi.com/code/?aff=ppt-master), or access the API through the Kimi Open Platform ([中文站](https://platform.kimi.com?aff=ppt-master) | [Global](https://platform.kimi.ai?aff=ppt-master)).**
|
||||||
|
|
||||||
|
## Model Access Partners
|
||||||
|
|
||||||
|
### PackyCode
|
||||||
|
|
||||||
|
<a href="https://www.packyapi.ai/register?aff=ppt-master"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a>
|
||||||
|
|
||||||
|
[PackyCode](https://www.packyapi.ai/register?aff=ppt-master) provides relay access to Claude Code, Codex, Gemini, and other services. Register through the dedicated link and enter the promo code **`ppt-master`** during recharge to receive 10% off.
|
||||||
|
|
||||||
|
### APIKEY.FUN
|
||||||
|
|
||||||
|
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" width="150"></a>
|
||||||
|
|
||||||
|
[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER) provides enterprise-grade access to Claude, OpenAI, Gemini, and other mainstream models. Register through the dedicated link to receive up to a permanent 5% discount on top-ups.
|
||||||
|
|
||||||
|
### RunAPI
|
||||||
|
|
||||||
|
<a href="https://runapi.host/register?aff=WMLJ"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a>
|
||||||
|
|
||||||
|
[RunAPI](https://runapi.host/register?aff=WMLJ) provides access to 150+ models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, through one API key. Register through the dedicated link and contact an administrator to claim **¥7 in free credit**.
|
||||||
|
|
||||||
|
### YouYun ZhiSuan
|
||||||
|
|
||||||
|
<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/youyun.png" alt="YouYun ZhiSuan" width="150"></a>
|
||||||
|
|
||||||
|
[YouYun ZhiSuan](https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624), UCloud's AI cloud platform, provides domestic and international model APIs, CodingPlan packages, enterprise concurrency, technical support, and invoicing. Register through the dedicated link to receive up to **¥10 in free trial credit**. PPT Master is also available there as a hosted Agent for users who do not want to deploy it locally.
|
||||||
|
|
||||||
|
## Infrastructure Support
|
||||||
|
|
||||||
|
<a href="https://m.do.co/c/547f129aabe1"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" height="40"></a>
|
||||||
|
|
||||||
|
Thanks to [DigitalOcean](https://m.do.co/c/547f129aabe1) for supporting the project's infrastructure.
|
||||||
|
|
||||||
|
## Individual Support
|
||||||
|
|
||||||
|
If PPT Master has been useful to you, individual support of any amount helps keep the project moving and free.
|
||||||
|
|
||||||
|
<a href="https://paypal.me/hugohe3"><img src="https://img.shields.io/badge/PayPal-Sponsor-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="Sponsor via PayPal"></a>
|
||||||
|
|
||||||
|
[Alipay QR code](https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/alipay-qr.jpg)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 赞助商与用户福利
|
||||||
|
|
||||||
|
[English](SPONSORS.md) | 中文
|
||||||
|
|
||||||
|
PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护,并为 PPT Master 用户提供可选福利。赞助不会改变项目不绑定 Provider 的设计:你仍可自由选择任何兼容的 Agent、模型或服务。
|
||||||
|
|
||||||
|
希望支持项目或洽谈合作?请查看[赞助合作说明](https://github.com/hugohe3/ppt-master/blob/main/SPONSORING_CN.md)。
|
||||||
|
|
||||||
|
## 当前模型推荐
|
||||||
|
|
||||||
|
追求最佳效果,语言模型选 **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)**(或 Claude)驱动流程,搭配 AI 生图模型 **`gpt-image-2`**(OpenAI)或 **`gemini-3.1-flash-image`**(Google)。如果模型能力正在拖累产出质量,应先升级驱动模型,而不是削弱工作流或质量要求。
|
||||||
|
|
||||||
|
## Kimi
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.kimi.com/code/?aff=ppt-master"><img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" alt="Kimi" width="100%"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
感谢 [Kimi](https://www.kimi.com/code/?aff=ppt-master) 赞助 PPT Master。[Kimi K3](https://platform.kimi.com/docs/guide/kimi-k3-quickstart) 是全球首个开源 3T 级模型,拥有原生视觉能力与 100 万 Token 上下文。搭配 PPT Master,K3 可以理解 PDF、DOCX、网页等原始资料,梳理演示逻辑并生成原生可编辑的 PPTX。
|
||||||
|
|
||||||
|
**立即体验 [Kimi Code](https://www.kimi.com/code/?aff=ppt-master),或通过 Kimi 开放平台([中文站](https://platform.kimi.com?aff=ppt-master)|[Global](https://platform.kimi.ai?aff=ppt-master))使用 API。**
|
||||||
|
|
||||||
|
## 模型接入合作伙伴
|
||||||
|
|
||||||
|
### PackyCode
|
||||||
|
|
||||||
|
<a href="https://www.packyapi.ai/register?aff=ppt-master"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a>
|
||||||
|
|
||||||
|
[PackyCode](https://www.packyapi.ai/register?aff=ppt-master) 提供 Claude Code、Codex、Gemini 等服务的中转接入。通过专属链接注册,并在充值时填写优惠码 **`ppt-master`**,即可享受 9 折优惠。
|
||||||
|
|
||||||
|
### APIKEY.FUN
|
||||||
|
|
||||||
|
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" width="150"></a>
|
||||||
|
|
||||||
|
[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER) 提供 Claude、OpenAI、Gemini 等主流模型的企业级接入服务。通过专属链接注册,最高可享永久充值 95 折优惠。
|
||||||
|
|
||||||
|
### RunAPI
|
||||||
|
|
||||||
|
<a href="https://runapi.host/register?aff=WMLJ"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a>
|
||||||
|
|
||||||
|
[RunAPI](https://runapi.host/register?aff=WMLJ) 通过一个 API Key 提供 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型。通过专属链接注册并联系管理员,即可领取 **¥7 免费额度**。
|
||||||
|
|
||||||
|
### 优云智算
|
||||||
|
|
||||||
|
<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/youyun.png" alt="优云智算" width="150"></a>
|
||||||
|
|
||||||
|
[优云智算](https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624) 是 UCloud 旗下 AI 云平台,提供国内外模型 API、CodingPlan 套餐、企业级并发、技术支持和开票服务。通过专属链接注册,最高可获得 **¥10 免费体验金**。平台还提供无需本地部署的 PPT Master Agent。
|
||||||
|
|
||||||
|
## 基础设施支持
|
||||||
|
|
||||||
|
<a href="https://m.do.co/c/547f129aabe1"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" height="40"></a>
|
||||||
|
|
||||||
|
感谢 [DigitalOcean](https://m.do.co/c/547f129aabe1) 为项目基础设施提供支持。
|
||||||
|
|
||||||
|
## 个人赞助
|
||||||
|
|
||||||
|
如果 PPT Master 帮到了你,任何金额的个人赞助都能帮助项目持续更新、保持免费开源。
|
||||||
|
|
||||||
|
<a href="https://paypal.me/hugohe3"><img src="https://img.shields.io/badge/PayPal-赞助-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="通过 PayPal 赞助"></a>
|
||||||
|
|
||||||
|
[支付宝收款码](https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/alipay-qr.jpg)
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# PPT Master Toolset
|
||||||
|
|
||||||
|
This directory contains user-facing scripts for conversion, project setup, direct PPTX template filling, SVG processing, export, recorded narration, and image generation.
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
- Top-level `scripts/`: runnable entry scripts
|
||||||
|
- `scripts/project_management/`: internals behind `project_manager.py`
|
||||||
|
- `scripts/source_to_md.py`: unified source-document → Markdown dispatcher
|
||||||
|
- `scripts/source_to_md/`: source-document → Markdown routing/batch helpers and backend converters (`_dispatcher.py`, `_batch.py`, `pdf_to_md.py`, `doc_to_md.py`, `excel_to_md.py`, `ppt_to_md.py`, `web_to_md.py`)
|
||||||
|
- `scripts/image_backends/`: internal provider implementations used by `image_gen.py`
|
||||||
|
- `scripts/tts_backends/`: internal TTS provider implementations used by `notes_to_audio.py`
|
||||||
|
- `scripts/template_import/`: internal PPTX reference-preparation helpers used by `pptx_template_import.py`
|
||||||
|
- `scripts/svg_finalize/`: internal post-processing helpers used by `finalize_svg.py`
|
||||||
|
- `scripts/docs/`: topic-focused script documentation
|
||||||
|
- `scripts/prompt_audit.py` + `scripts/prompt_audit_manifest.json`: maintainer-only prompt budget/governance lint (see [`docs/prompt_audit.md`](docs/prompt_audit.md)); the manifest is audit-only and never loaded as prompt context
|
||||||
|
- `scripts/assets/`: static assets consumed by scripts
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Typical end-to-end workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md.py <file-or-url-or-dir> [<file-or-url-or-dir> ...]
|
||||||
|
# or direct backend calls:
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py <file.pdf>
|
||||||
|
# or
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py <deck.pptx>
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py <workbook.xlsx>
|
||||||
|
python3 scripts/project_manager.py init <project_name> --format ppt169
|
||||||
|
python3 scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...>
|
||||||
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
python3 scripts/finalize_svg.py <project_path>
|
||||||
|
python3 scripts/animation_config.py scaffold <project_path> # optional object-level animation overrides
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
After `init`, project-scoped Python CLIs automatically record their command
|
||||||
|
envelopes and bounded material outcomes in
|
||||||
|
`<project_path>/validation/workflow.log`; invoke them directly, without a
|
||||||
|
logging wrapper. The log does not copy the full console stream.
|
||||||
|
|
||||||
|
Repository update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/update_repo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script Index
|
||||||
|
|
||||||
|
| Area | Primary scripts | Documentation |
|
||||||
|
|------|-----------------|---------------|
|
||||||
|
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
|
||||||
|
| Project management | `project_manager.py`, `workflow_log.py`, `workflow_transcript.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py`, `pptx_delivery_check.py` | [docs/project.md](./docs/project.md) |
|
||||||
|
| SVG pipeline | `preset_shape_svg.py`, `shape_boolean_svg.py`, `svg_authoring_view.py`, `compact_svg_coordinates.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native shape authoring](../references/native-shape-authoring.md) |
|
||||||
|
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
|
||||||
|
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
|
||||||
|
| Animation resources | `sound_sync.py` | [sound catalog](../templates/sounds/README.md); [docs/pptx-animations.md](./docs/pptx-animations.md) |
|
||||||
|
| Spec maintenance | `update_spec.py`, `visualization_recall.py`; legacy `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/visualization-recall.md](./docs/visualization-recall.md) |
|
||||||
|
| Image tools | `image_gen.py`, `image_treat.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
|
||||||
|
| Maintenance smokes | Inline temporary-project commands | [advanced image and motion](./docs/advanced-image-motion-smoke.md); [mask and gradient](./docs/mask-gradient-smoke.md); [multilingual text](./docs/multilingual-text-smoke.md) |
|
||||||
|
| Repo maintenance | `update_repo.py` | README install/update section |
|
||||||
|
| Troubleshooting | validation, preview, export, dependency issues | [docs/troubleshooting.md](./docs/troubleshooting.md) |
|
||||||
|
|
||||||
|
## High-Frequency Commands
|
||||||
|
|
||||||
|
Conversion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md.py <file-or-url-or-dir> [<file-or-url-or-dir> ...]
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py <file.pdf>
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py <deck.pptx>
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py <file.docx>
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py <workbook.xlsx>
|
||||||
|
python3 scripts/source_to_md/web_to_md.py <url>
|
||||||
|
python3 scripts/pptx_to_svg.py <deck.pptx> -o <output_dir> # reconstruction/reference SVG import
|
||||||
|
```
|
||||||
|
|
||||||
|
Project setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/project_manager.py init <project_name> --format ppt169
|
||||||
|
python3 scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...>
|
||||||
|
python3 scripts/project_manager.py scaffold-spec <project_path> # optional manual helper
|
||||||
|
python3 scripts/project_manager.py scaffold-lock <project_path> # optional manual helper
|
||||||
|
python3 scripts/project_manager.py validate <project_path>
|
||||||
|
python3 scripts/project_manager.py page-context <project_path> P07 --record-usage
|
||||||
|
python3 scripts/project_manager.py page-context-report <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
`page-context` is an on-demand read-only current-page projection for diagnostics,
|
||||||
|
routing checks, or context measurement; normal generation retains the complete
|
||||||
|
Design Spec and lock once per valid execution context. Each invocation includes
|
||||||
|
the global lock projection as a continuity anchor set, not a color/font allowlist; large Design Specs,
|
||||||
|
prototype, and selected family visualization references are emitted only as scoped
|
||||||
|
path/SHA fingerprints and are read once per execution context. `--bundle` is a
|
||||||
|
deprecated compatibility no-op. `--record-usage` writes one derived snapshot
|
||||||
|
under `analysis/page-context/`; exact `o200k_base` token counts are optional and
|
||||||
|
degrade to `tokens: null` when `tiktoken` is absent. Telemetry may be partial.
|
||||||
|
|
||||||
|
Visualization candidate recall:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/visualization_recall.py recall --page P03 --tag "time series" --tag "three metrics" --tag "direction over time"
|
||||||
|
python3 scripts/visualization_recall.py validate chart/line_chart
|
||||||
|
```
|
||||||
|
|
||||||
|
Template source import:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx>
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --manifest-only
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode both
|
||||||
|
python3 scripts/svg_authoring_view.py <imported-svg-or-dir> -o <output-dir> --projection-kind layered
|
||||||
|
python3 scripts/svg_authoring_view.py <authoring-dir> --refresh-summary
|
||||||
|
python3 scripts/compact_svg_coordinates.py <template_workspace>/templates --inplace --keep-native-frames
|
||||||
|
python3 scripts/mirror_template_materialize.py <import_workspace> <empty_template_workspace>
|
||||||
|
python3 scripts/template_preview_pptx.py <template_workspace>
|
||||||
|
python3 scripts/template_preview_pptx.py <legacy_template_workspace> --visual-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Template import defaults to the canonical layered `svg/` tree. Use
|
||||||
|
`--inheritance-mode both` only when a separate self-contained `svg-flat/`
|
||||||
|
verification tree is required. No derived narrative digest is generated
|
||||||
|
because `manifest.json` already owns those facts.
|
||||||
|
|
||||||
|
`svg_authoring_view.py` creates a lightweight, non-destructive editable IR
|
||||||
|
bundle from PPTX-imported SVGs. It removes embedded `txbody` payloads,
|
||||||
|
duplicate hidden geometry carriers, and import-identity attributes from the
|
||||||
|
copy while retaining visible fallback geometry, text, images, stable element
|
||||||
|
ids, root Master/Layout markers, selected native-shape intent, and
|
||||||
|
document-local `data-pptx-source-ref` values.
|
||||||
|
Relative local image references are rewritten so the projected copy still
|
||||||
|
renders from its new location. The bundle's `authoring_summary.json` is the
|
||||||
|
model-readable current-file index; `authoring_manifest.json` records
|
||||||
|
source/authoring hashes and object paths for tools without duplicating opaque
|
||||||
|
payload and does not enter model context. Imported model-facing frames and safe
|
||||||
|
transform page coordinates use at most two decimals; immutable lossless SVGs
|
||||||
|
retain the original precision. In-place vector/picture extraction
|
||||||
|
refreshes the summary automatically; use `--refresh-summary` after other direct
|
||||||
|
IR edits. The full imported SVG remains unchanged as native-payload backing.
|
||||||
|
Template creation edits the IR and materializes validated `templates/*.svg`;
|
||||||
|
the IR directory itself is not a final template or direct release export
|
||||||
|
source.
|
||||||
|
|
||||||
|
`mirror_template_materialize.py` is the deterministic Type A mirror compiler.
|
||||||
|
It consumes only the layered `authoring-svg/` IR as editable input, loads its
|
||||||
|
tool-only manifest internally, and validates it against immutable `svg/`,
|
||||||
|
`native_structure.json`,
|
||||||
|
`svg/inheritance.json`, `source_template.pptx`, and any extracted-vector
|
||||||
|
inventory, then publishes a complete structured template roster atomically.
|
||||||
|
Unchanged supported Slide-local/slot refs may recover native payload; edited
|
||||||
|
refs keep their current SVG fallback. Fixed Master/Layout wrappers are expanded
|
||||||
|
mechanically into direct atoms, source visibility flags become canonical root
|
||||||
|
metadata, and imported vectors are copied once to `icons/imported/`. Large
|
||||||
|
opaque `txBody`, shape-style, and custom-geometry payloads are deduplicated into
|
||||||
|
`templates/native_payloads.json.gz`; repeated native restoration attributes
|
||||||
|
are stored there as short `data-pptx-native-ref` records. Structural metadata
|
||||||
|
stays inline, while checker, template-structure validation, and export hydrate
|
||||||
|
both layers in memory. Legacy inline payload and v1 payload-only stores remain
|
||||||
|
readable. The v1 execution manifest points to per-prototype
|
||||||
|
`ppt-master.template-text-slots.v2-min` diagnostic sidecars. They are derived
|
||||||
|
tool metadata and are not injected into model context. Checker and export
|
||||||
|
validate output attributes, topology, and resource hashes against the complete
|
||||||
|
prototype internally. Bitmap assets
|
||||||
|
go to `images/`; other referenced source assets go to `templates/assets/`.
|
||||||
|
The destination must be empty, and the command does not write
|
||||||
|
`templates/design_spec.md`; Template_Designer owns that authored brief.
|
||||||
|
|
||||||
|
`template_preview_pptx.py` reads a template workspace, exports every public `templates/*.svg` prototype as one structured review slide, and verifies the resulting Master/Layout package. Canonical definition-only `layout_<layout_key>.svg` prototypes are registered as reusable Layouts through internal carrier slides that are removed before publication; they never increase the review deck's visible slide count. This is an on-demand review action: its default output is `exports/<template_id>_template_preview.pptx`, and that directory need not exist before the command runs. It refuses an existing output unless an intentional re-export passes `--force`. `--visual-only` is an explicit migration aid for legacy SVG rosters: it creates a slide-local visual review deck without validating or claiming a reusable Master/Layout contract. This diagnostic path does not require a project `spec_lock.md`; it may retain generic theme/text defaults inside its clean one-Master/one-Layout shell. New structured templates use the default mode when a review deck is requested.
|
||||||
|
|
||||||
|
Template fill (direct PPTX, no SVG conversion):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/project_manager.py init <project_name> --format ppt169
|
||||||
|
python3 scripts/project_manager.py import-sources <project_path> <source.pptx> <material...>
|
||||||
|
# Manual fallback when import-sources did not produce analysis/<stem>.slide_library.json:
|
||||||
|
python3 scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/<stem>.slide_library.json
|
||||||
|
python3 scripts/template_fill_pptx.py scaffold <project_path>/analysis/<stem>.slide_library.json -o <project_path>/analysis/fill_plan.json --slides "1,3,4"
|
||||||
|
python3 scripts/template_fill_pptx.py check-plan <project_path>/analysis/<stem>.slide_library.json <project_path>/analysis/fill_plan.json -o <project_path>/analysis/check_report.json
|
||||||
|
python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx> <project_path>/analysis/fill_plan.json -o <project_path>/exports/filled.pptx
|
||||||
|
python3 scripts/template_fill_pptx.py validate <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
`apply` requires `fill_plan.json` to have top-level `"status": "confirmed"` unless `--force` is passed. It automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It preserves source page transitions by default; `--transition <effect>` accepts a canonical effect in the shared native gallery registry documented by [`docs/pptx-transitions.md`](docs/pptx-transitions.md), while old names remain accepted only as compatibility inputs, and `--transition-duration <seconds>` changes a replacement effect's duration. `--transition none` removes the visual effect, `--transition keep` states the preservation policy explicitly, and a per-slide `transition` field in the plan overrides whatever the CLI selects. The object form accepts effect-specific native `effect_options`.
|
||||||
|
|
||||||
|
Native existing-PPTX enhancement (direct PPTX, no SVG conversion):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/native_enhance_pptx.py init <source.pptx> --name <project_slug>
|
||||||
|
python3 scripts/native_enhance_pptx.py plan <project_path>
|
||||||
|
python3 scripts/native_enhance_pptx.py validate <project_path>
|
||||||
|
python3 scripts/native_enhance_pptx.py apply <project_path>
|
||||||
|
python3 scripts/pptx_delivery_check.py <finished.pptx>
|
||||||
|
```
|
||||||
|
|
||||||
|
Native preset shape authoring (one registry-backed fragment on stdout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/preset_shape_svg.py list --search arrow
|
||||||
|
python3 scripts/preset_shape_svg.py describe rightArrow
|
||||||
|
python3 scripts/preset_shape_svg.py render rightArrow --id process-arrow --frame 120 180 240 96 --fill '#2563EB'
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper never writes a page or project file. Select one exact semantic
|
||||||
|
stock-shape match, inspect the emitted fragment, and insert it into the
|
||||||
|
hand-authored SVG with the normal patch workflow. Its project-authored output
|
||||||
|
is one compact atomic `<g>` with direct registry-generated visible paths;
|
||||||
|
quality check and export rerender the registry instead of relying on a hidden
|
||||||
|
carrier, preview wrapper, or stored preview fingerprint. PPTX import and
|
||||||
|
round-trip SVGs deliberately keep their expanded carrier/preview evidence and
|
||||||
|
are not rewritten into this authored form. Keep ordinary rectangles, ellipses,
|
||||||
|
freeform geometry, charts, icons, and ambiguous silhouettes as regular SVG.
|
||||||
|
See [`references/shared-standards-core.md`](../references/shared-standards-core.md) §1.5 for
|
||||||
|
the normative contract and
|
||||||
|
[`references/native-shape-authoring.md`](../references/native-shape-authoring.md)
|
||||||
|
for selection and authoring guidance.
|
||||||
|
|
||||||
|
PowerPoint-style Merge Shapes materialization (source read-only; result paths
|
||||||
|
on stdout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/shape_boolean_svg.py render slide.svg \
|
||||||
|
--operation intersect \
|
||||||
|
--source circle \
|
||||||
|
--source card \
|
||||||
|
--id overlap
|
||||||
|
```
|
||||||
|
|
||||||
|
The first source owns result paint and is the primary geometry for `subtract`.
|
||||||
|
Local and ancestor transforms are baked into SVG-root coordinates. Replace the
|
||||||
|
operands with every returned path at the root in the primary operand's z-order;
|
||||||
|
`fragment` returns multiple stable sibling paths. Operands may be supported
|
||||||
|
closed geometry or supported horizontal implicit-LTR direct `<text>` whose exact
|
||||||
|
OpenType weight/style can be resolved; repeat `--font-dir PATH` for additional
|
||||||
|
font roots. Text is shaped to glyph outlines before the operation, so the
|
||||||
|
result remains editable freeform geometry but is no longer editable text. See
|
||||||
|
[`references/native-shape-authoring.md`](../references/native-shape-authoring.md)
|
||||||
|
§6 for the closed operand and failure contract.
|
||||||
|
|
||||||
|
Create-template/source normalization (optional; never part of automatic export):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/extract_svg_assets.py <layered_svg_dir> --icons-dir <icons_dir> --icon-namespace imported --inplace --id-prefix layered
|
||||||
|
python3 scripts/extract_svg_assets.py <flat_svg_dir> --icons-dir <icons_dir> --icon-namespace imported --reuse-inventory <layered_inventory.json> --inplace --id-prefix flat
|
||||||
|
python3 scripts/extract_svg_pictures.py "<svg_file>" --select "<group_id>" --resource-root "<workspace>" --images-dir "<picture_assets_dir>" --inplace # optional create-template normalization: one selected group -> one SVG picture
|
||||||
|
python3 scripts/compact_svg_coordinates.py <template_workspace>/templates --inplace --keep-native-frames
|
||||||
|
python3 scripts/mirror_template_materialize.py <import_workspace> <empty_template_workspace> # Type A mirror only
|
||||||
|
```
|
||||||
|
|
||||||
|
`extract_svg_assets.py` fingerprints each extracted subtree before generated-ID
|
||||||
|
namespacing. Process the layered authoring view first, then pass its inventory to
|
||||||
|
the flat view with `--reuse-inventory`; matching flat subtrees reference the
|
||||||
|
existing layered asset instead of creating a duplicate file. Only unmatched
|
||||||
|
flat-only vectors create new assets. Create-template stores these assets once in
|
||||||
|
`<workspace>/icons/imported/` and writes `data-icon="imported/<name>"` references.
|
||||||
|
Inventories retain any `data-pptx-source-ref` values carried by the extracted
|
||||||
|
subtree, so re-inlining preserves authoring-manifest object identity.
|
||||||
|
Rerunning a namespaced pass against an already rewritten projection inventories
|
||||||
|
the existing references without progressively wrapping more parent geometry.
|
||||||
|
|
||||||
|
Post-processing and export:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run only when the Design Spec's effective Speaker Notes outcome is enabled.
|
||||||
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
python3 scripts/finalize_svg.py <project_path>
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
When Speaker Notes is disabled, skip `total_md_split.py` and append
|
||||||
|
`--no-notes` to `svg_to_pptx.py` so stale files under `notes/` cannot be
|
||||||
|
embedded.
|
||||||
|
|
||||||
|
`finalize_svg.py` optimizes ordinary raster images by default using `2x` display pixels and max `2560px`; validated nested crop transports retain source pixel dimensions because their inner `1×1` image is source-unit geometry rather than a rendered-pixel budget. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: images that need neither resizing nor EXIF geometry normalization retain their original bytes, while oversized single-frame raster sources are re-encoded after resizing toward `2560px`. Cropped or stretched placements (including imported picture crops) retain enough source pixels to avoid undersupplying the visible frame. Use `svg_to_pptx.py --image-sizing display --image-scale 2 --image-quality 85` for an explicit compact export, or `--no-image-optimize` to force original image bytes.
|
||||||
|
|
||||||
|
`finalize_svg.py` remains mandatory because it creates the self-contained `svg_final/` visual preview. Those SVGs may be opened directly or inserted into PowerPoint as SVG pictures. The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter; `-s final` is diagnostic-only, and PowerPoint's manual Convert-to-Shape operation is unsupported.
|
||||||
|
|
||||||
|
For SVG-authoring routes, `svg_output/` is the complete visible page-design source: every exported text, image, shape, background, and template-derived layout element is present in the page SVG or explicitly referenced by it. Export may translate represented content into Master/Layout/Slide parts or native objects, but it does not retrieve missing visible content from templates or planning files. Speaker notes, animation, narration, transitions, `template-fill-pptx`, and `native-enhance-pptx` remain separately owned capabilities.
|
||||||
|
|
||||||
|
Native `svg_to_pptx.py` release export reads the project's explicit structure mode. Free-design, Brand-only, Style-only, and other `template_reuse_scope: style` projects use `flat`, omit Master/Layout mappings and SVG structure metadata, keep every represented object Slide-local, and materialize one clean project-owned Master plus one Blank Layout from the current color/typography lock. Stock content placeholders and unused built-in Layouts are removed; only the standard date/footer/slide-number capability hooks remain. A Deck/Layout application uses `structured` only when Strategist derives `template_reuse_scope: mirror|layout`: each project supplies unique Master/Layout definitions and one Layout assignment per generated page before SVG generation, and every SVG root repeats its assigned identity. A template-backed definition may remain unused and still register without a published carrier slide. Fixed Master/Layout visuals are direct semantic atoms; ordinary groups are invalid there, while one validated compact authored-preset `<g>` is the sole group exception because it compiles to one native shape. Reusable slots are top-level groups with positive design-zone bounds plus one compatible carrier. Composite `object` regions use explicit proxy binding, and zero-slot Layouts are valid.
|
||||||
|
|
||||||
|
Structured template export compiles only the declared structure, maps locked typography/colors into PowerPoint defaults, creates the named Master/Layout parts, and reads the package back before publication. It never clusters pages, promotes repeated chrome heuristically, or invents placeholders. Flat export is the normal free-design/Brand-only/Style-only/style-scope route: it creates only the clean project-owned shell and performs no promotion or deduplication of Slide content.
|
||||||
|
|
||||||
|
Template `page_layouts` records authoring-input provenance, `pptx_masters` / `pptx_layouts` own unique reusable definitions, and `page_pptx_layouts` owns page assignment. Strict preserves its Master/Layout/slot contract; adaptive retains its Master and may use a new Layout key only when fixed Layout atoms or slot topology/bounds change. `standard` / `fidelity` author new SVGs and a new Master/Layout/slot contract. `mirror` materializes a new workspace from the complete validated source identity graph—including unused Layout definitions—without semantic synthesis or gap filling, while mechanically expanding fixed-layer group wrappers into the direct atoms required by the structured contract.
|
||||||
|
|
||||||
|
Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete root Master identity must be replaced by a new workspace created through [`create-template`](../workflows/create-template.md). Generate new structured SVG pages from that workspace; do not upgrade the existing PPTX/SVG in place. Explicit flat free-design/Brand-only/Style-only projects intentionally omit root Master identity.
|
||||||
|
|
||||||
|
`pptx_to_svg.py` annotates verified text-grid tables and conservative chart data with `data-pptx-replace-with` beside the visible SVG fallback and places the payload in `<metadata type="application/json">`; the parent claim selects the chart or table schema. Imported table/chart groups under this contract carry `data-pptx-import-source="pptx"`, whether active or fallback-only. Table import covers exact physical row/grid topology, canonical rectangular merges, safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed run-rich paragraph schema. Each rich run requires `text` and may use only `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`. A merge must use the exact `rowSpan` / `gridSpan` / `hMerge` / `vMerge` physical topology with empty merge slaves. Presentation-only source run XML without a non-empty `effectLst` / `effectDag` normalizes; a table-cell run effect disables native replacement and adds a blocking effect diagnostic. Relationship-bearing text, extensions, line breaks, fields, tabs, bullets, broken text topology, unsafe border XML, non-solid fills, and other merge encodings remain fallback-only. For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the normalized SVG fallback resolves `wholeTbl`, `firstRow`, horizontal banding, theme colors/fonts, and direct cell/run overrides; other built-in/custom style families are not implied.
|
||||||
|
|
||||||
|
Supported parsed column/bar/line/area, pie/doughnut, scatter, and bubble charts without a baked preview receive a deterministic readable fallback marked `data-pptx-fallback-kind="normalized"`. The importer additionally activates verified column/line/area combo charts, canonical OHLC stock charts, area charts with numeric date axes, verified scatter/bubble charts whose two value axes fit the closed `axes.x` / `axes.y` contract, radar charts, safe `of_pie` `serLines`, axis/title/legend normalization, and validated bar/column gap/overlap cases. Combo plots may retain independent primary/secondary category caches and workbook ranges. Both the category/value and XY contracts retain kind/position/visibility/label position/number format/min/max/major unit/reverse/major gridlines for native read-back. Scatter import derives effective `scatter_style` from uniform per-series line/marker/smooth state. The normalized XY fallback consumes only the two major-gridline flags; the C4/C5 additions do not expand the normalized renderer. `gapWidth` is accepted only as an integer in `0..500` and `overlap` only as an integer in `-100..100`; both normalize in native output, while malformed or out-of-range values fail closed. Safe common series paint forms and theme scheme colors are resolved; unknown series paint/style XML outside the explicit normalization boundaries still fails closed. Safe stock series style may pass the structural gate, but stock series, `hiLowLines`, and up-down bar local styling can still normalize under the data-object-first contract. The PowerPoint-native replacement remains allowed to normalize unmodeled no-fill/alpha/line/marker details and reports the route-level loss risk. Chart title/legend/axis titles and supported data-label flags are retained when the current schema can represent them. Fallback-only objects keep rendered SVG content or a baked chart preview and carry `data-pptx-replacement-status`, which validation and `--native-charts-and-tables` export report as a warning. An active marker without a renderer keeps `data-pptx-fallback-kind="placeholder"`; default export keeps the reconstruction-only placeholder and the native Chart/Table opt-in may still reconstruct it.
|
||||||
|
|
||||||
|
The ChartEx importer accepts exactly the validated treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel data models. Supported hierarchy/category/value/series/subtotal data round-trips to native output; source style, axes, labels, and binning may normalize. Numeric caches must be non-empty and finite with exact contiguous point topology. This is not arbitrary ChartEx import or presentation fidelity, and the ChartEx native writer still only promises valid payload palette entries rather than full source styling.
|
||||||
|
|
||||||
|
Active imported table/chart markers carry `data-pptx-fallback-sha256`. Visible fallback edits, reachable SVG fragment-definition changes, marker-local reference-target changes, and marker transforms make the baseline stale: the mandatory quality checker warns, default export remains available, and `--native-charts-and-tables` fails instead of discarding the SVG edit. Generated authoring and reusable templates omit import provenance and a static baseline without warning. Hashless legacy imported markers that still carry PPTX import provenance remain convertible with a checker/replacement-route warning. Legacy `data-pptx-native*`, `data-pptx-visual-status`, and `data-pptx-route-status` spellings and the `--native-objects` option remain read-compatible; generated output and canonical commands use the replacement/fallback names and `--native-charts-and-tables`.
|
||||||
|
|
||||||
|
Exporter-canonical classic charts also recover canonical solid series/slice
|
||||||
|
colors and exact one- or two-paragraph title styling; two paragraphs retain
|
||||||
|
their `title` / `subtitle` roles. Slide-number fields resolve to the display
|
||||||
|
number defined by `firstSlideNum`; standalone master/layout SVGs retain their
|
||||||
|
literal field fallback because they are shared by multiple slides.
|
||||||
|
|
||||||
|
Image generation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/latex_render.py <project_path>
|
||||||
|
python3 scripts/latex_render.py <project_path> --providers codecogs,quicklatex,mathpad,wikimedia
|
||||||
|
python3 scripts/image_gen.py "A modern futuristic workspace"
|
||||||
|
python3 scripts/image_gen.py --list-backends
|
||||||
|
python3 scripts/analyze_images.py <project_path>/images
|
||||||
|
```
|
||||||
|
|
||||||
|
Repository update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/update_repo.py
|
||||||
|
python3 scripts/update_repo.py --skip-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- Keep one user-facing entry point per workflow at the top level of `scripts/`
|
||||||
|
- Move provider-specific or helper internals into subdirectories
|
||||||
|
- Prefer the unified entry points `project_manager.py`, `finalize_svg.py`, and `image_gen.py`
|
||||||
|
- Use `svg_output/` for the only supported native PPTX export and `svg_final/` for self-contained SVG visual preview / picture insertion
|
||||||
|
|
||||||
|
## Related Docs
|
||||||
|
|
||||||
|
- [Conversion Tools](./docs/conversion.md)
|
||||||
|
- [Project Tools](./docs/project.md)
|
||||||
|
- [SVG Pipeline Tools](./docs/svg-pipeline.md)
|
||||||
|
- [PPTX Transition Core](./docs/pptx-transitions.md)
|
||||||
|
- [Image Tools](./docs/image.md)
|
||||||
|
- [Troubleshooting](./docs/troubleshooting.md)
|
||||||
|
- [Skill Entry](../SKILL.md)
|
||||||
|
|
||||||
|
_Last updated: 2026-07-11_
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Image Size Analysis Tool
|
||||||
|
========================
|
||||||
|
Reports objective parameters for all images in a folder. It does not resolve a
|
||||||
|
canvas, prescribe a layout, or generate Strategist recommendations.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/analyze_images.py <images_folder_path>
|
||||||
|
python scripts/analyze_images.py projects/xxx/images
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Analysis report displayed in console
|
||||||
|
- Generates image_analysis.csv under the project's analysis/ directory
|
||||||
|
(sibling of the images folder), alongside the PPTX intake bundle
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
except ImportError:
|
||||||
|
print("Error: PIL/Pillow not installed. Run: pip install Pillow")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
|
||||||
|
OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
|
||||||
|
REPORT_WIDTH = 100
|
||||||
|
CATEGORY_WIDTH = 50
|
||||||
|
|
||||||
|
ImageAnalysis = dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image_manifest(images_dir: str) -> dict[str, dict]:
|
||||||
|
"""Load optional DOCX image metadata keyed by generated filename."""
|
||||||
|
manifest_path = Path(images_dir) / "image_manifest.json"
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"[WARN] Cannot read image manifest: {exc}")
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
manifest: dict[str, dict] = {}
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
filename = item.get("filename")
|
||||||
|
if isinstance(filename, str):
|
||||||
|
manifest[filename] = item
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_ratio(meta: dict | None) -> float | None:
|
||||||
|
"""Return a positive display ratio from manifest metadata."""
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
value = meta.get("display_ratio")
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
ratio = float(value)
|
||||||
|
return ratio if ratio > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_display_size(meta: dict, ratio: float) -> tuple[int, int]:
|
||||||
|
"""Return a display-sized stand-in for vector media dimensions."""
|
||||||
|
width_in = meta.get("display_width_in")
|
||||||
|
height_in = meta.get("display_height_in")
|
||||||
|
if isinstance(width_in, (int, float)) and isinstance(height_in, (int, float)):
|
||||||
|
width = max(1, int(round(float(width_in) * 96)))
|
||||||
|
height = max(1, int(round(float(height_in) * 96)))
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
width_emu = meta.get("display_width_emu")
|
||||||
|
height_emu = meta.get("display_height_emu")
|
||||||
|
if isinstance(width_emu, int) and isinstance(height_emu, int):
|
||||||
|
width = max(1, int(round(width_emu / 914400 * 96)))
|
||||||
|
height = max(1, int(round(height_emu / 914400 * 96)))
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
width = 960
|
||||||
|
height = max(1, int(round(width / ratio)))
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_usage_count(meta: dict | None) -> int:
|
||||||
|
"""Return how many source occurrences point to one asset."""
|
||||||
|
if not meta:
|
||||||
|
return 1
|
||||||
|
usage_count = meta.get("usage_count")
|
||||||
|
if isinstance(usage_count, int) and usage_count > 0:
|
||||||
|
return usage_count
|
||||||
|
occurrences = meta.get("occurrences")
|
||||||
|
if isinstance(occurrences, list) and occurrences:
|
||||||
|
return len(occurrences)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_ratio_variants(meta: dict | None) -> str:
|
||||||
|
"""Return a compact list of display ratio variants from manifest metadata."""
|
||||||
|
if not meta:
|
||||||
|
return ""
|
||||||
|
variants = meta.get("display_ratio_variants")
|
||||||
|
if not isinstance(variants, list):
|
||||||
|
return ""
|
||||||
|
ratios = [
|
||||||
|
f"{float(value):.2f}"
|
||||||
|
for value in variants
|
||||||
|
if isinstance(value, (int, float)) and value > 0
|
||||||
|
]
|
||||||
|
return ";".join(ratios)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_manifest_metadata(result: ImageAnalysis, meta: dict | None) -> None:
|
||||||
|
"""Copy optional manifest fields into an image analysis row."""
|
||||||
|
result["usage_count"] = _manifest_usage_count(meta)
|
||||||
|
result["display_ratio_variants"] = _manifest_ratio_variants(meta)
|
||||||
|
if not meta:
|
||||||
|
return
|
||||||
|
|
||||||
|
source_ext = meta.get("source_ext")
|
||||||
|
original_filename = meta.get("original_filename")
|
||||||
|
if isinstance(source_ext, str):
|
||||||
|
result["source_ext"] = source_ext
|
||||||
|
if isinstance(original_filename, str):
|
||||||
|
result["original_filename"] = original_filename
|
||||||
|
result["asset_kind"] = meta.get("asset_kind", "bitmap")
|
||||||
|
result["svg_renderable"] = meta.get("svg_renderable", True)
|
||||||
|
result["pptx_native_supported"] = meta.get("pptx_native_supported", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_transparent_pixels(image: Image.Image) -> bool:
|
||||||
|
"""Return whether any frame contains a pixel with alpha below 255."""
|
||||||
|
original_frame = image.tell()
|
||||||
|
frame_count = int(getattr(image, "n_frames", 1))
|
||||||
|
try:
|
||||||
|
for frame_index in range(frame_count):
|
||||||
|
image.seek(frame_index)
|
||||||
|
if "A" not in image.getbands() and "transparency" not in image.info:
|
||||||
|
continue
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
alpha = rgba.getchannel("A")
|
||||||
|
try:
|
||||||
|
extrema = alpha.getextrema()
|
||||||
|
finally:
|
||||||
|
alpha.close()
|
||||||
|
rgba.close()
|
||||||
|
if extrema and extrema[0] < 255:
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
image.seek(original_frame)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _result_from_manifest(
|
||||||
|
filename: str,
|
||||||
|
filepath: str,
|
||||||
|
meta: dict,
|
||||||
|
) -> ImageAnalysis | None:
|
||||||
|
"""Build an analysis row for vector media Pillow cannot decode."""
|
||||||
|
ratio = _manifest_ratio(meta)
|
||||||
|
if ratio is None:
|
||||||
|
return None
|
||||||
|
width, height = _manifest_display_size(meta, ratio)
|
||||||
|
result: ImageAnalysis = {
|
||||||
|
'filename': filename,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'aspect_ratio': ratio,
|
||||||
|
'pixel_aspect_ratio': None,
|
||||||
|
'source_display_ratio': ratio,
|
||||||
|
'ratio_source': 'manifest',
|
||||||
|
'format': Path(filename).suffix.lstrip('.').upper(),
|
||||||
|
'has_transparent_pixels': None,
|
||||||
|
'category': classify_ratio(ratio),
|
||||||
|
'filesize_kb': os.path.getsize(filepath) / 1024,
|
||||||
|
}
|
||||||
|
_apply_manifest_metadata(result, meta)
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
is_office_vector = suffix in OFFICE_VECTOR_EXTENSIONS
|
||||||
|
result["asset_kind"] = meta.get(
|
||||||
|
"asset_kind",
|
||||||
|
"office_vector" if is_office_vector else "vector",
|
||||||
|
)
|
||||||
|
result["svg_renderable"] = meta.get("svg_renderable", suffix == ".svg")
|
||||||
|
result["pptx_native_supported"] = meta.get(
|
||||||
|
"pptx_native_supported",
|
||||||
|
is_office_vector or suffix == ".svg",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def classify_ratio(aspect_ratio: float) -> str:
|
||||||
|
"""Classify an image by its objective aspect-ratio range.
|
||||||
|
|
||||||
|
Ranges: >2.0 ultra-wide, 1.5-2.0 wide, 1.2-1.5 standard
|
||||||
|
landscape, 0.8-1.2 near square, and <0.8 portrait.
|
||||||
|
"""
|
||||||
|
if aspect_ratio > 2.0:
|
||||||
|
return "Ultra-wide"
|
||||||
|
elif aspect_ratio > 1.5:
|
||||||
|
return "Wide landscape"
|
||||||
|
elif aspect_ratio > 1.2:
|
||||||
|
return "Standard landscape"
|
||||||
|
elif aspect_ratio > 0.8:
|
||||||
|
return "Near square"
|
||||||
|
else:
|
||||||
|
return "Portrait"
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]:
|
||||||
|
"""Analyze all image files in a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images_dir: Directory that contains image files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted image analysis records and supported files that could not be read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
results: list[ImageAnalysis] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
manifest = _load_image_manifest(images_dir)
|
||||||
|
|
||||||
|
for filename in sorted(os.listdir(images_dir)):
|
||||||
|
filepath = os.path.join(images_dir, filename)
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
continue
|
||||||
|
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
meta = manifest.get(filename)
|
||||||
|
|
||||||
|
if suffix in IMAGE_EXTENSIONS:
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
image_format = img.format or suffix.lstrip(".").upper()
|
||||||
|
has_transparent_pixels = _has_transparent_pixels(img)
|
||||||
|
oriented = ImageOps.exif_transpose(img)
|
||||||
|
try:
|
||||||
|
width, height = oriented.size
|
||||||
|
finally:
|
||||||
|
if oriented is not img:
|
||||||
|
oriented.close()
|
||||||
|
|
||||||
|
aspect_ratio = width / height
|
||||||
|
|
||||||
|
result: ImageAnalysis = {
|
||||||
|
'filename': filename,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'aspect_ratio': aspect_ratio,
|
||||||
|
'pixel_aspect_ratio': aspect_ratio,
|
||||||
|
'source_display_ratio': _manifest_ratio(meta),
|
||||||
|
'ratio_source': 'native',
|
||||||
|
'format': image_format,
|
||||||
|
'has_transparent_pixels': has_transparent_pixels,
|
||||||
|
'category': classify_ratio(aspect_ratio),
|
||||||
|
'filesize_kb': os.path.getsize(filepath) / 1024
|
||||||
|
}
|
||||||
|
_apply_manifest_metadata(result, meta)
|
||||||
|
results.append(result)
|
||||||
|
except (
|
||||||
|
EOFError,
|
||||||
|
OSError,
|
||||||
|
SyntaxError,
|
||||||
|
ValueError,
|
||||||
|
ZeroDivisionError,
|
||||||
|
Image.DecompressionBombError,
|
||||||
|
) as exc:
|
||||||
|
message = f"{filename}: {exc}"
|
||||||
|
errors.append(message)
|
||||||
|
print(f"[WARN] Cannot read {message}")
|
||||||
|
elif meta:
|
||||||
|
result = _result_from_manifest(filename, filepath, meta)
|
||||||
|
if result:
|
||||||
|
results.append(result)
|
||||||
|
else:
|
||||||
|
message = f"{filename}: manifest has no valid display_ratio"
|
||||||
|
errors.append(message)
|
||||||
|
print(f"[WARN] Cannot analyze {message}")
|
||||||
|
elif suffix in OFFICE_VECTOR_EXTENSIONS:
|
||||||
|
message = f"{filename}: image_manifest.json metadata is required"
|
||||||
|
errors.append(message)
|
||||||
|
print(f"[WARN] Cannot analyze {message}")
|
||||||
|
|
||||||
|
return results, errors
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_images(images_dir: str) -> list[ImageAnalysis]:
|
||||||
|
"""Analyze readable image files while preserving the existing public API."""
|
||||||
|
results, _ = _analyze_images(images_dir)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def print_results(results: list[ImageAnalysis]) -> None:
|
||||||
|
"""Print the analysis report to stdout."""
|
||||||
|
|
||||||
|
print("\n" + "=" * REPORT_WIDTH)
|
||||||
|
print("Image Size Analysis Report")
|
||||||
|
print("=" * REPORT_WIDTH)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} "
|
||||||
|
f"{'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Filename'}"
|
||||||
|
)
|
||||||
|
print("-" * REPORT_WIDTH)
|
||||||
|
|
||||||
|
for i, img in enumerate(results, 1):
|
||||||
|
ratio_source = str(img.get('ratio_source', 'native'))
|
||||||
|
usage_count = int(img.get('usage_count', 1))
|
||||||
|
base = (
|
||||||
|
f"{i:<4} {img['width']:<7} {img['height']:<7} "
|
||||||
|
f"{img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} "
|
||||||
|
f"{img['filesize_kb']:<10.1f}KB {img['category']:<20}"
|
||||||
|
)
|
||||||
|
print(f"{base} {img['filename'][:40]}")
|
||||||
|
|
||||||
|
print("-" * REPORT_WIDTH)
|
||||||
|
print(f"Total: {len(results)} images\n")
|
||||||
|
|
||||||
|
# Group statistics by objective aspect-ratio ranges.
|
||||||
|
print("\nGroup by Aspect Ratio:")
|
||||||
|
print("-" * CATEGORY_WIDTH)
|
||||||
|
|
||||||
|
categories = {
|
||||||
|
"Ultra-wide (>2.0)": [],
|
||||||
|
"Wide (1.5-2.0)": [],
|
||||||
|
"Standard (1.2-1.5)": [],
|
||||||
|
"Square (0.8-1.2)": [],
|
||||||
|
"Portrait (<0.8)": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for img in results:
|
||||||
|
ar = img['aspect_ratio']
|
||||||
|
if ar > 2.0:
|
||||||
|
categories["Ultra-wide (>2.0)"].append(img)
|
||||||
|
elif ar > 1.5:
|
||||||
|
categories["Wide (1.5-2.0)"].append(img)
|
||||||
|
elif ar > 1.2:
|
||||||
|
categories["Standard (1.2-1.5)"].append(img)
|
||||||
|
elif ar > 0.8:
|
||||||
|
categories["Square (0.8-1.2)"].append(img)
|
||||||
|
else:
|
||||||
|
categories["Portrait (<0.8)"].append(img)
|
||||||
|
|
||||||
|
for cat, imgs in categories.items():
|
||||||
|
if imgs:
|
||||||
|
print(f"\n{cat}: {len(imgs)} images")
|
||||||
|
for img in imgs[:5]: # Show only the first 5
|
||||||
|
print(f" - {img['width']}x{img['height']} (ratio {img['aspect_ratio']:.2f}) - {img['filename'][:35]}...")
|
||||||
|
if len(imgs) > 5:
|
||||||
|
print(f" ... and {len(imgs) - 5} more")
|
||||||
|
|
||||||
|
native_only = [
|
||||||
|
img for img in results
|
||||||
|
if img.get('asset_kind') == 'office_vector'
|
||||||
|
and not img.get('svg_renderable', True)
|
||||||
|
]
|
||||||
|
if native_only:
|
||||||
|
print("\nOffice vector assets for PPTX native passthrough:")
|
||||||
|
for img in native_only[:10]:
|
||||||
|
original = img.get('original_filename', img['filename'])
|
||||||
|
print(f" - {original} (display ratio {img['aspect_ratio']:.2f}; SVG preview not supported)")
|
||||||
|
if len(native_only) > 10:
|
||||||
|
print(f" ... and {len(native_only) - 10} more")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_optional_number(value: object, digits: int = 2) -> str:
|
||||||
|
"""Format a numeric value for CSV, leaving unavailable facts blank."""
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return ""
|
||||||
|
return f"{float(value):.{digits}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def save_csv(results: list[ImageAnalysis], csv_path: str | Path) -> None:
|
||||||
|
"""Atomically save analysis results to a standards-compliant CSV file."""
|
||||||
|
target = Path(csv_path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
header = [
|
||||||
|
"No",
|
||||||
|
"Filename",
|
||||||
|
"Width",
|
||||||
|
"Height",
|
||||||
|
"AspectRatio",
|
||||||
|
"PixelAspectRatio",
|
||||||
|
"SourceDisplayRatio",
|
||||||
|
"RatioSource",
|
||||||
|
"Format",
|
||||||
|
"HasTransparentPixels",
|
||||||
|
"UsageCount",
|
||||||
|
"DisplayRatioVariants",
|
||||||
|
"AssetKind",
|
||||||
|
"SvgRenderable",
|
||||||
|
"PptxNativeSupported",
|
||||||
|
"SizeKB",
|
||||||
|
"Category",
|
||||||
|
]
|
||||||
|
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="",
|
||||||
|
prefix=f".{target.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=target.parent,
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temporary_path = Path(handle.name)
|
||||||
|
writer = csv.writer(handle, lineterminator="\n")
|
||||||
|
writer.writerow(header)
|
||||||
|
for index, image in enumerate(results, 1):
|
||||||
|
row = [
|
||||||
|
index,
|
||||||
|
image["filename"],
|
||||||
|
image["width"],
|
||||||
|
image["height"],
|
||||||
|
_format_optional_number(image["aspect_ratio"]),
|
||||||
|
_format_optional_number(image.get("pixel_aspect_ratio")),
|
||||||
|
_format_optional_number(image.get("source_display_ratio")),
|
||||||
|
image.get("ratio_source", "native"),
|
||||||
|
image.get("format", ""),
|
||||||
|
image.get("has_transparent_pixels", ""),
|
||||||
|
image.get("usage_count", 1),
|
||||||
|
image.get("display_ratio_variants", ""),
|
||||||
|
image.get("asset_kind", "bitmap"),
|
||||||
|
image.get("svg_renderable", True),
|
||||||
|
image.get("pptx_native_supported", True),
|
||||||
|
_format_optional_number(image["filesize_kb"], digits=1),
|
||||||
|
image["category"],
|
||||||
|
]
|
||||||
|
writer.writerow(row)
|
||||||
|
os.replace(temporary_path, target)
|
||||||
|
temporary_path = None
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
print(f"\n[REPORT] Image analysis CSV: {target}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run the CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Analyze objective image-file facts"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"images_dir",
|
||||||
|
help="Path to the images directory"
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
images_dir = Path(args.images_dir).resolve()
|
||||||
|
|
||||||
|
if not images_dir.exists():
|
||||||
|
print(f"Error: Directory not found: {images_dir}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not images_dir.is_dir():
|
||||||
|
print(f"Error: Not a directory: {images_dir}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Analyzing: {images_dir}")
|
||||||
|
|
||||||
|
results, errors = _analyze_images(str(images_dir))
|
||||||
|
|
||||||
|
if results:
|
||||||
|
print_results(results)
|
||||||
|
else:
|
||||||
|
print("No readable supported image files found in the directory.")
|
||||||
|
|
||||||
|
analysis_dir = images_dir.parent / "analysis"
|
||||||
|
csv_path = analysis_dir / "image_analysis.csv"
|
||||||
|
save_csv(results, csv_path)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(
|
||||||
|
f"[ERROR] {len(errors)} supported image file(s) could not be analyzed; "
|
||||||
|
"the current report was still written.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Animation Config Tool
|
||||||
|
|
||||||
|
Create and validate optional PPTX animation and deterministic Morph sidecars.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/animation_config.py scaffold <project_path>
|
||||||
|
python3 scripts/animation_config.py list-groups <project_path>
|
||||||
|
python3 scripts/animation_config.py validate <project_path>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/animation_config.py scaffold projects/demo --force
|
||||||
|
python3 scripts/animation_config.py list-groups projects/demo
|
||||||
|
python3 scripts/animation_config.py validate projects/demo
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (standard library only)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
from svg_to_pptx.animation_config import ( # noqa: E402
|
||||||
|
build_group_listing,
|
||||||
|
load_animation_config,
|
||||||
|
validate_animation_config,
|
||||||
|
validate_animation_config_errors,
|
||||||
|
validate_transition_config,
|
||||||
|
write_scaffold,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Create or validate PPTX motion sidecar configuration.',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest='command', required=True)
|
||||||
|
|
||||||
|
scaffold = subparsers.add_parser(
|
||||||
|
'scaffold',
|
||||||
|
help='scan svg_output/*.svg and write animations.json scaffold',
|
||||||
|
)
|
||||||
|
scaffold.add_argument('project_path', help='Project directory')
|
||||||
|
scaffold.add_argument('-o', '--output', default=None, help='Output path; default: <project>/animations.json')
|
||||||
|
scaffold.add_argument('--force', action='store_true', help='Overwrite an existing output file')
|
||||||
|
|
||||||
|
list_groups = subparsers.add_parser(
|
||||||
|
'list-groups',
|
||||||
|
help='print one compact line per slide listing animatable group ids '
|
||||||
|
'(chrome groups excluded); use during planning to avoid reading '
|
||||||
|
'the full scaffold file',
|
||||||
|
)
|
||||||
|
list_groups.add_argument('project_path', help='Project directory')
|
||||||
|
|
||||||
|
validate = subparsers.add_parser(
|
||||||
|
'validate',
|
||||||
|
help='validate animations.json values and project-local references',
|
||||||
|
)
|
||||||
|
validate.add_argument('project_path', help='Project directory')
|
||||||
|
validate.add_argument('-c', '--config', default=None, help='Config path; default: <project>/animations.json')
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
project_path = Path(args.project_path)
|
||||||
|
if not project_path.exists():
|
||||||
|
print(f'Error: Project path does not exist: {project_path}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.command == 'scaffold':
|
||||||
|
try:
|
||||||
|
output_path = write_scaffold(
|
||||||
|
project_path,
|
||||||
|
output_path=args.output,
|
||||||
|
force=args.force,
|
||||||
|
)
|
||||||
|
except (FileExistsError, ValueError) as exc:
|
||||||
|
print(f'Error: {exc}', file=sys.stderr)
|
||||||
|
if isinstance(exc, FileExistsError):
|
||||||
|
print('Use --force to overwrite.', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f'Animation config scaffold written: {output_path}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == 'list-groups':
|
||||||
|
try:
|
||||||
|
lines, anonymous = build_group_listing(project_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f'Error: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
for line in lines:
|
||||||
|
print(line)
|
||||||
|
for warning in anonymous:
|
||||||
|
print(f'Warning: {warning}', file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == 'validate':
|
||||||
|
try:
|
||||||
|
config = load_animation_config(project_path, args.config)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'Error: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if config is None:
|
||||||
|
print('No animations.json found; default animation policy will be used.')
|
||||||
|
return 0
|
||||||
|
errors = list(dict.fromkeys(
|
||||||
|
validate_transition_config(config)
|
||||||
|
+ validate_animation_config_errors(config)
|
||||||
|
))
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
print(f'Error: {error}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
reference_messages = validate_animation_config(project_path, config)
|
||||||
|
reference_warnings = [
|
||||||
|
message for message in reference_messages
|
||||||
|
if ' has no id and cannot be customized in animations.json' in message
|
||||||
|
]
|
||||||
|
reference_errors = [
|
||||||
|
message for message in reference_messages
|
||||||
|
if message not in reference_warnings
|
||||||
|
]
|
||||||
|
for warning in reference_warnings:
|
||||||
|
print(f'Warning: {warning}', file=sys.stderr)
|
||||||
|
if reference_errors:
|
||||||
|
for error in reference_errors:
|
||||||
|
print(f'Error: {error}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print('Animation config validated successfully.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Skill Attribution Guard
|
||||||
|
|
||||||
|
Fail closed when the distributed Skill attribution bundle or its execution
|
||||||
|
gates are missing or modified.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/attribution_guard.py
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/attribution_guard.py
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (only uses standard library)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_ERROR_MESSAGE = (
|
||||||
|
"PPT Master skill integrity check failed. Please use the complete official "
|
||||||
|
"distribution without removing its attribution files."
|
||||||
|
)
|
||||||
|
_SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
_EXACT_METADATA_VALUES = {
|
||||||
|
"copyright": '"Copyright (c) 2025-2026 Hugo He"',
|
||||||
|
"license": '"MIT"',
|
||||||
|
"official_repository": '"https://github.com/hugohe3/ppt-master"',
|
||||||
|
}
|
||||||
|
_REQUIRED_METADATA_FIELDS = ("sponsors",)
|
||||||
|
_REQUIRED_ATTRIBUTION_FILES = ("LICENSE", "SPONSORS.md", "SPONSORS_CN.md")
|
||||||
|
_LICENSE_DIGEST = "80cefc234c1ec12a8cece4344f16300c634fa03df7891686fcf979e3828f0921"
|
||||||
|
_REQUIRED_GATE_FILES = (
|
||||||
|
"scripts/console_encoding.py",
|
||||||
|
"scripts/project_manager.py",
|
||||||
|
"scripts/project_management/cli.py",
|
||||||
|
"scripts/svg_quality_checker.py",
|
||||||
|
"scripts/svg_quality/cli.py",
|
||||||
|
"scripts/svg_to_pptx.py",
|
||||||
|
"scripts/svg_to_pptx/pptx_package/cli.py",
|
||||||
|
"scripts/template_fill_pptx.py",
|
||||||
|
"scripts/template_fill_pptx/cli.py",
|
||||||
|
"scripts/native_enhance_pptx.py",
|
||||||
|
"scripts/native_enhance_pptx_core.py",
|
||||||
|
"scripts/register_template.py",
|
||||||
|
"scripts/template_preview_pptx.py",
|
||||||
|
)
|
||||||
|
_SKILL_GATE_MARKER = "python3 scripts/attribution_guard.py"
|
||||||
|
_SECONDARY_GATE_FILE = "scripts/console_encoding.py"
|
||||||
|
_SECONDARY_GATE_NAME = "_require_official_distribution_identity"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_bytes(path: Path) -> bytes:
|
||||||
|
"""Return UTF-8 text bytes with platform line endings normalized."""
|
||||||
|
data = path.read_bytes()
|
||||||
|
if data.startswith(b"\xef\xbb\xbf"):
|
||||||
|
raise ValueError("UTF-8 BOM is not allowed")
|
||||||
|
text = data.decode("utf-8")
|
||||||
|
return text.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _frontmatter(skill_text: str) -> str:
|
||||||
|
"""Return the opening YAML frontmatter or reject malformed input."""
|
||||||
|
if not skill_text.startswith("---\n"):
|
||||||
|
raise ValueError("missing frontmatter")
|
||||||
|
end = skill_text.find("\n---\n", 4)
|
||||||
|
if end < 0:
|
||||||
|
raise ValueError("unterminated frontmatter")
|
||||||
|
return skill_text[4:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_is_valid() -> bool:
|
||||||
|
"""Require fixed identity values and each structural attribution field."""
|
||||||
|
skill_text = _normalized_bytes(_SKILL_DIR / "SKILL.md").decode("utf-8")
|
||||||
|
metadata = _frontmatter(skill_text)
|
||||||
|
return (
|
||||||
|
all(
|
||||||
|
len(re.findall(
|
||||||
|
rf"(?m)^ {re.escape(field)}\s*:\s*{re.escape(value)}\s*$",
|
||||||
|
metadata,
|
||||||
|
)) == 1
|
||||||
|
for field, value in _EXACT_METADATA_VALUES.items()
|
||||||
|
)
|
||||||
|
and all(
|
||||||
|
len(re.findall(rf"(?m)^ {re.escape(field)}\s*:", metadata)) == 1
|
||||||
|
for field in _REQUIRED_METADATA_FIELDS
|
||||||
|
)
|
||||||
|
and skill_text.count(_SKILL_GATE_MARKER) == 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _protected_files_are_valid() -> bool:
|
||||||
|
"""Require all attribution paths and the exact MIT license text."""
|
||||||
|
for relative_path in _REQUIRED_ATTRIBUTION_FILES:
|
||||||
|
path = _SKILL_DIR / relative_path
|
||||||
|
if not path.is_file():
|
||||||
|
return False
|
||||||
|
license_digest = hashlib.sha256(_normalized_bytes(_SKILL_DIR / "LICENSE")).hexdigest()
|
||||||
|
return license_digest == _LICENSE_DIGEST
|
||||||
|
|
||||||
|
|
||||||
|
def _is_zero_arg_call(node: ast.stmt, function_name: str) -> bool:
|
||||||
|
"""Return whether one statement directly invokes the named function."""
|
||||||
|
return (
|
||||||
|
isinstance(node, ast.Expr)
|
||||||
|
and isinstance(node.value, ast.Call)
|
||||||
|
and isinstance(node.value.func, ast.Name)
|
||||||
|
and node.value.func.id == function_name
|
||||||
|
and not node.value.args
|
||||||
|
and not node.value.keywords
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gate_call(node: ast.stmt) -> bool:
|
||||||
|
"""Return whether one statement directly invokes the primary integrity gate."""
|
||||||
|
return _is_zero_arg_call(node, "require_skill_integrity")
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_gate_is_valid(path: Path) -> bool:
|
||||||
|
"""Require one live import and one live entry-point call."""
|
||||||
|
tree = ast.parse(_normalized_bytes(path).decode("utf-8"), filename=str(path))
|
||||||
|
has_import = any(
|
||||||
|
isinstance(node, ast.ImportFrom)
|
||||||
|
and node.module == "attribution_guard"
|
||||||
|
and any(alias.name == "require_skill_integrity" for alias in node.names)
|
||||||
|
for node in tree.body
|
||||||
|
)
|
||||||
|
has_entry_call = False
|
||||||
|
for node in tree.body:
|
||||||
|
if (
|
||||||
|
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and node.name in {"configure_utf8_stdio", "main"}
|
||||||
|
):
|
||||||
|
body = node.body[1:] if (
|
||||||
|
node.body
|
||||||
|
and isinstance(node.body[0], ast.Expr)
|
||||||
|
and isinstance(node.body[0].value, ast.Constant)
|
||||||
|
and isinstance(node.body[0].value.value, str)
|
||||||
|
) else node.body
|
||||||
|
has_entry_call = bool(body and _is_gate_call(body[0]))
|
||||||
|
elif isinstance(node, ast.If) and any(_is_gate_call(statement) for statement in node.body):
|
||||||
|
has_entry_call = True
|
||||||
|
return has_import and has_entry_call
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_gates_are_valid() -> bool:
|
||||||
|
"""Require every supported route to retain executable guard calls."""
|
||||||
|
for relative_path in _REQUIRED_GATE_FILES:
|
||||||
|
if not _execution_gate_is_valid(_SKILL_DIR / relative_path):
|
||||||
|
return False
|
||||||
|
return _secondary_execution_gate_is_valid(_SKILL_DIR / _SECONDARY_GATE_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _secondary_execution_gate_is_valid(path: Path) -> bool:
|
||||||
|
"""Require the independent identity guard and its live bootstrap call."""
|
||||||
|
tree = ast.parse(_normalized_bytes(path).decode("utf-8"), filename=str(path))
|
||||||
|
has_guard = any(
|
||||||
|
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and node.name == _SECONDARY_GATE_NAME
|
||||||
|
for node in tree.body
|
||||||
|
)
|
||||||
|
for node in tree.body:
|
||||||
|
if not (
|
||||||
|
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and node.name == "configure_utf8_stdio"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
body = node.body[1:] if (
|
||||||
|
node.body
|
||||||
|
and isinstance(node.body[0], ast.Expr)
|
||||||
|
and isinstance(node.body[0].value, ast.Constant)
|
||||||
|
and isinstance(node.body[0].value.value, str)
|
||||||
|
) else node.body
|
||||||
|
return (
|
||||||
|
has_guard
|
||||||
|
and len(body) >= 2
|
||||||
|
and _is_gate_call(body[0])
|
||||||
|
and _is_zero_arg_call(body[1], _SECONDARY_GATE_NAME)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _integrity_is_valid() -> bool:
|
||||||
|
"""Validate every local attribution and execution invariant."""
|
||||||
|
return (
|
||||||
|
_metadata_is_valid()
|
||||||
|
and _protected_files_are_valid()
|
||||||
|
and _execution_gates_are_valid()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_skill_integrity() -> None:
|
||||||
|
"""Stop the active command with one generic message on any expected failure."""
|
||||||
|
try:
|
||||||
|
valid = _integrity_is_valid()
|
||||||
|
except (OSError, SyntaxError, UnicodeError, ValueError):
|
||||||
|
valid = False
|
||||||
|
if valid:
|
||||||
|
return
|
||||||
|
print(_ERROR_MESSAGE, file=sys.stderr)
|
||||||
|
raise SystemExit(78)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the fail-closed Skill integrity gate."""
|
||||||
|
require_skill_integrity()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Batch Project Validation Tool
|
||||||
|
|
||||||
|
Checks the structural integrity and compliance of multiple projects at once.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/batch_validate.py examples
|
||||||
|
python3 scripts/batch_validate.py projects
|
||||||
|
python3 scripts/batch_validate.py --all
|
||||||
|
python3 scripts/batch_validate.py examples projects
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from project_utils import (
|
||||||
|
find_all_projects,
|
||||||
|
get_project_info,
|
||||||
|
validate_project_structure,
|
||||||
|
validate_svg_viewbox,
|
||||||
|
CANVAS_FORMATS
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
print("Error: Unable to import project_utils module")
|
||||||
|
print("Please ensure project_utils.py is in the same directory")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchValidator:
|
||||||
|
"""Batch validator"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.results: list[dict[str, object]] = []
|
||||||
|
self.summary = {
|
||||||
|
'total': 0,
|
||||||
|
'valid': 0,
|
||||||
|
'has_errors': 0,
|
||||||
|
'has_warnings': 0,
|
||||||
|
'missing_readme': 0,
|
||||||
|
'missing_spec': 0,
|
||||||
|
'svg_issues': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_directory(self, directory: str, recursive: bool = False) -> list[dict[str, object]]:
|
||||||
|
"""
|
||||||
|
Validate all projects in a directory
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory path
|
||||||
|
recursive: Whether to recursively search subdirectories
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of validation results
|
||||||
|
"""
|
||||||
|
dir_path = Path(directory)
|
||||||
|
if not dir_path.exists():
|
||||||
|
print(f"[ERROR] Directory does not exist: {directory}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f"\n[SCAN] Scanning directory: {directory}")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
projects = find_all_projects(directory)
|
||||||
|
|
||||||
|
if not projects:
|
||||||
|
print(f"[WARN] No projects found")
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f"Found {len(projects)} project(s)\n")
|
||||||
|
|
||||||
|
for project_path in projects:
|
||||||
|
self.validate_project(str(project_path))
|
||||||
|
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
def validate_project(self, project_path: str) -> dict[str, object]:
|
||||||
|
"""
|
||||||
|
Validate a single project
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Project path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result dictionary
|
||||||
|
"""
|
||||||
|
self.summary['total'] += 1
|
||||||
|
|
||||||
|
# Get project info
|
||||||
|
info = get_project_info(project_path)
|
||||||
|
|
||||||
|
# Validate project structure
|
||||||
|
is_valid, errors, warnings = validate_project_structure(project_path)
|
||||||
|
|
||||||
|
# Validate SVG viewBox
|
||||||
|
svg_warnings = []
|
||||||
|
if info['svg_files']:
|
||||||
|
project_path_obj = Path(project_path)
|
||||||
|
svg_files = [project_path_obj / 'svg_output' /
|
||||||
|
f for f in info['svg_files']]
|
||||||
|
svg_warnings = validate_svg_viewbox(svg_files, info['format'])
|
||||||
|
|
||||||
|
# Aggregate results
|
||||||
|
result = {
|
||||||
|
'path': project_path,
|
||||||
|
'name': info['name'],
|
||||||
|
'format': info['format_name'],
|
||||||
|
'date': info['date_formatted'],
|
||||||
|
'svg_count': info['svg_count'],
|
||||||
|
'is_valid': is_valid,
|
||||||
|
'errors': errors,
|
||||||
|
'warnings': warnings + svg_warnings,
|
||||||
|
'has_readme': info['has_readme'],
|
||||||
|
'has_spec': info['has_spec']
|
||||||
|
}
|
||||||
|
|
||||||
|
self.results.append(result)
|
||||||
|
|
||||||
|
# Update statistics
|
||||||
|
if is_valid and not warnings and not svg_warnings:
|
||||||
|
self.summary['valid'] += 1
|
||||||
|
status = "[OK]"
|
||||||
|
elif errors:
|
||||||
|
self.summary['has_errors'] += 1
|
||||||
|
status = "[ERROR]"
|
||||||
|
else:
|
||||||
|
self.summary['has_warnings'] += 1
|
||||||
|
status = "[WARN]"
|
||||||
|
|
||||||
|
if not info['has_readme']:
|
||||||
|
self.summary['missing_readme'] += 1
|
||||||
|
if not info['has_spec']:
|
||||||
|
self.summary['missing_spec'] += 1
|
||||||
|
if svg_warnings:
|
||||||
|
self.summary['svg_issues'] += 1
|
||||||
|
|
||||||
|
# Print result
|
||||||
|
print(f"{status} {info['name']}")
|
||||||
|
print(f" Path: {project_path}")
|
||||||
|
print(
|
||||||
|
f" Format: {info['format_name']} | SVG: {info['svg_count']} file(s) | Date: {info['date_formatted']}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f" [ERROR] Errors ({len(errors)}):")
|
||||||
|
for error in errors:
|
||||||
|
print(f" - {error}")
|
||||||
|
|
||||||
|
if warnings or svg_warnings:
|
||||||
|
all_warnings = warnings + svg_warnings
|
||||||
|
print(f" [WARN] Warnings ({len(all_warnings)}):")
|
||||||
|
for warning in all_warnings[:3]: # Only show first 3 warnings
|
||||||
|
print(f" - {warning}")
|
||||||
|
if len(all_warnings) > 3:
|
||||||
|
print(f" ... and {len(all_warnings) - 3} more warning(s)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def print_summary(self) -> None:
|
||||||
|
"""Print a summary of validation results."""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("[Summary] Validation Summary")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
print(f"\nTotal projects: {self.summary['total']}")
|
||||||
|
print(
|
||||||
|
f" [OK] Fully valid: {self.summary['valid']} ({self._percentage(self.summary['valid'])}%)")
|
||||||
|
print(
|
||||||
|
f" [WARN] With warnings: {self.summary['has_warnings']} ({self._percentage(self.summary['has_warnings'])}%)")
|
||||||
|
print(
|
||||||
|
f" [ERROR] With errors: {self.summary['has_errors']} ({self._percentage(self.summary['has_errors'])}%)")
|
||||||
|
|
||||||
|
print(f"\nCommon issues:")
|
||||||
|
print(f" Missing README.md: {self.summary['missing_readme']} project(s)")
|
||||||
|
print(f" Missing design spec: {self.summary['missing_spec']} project(s)")
|
||||||
|
print(f" SVG format issues: {self.summary['svg_issues']} project(s)")
|
||||||
|
|
||||||
|
# Group statistics by format
|
||||||
|
format_stats = defaultdict(int)
|
||||||
|
for result in self.results:
|
||||||
|
format_stats[result['format']] += 1
|
||||||
|
|
||||||
|
if format_stats:
|
||||||
|
print(f"\nCanvas format distribution:")
|
||||||
|
for fmt, count in sorted(format_stats.items(), key=lambda x: x[1], reverse=True):
|
||||||
|
print(f" {fmt}: {count} project(s)")
|
||||||
|
|
||||||
|
# Provide fix suggestions
|
||||||
|
if self.summary['has_errors'] > 0 or self.summary['has_warnings'] > 0:
|
||||||
|
print(f"\n[TIP] Fix suggestions:")
|
||||||
|
|
||||||
|
if self.summary['missing_readme'] > 0:
|
||||||
|
print(f" 1. Create documentation for projects missing README")
|
||||||
|
print(
|
||||||
|
f" Include the project goal, sources, canvas, artifacts, and export path")
|
||||||
|
|
||||||
|
if self.summary['svg_issues'] > 0:
|
||||||
|
print(f" 2. Check SVG root viewBox settings")
|
||||||
|
print(f" The SVG root viewBox is the export canvas authority")
|
||||||
|
|
||||||
|
if self.summary['missing_spec'] > 0:
|
||||||
|
print(f" 3. Add design specification files")
|
||||||
|
|
||||||
|
def _percentage(self, count: int) -> int:
|
||||||
|
"""Calculate percentage"""
|
||||||
|
if self.summary['total'] == 0:
|
||||||
|
return 0
|
||||||
|
return int(count / self.summary['total'] * 100)
|
||||||
|
|
||||||
|
def export_report(self, output_file: str = 'validation_report.txt') -> None:
|
||||||
|
"""
|
||||||
|
Export validation report to file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_file: Output file path
|
||||||
|
"""
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("PPT Master Project Validation Report\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
for result in self.results:
|
||||||
|
status = "[OK] Valid" if result['is_valid'] and not result['warnings'] else \
|
||||||
|
"[ERROR] Error" if result['errors'] else "[WARN] Warning"
|
||||||
|
|
||||||
|
f.write(f"{status} - {result['name']}\n")
|
||||||
|
f.write(f"Path: {result['path']}\n")
|
||||||
|
f.write(
|
||||||
|
f"Format: {result['format']} | SVG: {result['svg_count']} file(s)\n")
|
||||||
|
|
||||||
|
if result['errors']:
|
||||||
|
f.write(f"\nErrors:\n")
|
||||||
|
for error in result['errors']:
|
||||||
|
f.write(f" - {error}\n")
|
||||||
|
|
||||||
|
if result['warnings']:
|
||||||
|
f.write(f"\nWarnings:\n")
|
||||||
|
for warning in result['warnings']:
|
||||||
|
f.write(f" - {warning}\n")
|
||||||
|
|
||||||
|
f.write("\n" + "-" * 80 + "\n\n")
|
||||||
|
|
||||||
|
# Write summary
|
||||||
|
f.write("\n" + "=" * 80 + "\n")
|
||||||
|
f.write("Validation Summary\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
f.write(f"Total projects: {self.summary['total']}\n")
|
||||||
|
f.write(f"Fully valid: {self.summary['valid']}\n")
|
||||||
|
f.write(f"With warnings: {self.summary['has_warnings']}\n")
|
||||||
|
f.write(f"With errors: {self.summary['has_errors']}\n")
|
||||||
|
|
||||||
|
print(f"\n[REPORT] Validation report exported: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the command-line parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate one or more PPT Master project directories.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""Examples:
|
||||||
|
python3 scripts/batch_validate.py examples
|
||||||
|
python3 scripts/batch_validate.py projects
|
||||||
|
python3 scripts/batch_validate.py examples projects
|
||||||
|
python3 scripts/batch_validate.py --all
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("directories", nargs="*", help="Directories to scan")
|
||||||
|
parser.add_argument("--all", action="store_true", help="Validate examples and projects")
|
||||||
|
parser.add_argument("--export", action="store_true", help="Write a validation report")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="validation_report.txt",
|
||||||
|
help="Report path when --export is used",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run the CLI entry point."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
validator = BatchValidator()
|
||||||
|
|
||||||
|
if args.all:
|
||||||
|
directories = ['examples', 'projects']
|
||||||
|
else:
|
||||||
|
directories = args.directories
|
||||||
|
|
||||||
|
if not directories:
|
||||||
|
parser.print_help()
|
||||||
|
print(
|
||||||
|
"\n[ERROR] Provide at least one directory or pass --all.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Validate each directory
|
||||||
|
for directory in directories:
|
||||||
|
if Path(directory).exists():
|
||||||
|
validator.validate_directory(directory)
|
||||||
|
else:
|
||||||
|
print(f"[WARN] Skipping non-existent directory: {directory}\n")
|
||||||
|
|
||||||
|
if validator.summary['total'] == 0:
|
||||||
|
print(
|
||||||
|
"[ERROR] No projects were found in the requested directories.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
validator.print_summary()
|
||||||
|
|
||||||
|
# Export report (if specified)
|
||||||
|
if args.export:
|
||||||
|
validator.export_report(args.output)
|
||||||
|
|
||||||
|
# Return exit code
|
||||||
|
if validator.summary['has_errors'] > 0:
|
||||||
|
return 1
|
||||||
|
elif validator.summary['has_warnings'] > 0:
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Beautify Identity Extractor
|
||||||
|
|
||||||
|
Extract a source deck's visual identity as JSON for the beautify-pptx profile:
|
||||||
|
the declared `theme` (palette + major/minor fonts + master placeholder sizes,
|
||||||
|
full bodyStyle ramp in `sizes.body_levels`) plus `observed` usage (run-level
|
||||||
|
fonts incl. CJK `ea`, explicit point sizes, and frequent explicit fill colors)
|
||||||
|
sampled across slides, plus `layout_sizes_pt` (in-use layout body-placeholder
|
||||||
|
level-1 sizes — a reference hint, not an auto-seed) — so the workflow can
|
||||||
|
recommend theme vs actual-usage identity, incl. a source-derived body size
|
||||||
|
(seed chain: observed → theme.sizes.body → canvas baseline) and let the user
|
||||||
|
confirm. Pure read: reuses the pptx_to_svg resolver, writes
|
||||||
|
no PPTX.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/beautify_identity.py <source.pptx> [-o identity.json]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/beautify_identity.py projects/x/sources/deck.pptx
|
||||||
|
python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/deck.identity.json
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None beyond the standard library (reuses scripts/pptx_to_svg/).
|
||||||
|
|
||||||
|
See workflows/profiles/beautify-pptx.md for how the emitted identity is consumed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
from pptx_to_svg.color_resolver import ColorPalette # noqa: E402
|
||||||
|
from pptx_to_svg.emu_units import NS # noqa: E402
|
||||||
|
from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
def _font_pair(theme_root, font_tag: str) -> dict[str, object]:
|
||||||
|
"""Read one theme font family, including explicit CJK script mappings."""
|
||||||
|
out: dict[str, object] = {}
|
||||||
|
font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS)
|
||||||
|
if font is None:
|
||||||
|
return out
|
||||||
|
for slot, key in (("a:latin", "latin"), ("a:ea", "ea"), ("a:cs", "cs")):
|
||||||
|
elem = font.find(slot, NS)
|
||||||
|
if elem is not None:
|
||||||
|
face = (elem.attrib.get("typeface") or "").strip()
|
||||||
|
if face:
|
||||||
|
out[key] = face
|
||||||
|
scripts: dict[str, str] = {}
|
||||||
|
for elem in font.findall("a:font", NS):
|
||||||
|
script = (elem.attrib.get("script") or "").strip()
|
||||||
|
face = (elem.attrib.get("typeface") or "").strip()
|
||||||
|
if script in {"Hans", "Hant", "Jpan", "Hang"} and face:
|
||||||
|
scripts[script] = face
|
||||||
|
if scripts:
|
||||||
|
out["scripts"] = scripts
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _rank(counter: dict, limit: int) -> list[dict]:
|
||||||
|
"""Frequency-ranked [{value, count}, ...], most common first."""
|
||||||
|
ranked = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||||
|
return [{"value": v, "count": n} for v, n in ranked[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def _master_text_sizes(master_root) -> dict:
|
||||||
|
"""Declared title/body point sizes from the master's <p:txStyles>.
|
||||||
|
|
||||||
|
`title` is <p:titleStyle> level-1; `body` is <p:bodyStyle> level-1 and
|
||||||
|
`body_levels` is every declared body outline level in order (lvl1..lvl9).
|
||||||
|
`sz` is in hundredths of a point. This is the *declared* size — the
|
||||||
|
placeholder default a run inherits when it sets no explicit `sz` — the size
|
||||||
|
counterpart of `theme.fonts`. Note level-1 is the coarsest/largest body
|
||||||
|
level and commonly over-reads real density; `body_levels` exposes the full
|
||||||
|
ramp. Returns {} when no txStyles are present.
|
||||||
|
"""
|
||||||
|
out: dict[str, object] = {}
|
||||||
|
title = master_root.find(".//p:txStyles/p:titleStyle//a:defRPr", NS)
|
||||||
|
if title is not None:
|
||||||
|
sz = (title.attrib.get("sz") or "").strip()
|
||||||
|
if sz.isdigit():
|
||||||
|
out["title"] = int(sz) / 100
|
||||||
|
body = master_root.find(".//p:txStyles/p:bodyStyle", NS)
|
||||||
|
levels: list[float] = []
|
||||||
|
if body is not None:
|
||||||
|
for lvl in body: # a:lvl1pPr .. a:lvl9pPr, in document order
|
||||||
|
defrpr = lvl.find("a:defRPr", NS)
|
||||||
|
if defrpr is None:
|
||||||
|
continue
|
||||||
|
sz = (defrpr.attrib.get("sz") or "").strip()
|
||||||
|
if sz.isdigit():
|
||||||
|
levels.append(int(sz) / 100)
|
||||||
|
if levels:
|
||||||
|
out["body"] = levels[0]
|
||||||
|
out["body_levels"] = levels
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Placeholder types that carry chrome, not body text — excluded from the layout
|
||||||
|
# body-size sample so a 9pt footer / slide-number doesn't masquerade as body.
|
||||||
|
_NON_BODY_PH = {"title", "ctrTitle", "ftr", "sldNum", "dt", "hdr"}
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_text_sizes(pkg: "OoxmlPackage") -> list[dict]:
|
||||||
|
"""Frequency-ranked **body** placeholder `defRPr` sizes across the layouts in use.
|
||||||
|
|
||||||
|
For a theme/template-driven deck whose runs set no explicit `sz` (everything
|
||||||
|
inherits its placeholder), this is where a real body size can show: each
|
||||||
|
layout's body placeholder may declare its own size where the master only gives
|
||||||
|
the coarse level-1 default. Only layouts actually referenced by a slide count,
|
||||||
|
only body-ish placeholders (title / footer / slide-number / date / header
|
||||||
|
skipped), and only the placeholder's **level-1** `defRPr` — the primary size —
|
||||||
|
so deeper outline levels (lvl2..lvl9) don't masquerade as body. Sizes in
|
||||||
|
points. Often sparse: placeholders that don't override level-1 inherit the
|
||||||
|
master default and contribute nothing here.
|
||||||
|
"""
|
||||||
|
sizes: dict[float, int] = {}
|
||||||
|
seen: set[str] = set()
|
||||||
|
for slide in pkg.iter_slides():
|
||||||
|
layout = slide.layout
|
||||||
|
if layout is None or layout.path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(layout.path)
|
||||||
|
for sp in layout.xml.iterfind(".//p:sp", NS):
|
||||||
|
ph = sp.find(".//p:nvSpPr/p:nvPr/p:ph", NS)
|
||||||
|
ph_type = ph.attrib.get("type") if ph is not None else None
|
||||||
|
if ph_type in _NON_BODY_PH:
|
||||||
|
continue
|
||||||
|
lvl1 = sp.find(".//a:lstStyle/a:lvl1pPr/a:defRPr", NS)
|
||||||
|
if lvl1 is None:
|
||||||
|
continue
|
||||||
|
sz = (lvl1.attrib.get("sz") or "").strip()
|
||||||
|
if sz.isdigit():
|
||||||
|
pt = int(sz) / 100
|
||||||
|
sizes[pt] = sizes.get(pt, 0) + 1
|
||||||
|
return _rank(sizes, 10)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_observed(pkg: "OoxmlPackage") -> dict:
|
||||||
|
"""Aggregate run-level fonts, explicit point sizes, and explicit fill colors.
|
||||||
|
|
||||||
|
Theme extraction reports the *declared* identity; a hand-edited deck often
|
||||||
|
overrides it per shape / run. This is a frequency sample of run-level usage
|
||||||
|
(not a full style resolution — it misses schemeClr + master/layout
|
||||||
|
inheritance, and counts chart/gradient fills), enough for the workflow to
|
||||||
|
recommend theme vs observed. `sizes_pt` only counts runs that set an explicit
|
||||||
|
`sz`; runs inheriting the placeholder size are not seen here (use
|
||||||
|
`theme.sizes` for those), so a small sample is a hint, not the full picture.
|
||||||
|
"""
|
||||||
|
latin: dict[str, int] = {}
|
||||||
|
ea: dict[str, int] = {}
|
||||||
|
sizes: dict[float, int] = {}
|
||||||
|
colors: dict[str, int] = {}
|
||||||
|
for slide in pkg.iter_slides():
|
||||||
|
root = slide.part.xml
|
||||||
|
for tag, bucket in (("a:latin", latin), ("a:ea", ea)):
|
||||||
|
for elem in root.iterfind(f".//{tag}", NS):
|
||||||
|
face = (elem.attrib.get("typeface") or "").strip()
|
||||||
|
if face and not face.startswith("+"): # skip +mj-*/+mn-* theme refs
|
||||||
|
bucket[face] = bucket.get(face, 0) + 1
|
||||||
|
for elem in root.iterfind(".//a:rPr", NS):
|
||||||
|
sz = (elem.attrib.get("sz") or "").strip()
|
||||||
|
if sz.isdigit():
|
||||||
|
pt = int(sz) / 100
|
||||||
|
sizes[pt] = sizes.get(pt, 0) + 1
|
||||||
|
for elem in root.iterfind(".//a:srgbClr", NS):
|
||||||
|
val = (elem.attrib.get("val") or "").strip().upper()
|
||||||
|
if val:
|
||||||
|
colors[f"#{val}"] = colors.get(f"#{val}", 0) + 1
|
||||||
|
return {
|
||||||
|
"fonts": {"latin": _rank(latin, 5), "ea": _rank(ea, 5)},
|
||||||
|
"sizes_pt": _rank(sizes, 8),
|
||||||
|
"colors": _rank(colors, 8),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_identity(pptx_path: Path) -> dict:
|
||||||
|
"""Resolve the deck's theme + observed-usage identity, plus canvas."""
|
||||||
|
with OoxmlPackage(pptx_path) as pkg:
|
||||||
|
first = pkg.get_slide(1)
|
||||||
|
master = first.master if first else None
|
||||||
|
theme = pkg.resolve_theme(master)
|
||||||
|
palette_resolver = ColorPalette(master, theme, strict=False)
|
||||||
|
|
||||||
|
# Presentation-level scheme names; ColorPalette applies clrMap + aliases.
|
||||||
|
scheme = {
|
||||||
|
"background": palette_resolver.resolve_scheme("bg1"),
|
||||||
|
"background_alt": palette_resolver.resolve_scheme("bg2"),
|
||||||
|
"text": palette_resolver.resolve_scheme("tx1"),
|
||||||
|
"text_alt": palette_resolver.resolve_scheme("tx2"),
|
||||||
|
"hyperlink": palette_resolver.resolve_scheme("hlink"),
|
||||||
|
}
|
||||||
|
accents = {
|
||||||
|
f"accent{i}": palette_resolver.resolve_scheme(f"accent{i}")
|
||||||
|
for i in range(1, 7)
|
||||||
|
}
|
||||||
|
palette = {
|
||||||
|
k: (f"#{v}" if v else None)
|
||||||
|
for k, v in {**scheme, **accents}.items()
|
||||||
|
}
|
||||||
|
# accent1 is the conventional primary.
|
||||||
|
palette["primary"] = palette.get("accent1")
|
||||||
|
|
||||||
|
fonts = {}
|
||||||
|
if theme is not None:
|
||||||
|
fonts = {
|
||||||
|
"title": _font_pair(theme.xml, "majorFont"),
|
||||||
|
"body": _font_pair(theme.xml, "minorFont"),
|
||||||
|
}
|
||||||
|
sizes = _master_text_sizes(master.xml) if master is not None else {}
|
||||||
|
|
||||||
|
w, h = pkg.slide_size_px
|
||||||
|
canvas = {
|
||||||
|
"width_px": round(w),
|
||||||
|
"height_px": round(h),
|
||||||
|
"aspect": round(w / h, 4) if h else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": str(pptx_path),
|
||||||
|
"slide_count": pkg.slide_count,
|
||||||
|
"canvas": canvas,
|
||||||
|
"theme": {"palette": palette, "fonts": fonts, "sizes": sizes},
|
||||||
|
"observed": _sample_observed(pkg),
|
||||||
|
"layout_sizes_pt": _layout_text_sizes(pkg),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Extract a source deck's theme palette + fonts + sizes + canvas as JSON.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("source", help="Source .pptx file")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output",
|
||||||
|
help="Write JSON here (default: stdout)",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
src = Path(args.source)
|
||||||
|
if not src.is_file():
|
||||||
|
print(f"[ERROR] source not found: {src}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
identity = extract_identity(src)
|
||||||
|
except (RuntimeError, KeyError, ValueError) as exc:
|
||||||
|
print(f"[ERROR] failed to extract identity: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
payload = json.dumps(identity, ensure_ascii=False, indent=2)
|
||||||
|
if args.output:
|
||||||
|
out = Path(args.output)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(payload + "\n", encoding="utf-8")
|
||||||
|
print(f"[OK] identity written to: {out}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Beautify Inventory Builder
|
||||||
|
|
||||||
|
Mechanically merge a source deck's extracts into one per-slide ledger for the
|
||||||
|
beautify-pptx profile: text blocks + tables + charts + SmartArt structure (from a
|
||||||
|
`template_fill_pptx.py analyze` slide_library.json) joined with the images
|
||||||
|
bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic
|
||||||
|
join only — `ignored` and `needs_confirmation` are emitted empty for the agent
|
||||||
|
to fill with judgment (hidden shapes, combo charts, overcrowded pages, ...).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/beautify_inventory.py <slide_library.json> [--images <image_manifest.json>] [-o inventory.json]
|
||||||
|
python3 scripts/beautify_inventory.py <inventory.json> --summary
|
||||||
|
python3 scripts/beautify_inventory.py <inventory.json> --page N [--with-geometry]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/beautify_inventory.py projects/x/analysis/<stem>.slide_library.json \
|
||||||
|
--images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json
|
||||||
|
python3 scripts/beautify_inventory.py projects/x/analysis/beautify_inventory.json --summary
|
||||||
|
python3 scripts/beautify_inventory.py projects/x/analysis/beautify_inventory.json --page 7
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (standard library only).
|
||||||
|
|
||||||
|
See workflows/profiles/beautify-pptx.md Step 4 for how the inventory is consumed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
_GEOMETRY_KEYS = {
|
||||||
|
"geometry",
|
||||||
|
"display_ratio",
|
||||||
|
"display_left_emu",
|
||||||
|
"display_top_emu",
|
||||||
|
"display_width_emu",
|
||||||
|
"display_height_emu",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _images_by_slide(manifest: list) -> dict[int, list[dict]]:
|
||||||
|
"""Map slide_index -> [image entries on that slide], from ppt_to_md occurrences."""
|
||||||
|
by_slide: dict[int, list[dict]] = {}
|
||||||
|
for entry in manifest:
|
||||||
|
filename = entry.get("filename")
|
||||||
|
for occ in entry.get("occurrences", []):
|
||||||
|
idx = occ.get("slide_index")
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
by_slide.setdefault(idx, []).append({
|
||||||
|
"filename": filename,
|
||||||
|
"shape_name": occ.get("shape_name"),
|
||||||
|
"pixel_width": entry.get("pixel_width"),
|
||||||
|
"pixel_height": entry.get("pixel_height"),
|
||||||
|
"display_ratio": occ.get("display_ratio"),
|
||||||
|
"display_left_emu": occ.get("display_left_emu"),
|
||||||
|
"display_top_emu": occ.get("display_top_emu"),
|
||||||
|
"display_width_emu": occ.get("display_width_emu"),
|
||||||
|
"display_height_emu": occ.get("display_height_emu"),
|
||||||
|
"usage_count": entry.get("usage_count"),
|
||||||
|
})
|
||||||
|
return by_slide
|
||||||
|
|
||||||
|
|
||||||
|
def _table_cells(table: dict) -> list[list[str]]:
|
||||||
|
"""Row-major 2D grid of cell text from a slide_library table."""
|
||||||
|
grid = []
|
||||||
|
for row in table.get("rows", []):
|
||||||
|
grid.append([c.get("text", "") for c in row.get("cells", [])])
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]]) -> dict:
|
||||||
|
"""Join slide_library slides with per-slide images into one ledger."""
|
||||||
|
slides_out = []
|
||||||
|
for slide in slide_library.get("slides", []):
|
||||||
|
idx = slide.get("slide_index")
|
||||||
|
text_blocks = [
|
||||||
|
{
|
||||||
|
"slot_id": s.get("slot_id"),
|
||||||
|
"role": s.get("role"),
|
||||||
|
"text": s.get("text", ""),
|
||||||
|
"paragraph_count": s.get("paragraph_count"),
|
||||||
|
"geometry": s.get("geometry"),
|
||||||
|
}
|
||||||
|
for s in slide.get("slots", [])
|
||||||
|
]
|
||||||
|
tables = [
|
||||||
|
{
|
||||||
|
"table_id": t.get("table_id"),
|
||||||
|
"row_count": t.get("row_count"),
|
||||||
|
"column_count": t.get("column_count"),
|
||||||
|
"cells": _table_cells(t), # 2D text grid — convenient for diff
|
||||||
|
"rows": t.get("rows", []), # raw row/col cells — preserves merged / multi-header fidelity
|
||||||
|
}
|
||||||
|
for t in slide.get("tables", [])
|
||||||
|
]
|
||||||
|
charts = [
|
||||||
|
{
|
||||||
|
"chart_id": c.get("chart_id"),
|
||||||
|
"chart_type": c.get("chart_type"),
|
||||||
|
"category_count": c.get("category_count"),
|
||||||
|
"series_count": c.get("series_count"),
|
||||||
|
"categories": c.get("categories", []), # frozen data
|
||||||
|
"series": c.get("series", []), # frozen data (name + values)
|
||||||
|
}
|
||||||
|
for c in slide.get("charts", [])
|
||||||
|
]
|
||||||
|
diagrams = [
|
||||||
|
{
|
||||||
|
"diagram_id": diagram.get("diagram_id"),
|
||||||
|
"shape_name": diagram.get("shape_name"),
|
||||||
|
"geometry": diagram.get("geometry"),
|
||||||
|
"layout": diagram.get("layout", {}),
|
||||||
|
"root_ids": diagram.get("root_ids", []),
|
||||||
|
"nodes": diagram.get("nodes", []),
|
||||||
|
"connections": diagram.get("connections", []),
|
||||||
|
"status": diagram.get("status"),
|
||||||
|
"warnings": diagram.get("warnings", []),
|
||||||
|
}
|
||||||
|
for diagram in slide.get("diagrams", [])
|
||||||
|
]
|
||||||
|
slides_out.append({
|
||||||
|
"slide_index": idx,
|
||||||
|
"page_type": slide.get("page_type"),
|
||||||
|
"text_blocks": text_blocks,
|
||||||
|
"tables": tables,
|
||||||
|
"charts": charts,
|
||||||
|
"diagrams": diagrams,
|
||||||
|
"images": images_by_slide.get(idx, []),
|
||||||
|
"ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation
|
||||||
|
"needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": "beautify_inventory.v1",
|
||||||
|
"source": slide_library.get("source_pptx"),
|
||||||
|
"slide_count": slide_library.get("slide_count", len(slides_out)),
|
||||||
|
"canvas_px": slide_library.get("canvas_px"),
|
||||||
|
"slides": slides_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _view_payload(inventory: dict, view: str, slides: list[dict]) -> dict:
|
||||||
|
"""Wrap projected slides with the canonical deck-level facts."""
|
||||||
|
return {
|
||||||
|
"schema": "beautify_inventory.view.v1",
|
||||||
|
"inventory_schema": inventory.get("schema", "beautify_inventory.v1"),
|
||||||
|
"view": view,
|
||||||
|
"source": inventory.get("source"),
|
||||||
|
"slide_count": inventory.get("slide_count", len(inventory.get("slides", []))),
|
||||||
|
"canvas_px": inventory.get("canvas_px"),
|
||||||
|
"slides": slides,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_view(inventory: dict) -> dict:
|
||||||
|
"""Project the whole roster into compact per-slide counts and review flags."""
|
||||||
|
slides = []
|
||||||
|
for slide in inventory.get("slides", []):
|
||||||
|
slides.append({
|
||||||
|
"slide_index": slide.get("slide_index"),
|
||||||
|
"page_type": slide.get("page_type"),
|
||||||
|
"text_block_count": len(slide.get("text_blocks", [])),
|
||||||
|
"table_count": len(slide.get("tables", [])),
|
||||||
|
"chart_count": len(slide.get("charts", [])),
|
||||||
|
"diagram_count": len(slide.get("diagrams", [])),
|
||||||
|
"image_count": len(slide.get("images", [])),
|
||||||
|
"ignored": slide.get("ignored", []),
|
||||||
|
"needs_confirmation": slide.get("needs_confirmation", []),
|
||||||
|
})
|
||||||
|
return _view_payload(inventory, "summary", slides)
|
||||||
|
|
||||||
|
|
||||||
|
def _without_geometry(value: Any) -> Any:
|
||||||
|
"""Remove explicit source-layout geometry while preserving content and data."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _without_geometry(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if key not in _GEOMETRY_KEYS
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_without_geometry(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _page_view(inventory: dict, slide_index: int, with_geometry: bool) -> Optional[dict]:
|
||||||
|
"""Project one slide by its canonical slide_index."""
|
||||||
|
for slide in inventory.get("slides", []):
|
||||||
|
if slide.get("slide_index") != slide_index:
|
||||||
|
continue
|
||||||
|
projected = slide if with_geometry else _without_geometry(slide)
|
||||||
|
return _view_payload(inventory, "page", [projected])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: str) -> int:
|
||||||
|
"""Parse a positive slide index for argparse."""
|
||||||
|
parsed = int(value)
|
||||||
|
if parsed < 1:
|
||||||
|
raise argparse.ArgumentTypeError("page must be a positive integer")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Build a beautify inventory from a slide library, or print a read-only "
|
||||||
|
"model view from a canonical beautify inventory."
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"input_json",
|
||||||
|
help="slide_library.json in builder mode, or beautify_inventory.v1 JSON in a view mode",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--images",
|
||||||
|
help="image_manifest.json for a slide_library input (optional)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
help="Write the full built inventory here (builder mode only; default: stdout)",
|
||||||
|
)
|
||||||
|
view_group = parser.add_mutually_exclusive_group()
|
||||||
|
view_group.add_argument(
|
||||||
|
"--summary",
|
||||||
|
action="store_true",
|
||||||
|
help="Print deck facts, per-slide object counts, and review flags to stdout",
|
||||||
|
)
|
||||||
|
view_group.add_argument(
|
||||||
|
"--page",
|
||||||
|
type=_positive_int,
|
||||||
|
metavar="N",
|
||||||
|
help="Print one slide's frozen content/data by slide_index to stdout",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--with-geometry",
|
||||||
|
action="store_true",
|
||||||
|
help="Retain source-layout geometry in a --page view",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
view_requested = args.summary or args.page is not None
|
||||||
|
if view_requested and args.output:
|
||||||
|
parser.error("--summary/--page write to stdout and cannot be combined with --output")
|
||||||
|
if args.with_geometry and args.page is None:
|
||||||
|
parser.error("--with-geometry requires --page")
|
||||||
|
|
||||||
|
input_path = Path(args.input_json)
|
||||||
|
if not input_path.is_file():
|
||||||
|
print(f"[ERROR] input JSON not found: {input_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
input_data = json.loads(input_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
input_schema = input_data.get("schema")
|
||||||
|
if input_schema == "beautify_inventory.view.v1":
|
||||||
|
parser.error(
|
||||||
|
"beautify_inventory.view.v1 is a read-only stdout projection and "
|
||||||
|
"cannot be used as input"
|
||||||
|
)
|
||||||
|
is_inventory = input_schema == "beautify_inventory.v1"
|
||||||
|
if is_inventory and not view_requested:
|
||||||
|
parser.error(
|
||||||
|
"beautify_inventory.v1 input requires --summary or --page; "
|
||||||
|
"builder mode requires slide_library.json"
|
||||||
|
)
|
||||||
|
if is_inventory and args.images:
|
||||||
|
parser.error("--images cannot be combined with a beautify_inventory.v1 input")
|
||||||
|
if view_requested and not is_inventory:
|
||||||
|
parser.error("--summary/--page require a canonical beautify_inventory.v1 input")
|
||||||
|
|
||||||
|
manifest: list = []
|
||||||
|
if args.images and not is_inventory:
|
||||||
|
img_path = Path(args.images)
|
||||||
|
if not img_path.is_file():
|
||||||
|
print(f"[ERROR] image manifest not found: {img_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
manifest = json.loads(img_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
inventory = input_data if is_inventory else build_inventory(
|
||||||
|
input_data,
|
||||||
|
_images_by_slide(manifest),
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.summary:
|
||||||
|
print(json.dumps(_summary_view(inventory), ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
if args.page is not None:
|
||||||
|
page_view = _page_view(inventory, args.page, args.with_geometry)
|
||||||
|
if page_view is None:
|
||||||
|
available = ", ".join(
|
||||||
|
str(slide.get("slide_index"))
|
||||||
|
for slide in inventory.get("slides", [])
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[ERROR] slide_index {args.page} not found; available: {available or 'none'}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(json.dumps(page_view, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
payload = json.dumps(inventory, ensure_ascii=False, indent=2)
|
||||||
|
if args.output:
|
||||||
|
out = Path(args.output)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(payload + "\n", encoding="utf-8")
|
||||||
|
print(f"[OK] inventory written to: {out}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Legacy Chart Recall Compatibility
|
||||||
|
|
||||||
|
Preserve the historical broad chart-recall CLI while delegating to the unified
|
||||||
|
visualization recall implementation. Published Structure bare keys validate as
|
||||||
|
intent-only compatibility values; they do not recall or load SVG assets. The
|
||||||
|
legacy ``valid`` list remains intact while ``resolved`` exposes each kind.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/chart_recall.py recall --page P03 --tag "time series" --tag "three metrics" --tag "trend"
|
||||||
|
python3 scripts/chart_recall.py validate line_chart column_chart
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/chart_recall.py validate process_flow
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (only uses the standard library)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from visualization_recall import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(legacy_output=True))
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - SVG Annotation Checker
|
||||||
|
|
||||||
|
Scans SVG files for edit annotations (data-edit-target / data-edit-annotation attributes)
|
||||||
|
and prints a human-readable summary. Used by AI agents to discover pending annotations.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/check_annotations.py <project_dir>
|
||||||
|
python3 scripts/check_annotations.py <svg_file>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/check_annotations.py projects/my-project
|
||||||
|
python3 scripts/check_annotations.py projects/my-project/svg_output/slide_01.svg
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (only uses standard library)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
from slide_roster import discover_slide_svgs
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
def scan_svg_file(svg_path: Path) -> list[dict]:
|
||||||
|
"""Scan a single SVG file for edit annotations."""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(svg_path)
|
||||||
|
except ET.ParseError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
root = tree.getroot()
|
||||||
|
annotations = []
|
||||||
|
|
||||||
|
for elem in root.iter():
|
||||||
|
if elem.get('data-edit-target') == 'true':
|
||||||
|
tag = elem.tag
|
||||||
|
if '}' in tag:
|
||||||
|
tag = tag.split('}', 1)[1]
|
||||||
|
|
||||||
|
content_preview = ''
|
||||||
|
if tag == 'text' and elem.text:
|
||||||
|
content_preview = elem.text.strip()[:50]
|
||||||
|
|
||||||
|
annotations.append({
|
||||||
|
'element_id': elem.get('id', '(no id)'),
|
||||||
|
'tag': tag,
|
||||||
|
'annotation': elem.get('data-edit-annotation', ''),
|
||||||
|
'content_preview': content_preview,
|
||||||
|
})
|
||||||
|
|
||||||
|
return annotations
|
||||||
|
|
||||||
|
|
||||||
|
def scan_directory(dir_path: Path) -> dict[str, list[dict]]:
|
||||||
|
"""Scan all SVG files in svg_output/ for edit annotations."""
|
||||||
|
svg_dir = dir_path / 'svg_output'
|
||||||
|
if not svg_dir.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for svg_file in discover_slide_svgs(svg_dir):
|
||||||
|
annotations = scan_svg_file(svg_file)
|
||||||
|
if annotations:
|
||||||
|
results[svg_file.name] = annotations
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def print_results(results: dict[str, list[dict]]) -> None:
|
||||||
|
"""Print annotation results in human-readable format."""
|
||||||
|
if not results:
|
||||||
|
print("[OK] No annotations found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
total = sum(len(anns) for anns in results.values())
|
||||||
|
file_count = len(results)
|
||||||
|
ann_word = "annotation" if total == 1 else "annotations"
|
||||||
|
file_word = "file" if file_count == 1 else "files"
|
||||||
|
print(f"Found {total} {ann_word} in {file_count} {file_word}:\n")
|
||||||
|
|
||||||
|
for filename, annotations in results.items():
|
||||||
|
print(f"{filename}")
|
||||||
|
for i, ann in enumerate(annotations, 1):
|
||||||
|
content = f' "{ann["content_preview"]}"' if ann['content_preview'] else ''
|
||||||
|
print(f" [{i}] <{ann['tag']} id=\"{ann['element_id']}\">{content}")
|
||||||
|
print(f" → {ann['annotation']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Check SVG files for edit annotations',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument('path', help='Project directory or single SVG file path')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
target = Path(args.path).resolve()
|
||||||
|
|
||||||
|
if not target.exists():
|
||||||
|
print(f"Error: Path not found: {target}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if target.is_file() and target.suffix == '.svg':
|
||||||
|
annotations = scan_svg_file(target)
|
||||||
|
results = {target.name: annotations} if annotations else {}
|
||||||
|
elif target.is_dir():
|
||||||
|
results = scan_directory(target)
|
||||||
|
else:
|
||||||
|
print(f"Error: Expected a project directory or .svg file, got: {target}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print_results(results)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - SVG Coordinate Compactor
|
||||||
|
|
||||||
|
Compact model-facing page-space SVG coordinates to at most two decimal places
|
||||||
|
without rounding normalized crop ratios or transform linear coefficients.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/compact_svg_coordinates.py <svg-file-or-directory> [--inplace]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/compact_svg_coordinates.py projects/example/templates --inplace
|
||||||
|
python3 scripts/compact_svg_coordinates.py imported/authoring-svg
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (standard library only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
COORDINATE_DECIMAL_PLACES = 2
|
||||||
|
_NUMBER_TOKEN = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
|
||||||
|
_NUMBER_RE = re.compile(rf"^{_NUMBER_TOKEN}$")
|
||||||
|
_TRANSFORM_FUNCTION_RE = re.compile(r"([A-Za-z]+)\s*\(([^)]*)\)")
|
||||||
|
_COMPACTABLE_ATTRIBUTE_RE = re.compile(
|
||||||
|
r"(?<![A-Za-z0-9_.:-])"
|
||||||
|
r"(?P<name>data-pptx-frame|data-pptx-bounds|transform)"
|
||||||
|
r"(?P<spacing>\s*=\s*)"
|
||||||
|
r"(?P<quote>[\"'])"
|
||||||
|
r"(?P<value>.*?)"
|
||||||
|
r"(?P=quote)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CoordinateCompactionStats:
|
||||||
|
"""Count safely compacted coordinate-bearing SVG attributes."""
|
||||||
|
|
||||||
|
native_frames: int = 0
|
||||||
|
bounds: int = 0
|
||||||
|
transforms: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def changed_attributes(self) -> int:
|
||||||
|
return (
|
||||||
|
self.native_frames
|
||||||
|
+ self.bounds
|
||||||
|
+ self.transforms
|
||||||
|
)
|
||||||
|
|
||||||
|
def merge(self, other: "CoordinateCompactionStats") -> None:
|
||||||
|
self.native_frames += other.native_frames
|
||||||
|
self.bounds += other.bounds
|
||||||
|
self.transforms += other.transforms
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"native_frames": self.native_frames,
|
||||||
|
"bounds": self.bounds,
|
||||||
|
"transforms": self.transforms,
|
||||||
|
"changed_attributes": self.changed_attributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _number_tokens(value: str) -> list[str] | None:
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
tokens = re.split(r"[\s,]+", stripped)
|
||||||
|
if not tokens or any(_NUMBER_RE.fullmatch(token) is None for token in tokens):
|
||||||
|
return None
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def format_coordinate(value: str | float) -> str:
|
||||||
|
"""Format one finite page-space coordinate with at most two decimals."""
|
||||||
|
numeric = float(value)
|
||||||
|
if not math.isfinite(numeric):
|
||||||
|
raise ValueError(f"Coordinate is not finite: {value!r}")
|
||||||
|
compact = (
|
||||||
|
f"{numeric:.{COORDINATE_DECIMAL_PLACES}f}".rstrip("0").rstrip(".")
|
||||||
|
)
|
||||||
|
return "0" if compact in {"", "-0"} else compact
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_coordinate_quad(value: str) -> str:
|
||||||
|
tokens = _number_tokens(value)
|
||||||
|
if tokens is None or len(tokens) != 4:
|
||||||
|
return value
|
||||||
|
return " ".join(format_coordinate(token) for token in tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_transform(value: str) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
name, arguments = match.groups()
|
||||||
|
tokens = _number_tokens(arguments)
|
||||||
|
if tokens is None:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
lowered = name.lower()
|
||||||
|
compacted: list[str]
|
||||||
|
if lowered == "translate" and len(tokens) in {1, 2}:
|
||||||
|
compacted = [format_coordinate(token) for token in tokens]
|
||||||
|
elif lowered == "rotate" and len(tokens) == 3:
|
||||||
|
compacted = [
|
||||||
|
tokens[0],
|
||||||
|
format_coordinate(tokens[1]),
|
||||||
|
format_coordinate(tokens[2]),
|
||||||
|
]
|
||||||
|
elif lowered == "matrix" and len(tokens) == 6:
|
||||||
|
compacted = [
|
||||||
|
*tokens[:4],
|
||||||
|
format_coordinate(tokens[4]),
|
||||||
|
format_coordinate(tokens[5]),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
return match.group(0)
|
||||||
|
return f"{name}({' '.join(compacted)})"
|
||||||
|
|
||||||
|
return _TRANSFORM_FUNCTION_RE.sub(replace, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_attribute_value(
|
||||||
|
name: str,
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
compact_native_frames: bool,
|
||||||
|
) -> str:
|
||||||
|
if name == "data-pptx-frame":
|
||||||
|
return _compact_coordinate_quad(value) if compact_native_frames else value
|
||||||
|
if name == "data-pptx-bounds":
|
||||||
|
return _compact_coordinate_quad(value)
|
||||||
|
if name == "transform":
|
||||||
|
return _compact_transform(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _record_change(stats: CoordinateCompactionStats, name: str) -> None:
|
||||||
|
if name == "data-pptx-frame":
|
||||||
|
stats.native_frames += 1
|
||||||
|
elif name == "data-pptx-bounds":
|
||||||
|
stats.bounds += 1
|
||||||
|
elif name == "transform":
|
||||||
|
stats.transforms += 1
|
||||||
|
|
||||||
|
|
||||||
|
def compact_svg_tree(
|
||||||
|
root: ET.Element,
|
||||||
|
*,
|
||||||
|
compact_native_frames: bool = True,
|
||||||
|
) -> CoordinateCompactionStats:
|
||||||
|
"""Compact safe coordinate metadata in one parsed SVG tree."""
|
||||||
|
stats = CoordinateCompactionStats()
|
||||||
|
for element in root.iter():
|
||||||
|
for name in (
|
||||||
|
"data-pptx-frame",
|
||||||
|
"data-pptx-bounds",
|
||||||
|
"transform",
|
||||||
|
):
|
||||||
|
current = element.get(name)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
compacted = _compact_attribute_value(
|
||||||
|
name,
|
||||||
|
current,
|
||||||
|
compact_native_frames=compact_native_frames,
|
||||||
|
)
|
||||||
|
if compacted == current:
|
||||||
|
continue
|
||||||
|
element.set(name, compacted)
|
||||||
|
_record_change(stats, name)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def compact_svg_text(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
compact_native_frames: bool = True,
|
||||||
|
) -> tuple[str, CoordinateCompactionStats]:
|
||||||
|
"""Compact safe coordinates while preserving unrelated SVG formatting."""
|
||||||
|
stats = CoordinateCompactionStats()
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
name = match.group("name")
|
||||||
|
current = match.group("value")
|
||||||
|
compacted = _compact_attribute_value(
|
||||||
|
name,
|
||||||
|
current,
|
||||||
|
compact_native_frames=compact_native_frames,
|
||||||
|
)
|
||||||
|
if compacted == current:
|
||||||
|
return match.group(0)
|
||||||
|
_record_change(stats, name)
|
||||||
|
return (
|
||||||
|
f"{name}{match.group('spacing')}{match.group('quote')}"
|
||||||
|
f"{compacted}{match.group('quote')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _COMPACTABLE_ATTRIBUTE_RE.sub(replace, text), stats
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_files(input_path: Path) -> list[Path]:
|
||||||
|
if input_path.is_file():
|
||||||
|
return [input_path] if input_path.suffix.lower() == ".svg" else []
|
||||||
|
return sorted(path for path in input_path.rglob("*.svg") if path.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(path: Path, payload: str) -> None:
|
||||||
|
mode = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
prefix=f".{path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=path.parent,
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temporary_path = Path(handle.name)
|
||||||
|
handle.write(payload)
|
||||||
|
try:
|
||||||
|
temporary_path.chmod(mode)
|
||||||
|
temporary_path.replace(path)
|
||||||
|
except OSError:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Compact safe page-space SVG coordinates to at most two decimal "
|
||||||
|
"places. Runs as a dry-run unless --inplace is supplied."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("input", type=Path, help="SVG file or directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"--inplace",
|
||||||
|
action="store_true",
|
||||||
|
help="Atomically replace changed SVG files",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-native-frames",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Leave data-pptx-frame unchanged while compacting "
|
||||||
|
"data-pptx-bounds and transform translations"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
input_path = args.input.resolve()
|
||||||
|
if not input_path.exists():
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"error": f"Input does not exist: {input_path}"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
paths = _svg_files(input_path)
|
||||||
|
if not paths:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"error": f"No SVG files found under {input_path}"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
staged: list[tuple[Path, str]] = []
|
||||||
|
totals = CoordinateCompactionStats()
|
||||||
|
bytes_before = 0
|
||||||
|
bytes_after = 0
|
||||||
|
try:
|
||||||
|
for path in paths:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
ET.fromstring(original)
|
||||||
|
compacted, stats = compact_svg_text(
|
||||||
|
original,
|
||||||
|
compact_native_frames=not args.keep_native_frames,
|
||||||
|
)
|
||||||
|
totals.merge(stats)
|
||||||
|
before = len(original.encode("utf-8"))
|
||||||
|
after = len(compacted.encode("utf-8"))
|
||||||
|
bytes_before += before
|
||||||
|
bytes_after += after
|
||||||
|
if compacted != original:
|
||||||
|
staged.append((path, compacted))
|
||||||
|
except (OSError, UnicodeDecodeError, ET.ParseError, ValueError) as exc:
|
||||||
|
print(
|
||||||
|
json.dumps({"error": str(exc)}, ensure_ascii=False),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.inplace:
|
||||||
|
try:
|
||||||
|
for path, payload in staged:
|
||||||
|
_write_atomic(path, payload)
|
||||||
|
except OSError as exc:
|
||||||
|
print(
|
||||||
|
json.dumps({"error": str(exc)}, ensure_ascii=False),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"input": str(input_path),
|
||||||
|
"inplace": args.inplace,
|
||||||
|
"files_scanned": len(paths),
|
||||||
|
"files_changed": len(staged),
|
||||||
|
"bytes_before": bytes_before,
|
||||||
|
"bytes_after": bytes_after,
|
||||||
|
"bytes_saved": bytes_before - bytes_after,
|
||||||
|
"coordinates": totals.as_dict(),
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Unified Configuration Management Module
|
||||||
|
|
||||||
|
Centrally manages all project configuration items to ensure consistency and maintainability.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from config import Config, CANVAS_FORMATS, DESIGN_COLORS
|
||||||
|
|
||||||
|
# Get canvas format
|
||||||
|
ppt169 = Config.get_canvas_format('ppt169')
|
||||||
|
|
||||||
|
# Get color scheme
|
||||||
|
colors = Config.get_color_scheme('consulting')
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Path Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Project root directory
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# Core directories
|
||||||
|
SCRIPTS_DIR = PROJECT_ROOT / 'scripts'
|
||||||
|
REFERENCES_DIR = PROJECT_ROOT / 'references'
|
||||||
|
TEMPLATES_DIR = PROJECT_ROOT / 'templates'
|
||||||
|
WORKFLOWS_DIR = PROJECT_ROOT / 'workflows'
|
||||||
|
|
||||||
|
# Repository root directory
|
||||||
|
REPO_ROOT = PROJECT_ROOT.parent.parent
|
||||||
|
EXAMPLES_DIR = REPO_ROOT / 'examples'
|
||||||
|
PROJECTS_DIR = REPO_ROOT / 'projects'
|
||||||
|
|
||||||
|
# Template subdirectories
|
||||||
|
CHART_TEMPLATES_DIR = TEMPLATES_DIR / 'charts'
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Environment Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
USER_CONFIG_DIR = Path.home() / '.ppt-master'
|
||||||
|
USER_ENV_FILE = USER_CONFIG_DIR / '.env'
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_candidates() -> list[Path]:
|
||||||
|
"""Return the supported .env lookup order."""
|
||||||
|
return [
|
||||||
|
Path.cwd() / '.env',
|
||||||
|
PROJECT_ROOT / '.env',
|
||||||
|
REPO_ROOT / '.env',
|
||||||
|
USER_ENV_FILE,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_env_path() -> Path:
|
||||||
|
"""
|
||||||
|
Return the first existing .env path.
|
||||||
|
|
||||||
|
If no candidate exists, return the CWD .env path so callers can no-op
|
||||||
|
consistently while still showing a useful default location in messages.
|
||||||
|
"""
|
||||||
|
candidates = get_env_candidates()
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def strip_env_quotes(value: str) -> str:
|
||||||
|
"""Strip matching surrounding quotes from a .env value."""
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def strip_inline_env_comment(value: str) -> str:
|
||||||
|
"""Strip an unquoted inline ``#`` comment from a .env value.
|
||||||
|
|
||||||
|
Matches standard dotenv behavior: a ``#`` outside surrounding quotes
|
||||||
|
starts a comment and is dropped along with the rest of the line. To keep
|
||||||
|
a literal ``#`` in the value, wrap it in single or double quotes.
|
||||||
|
"""
|
||||||
|
stripped = value.lstrip()
|
||||||
|
if stripped.startswith(('"', "'")):
|
||||||
|
quote = stripped[0]
|
||||||
|
end = stripped.find(quote, 1)
|
||||||
|
if end != -1:
|
||||||
|
head = value[: len(value) - len(stripped) + end + 1]
|
||||||
|
tail = value[len(head):]
|
||||||
|
hash_pos = tail.find('#')
|
||||||
|
if hash_pos == -1:
|
||||||
|
return value
|
||||||
|
return head + tail[:hash_pos]
|
||||||
|
return value
|
||||||
|
hash_pos = value.find('#')
|
||||||
|
if hash_pos == -1:
|
||||||
|
return value
|
||||||
|
return value[:hash_pos]
|
||||||
|
|
||||||
|
|
||||||
|
def load_prefixed_env_file(
|
||||||
|
prefixes: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
deprecated_keys: Optional[dict[str, str]] = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Load matching keys from the first supported .env file.
|
||||||
|
|
||||||
|
Existing process environment variables always win. Keys outside the
|
||||||
|
requested prefixes are ignored so one shared .env can hold image, search,
|
||||||
|
and narration credentials without leaking unrelated values into the
|
||||||
|
process.
|
||||||
|
"""
|
||||||
|
env_path = resolve_env_path()
|
||||||
|
if not env_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
deprecated_keys = deprecated_keys or {}
|
||||||
|
with env_path.open('r', encoding='utf-8') as fh:
|
||||||
|
for lineno, raw_line in enumerate(fh, start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
if line.startswith('export '):
|
||||||
|
line = line[7:].lstrip()
|
||||||
|
if '=' not in line:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid line in {env_path}:{lineno}. Expected KEY=VALUE."
|
||||||
|
)
|
||||||
|
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid line in {env_path}:{lineno}. Missing variable name."
|
||||||
|
)
|
||||||
|
if not any(key.startswith(prefix) for prefix in prefixes):
|
||||||
|
continue
|
||||||
|
if key in deprecated_keys:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported key in {env_path}:{lineno}: {key}\n"
|
||||||
|
f"{deprecated_keys[key]}"
|
||||||
|
)
|
||||||
|
cleaned = strip_inline_env_comment(value).strip()
|
||||||
|
os.environ.setdefault(key, strip_env_quotes(cleaned))
|
||||||
|
|
||||||
|
return env_path
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Canvas Format Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
CANVAS_FORMATS = {
|
||||||
|
'ppt169': {
|
||||||
|
'name': 'PPT 16:9',
|
||||||
|
'dimensions': '1280×720',
|
||||||
|
'viewbox': '0 0 1280 720',
|
||||||
|
'width': 1280,
|
||||||
|
'height': 720,
|
||||||
|
'aspect_ratio': '16:9',
|
||||||
|
'use_case': 'Modern projectors, online presentations'
|
||||||
|
},
|
||||||
|
'ppt43': {
|
||||||
|
'name': 'PPT 4:3',
|
||||||
|
'dimensions': '1024×768',
|
||||||
|
'viewbox': '0 0 1024 768',
|
||||||
|
'width': 1024,
|
||||||
|
'height': 768,
|
||||||
|
'aspect_ratio': '4:3',
|
||||||
|
'use_case': 'Traditional projectors'
|
||||||
|
},
|
||||||
|
'wechat': {
|
||||||
|
'name': 'WeChat Article Header',
|
||||||
|
'dimensions': '900×383',
|
||||||
|
'viewbox': '0 0 900 383',
|
||||||
|
'width': 900,
|
||||||
|
'height': 383,
|
||||||
|
'aspect_ratio': '2.35:1',
|
||||||
|
'use_case': 'WeChat article cover images'
|
||||||
|
},
|
||||||
|
'xiaohongshu': {
|
||||||
|
'name': '小红书',
|
||||||
|
'dimensions': '1242×1660',
|
||||||
|
'viewbox': '0 0 1242 1660',
|
||||||
|
'width': 1242,
|
||||||
|
'height': 1660,
|
||||||
|
'aspect_ratio': '3:4',
|
||||||
|
'use_case': 'Knowledge sharing, product reviews'
|
||||||
|
},
|
||||||
|
'moments': {
|
||||||
|
'name': 'Moments/Instagram',
|
||||||
|
'dimensions': '1080×1080',
|
||||||
|
'viewbox': '0 0 1080 1080',
|
||||||
|
'width': 1080,
|
||||||
|
'height': 1080,
|
||||||
|
'aspect_ratio': '1:1',
|
||||||
|
'use_case': 'Social media square images'
|
||||||
|
},
|
||||||
|
'story': {
|
||||||
|
'name': 'Story/Vertical',
|
||||||
|
'dimensions': '1080×1920',
|
||||||
|
'viewbox': '0 0 1080 1920',
|
||||||
|
'width': 1080,
|
||||||
|
'height': 1920,
|
||||||
|
'aspect_ratio': '9:16',
|
||||||
|
'use_case': 'Short video covers, stories'
|
||||||
|
},
|
||||||
|
'banner': {
|
||||||
|
'name': 'Horizontal Banner',
|
||||||
|
'dimensions': '1920×1080',
|
||||||
|
'viewbox': '0 0 1920 1080',
|
||||||
|
'width': 1920,
|
||||||
|
'height': 1080,
|
||||||
|
'aspect_ratio': '16:9',
|
||||||
|
'use_case': 'Web banners, large screen displays'
|
||||||
|
},
|
||||||
|
'a4': {
|
||||||
|
'name': 'A4 Print',
|
||||||
|
'dimensions': '1240×1754',
|
||||||
|
'viewbox': '0 0 1240 1754',
|
||||||
|
'width': 1240,
|
||||||
|
'height': 1754,
|
||||||
|
'aspect_ratio': '√2:1',
|
||||||
|
'use_case': 'Print documents, PDF export'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Design Color Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
DESIGN_COLORS = {
|
||||||
|
'consulting': {
|
||||||
|
'name': 'Consulting Style',
|
||||||
|
'primary': '#005587',
|
||||||
|
'secondary': '#0076A8',
|
||||||
|
'accent': '#F5A623',
|
||||||
|
'success': '#27AE60',
|
||||||
|
'warning': '#E74C3C',
|
||||||
|
'text_dark': '#1A252F',
|
||||||
|
'text_light': '#FFFFFF',
|
||||||
|
'text_muted': '#7F8C8D',
|
||||||
|
'background': '#FFFFFF',
|
||||||
|
'background_alt': '#F8F9FA'
|
||||||
|
},
|
||||||
|
'general': {
|
||||||
|
'name': 'General Flexible Style',
|
||||||
|
'primary': '#2196F3',
|
||||||
|
'secondary': '#4CAF50',
|
||||||
|
'accent': '#FF9800',
|
||||||
|
'purple': '#9C27B0',
|
||||||
|
'success': '#27AE60',
|
||||||
|
'warning': '#E74C3C',
|
||||||
|
'text_dark': '#2C3E50',
|
||||||
|
'text_light': '#FFFFFF',
|
||||||
|
'text_muted': '#7F8C8D',
|
||||||
|
'background': '#FFFFFF',
|
||||||
|
'background_alt': '#F8F9FA'
|
||||||
|
},
|
||||||
|
'tech': {
|
||||||
|
'name': 'Tech Style',
|
||||||
|
'primary': '#00D1FF',
|
||||||
|
'secondary': '#7B61FF',
|
||||||
|
'accent': '#00FF88',
|
||||||
|
'success': '#00FF88',
|
||||||
|
'warning': '#FF6B6B',
|
||||||
|
'text_dark': '#0A0E17',
|
||||||
|
'text_light': '#FFFFFF',
|
||||||
|
'text_muted': '#8892A0',
|
||||||
|
'background': '#0A0E17',
|
||||||
|
'background_alt': '#1A1F2E'
|
||||||
|
},
|
||||||
|
'academic': {
|
||||||
|
'name': 'Academic Style',
|
||||||
|
'primary': '#8B0000',
|
||||||
|
'secondary': '#1E3A5F',
|
||||||
|
'accent': '#C9B037',
|
||||||
|
'success': '#2E7D32',
|
||||||
|
'warning': '#D32F2F',
|
||||||
|
'text_dark': '#1A1A1A',
|
||||||
|
'text_light': '#FFFFFF',
|
||||||
|
'text_muted': '#666666',
|
||||||
|
'background': '#FFFFFF',
|
||||||
|
'background_alt': '#F5F5F5'
|
||||||
|
},
|
||||||
|
'government': {
|
||||||
|
'name': 'Government Style',
|
||||||
|
'primary': '#C41E3A',
|
||||||
|
'secondary': '#1E3A5F',
|
||||||
|
'accent': '#D4AF37',
|
||||||
|
'success': '#2E7D32',
|
||||||
|
'warning': '#B71C1C',
|
||||||
|
'text_dark': '#1A1A1A',
|
||||||
|
'text_light': '#FFFFFF',
|
||||||
|
'text_muted': '#555555',
|
||||||
|
'background': '#FFFFFF',
|
||||||
|
'background_alt': '#FFF8E1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Industry Color Templates
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
INDUSTRY_COLORS = {
|
||||||
|
'finance': {
|
||||||
|
'name': 'Finance/Banking',
|
||||||
|
'primary': '#003366',
|
||||||
|
'secondary': '#4A90D9',
|
||||||
|
'accent': '#D4AF37'
|
||||||
|
},
|
||||||
|
'healthcare': {
|
||||||
|
'name': 'Healthcare/Medical',
|
||||||
|
'primary': '#00796B',
|
||||||
|
'secondary': '#4DB6AC',
|
||||||
|
'accent': '#FF7043'
|
||||||
|
},
|
||||||
|
'technology': {
|
||||||
|
'name': 'Technology/Internet',
|
||||||
|
'primary': '#1565C0',
|
||||||
|
'secondary': '#42A5F5',
|
||||||
|
'accent': '#00E676'
|
||||||
|
},
|
||||||
|
'education': {
|
||||||
|
'name': 'Education/Training',
|
||||||
|
'primary': '#5E35B1',
|
||||||
|
'secondary': '#7E57C2',
|
||||||
|
'accent': '#FFD54F'
|
||||||
|
},
|
||||||
|
'retail': {
|
||||||
|
'name': 'Retail/Consumer',
|
||||||
|
'primary': '#E53935',
|
||||||
|
'secondary': '#EF5350',
|
||||||
|
'accent': '#FFB300'
|
||||||
|
},
|
||||||
|
'manufacturing': {
|
||||||
|
'name': 'Manufacturing/Industrial',
|
||||||
|
'primary': '#455A64',
|
||||||
|
'secondary': '#78909C',
|
||||||
|
'accent': '#FF6F00'
|
||||||
|
},
|
||||||
|
'energy': {
|
||||||
|
'name': 'Energy/Environmental',
|
||||||
|
'primary': '#2E7D32',
|
||||||
|
'secondary': '#66BB6A',
|
||||||
|
'accent': '#FDD835'
|
||||||
|
},
|
||||||
|
'realestate': {
|
||||||
|
'name': 'Real Estate/Construction',
|
||||||
|
'primary': '#795548',
|
||||||
|
'secondary': '#A1887F',
|
||||||
|
'accent': '#4CAF50'
|
||||||
|
},
|
||||||
|
'legal': {
|
||||||
|
'name': 'Legal/Compliance',
|
||||||
|
'primary': '#37474F',
|
||||||
|
'secondary': '#546E7A',
|
||||||
|
'accent': '#8D6E63'
|
||||||
|
},
|
||||||
|
'media': {
|
||||||
|
'name': 'Media/Entertainment',
|
||||||
|
'primary': '#7B1FA2',
|
||||||
|
'secondary': '#AB47BC',
|
||||||
|
'accent': '#FF4081'
|
||||||
|
},
|
||||||
|
'logistics': {
|
||||||
|
'name': 'Logistics/Supply Chain',
|
||||||
|
'primary': '#F57C00',
|
||||||
|
'secondary': '#FFB74D',
|
||||||
|
'accent': '#0288D1'
|
||||||
|
},
|
||||||
|
'agriculture': {
|
||||||
|
'name': 'Agriculture/Food',
|
||||||
|
'primary': '#558B2F',
|
||||||
|
'secondary': '#8BC34A',
|
||||||
|
'accent': '#FFCA28'
|
||||||
|
},
|
||||||
|
'tourism': {
|
||||||
|
'name': 'Tourism/Hospitality',
|
||||||
|
'primary': '#00ACC1',
|
||||||
|
'secondary': '#4DD0E1',
|
||||||
|
'accent': '#FF7043'
|
||||||
|
},
|
||||||
|
'automotive': {
|
||||||
|
'name': 'Automotive/Transportation',
|
||||||
|
'primary': '#263238',
|
||||||
|
'secondary': '#455A64',
|
||||||
|
'accent': '#D32F2F'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Font Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
FONTS = {
|
||||||
|
'system_ui': "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
'sans_serif': "'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||||
|
'monospace': "'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace"
|
||||||
|
}
|
||||||
|
|
||||||
|
FONT_SIZES = {
|
||||||
|
'title_large': 48,
|
||||||
|
'title': 36,
|
||||||
|
'title_small': 28,
|
||||||
|
'heading': 24,
|
||||||
|
'subheading': 20,
|
||||||
|
'body': 18,
|
||||||
|
'body_small': 16,
|
||||||
|
'caption': 14,
|
||||||
|
'footnote': 12
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layout Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
LAYOUT_MARGINS = {
|
||||||
|
'ppt169': {
|
||||||
|
'top': 60,
|
||||||
|
'right': 60,
|
||||||
|
'bottom': 60,
|
||||||
|
'left': 60,
|
||||||
|
'content_width': 1160,
|
||||||
|
'content_height': 600
|
||||||
|
},
|
||||||
|
'ppt43': {
|
||||||
|
'top': 50,
|
||||||
|
'right': 50,
|
||||||
|
'bottom': 50,
|
||||||
|
'left': 50,
|
||||||
|
'content_width': 924,
|
||||||
|
'content_height': 608
|
||||||
|
},
|
||||||
|
'xiaohongshu': {
|
||||||
|
'top': 80,
|
||||||
|
'right': 60,
|
||||||
|
'bottom': 80,
|
||||||
|
'left': 60,
|
||||||
|
'content_width': 1122,
|
||||||
|
'content_height': 1500
|
||||||
|
},
|
||||||
|
'moments': {
|
||||||
|
'top': 60,
|
||||||
|
'right': 60,
|
||||||
|
'bottom': 60,
|
||||||
|
'left': 60,
|
||||||
|
'content_width': 960,
|
||||||
|
'content_height': 960
|
||||||
|
},
|
||||||
|
'story': {
|
||||||
|
'top': 120,
|
||||||
|
'right': 60,
|
||||||
|
'bottom': 180,
|
||||||
|
'left': 60,
|
||||||
|
'content_width': 960,
|
||||||
|
'content_height': 1620
|
||||||
|
},
|
||||||
|
'wechat': {
|
||||||
|
'top': 40,
|
||||||
|
'right': 40,
|
||||||
|
'bottom': 40,
|
||||||
|
'left': 40,
|
||||||
|
'content_width': 820,
|
||||||
|
'content_height': 303
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SVG Policy Reference
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Do not mirror element/attribute rules here. The router selects the mandatory
|
||||||
|
# core and feature-triggered interfaces; the quality checker enforces them.
|
||||||
|
# Keep the exported authority key as the compatibility router for existing
|
||||||
|
# config consumers.
|
||||||
|
SVG_CONSTRAINTS = {
|
||||||
|
'authority': 'skills/ppt-master/references/shared-standards.md',
|
||||||
|
'core_authority': 'skills/ppt-master/references/shared-standards-core.md',
|
||||||
|
'conditional_authorities': {
|
||||||
|
'effects': 'skills/ppt-master/references/svg-effects.md',
|
||||||
|
'native_data': 'skills/ppt-master/references/native-data-interface.md',
|
||||||
|
'pptx_structure': 'skills/ppt-master/references/pptx-structure-interface.md',
|
||||||
|
},
|
||||||
|
'validator': 'skills/ppt-master/scripts/svg_quality_checker.py',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration Manager Class
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration manager."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_canvas_format(format_key: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Get canvas format configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_key: Format key name (e.g. 'ppt169', 'xiaohongshu')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Format configuration dict, or None if not found
|
||||||
|
"""
|
||||||
|
return CANVAS_FORMATS.get(format_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_canvas_formats() -> Dict:
|
||||||
|
"""Get all canvas formats."""
|
||||||
|
return CANVAS_FORMATS.copy()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_color_scheme(style: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Get color scheme.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
style: Style name (e.g. 'consulting', 'general', 'tech')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Color scheme dict
|
||||||
|
"""
|
||||||
|
return DESIGN_COLORS.get(style)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_industry_colors(industry: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Get industry color palette.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
industry: Industry name (e.g. 'finance', 'healthcare')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Industry color dict
|
||||||
|
"""
|
||||||
|
return INDUSTRY_COLORS.get(industry)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_industries() -> List[str]:
|
||||||
|
"""Get list of all industries."""
|
||||||
|
return list(INDUSTRY_COLORS.keys())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_layout_margins(format_key: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Get layout margin configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_key: Format key name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Margin configuration dict
|
||||||
|
"""
|
||||||
|
return LAYOUT_MARGINS.get(format_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_font(font_type: str = 'system_ui') -> str:
|
||||||
|
"""
|
||||||
|
Get font declaration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_type: Font type ('system_ui', 'sans_serif', 'monospace')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Font declaration string
|
||||||
|
"""
|
||||||
|
return FONTS.get(font_type, FONTS['system_ui'])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_font_size(size_name: str) -> int:
|
||||||
|
"""
|
||||||
|
Get font size.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size_name: Size name (e.g. 'title', 'body', 'caption')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Font size (pixels)
|
||||||
|
"""
|
||||||
|
return FONT_SIZES.get(size_name, FONT_SIZES['body'])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_project_path(subdir: str = '') -> Path:
|
||||||
|
"""
|
||||||
|
Get project path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subdir: Subdirectory name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full path
|
||||||
|
"""
|
||||||
|
if subdir:
|
||||||
|
return PROJECT_ROOT / subdir
|
||||||
|
return PROJECT_ROOT
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_config(output_file: str = 'config_export.json'):
|
||||||
|
"""
|
||||||
|
Export configuration to a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_file: Output file path
|
||||||
|
"""
|
||||||
|
config_data = {
|
||||||
|
'canvas_formats': CANVAS_FORMATS,
|
||||||
|
'design_colors': DESIGN_COLORS,
|
||||||
|
'industry_colors': INDUSTRY_COLORS,
|
||||||
|
'fonts': FONTS,
|
||||||
|
'font_sizes': FONT_SIZES,
|
||||||
|
'svg_constraints': SVG_CONSTRAINTS
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(config_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"Configuration exported to: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Command Line Interface
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the command-line parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="PPT Master configuration management tool.",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
subparsers.add_parser("list-formats", help="List all canvas formats")
|
||||||
|
subparsers.add_parser("list-colors", help="List all color schemes")
|
||||||
|
subparsers.add_parser("list-industries", help="List all industry colors")
|
||||||
|
|
||||||
|
export = subparsers.add_parser("export", help="Export configuration to JSON")
|
||||||
|
export.add_argument(
|
||||||
|
"output_path",
|
||||||
|
nargs="?",
|
||||||
|
help="Output JSON path (backward-compatible positional form)",
|
||||||
|
)
|
||||||
|
export.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
default=None,
|
||||||
|
help="Output JSON path (default: config_export.json)",
|
||||||
|
)
|
||||||
|
|
||||||
|
format_parser = subparsers.add_parser("format", help="View a specific canvas format")
|
||||||
|
format_parser.add_argument("key", choices=sorted(CANVAS_FORMATS), help="Canvas format key")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Command line entry point."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.command == 'list-formats':
|
||||||
|
print("\nCanvas Format List:\n")
|
||||||
|
for key, info in CANVAS_FORMATS.items():
|
||||||
|
print(
|
||||||
|
f" {key:15} | {info['name']:15} | {info['dimensions']:12} | {info['use_case']}")
|
||||||
|
|
||||||
|
elif args.command == 'list-colors':
|
||||||
|
print("\nColor Scheme List:\n")
|
||||||
|
for key, info in DESIGN_COLORS.items():
|
||||||
|
print(f" {key:12} | {info['name']:15} | Primary: {info['primary']}")
|
||||||
|
|
||||||
|
elif args.command == 'list-industries':
|
||||||
|
print("\nIndustry Color List:\n")
|
||||||
|
for key, info in INDUSTRY_COLORS.items():
|
||||||
|
print(f" {key:15} | {info['name']:15} | Primary: {info['primary']}")
|
||||||
|
|
||||||
|
elif args.command == 'export':
|
||||||
|
Config.export_config(args.output or args.output_path or "config_export.json")
|
||||||
|
|
||||||
|
elif args.command == 'format':
|
||||||
|
info = Config.get_canvas_format(args.key)
|
||||||
|
print(f"\nCanvas Format: {args.key}\n")
|
||||||
|
for key, value in info.items():
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""PPT Master - interactive Strategist confirmation stage UI (Step 4)."""
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title data-i18n="page_title">PPT Master - Confirm Design</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<aside id="preview-panel" aria-label="Design preview">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-mark">PM</div>
|
||||||
|
<div>
|
||||||
|
<div class="panel-kicker">PPT Master</div>
|
||||||
|
<div class="panel-title" data-i18n="page_title">PPT Master - Confirm Design</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="topbar-preview"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="control-panel">
|
||||||
|
<header id="topbar">
|
||||||
|
<div id="topbar-inner">
|
||||||
|
<div class="topbar-titles">
|
||||||
|
<h1 data-i18n="page_title">PPT Master - Confirm Design</h1>
|
||||||
|
<p id="topbar-hint" data-i18n="topbar_hint">After confirming, return to the chat and say “done”.</p>
|
||||||
|
</div>
|
||||||
|
<div class="lang-select">
|
||||||
|
<button id="btn-lang-toggle" class="lang-select-btn" type="button" title="Switch language" aria-haspopup="listbox" aria-expanded="false"><span id="lang-current"></span><span class="lang-caret" aria-hidden="true">▾</span></button>
|
||||||
|
<ul id="lang-menu" class="lang-menu" role="listbox" hidden>
|
||||||
|
<li role="option" tabindex="0" aria-selected="false" data-lang="zh">中文</li>
|
||||||
|
<li role="option" tabindex="0" aria-selected="false" data-lang="zh-TW">繁體中文</li>
|
||||||
|
<li role="option" tabindex="0" aria-selected="false" data-lang="en">English</li>
|
||||||
|
<li role="option" tabindex="0" aria-selected="false" data-lang="ja">日本語</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="form">
|
||||||
|
<div id="loading" data-i18n="loading">Loading recommendations…</div>
|
||||||
|
<div id="error" style="display:none;"></div>
|
||||||
|
<div id="sections" style="display:none;"></div>
|
||||||
|
</main>
|
||||||
|
<footer id="actionbar" style="display:none;">
|
||||||
|
<span id="confirm-status"></span>
|
||||||
|
<button id="btn-confirm" data-i18n="btn_confirm">Confirm</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="confirmed-overlay" style="display:none;">
|
||||||
|
<div class="cf-card">
|
||||||
|
<div class="cf-title">✓ Confirmed</div>
|
||||||
|
<div class="cf-hint">Your choices are saved. You can close this page.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<pattern id="bp-grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M40 0H0V40" fill="none" stroke="#2E6EA8" stroke-width="0.8" opacity="0.55"/>
|
||||||
|
</pattern>
|
||||||
|
<pattern id="bp-grid5" width="200" height="200" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M200 0H0V200" fill="none" stroke="#5AA0D6" stroke-width="1.2" opacity="0.5"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#0C3A63"/>
|
||||||
|
<rect width="1280" height="720" fill="url(#bp-grid)"/>
|
||||||
|
<rect width="1280" height="720" fill="url(#bp-grid5)"/>
|
||||||
|
<text x="80" y="108" font-family="Consolas, monospace" font-size="24" letter-spacing="3" fill="#CFE8FF">DWG / SECTION-04</text>
|
||||||
|
<text x="78" y="198" font-family="Helvetica Neue, Arial, sans-serif" font-size="76" font-weight="700" fill="#EAF4FF">Lorem ipsum</text>
|
||||||
|
<text x="80" y="248" font-family="Consolas, monospace" font-size="21" fill="#9CC9EC">DARK LINEWORK · ISOMETRIC · ANNOTATED</text>
|
||||||
|
<g stroke="#BAE0FF" stroke-width="2" fill="none">
|
||||||
|
<path d="M520 470L720 560L920 470L720 380Z"/>
|
||||||
|
<path d="M520 470L520 320L720 230L720 380M920 470L920 320L720 230M720 380L720 560M520 320L720 410L920 320M720 410L720 560"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#7FC4FF" stroke-width="1.5">
|
||||||
|
<path d="M520 620H920"/>
|
||||||
|
<path d="M520 610V630M920 610V630"/>
|
||||||
|
<path d="M520 620l16 -6v12zM920 620l-16 -6v12z" fill="#7FC4FF"/>
|
||||||
|
</g>
|
||||||
|
<text x="700" y="612" font-family="Consolas, monospace" font-size="20" fill="#CFE8FF">400.0</text>
|
||||||
|
<circle cx="720" cy="230" r="6" fill="#FBBF24"/>
|
||||||
|
<path d="M720 230L1010 156" stroke="#FBBF24" stroke-width="1.5"/>
|
||||||
|
<text x="1020" y="162" font-family="Consolas, monospace" font-size="18" fill="#FBBF24">NODE A-01</text>
|
||||||
|
<path d="M980 320H1120" stroke="#7FC4FF" stroke-width="1.5"/>
|
||||||
|
<text x="1000" y="308" font-family="Consolas, monospace" font-size="17" fill="#CFE8FF">R = 120</text>
|
||||||
|
<g stroke="#7FC4FF" stroke-width="1.5" fill="none">
|
||||||
|
<rect x="960" y="600" width="240" height="80"/>
|
||||||
|
<path d="M960 640H1200M1080 600V680"/>
|
||||||
|
</g>
|
||||||
|
<text x="972" y="628" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">SCALE 1:50</text>
|
||||||
|
<text x="1092" y="628" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">REV 04</text>
|
||||||
|
<text x="972" y="666" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">BLUEPRINT</text>
|
||||||
|
<text x="1092" y="666" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">SHT 1/3</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<pattern id="bru-half" width="14" height="14" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="4" cy="4" r="2.4" fill="#111"/><circle cx="11" cy="11" r="2.4" fill="#111"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#EDEAE0"/>
|
||||||
|
<rect x="40" y="40" width="1200" height="640" fill="none" stroke="#111" stroke-width="7"/>
|
||||||
|
<rect x="40" y="40" width="1200" height="118" fill="#111"/>
|
||||||
|
<text x="66" y="124" font-family="Arial Black, Arial, sans-serif" font-size="62" font-weight="900" fill="#EDEAE0">LOREM IPSUM</text>
|
||||||
|
<text x="1214" y="112" text-anchor="end" font-family="Courier New, monospace" font-size="20" fill="#EDEAE0">VOL.04</text>
|
||||||
|
<text x="1214" y="140" text-anchor="end" font-family="Courier New, monospace" font-size="20" fill="#EDEAE0">RAW/FLAT</text>
|
||||||
|
<path d="M40 300H1240M40 470H1240M470 158V680M820 158V680" stroke="#111" stroke-width="5"/>
|
||||||
|
<text x="66" y="208" font-family="Courier New, monospace" font-size="19" fill="#111">NEWSPAPER DENSITY · HEAVY RULES · NO SOFT CHROME</text>
|
||||||
|
<text x="66" y="252" font-family="Georgia, serif" font-size="21" fill="#111">Lorem ipsum dolor sit amet, consectetur</text>
|
||||||
|
<text x="66" y="282" font-family="Georgia, serif" font-size="21" fill="#111">adipiscing elit — a content page, not a poster.</text>
|
||||||
|
<rect x="490" y="180" width="310" height="270" fill="url(#bru-half)"/>
|
||||||
|
<rect x="490" y="180" width="310" height="270" fill="none" stroke="#111" stroke-width="5"/>
|
||||||
|
<rect x="510" y="330" width="270" height="100" fill="#EDEAE0"/>
|
||||||
|
<text x="524" y="410" font-family="Arial Black, Arial, sans-serif" font-size="82" font-weight="900" fill="#111">72</text>
|
||||||
|
<text x="662" y="410" font-family="Arial Black, Arial, sans-serif" font-size="42" fill="#DC2626">%</text>
|
||||||
|
<rect x="840" y="180" width="360" height="270" fill="#DC2626"/>
|
||||||
|
<text x="864" y="268" font-family="Arial Black, Arial, sans-serif" font-size="56" font-weight="900" fill="#fff">ONE</text>
|
||||||
|
<text x="864" y="328" font-family="Arial Black, Arial, sans-serif" font-size="56" font-weight="900" fill="#fff">LOUD</text>
|
||||||
|
<text x="864" y="388" font-family="Arial Black, Arial, sans-serif" font-size="56" font-weight="900" fill="#fff">SPOT</text>
|
||||||
|
<text x="66" y="514" font-family="Courier New, monospace" font-size="19" fill="#111">INDEX ------------------- VALUE -- TREND</text>
|
||||||
|
<text x="66" y="550" font-family="Courier New, monospace" font-size="19" fill="#111">A LOREM 124 UP</text>
|
||||||
|
<text x="66" y="586" font-family="Courier New, monospace" font-size="19" fill="#111">B IPSUM 092 FLAT</text>
|
||||||
|
<text x="66" y="622" font-family="Courier New, monospace" font-size="19" fill="#111">C DOLOR 051 DOWN</text>
|
||||||
|
<text x="66" y="658" font-family="Courier New, monospace" font-size="19" fill="#111">D AMET 148 UP</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<filter id="ck-rough"><feTurbulence type="fractalNoise" baseFrequency="0.04 0.09" numOctaves="2" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="3"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#23423C"/>
|
||||||
|
<rect width="1280" height="720" fill="#1C3833" opacity="0.5"/>
|
||||||
|
<text x="96" y="172" font-family="'Segoe Print',Arial,sans-serif" font-size="78" font-weight="700" fill="#F4F7EF" filter="url(#ck-rough)">Lorem ipsum</text>
|
||||||
|
<path d="M96 198q220 -10 420 2" stroke="#F4F7EF" stroke-width="4" fill="none" opacity="0.8" filter="url(#ck-rough)"/>
|
||||||
|
<text x="98" y="252" font-family="Arial,sans-serif" font-size="26" fill="#BEE3D0">Dark slate · chalk strokes · pastel emphasis</text>
|
||||||
|
<g filter="url(#ck-rough)">
|
||||||
|
<circle cx="300" cy="480" r="90" fill="none" stroke="#FDE68A" stroke-width="4"/>
|
||||||
|
<text x="300" y="494" text-anchor="middle" font-family="'Segoe Print',sans-serif" font-size="40" fill="#FDE68A">72%</text>
|
||||||
|
<path d="M410 480h150" stroke="#F4F7EF" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M560 480l-24 -10v20z" fill="#F4F7EF"/>
|
||||||
|
<rect x="580" y="420" width="190" height="120" rx="6" fill="none" stroke="#93C5FD" stroke-width="4"/>
|
||||||
|
<text x="602" y="490" font-family="'Segoe Print',sans-serif" font-size="26" fill="#93C5FD">Ipsum</text>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#ck-rough)" stroke="#F4F7EF" stroke-width="3" fill="none">
|
||||||
|
<path d="M900 560h280M900 560v-200"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#F9A8D4" opacity="0.85"><rect x="930" y="482" width="40" height="76"/><rect x="990" y="442" width="40" height="116"/><rect x="1050" y="402" width="40" height="156"/><rect x="1110" y="462" width="40" height="96"/></g>
|
||||||
|
<g fill="#F4F7EF" opacity="0.14"><circle cx="200" cy="640" r="3"/><circle cx="240" cy="650" r="2"/><circle cx="1000" cy="605" r="2"/><circle cx="700" cy="300" r="2"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<filter id="neon" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="dt-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#22D3EE" stop-opacity="0.4"/><stop offset="1" stop-color="#22D3EE" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#070B16"/>
|
||||||
|
<g stroke="#16324D" stroke-width="1" opacity="0.6">
|
||||||
|
<path d="M0 180H1280M0 360H1280M0 540H1280M160 0V720M400 0V720M640 0V720M880 0V720M1120 0V720"/>
|
||||||
|
</g>
|
||||||
|
<text x="96" y="118" font-family="Consolas, monospace" font-size="24" letter-spacing="4" fill="#22D3EE">// SYSTEM.OVERVIEW_04</text>
|
||||||
|
<text x="94" y="248" font-family="Helvetica Neue, Arial, sans-serif" font-size="94" font-weight="700" fill="#F1F5F9">Lorem ipsum</text>
|
||||||
|
<text x="98" y="306" font-family="Arial, sans-serif" font-size="27" fill="#7DD3FC">Dark canvas · glowing accents · precise geometry</text>
|
||||||
|
<g transform="translate(96 360)">
|
||||||
|
<path d="M0 220L120 170L240 190L360 110L480 140L600 60L720 90L840 20" fill="none" stroke="#22D3EE" stroke-width="4" filter="url(#neon)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M0 220L120 170L240 190L360 110L480 140L600 60L720 90L840 20L840 240L0 240Z" fill="url(#dt-fill)"/>
|
||||||
|
<circle cx="600" cy="60" r="8" fill="#A3E635" filter="url(#neon)"/>
|
||||||
|
<circle cx="840" cy="20" r="8" fill="#A3E635" filter="url(#neon)"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(980 360)">
|
||||||
|
<rect x="0" y="0" width="200" height="240" rx="10" fill="#0C1526" stroke="#1E3A5F"/>
|
||||||
|
<text x="24" y="92" font-family="Helvetica Neue, Arial, sans-serif" font-size="74" font-weight="700" fill="#22D3EE" filter="url(#neon)">72%</text>
|
||||||
|
<text x="24" y="130" font-family="Consolas, monospace" font-size="18" fill="#64748B">UPTIME</text>
|
||||||
|
<rect x="24" y="162" width="152" height="10" rx="5" fill="#12233B"/>
|
||||||
|
<rect x="24" y="162" width="110" height="10" rx="5" fill="#A3E635"/>
|
||||||
|
<text x="24" y="214" font-family="Consolas, monospace" font-size="16" fill="#7DD3FC">+18% QoQ</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#FFFFFF"/>
|
||||||
|
<text x="72" y="86" font-family="Georgia, serif" font-size="20" letter-spacing="3" fill="#C2410C">DATA DESK · ANALYSIS</text>
|
||||||
|
<text x="70" y="150" font-family="Georgia, 'Times New Roman', serif" font-size="58" font-weight="700" fill="#111827">Lorem ipsum dolor</text>
|
||||||
|
<text x="72" y="192" font-family="Arial, sans-serif" font-size="23" fill="#6B7280">Multi-column microcharts, sidebars and source lines</text>
|
||||||
|
<path d="M72 220H1208" stroke="#111827" stroke-width="2"/>
|
||||||
|
<g transform="translate(72 250)">
|
||||||
|
<rect x="0" y="0" width="560" height="260" fill="#F8FAFC" stroke="#E2E8F0"/>
|
||||||
|
<text x="16" y="30" font-family="Arial, sans-serif" font-size="18" font-weight="700" fill="#111827">Trend · 2019–2026</text>
|
||||||
|
<g stroke="#E2E8F0"><path d="M16 210H544M16 160H544M16 110H544M16 60H544"/></g>
|
||||||
|
<path d="M16 190L100 170L184 176L268 120L352 130L436 70L520 40" fill="none" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<path d="M16 190L100 170L184 176L268 120L352 130L436 70L520 40L520 210L16 210Z" fill="#2563EB" opacity="0.08"/>
|
||||||
|
<circle cx="520" cy="40" r="5" fill="#C2410C"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(660 250)">
|
||||||
|
<rect x="0" y="0" width="250" height="122" fill="#F8FAFC" stroke="#E2E8F0"/>
|
||||||
|
<text x="14" y="26" font-family="Arial, sans-serif" font-size="15" font-weight="700" fill="#111827">Share</text>
|
||||||
|
<g fill="#2563EB"><rect x="16" y="96" width="26" height="14"/><rect x="56" y="78" width="26" height="32"/><rect x="96" y="58" width="26" height="52"/><rect x="136" y="70" width="26" height="40"/><rect x="176" y="44" width="26" height="66"/><rect x="216" y="86" width="26" height="24"/></g>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(940 250)">
|
||||||
|
<rect x="0" y="0" width="268" height="122" fill="#F8FAFC" stroke="#E2E8F0"/>
|
||||||
|
<text x="14" y="26" font-family="Arial, sans-serif" font-size="15" font-weight="700" fill="#111827">Mix</text>
|
||||||
|
<circle cx="70" cy="74" r="34" fill="none" stroke="#E2E8F0" stroke-width="16"/>
|
||||||
|
<circle cx="70" cy="74" r="34" fill="none" stroke="#2563EB" stroke-width="16" stroke-dasharray="150 214" transform="rotate(-90 70 74)"/>
|
||||||
|
<circle cx="70" cy="74" r="34" fill="none" stroke="#C2410C" stroke-width="16" stroke-dasharray="55 214" stroke-dashoffset="-150" transform="rotate(-90 70 74)"/>
|
||||||
|
<text x="132" y="62" font-family="Arial, sans-serif" font-size="16" fill="#2563EB">68% a</text>
|
||||||
|
<text x="132" y="92" font-family="Arial, sans-serif" font-size="16" fill="#C2410C">24% b</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(660 396)">
|
||||||
|
<rect x="0" y="0" width="250" height="114" fill="#111827"/>
|
||||||
|
<text x="16" y="54" font-family="Georgia, serif" font-size="46" font-weight="700" fill="#fff">72%</text>
|
||||||
|
<text x="16" y="88" font-family="Arial, sans-serif" font-size="16" fill="#CBD5E1">Consectetur elit</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(940 396)">
|
||||||
|
<rect x="0" y="0" width="268" height="114" fill="#F8FAFC" stroke="#E2E8F0"/>
|
||||||
|
<path d="M14 96L60 80L106 88L152 60L198 70L254 40L254 104L14 104Z" fill="#2563EB" opacity="0.15"/>
|
||||||
|
<path d="M14 96L60 80L106 88L152 60L198 70L254 40" fill="none" stroke="#2563EB" stroke-width="2.5"/>
|
||||||
|
</g>
|
||||||
|
<path d="M72 548H1208" stroke="#E2E8F0" stroke-width="1"/>
|
||||||
|
<text x="72" y="576" font-family="Arial, sans-serif" font-size="16" fill="#9CA3AF">Source: lorem ipsum operating data (2019–2026) · Chart: PM Data Desk</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#FBFAF7"/>
|
||||||
|
<text x="80" y="76" font-family="Georgia, serif" font-size="24" letter-spacing="8" fill="#111827">THE REVIEW</text>
|
||||||
|
<text x="1200" y="76" text-anchor="end" font-family="Georgia, serif" font-size="22" fill="#8A8377">FEATURE · No.04</text>
|
||||||
|
<path d="M80 96H1200" stroke="#111827" stroke-width="3"/>
|
||||||
|
<path d="M80 103H1200" stroke="#111827" stroke-width="1"/>
|
||||||
|
<text x="80" y="216" font-family="Georgia, 'Times New Roman', serif" font-size="98" font-weight="700" fill="#111827">Lorem ipsum</text>
|
||||||
|
<text x="80" y="272" font-family="Georgia, serif" font-size="30" font-style="italic" fill="#6B7280">A serif / sans-serif hierarchy set in columns</text>
|
||||||
|
<path d="M80 308H1200" stroke="#111827" stroke-width="1"/>
|
||||||
|
<path d="M450 336V648M820 336V648" stroke="#D8D2C4" stroke-width="1.5"/>
|
||||||
|
<text x="80" y="452" font-family="Georgia, serif" font-size="118" font-weight="700" fill="#B45309">L</text>
|
||||||
|
<g fill="#CFC9BC"><rect x="196" y="356" width="222" height="11" rx="2"/><rect x="196" y="384" width="222" height="11" rx="2"/><rect x="196" y="412" width="205" height="11" rx="2"/><rect x="80" y="470" width="338" height="11" rx="2"/><rect x="80" y="498" width="338" height="11" rx="2"/><rect x="80" y="526" width="300" height="11" rx="2"/><rect x="80" y="554" width="338" height="11" rx="2"/><rect x="80" y="582" width="250" height="11" rx="2"/></g>
|
||||||
|
<text x="490" y="404" font-family="Georgia, serif" font-size="33" font-style="italic" fill="#111827">“Lorem ipsum</text>
|
||||||
|
<text x="490" y="446" font-family="Georgia, serif" font-size="33" font-style="italic" fill="#111827">dolor sit amet,</text>
|
||||||
|
<text x="490" y="488" font-family="Georgia, serif" font-size="33" font-style="italic" fill="#111827">consectetur.”</text>
|
||||||
|
<g fill="#CFC9BC"><rect x="490" y="536" width="300" height="11" rx="2"/><rect x="490" y="564" width="300" height="11" rx="2"/><rect x="490" y="592" width="230" height="11" rx="2"/></g>
|
||||||
|
<g fill="#CFC9BC"><rect x="860" y="356" width="340" height="11" rx="2"/><rect x="860" y="384" width="340" height="11" rx="2"/><rect x="860" y="412" width="300" height="11" rx="2"/><rect x="860" y="440" width="340" height="11" rx="2"/><rect x="860" y="468" width="270" height="11" rx="2"/></g>
|
||||||
|
<rect x="860" y="506" width="340" height="110" fill="#EDE9DF"/>
|
||||||
|
<text x="882" y="558" font-family="Georgia, serif" font-size="46" font-weight="700" fill="#111827">72%</text>
|
||||||
|
<text x="882" y="594" font-family="Georgia, serif" font-size="20" fill="#6B7280">dolor sit amet</text>
|
||||||
|
<text x="80" y="686" font-family="Georgia, serif" font-size="20" font-style="italic" fill="#8A8377">By Strategy Desk · Illustration by PM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#4F46E5"/><stop offset="0.5" stop-color="#7C3AED"/><stop offset="1" stop-color="#EC4899"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="g-blur" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="44"/></filter>
|
||||||
|
<linearGradient id="g-panel" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/><stop offset="1" stop-color="#FFFFFF" stop-opacity="0.12"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="url(#g-bg)"/>
|
||||||
|
<circle cx="240" cy="180" r="150" fill="#22D3EE" filter="url(#g-blur)" opacity="0.85"/>
|
||||||
|
<circle cx="1080" cy="560" r="185" fill="#F472B6" filter="url(#g-blur)" opacity="0.9"/>
|
||||||
|
<circle cx="980" cy="150" r="95" fill="#FDE68A" filter="url(#g-blur)" opacity="0.75"/>
|
||||||
|
<rect x="150" y="150" width="640" height="420" rx="36" fill="url(#g-panel)" stroke="#FFFFFF" stroke-opacity="0.55" stroke-width="1.5"/>
|
||||||
|
<rect x="200" y="205" width="150" height="48" rx="24" fill="#FFFFFF" fill-opacity="0.25" stroke="#fff" stroke-opacity="0.5"/>
|
||||||
|
<text x="224" y="238" font-family="Arial, sans-serif" font-size="24" fill="#fff">GLASS 04</text>
|
||||||
|
<text x="200" y="362" font-family="Helvetica Neue, Arial, sans-serif" font-size="80" font-weight="700" fill="#FFFFFF">Lorem ipsum</text>
|
||||||
|
<text x="202" y="414" font-family="Arial, sans-serif" font-size="26" fill="#EDE9FE">Translucent panels · gradient light · depth</text>
|
||||||
|
<rect x="200" y="452" width="270" height="92" rx="20" fill="#FFFFFF" fill-opacity="0.2" stroke="#fff" stroke-opacity="0.4"/>
|
||||||
|
<rect x="490" y="452" width="270" height="92" rx="20" fill="#FFFFFF" fill-opacity="0.2" stroke="#fff" stroke-opacity="0.4"/>
|
||||||
|
<text x="228" y="512" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#fff">72%</text>
|
||||||
|
<text x="360" y="512" font-family="Arial, sans-serif" font-size="20" fill="#EDE9FE">dolor</text>
|
||||||
|
<text x="518" y="512" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#fff">1.8x</text>
|
||||||
|
<text x="652" y="512" font-family="Arial, sans-serif" font-size="20" fill="#EDE9FE">amet</text>
|
||||||
|
<rect x="830" y="205" width="260" height="310" rx="26" fill="#FFFFFF" fill-opacity="0.16" stroke="#fff" stroke-opacity="0.4"/>
|
||||||
|
<circle cx="960" cy="308" r="54" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="13"/>
|
||||||
|
<path d="M960 254a54 54 0 0 1 47 81" fill="none" stroke="#FDE68A" stroke-width="13" stroke-linecap="round"/>
|
||||||
|
<rect x="870" y="400" width="180" height="16" rx="8" fill="#fff" fill-opacity="0.55"/>
|
||||||
|
<rect x="870" y="432" width="130" height="14" rx="7" fill="#fff" fill-opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#FAFAF7"/>
|
||||||
|
<text x="100" y="150" font-family="'Segoe Script','Bradley Hand',cursive" font-size="30" fill="#111827">04 —</text>
|
||||||
|
<text x="100" y="252" font-family="Georgia, serif" font-size="88" font-weight="700" fill="#111827">Lorem ipsum</text>
|
||||||
|
<path d="M100 278q200 -14 380 -2" fill="none" stroke="#111827" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<text x="100" y="332" font-family="Arial, sans-serif" font-size="26" fill="#4B5563">Light ground · handwritten ink · sparse emphasis</text>
|
||||||
|
<text x="100" y="432" font-family="Arial, sans-serif" font-size="30" fill="#1F2937">— Lorem ipsum dolor</text>
|
||||||
|
<text x="100" y="492" font-family="Arial, sans-serif" font-size="30" fill="#1F2937">— Sit amet</text>
|
||||||
|
<path d="M182 464q120 -22 210 6q34 42 -10 62q-150 26 -214 -8q-32 -34 14 -60Z" fill="none" stroke="#DC2626" stroke-width="4"/>
|
||||||
|
<text x="100" y="552" font-family="Arial, sans-serif" font-size="30" fill="#1F2937">— Consectetur adipiscing</text>
|
||||||
|
<path d="M820 170q120 40 60 160t80 210" fill="none" stroke="#111827" stroke-width="11" stroke-linecap="round" opacity="0.85"/>
|
||||||
|
<path d="M900 250q80 -10 140 40" fill="none" stroke="#111827" stroke-width="5" stroke-linecap="round" opacity="0.55"/>
|
||||||
|
<path d="M760 548q120 -30 250 -72" fill="none" stroke="#DC2626" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M1010 476l-30 4l16 24z" fill="#DC2626"/>
|
||||||
|
<text x="1030" y="600" text-anchor="end" font-family="Georgia, serif" font-size="64" font-weight="700" fill="#111827">72%</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="iw-paper" cx="50%" cy="40%" r="75%"><stop offset="0" stop-color="#FBF8EF"/><stop offset="1" stop-color="#EEE6D2"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="url(#iw-paper)"/>
|
||||||
|
<path d="M620 470C700 300 800 250 900 300C860 340 900 380 980 360C920 420 1000 460 1080 430C1000 500 900 520 800 500C720 486 660 500 620 470Z" fill="#1F2937" opacity="0.16"/>
|
||||||
|
<path d="M700 480C780 360 860 330 940 370C900 400 940 430 1010 418C960 470 1030 496 1110 470C1030 520 940 528 860 512C790 498 740 508 700 480Z" fill="#1F2937" opacity="0.3"/>
|
||||||
|
<path d="M640 560C760 500 900 540 1160 470" fill="none" stroke="#1F2937" stroke-width="14" stroke-linecap="round" opacity="0.85"/>
|
||||||
|
<path d="M660 600C820 560 980 588 1140 548" fill="none" stroke="#1F2937" stroke-width="5" stroke-linecap="round" opacity="0.4"/>
|
||||||
|
<text x="120" y="176" font-family="Georgia, serif" font-size="28" letter-spacing="4" fill="#7F1D1D">SCROLL · IV</text>
|
||||||
|
<text x="118" y="288" font-family="'Songti SC','STSong',Georgia,serif" font-size="90" font-weight="700" fill="#1F2937">Lorem ipsum</text>
|
||||||
|
<text x="120" y="346" font-family="Arial,sans-serif" font-size="24" fill="#4B5563">Rice-paper whitespace · brushwork · seal · calm</text>
|
||||||
|
<text x="120" y="446" font-family="Georgia,serif" font-size="30" fill="#374151">I. Lorem ipsum dolor</text>
|
||||||
|
<text x="120" y="500" font-family="Georgia,serif" font-size="30" fill="#374151">II. Sit amet consectetur</text>
|
||||||
|
<text x="120" y="554" font-family="Georgia,serif" font-size="30" fill="#374151">III. Adipiscing elit sed</text>
|
||||||
|
<rect x="470" y="474" width="72" height="72" rx="6" fill="#B91C1C"/>
|
||||||
|
<g stroke="#FBEAEA" stroke-width="6" stroke-linecap="round"><path d="M490 496h32M490 512h32M506 496v34"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#FFF6EC"/>
|
||||||
|
<g stroke="#111827" stroke-width="6">
|
||||||
|
<circle cx="150" cy="140" r="52" fill="#FDE047"/>
|
||||||
|
<rect x="1050" y="90" width="110" height="110" transform="rotate(12 1105 145)" fill="#F472B6"/>
|
||||||
|
<path d="M1080 470l70 -96l70 96z" fill="#34D399"/>
|
||||||
|
<path d="M90 560h58M90 590h58M90 620h58" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M980 610q26 -34 52 0t52 0" fill="none"/>
|
||||||
|
<circle cx="1180" cy="360" r="30" fill="#60A5FA"/>
|
||||||
|
<path d="M60 330q26 -30 52 0t52 0t52 0" fill="none"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#F97316"><circle cx="620" cy="90" r="9"/><circle cx="660" cy="90" r="9"/><circle cx="700" cy="90" r="9"/><circle cx="620" cy="130" r="9"/><circle cx="660" cy="130" r="9"/><circle cx="700" cy="130" r="9"/></g>
|
||||||
|
<rect x="150" y="230" width="640" height="200" rx="18" fill="#FFFFFF" stroke="#111827" stroke-width="7"/>
|
||||||
|
<text x="186" y="326" font-family="Arial Black, Arial, sans-serif" font-size="72" font-weight="900" fill="#111827">Lorem</text>
|
||||||
|
<text x="186" y="392" font-family="Arial, sans-serif" font-size="24" fill="#111827">Color blocks · geometric scraps · bold outlines</text>
|
||||||
|
<circle cx="980" cy="300" r="130" fill="#A78BFA" stroke="#111827" stroke-width="7"/>
|
||||||
|
<text x="980" y="292" text-anchor="middle" font-family="Arial Black, Arial, sans-serif" font-size="72" font-weight="900" fill="#111827">72%</text>
|
||||||
|
<text x="980" y="338" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#111827">dolor</text>
|
||||||
|
<g stroke="#111827" stroke-width="7">
|
||||||
|
<rect x="150" y="500" width="90" height="120" fill="#60A5FA"/>
|
||||||
|
<rect x="270" y="470" width="90" height="150" fill="#FDE047"/>
|
||||||
|
<rect x="390" y="510" width="90" height="110" fill="#34D399"/>
|
||||||
|
<rect x="510" y="450" width="90" height="170" fill="#F472B6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<filter id="pc-sh" x="-20%" y="-45%" width="140%" height="190%"><feDropShadow dx="0" dy="-8" stdDeviation="10" flood-color="#1E3A5F" flood-opacity="0.18"/></filter>
|
||||||
|
<filter id="pc-sh2" x="-20%" y="-45%" width="140%" height="190%"><feDropShadow dx="0" dy="-11" stdDeviation="14" flood-color="#0F2A44" flood-opacity="0.2"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#EAF3FB"/>
|
||||||
|
<circle cx="980" cy="220" r="112" fill="#FBBF6E" filter="url(#pc-sh)"/>
|
||||||
|
<path d="M0 430C220 370 420 470 660 420C900 370 1080 450 1280 400V720H0Z" fill="#BFE0F5" filter="url(#pc-sh)"/>
|
||||||
|
<path d="M0 520C260 460 460 560 720 500C980 440 1120 520 1280 480V720H0Z" fill="#7FBEE8" filter="url(#pc-sh2)"/>
|
||||||
|
<path d="M0 610C280 555 520 640 800 580C1060 525 1160 600 1280 570V720H0Z" fill="#3E8FCB" filter="url(#pc-sh2)"/>
|
||||||
|
<path d="M560 470l64 -30l-8 44z" fill="#F87171" filter="url(#pc-sh)"/>
|
||||||
|
<rect x="90" y="150" width="540" height="200" rx="18" fill="#FFFFFF" filter="url(#pc-sh)"/>
|
||||||
|
<text x="128" y="238" font-family="Arial, sans-serif" font-size="62" font-weight="800" fill="#1E4E79">Lorem ipsum</text>
|
||||||
|
<text x="130" y="292" font-family="Arial, sans-serif" font-size="24" fill="#5B7A94">Layered cut paper · soft shadows · handmade</text>
|
||||||
|
<rect x="130" y="316" width="120" height="12" rx="6" fill="#FBBF6E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pe-sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1B2A4A"/><stop offset="0.5" stop-color="#7C4A6B"/><stop offset="0.78" stop-color="#E08A63"/><stop offset="1" stop-color="#F3B77E"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="pe-sun" cx="72%" cy="72%" r="36%">
|
||||||
|
<stop offset="0" stop-color="#FFE7B8"/><stop offset="0.35" stop-color="#FBC26B" stop-opacity="0.8"/><stop offset="1" stop-color="#FBC26B" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="pe-grad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.78"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="url(#pe-sky)"/>
|
||||||
|
<rect width="1280" height="720" fill="url(#pe-sun)"/>
|
||||||
|
<circle cx="922" cy="516" r="72" fill="#FFEFC8" opacity="0.95"/>
|
||||||
|
<path d="M0 470L220 420L470 500L720 430L980 500L1280 440V720H0Z" fill="#5A4A6E" opacity="0.55"/>
|
||||||
|
<path d="M0 540L260 500L520 560L780 505L1040 570L1280 520V720H0Z" fill="#3E3352" opacity="0.72"/>
|
||||||
|
<path d="M0 610L300 575L600 630L900 580L1280 625V720H0Z" fill="#241C33"/>
|
||||||
|
<path d="M300 250q14 -12 28 0q14 -12 28 0" fill="none" stroke="#1B2A4A" stroke-width="3"/>
|
||||||
|
<path d="M362 286q10 -9 20 0q10 -9 20 0" fill="none" stroke="#1B2A4A" stroke-width="2.5"/>
|
||||||
|
<rect x="0" y="440" width="1280" height="280" fill="url(#pe-grad)"/>
|
||||||
|
<rect x="80" y="560" width="8" height="96" fill="#F3B77E"/>
|
||||||
|
<text x="112" y="548" font-family="Helvetica Neue, Arial, sans-serif" font-size="22" letter-spacing="5" fill="#F3B77E">FIELD NOTES · 04</text>
|
||||||
|
<text x="108" y="622" font-family="Helvetica Neue, Arial, sans-serif" font-size="76" font-weight="700" fill="#fff">Lorem ipsum</text>
|
||||||
|
<text x="112" y="666" font-family="Helvetica Neue, Arial, sans-serif" font-size="24" fill="#E7DCD2">Full-bleed photography with light text and captions</text>
|
||||||
|
<rect x="20" y="20" width="1240" height="680" fill="none" stroke="#fff" stroke-opacity="0.6" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" shape-rendering="crispEdges">
|
||||||
|
<rect width="1280" height="720" fill="#1A1C2C"/>
|
||||||
|
<rect x="0" y="600" width="1280" height="120" fill="#2A2F4A"/>
|
||||||
|
<rect x="0" y="600" width="1280" height="20" fill="#3B4670"/>
|
||||||
|
<text x="80" y="182" font-family="'Courier New',monospace" font-size="92" font-weight="700" fill="#FFCD75" letter-spacing="2">LOREM IPSUM</text>
|
||||||
|
<text x="84" y="238" font-family="'Courier New',monospace" font-size="27" fill="#41A6F6">STRICT PIXEL GRID · LIMITED PALETTE</text>
|
||||||
|
<g>
|
||||||
|
<rect x="160" y="340" width="160" height="40" fill="#EF7D57"/>
|
||||||
|
<rect x="200" y="300" width="80" height="40" fill="#FFCD75"/>
|
||||||
|
<rect x="200" y="380" width="120" height="120" fill="#41A6F6"/>
|
||||||
|
<rect x="160" y="420" width="40" height="80" fill="#41A6F6"/>
|
||||||
|
<rect x="320" y="420" width="40" height="80" fill="#41A6F6"/>
|
||||||
|
<rect x="200" y="500" width="40" height="60" fill="#38213A"/>
|
||||||
|
<rect x="280" y="500" width="40" height="60" fill="#38213A"/>
|
||||||
|
<rect x="220" y="320" width="16" height="16" fill="#1A1C2C"/>
|
||||||
|
<rect x="252" y="320" width="16" height="16" fill="#1A1C2C"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="640" y="480" width="60" height="80" fill="#38B764"/>
|
||||||
|
<rect x="720" y="420" width="60" height="140" fill="#38B764"/>
|
||||||
|
<rect x="800" y="360" width="60" height="200" fill="#FFCD75"/>
|
||||||
|
<rect x="880" y="440" width="60" height="120" fill="#38B764"/>
|
||||||
|
<rect x="960" y="300" width="60" height="260" fill="#EF7D57"/>
|
||||||
|
</g>
|
||||||
|
<path d="M620 560H1060" stroke="#94B0C2" stroke-width="8"/>
|
||||||
|
<g fill="#FFCD75"><rect x="1120" y="120" width="24" height="24"/><rect x="1096" y="144" width="72" height="24"/><rect x="1120" y="168" width="24" height="24"/></g>
|
||||||
|
<text x="1250" y="470" text-anchor="end" font-family="'Courier New',monospace" font-size="84" font-weight="700" fill="#FFCD75">72%</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#FFFBEF"/>
|
||||||
|
<rect x="86" y="118" width="430" height="48" rx="8" fill="#FDE68A" opacity="0.8" transform="rotate(-1 300 142)"/>
|
||||||
|
<text x="96" y="172" font-family="'Comic Sans MS','Segoe Print',cursive" font-size="70" font-weight="700" fill="#1F2937">Lorem ipsum</text>
|
||||||
|
<text x="98" y="220" font-family="'Comic Sans MS',cursive" font-size="26" fill="#6B7280">Warm paper · doodle lines · soft color blocks</text>
|
||||||
|
<path d="M96 280q220 -8 300 0q6 90 0 150q-160 8 -300 2q-6 -80 0 -154Z" fill="#FEE2E2" stroke="#1F2937" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<text x="128" y="342" font-family="'Comic Sans MS',cursive" font-size="30" font-weight="700" fill="#1F2937">Lorem</text>
|
||||||
|
<path d="M128 372h230M128 400h180" stroke="#9CA3AF" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M430 360q90 -30 150 10" fill="none" stroke="#2563EB" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M580 370l-22 -6l14 20z" fill="#2563EB"/>
|
||||||
|
<path d="M600 300q210 -8 290 4q6 80 0 140q-150 8 -290 0q-6 -74 0 -144Z" fill="#DBEAFE" stroke="#1F2937" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<text x="632" y="358" font-family="'Comic Sans MS',cursive" font-size="30" font-weight="700" fill="#1F2937">Ipsum</text>
|
||||||
|
<circle cx="652" cy="394" r="8" fill="#34D399" stroke="#1F2937" stroke-width="2"/>
|
||||||
|
<circle cx="692" cy="394" r="8" fill="#FBBF24" stroke="#1F2937" stroke-width="2"/>
|
||||||
|
<path d="M1000 200l14 40l42 2l-33 26l12 41l-35 -24l-35 24l12 -41l-33 -26l42 -2z" fill="#FDE047" stroke="#1F2937" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<g stroke="#1F2937" stroke-width="3" fill="none"><path d="M120 560v120h520" stroke-linecap="round"/></g>
|
||||||
|
<path d="M150 640q90 -60 180 -20t180 -70" fill="none" stroke="#EC4899" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<circle cx="510" cy="530" r="6" fill="#EC4899"/>
|
||||||
|
<text x="1000" y="300" font-family="'Comic Sans MS',cursive" font-size="40" font-weight="700" fill="#1F2937">72%!</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<filter id="sr-sh" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="20" stdDeviation="28" flood-color="#1D4ED8" flood-opacity="0.16"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="sr-ic" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#60A5FA"/><stop offset="1" stop-color="#2563EB"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#EAF1FB"/>
|
||||||
|
<rect x="90" y="96" width="700" height="528" rx="48" fill="#FFFFFF" filter="url(#sr-sh)"/>
|
||||||
|
<rect x="140" y="150" width="176" height="54" rx="27" fill="#DBEAFE"/>
|
||||||
|
<text x="166" y="186" font-family="Arial, sans-serif" font-size="25" font-weight="600" fill="#2563EB">Overview 04</text>
|
||||||
|
<circle cx="184" cy="300" r="48" fill="url(#sr-ic)"/>
|
||||||
|
<path d="M162 300l16 16l28 -34" stroke="#fff" stroke-width="8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="256" y="292" font-family="Arial, sans-serif" font-size="60" font-weight="700" fill="#0F172A">Lorem ipsum</text>
|
||||||
|
<text x="258" y="344" font-family="Arial, sans-serif" font-size="26" fill="#64748B">Rounded cards · soft shadows · friendly</text>
|
||||||
|
<rect x="140" y="424" width="290" height="152" rx="30" fill="#F1F6FE"/>
|
||||||
|
<rect x="460" y="424" width="290" height="152" rx="30" fill="#F1F6FE"/>
|
||||||
|
<text x="172" y="494" font-family="Arial, sans-serif" font-size="54" font-weight="700" fill="#2563EB">72%</text>
|
||||||
|
<text x="172" y="536" font-family="Arial, sans-serif" font-size="22" fill="#64748B">dolor sit</text>
|
||||||
|
<text x="492" y="494" font-family="Arial, sans-serif" font-size="54" font-weight="700" fill="#2563EB">1.8x</text>
|
||||||
|
<text x="492" y="536" font-family="Arial, sans-serif" font-size="22" fill="#64748B">amet elit</text>
|
||||||
|
<rect x="840" y="150" width="350" height="182" rx="40" fill="#FFFFFF" filter="url(#sr-sh)"/>
|
||||||
|
<rect x="840" y="382" width="350" height="242" rx="40" fill="#2563EB" filter="url(#sr-sh)"/>
|
||||||
|
<circle cx="905" cy="241" r="30" fill="#BFDBFE"/>
|
||||||
|
<rect x="955" y="214" width="185" height="20" rx="10" fill="#E2E8F0"/>
|
||||||
|
<rect x="955" y="250" width="120" height="16" rx="8" fill="#EDF2F8"/>
|
||||||
|
<text x="885" y="474" font-family="Arial, sans-serif" font-size="32" font-weight="700" fill="#fff">Consectetur</text>
|
||||||
|
<rect x="885" y="506" width="200" height="64" rx="32" fill="#fff"/>
|
||||||
|
<text x="917" y="548" font-family="Arial, sans-serif" font-size="27" font-weight="600" fill="#2563EB">Get started →</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#FFFFFF"/>
|
||||||
|
<g stroke="#ECEFF3" stroke-width="1.5">
|
||||||
|
<path d="M96 0V720M448 0V720M800 0V720M1152 0V720"/>
|
||||||
|
<path d="M0 96H1280M0 624H1280"/>
|
||||||
|
</g>
|
||||||
|
<path d="M96 96H1184" stroke="#0F172A" stroke-width="3"/>
|
||||||
|
<text x="96" y="78" font-family="Helvetica Neue, Arial, sans-serif" font-size="27" letter-spacing="7" fill="#0F172A">SECTION 04</text>
|
||||||
|
<text x="1184" y="78" text-anchor="end" font-family="Helvetica Neue, Arial, sans-serif" font-size="27" letter-spacing="3" fill="#9AA3AF">SWISS · GRID</text>
|
||||||
|
<text x="94" y="322" font-family="Helvetica Neue, Arial, sans-serif" font-size="128" font-weight="700" fill="#0F172A" letter-spacing="-3">Lorem ipsum</text>
|
||||||
|
<text x="98" y="396" font-family="Helvetica Neue, Arial, sans-serif" font-size="38" font-weight="300" fill="#475569">Grid-locked hierarchy, generous whitespace</text>
|
||||||
|
<rect x="96" y="470" width="130" height="130" fill="#E4002B"/>
|
||||||
|
<text x="1184" y="556" text-anchor="end" font-family="Helvetica Neue, Arial, sans-serif" font-size="200" font-weight="700" fill="#0F172A" letter-spacing="-4">72</text>
|
||||||
|
<text x="1184" y="602" text-anchor="end" font-family="Helvetica Neue, Arial, sans-serif" font-size="30" fill="#64748B">aggregate index</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<pattern id="vp-half" width="12" height="12" patternUnits="userSpaceOnUse"><circle cx="6" cy="6" r="2.4" fill="#7C2D12" opacity="0.45"/></pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#EFD9A8"/>
|
||||||
|
<circle cx="890" cy="300" r="180" fill="#E07A3E"/>
|
||||||
|
<rect x="710" y="300" width="360" height="180" fill="url(#vp-half)"/>
|
||||||
|
<path d="M0 520L240 380L440 520Z" fill="#2E6E5E"/>
|
||||||
|
<path d="M320 540L620 340L900 540Z" fill="#245A4C"/>
|
||||||
|
<path d="M760 540L1020 400L1280 540V560H760Z" fill="#2E6E5E"/>
|
||||||
|
<rect x="0" y="540" width="1280" height="180" fill="#C6612E"/>
|
||||||
|
<rect x="0" y="540" width="1280" height="180" fill="url(#vp-half)"/>
|
||||||
|
<text x="90" y="180" font-family="Georgia, serif" font-size="26" letter-spacing="8" fill="#7C2D12">EST. 04 · SERIES</text>
|
||||||
|
<text x="86" y="296" font-family="Georgia, 'Times New Roman', serif" font-size="120" font-weight="900" fill="#7C2D12">Lorem</text>
|
||||||
|
<text x="90" y="352" font-family="Georgia, serif" font-size="27" fill="#8A4B24">Mid-century flat blocks · halftone · warm geometry</text>
|
||||||
|
<text x="90" y="628" font-family="Georgia, serif" font-size="40" font-weight="700" fill="#F3E4C4">72% · dolor sit amet</text>
|
||||||
|
<rect x="28" y="28" width="1224" height="664" fill="none" stroke="#7C2D12" stroke-width="4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs>
|
||||||
|
<pattern id="z-half" width="10" height="10" patternUnits="userSpaceOnUse"><circle cx="5" cy="5" r="2.6" fill="#EC4899"/></pattern>
|
||||||
|
<filter id="z-grain" x="0" y="0" width="100%" height="100%">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/>
|
||||||
|
<feColorMatrix type="saturate" values="0"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1280" height="720" fill="#F6F1E7"/>
|
||||||
|
<text x="96" y="248" font-family="Arial Black, Arial, sans-serif" font-size="132" font-weight="900" fill="#2563EB">LOREM</text>
|
||||||
|
<text x="86" y="240" font-family="Arial Black, Arial, sans-serif" font-size="132" font-weight="900" fill="#EC4899" opacity="0.85">LOREM</text>
|
||||||
|
<circle cx="1010" cy="230" r="150" fill="url(#z-half)"/>
|
||||||
|
<circle cx="1010" cy="230" r="150" fill="none" stroke="#2563EB" stroke-width="4"/>
|
||||||
|
<circle cx="990" cy="212" r="150" fill="none" stroke="#EC4899" stroke-width="4" opacity="0.7"/>
|
||||||
|
<rect x="96" y="322" width="560" height="70" fill="#EC4899"/>
|
||||||
|
<rect x="88" y="314" width="560" height="70" fill="#2563EB" opacity="0.55"/>
|
||||||
|
<text x="120" y="372" font-family="Courier New, monospace" font-size="30" font-weight="700" fill="#F6F1E7">RISO · 2-COLOR · No.04</text>
|
||||||
|
<text x="96" y="466" font-family="Courier New, monospace" font-size="26" fill="#2563EB">Riso offsets · halftone dots</text>
|
||||||
|
<text x="96" y="508" font-family="Courier New, monospace" font-size="26" fill="#EC4899">limited colors · print grain</text>
|
||||||
|
<g stroke="#111827" stroke-width="4"><path d="M96 556h120M96 592h90"/></g>
|
||||||
|
<rect x="760" y="440" width="420" height="200" fill="url(#z-half)" opacity="0.9"/>
|
||||||
|
<rect x="752" y="432" width="420" height="200" fill="none" stroke="#2563EB" stroke-width="4"/>
|
||||||
|
<text x="792" y="562" font-family="Arial Black, Arial, sans-serif" font-size="92" font-weight="900" fill="#2563EB">72</text>
|
||||||
|
<text x="792" y="602" font-family="Courier New, monospace" font-size="22" fill="#111827">PERCENT</text>
|
||||||
|
<rect width="1280" height="720" filter="url(#z-grain)" opacity="0.08"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Console encoding helpers for PPT Master CLI scripts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TextIO
|
||||||
|
|
||||||
|
from attribution_guard import require_skill_integrity
|
||||||
|
from workflow_transcript import install_auto_transcript
|
||||||
|
|
||||||
|
|
||||||
|
_SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
_SECONDARY_METADATA_FIELDS = ("copyright", "license", "official_repository")
|
||||||
|
_SECONDARY_METADATA_DIGEST = "efb48651eb07614e564d54ab28623b8561ad57c8300c123a6893cd1fada56337"
|
||||||
|
_SECONDARY_LICENSE_DIGEST = "80cefc234c1ec12a8cece4344f16300c634fa03df7891686fcf979e3828f0921"
|
||||||
|
|
||||||
|
|
||||||
|
def _secondary_normalized_text(path: Path) -> str:
|
||||||
|
"""Return canonical UTF-8 text for the independent identity check."""
|
||||||
|
data = path.read_bytes()
|
||||||
|
if data.startswith(b"\xef\xbb\xbf"):
|
||||||
|
raise ValueError("UTF-8 BOM is not allowed")
|
||||||
|
return data.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _secondary_identity_is_valid() -> bool:
|
||||||
|
"""Validate the canonical identity without reusing primary guard data."""
|
||||||
|
skill_text = _secondary_normalized_text(_SKILL_DIR / "SKILL.md")
|
||||||
|
if not skill_text.startswith("---\n"):
|
||||||
|
return False
|
||||||
|
metadata_end = skill_text.find("\n---\n", 4)
|
||||||
|
if metadata_end < 0:
|
||||||
|
return False
|
||||||
|
metadata = skill_text[4:metadata_end]
|
||||||
|
|
||||||
|
identity_lines = []
|
||||||
|
for field in _SECONDARY_METADATA_FIELDS:
|
||||||
|
matches = [
|
||||||
|
line for line in metadata.splitlines()
|
||||||
|
if line.startswith(f" {field}:")
|
||||||
|
]
|
||||||
|
if len(matches) != 1:
|
||||||
|
return False
|
||||||
|
identity_lines.append(matches[0])
|
||||||
|
|
||||||
|
metadata_digest = hashlib.sha256("\n".join(identity_lines).encode("utf-8")).hexdigest()
|
||||||
|
license_text = _secondary_normalized_text(_SKILL_DIR / "LICENSE")
|
||||||
|
license_digest = hashlib.sha256(license_text.encode("utf-8")).hexdigest()
|
||||||
|
return (
|
||||||
|
metadata_digest == _SECONDARY_METADATA_DIGEST
|
||||||
|
and license_digest == _SECONDARY_LICENSE_DIGEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_official_distribution_identity() -> None:
|
||||||
|
"""Stop silently when the independent official identity check fails."""
|
||||||
|
try:
|
||||||
|
valid = _secondary_identity_is_valid()
|
||||||
|
except (OSError, UnicodeError, ValueError):
|
||||||
|
valid = False
|
||||||
|
if not valid:
|
||||||
|
raise SystemExit(78)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconfigure_stream(stream: TextIO) -> TextIO:
|
||||||
|
try:
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
return stream
|
||||||
|
except AttributeError:
|
||||||
|
buffer = getattr(stream, "buffer", None)
|
||||||
|
if buffer is None:
|
||||||
|
return stream
|
||||||
|
return io.TextIOWrapper(buffer, encoding="utf-8", errors="replace")
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
def configure_utf8_stdio() -> None:
|
||||||
|
"""Configure CLI streams and enable project-scoped output recording."""
|
||||||
|
require_skill_integrity()
|
||||||
|
_require_official_distribution_identity()
|
||||||
|
sys.stdout = _reconfigure_stream(sys.stdout)
|
||||||
|
sys.stderr = _reconfigure_stream(sys.stderr)
|
||||||
|
install_auto_transcript()
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
# Advanced Image and Motion Maintenance Smoke
|
||||||
|
|
||||||
|
Run this manual smoke from the repository root after changing nested image
|
||||||
|
crop finalization, native picture export, deterministic Morph pairing, authored
|
||||||
|
PowerPoint presets, or their planning contracts. It builds one temporary
|
||||||
|
two-slide project under the gitignored `projects/_smoke_*` namespace and follows
|
||||||
|
the inline-smoke convention from
|
||||||
|
[`code-style.md`](../../../../docs/rules/code-style.md) §11; do not turn it into
|
||||||
|
a test file or public example deck.
|
||||||
|
|
||||||
|
The fixture deliberately closes the full planning and execution chain:
|
||||||
|
|
||||||
|
- `design_spec.md` carries `Motion suggestion`, `Native shape suggestion`, one
|
||||||
|
current §VIII image row, and `Crop Policy`;
|
||||||
|
- `spec_lock.md` projects that row with optional layout pattern `#M1-11`;
|
||||||
|
- both pages reuse one raster through ordinary, ellipse-preset, and custom-path
|
||||||
|
independent nested crops;
|
||||||
|
- `animations.json` pairs the main crop across adjacent Morph pages;
|
||||||
|
- one helper-authored `rightArrow` verifies native preset discovery and export.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
project = Path(
|
||||||
|
tempfile.mkdtemp(
|
||||||
|
prefix="_smoke_advanced_image_motion_",
|
||||||
|
dir="projects",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scripts = Path("skills/ppt-master/scripts")
|
||||||
|
images = project / "images"
|
||||||
|
svg_output = project / "svg_output"
|
||||||
|
images.mkdir()
|
||||||
|
svg_output.mkdir()
|
||||||
|
(project / "README.md").write_text(
|
||||||
|
"# Advanced image and motion maintenance smoke\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_tool(script, *args):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(scripts / script), *map(str, args)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
scene = Image.new("RGB", (1280, 720), "#E2E8F0")
|
||||||
|
draw = ImageDraw.Draw(scene)
|
||||||
|
draw.rectangle((0, 0, 420, 720), fill="#2563EB")
|
||||||
|
draw.rectangle((420, 0, 860, 720), fill="#F97316")
|
||||||
|
draw.rectangle((860, 0, 1280, 720), fill="#0F172A")
|
||||||
|
draw.ellipse((460, 150, 820, 510), fill="#F8FAFC")
|
||||||
|
scene.save(images / "scene.png")
|
||||||
|
|
||||||
|
preset = run_tool(
|
||||||
|
"preset_shape_svg.py",
|
||||||
|
"render",
|
||||||
|
"rightArrow",
|
||||||
|
"--id",
|
||||||
|
"native-arrow",
|
||||||
|
"--frame",
|
||||||
|
"500",
|
||||||
|
"570",
|
||||||
|
"280",
|
||||||
|
"80",
|
||||||
|
"--fill",
|
||||||
|
"#2563EB",
|
||||||
|
"--stroke",
|
||||||
|
"none",
|
||||||
|
)
|
||||||
|
preset = (
|
||||||
|
'<g id="native-arrow-module" data-pptx-bounds="500 570 280 80">'
|
||||||
|
+ preset
|
||||||
|
+ "</g>"
|
||||||
|
)
|
||||||
|
|
||||||
|
(project / "design_spec.md").write_text(
|
||||||
|
"""<!-- ppt-master-schema: design-spec/v1 -->
|
||||||
|
# Advanced Image and Motion Smoke - Design Spec
|
||||||
|
|
||||||
|
## I. Project Information
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Project Name | advanced-image-motion-smoke |
|
||||||
|
| Canvas Format | PPT 16:9 |
|
||||||
|
| Page Count | 2 |
|
||||||
|
|
||||||
|
## II. Canvas Specification
|
||||||
|
|
||||||
|
- ViewBox: `0 0 1280 720`
|
||||||
|
- Format: `ppt169`
|
||||||
|
|
||||||
|
## III. Visual Theme
|
||||||
|
|
||||||
|
- Direction: restrained technical maintenance fixture
|
||||||
|
- Colors: blue, orange, slate, and white
|
||||||
|
|
||||||
|
## IV. Typography System
|
||||||
|
|
||||||
|
- Title: Arial, sans-serif at 36px
|
||||||
|
- Body: Arial, sans-serif at 20px
|
||||||
|
|
||||||
|
## V. Layout Principles
|
||||||
|
|
||||||
|
- Reuse one source image through independently editable crop objects.
|
||||||
|
- Keep every visible crop inside the slide canvas.
|
||||||
|
|
||||||
|
## VI. Icon Usage Specification
|
||||||
|
|
||||||
|
- No icons.
|
||||||
|
|
||||||
|
## VIII. Image Resource List
|
||||||
|
|
||||||
|
| Filename | Dimensions | Ratio | Purpose | Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference | text_policy | page_role |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| scene.png | 1280x720 | 16:9 | Morph crop continuity | Photo | #M1-11 same-source independent crops with a shaped detail | adaptive | user | Existing | Synthetic three-band scene for crop and Morph verification | none | local |
|
||||||
|
|
||||||
|
## IX. Content Outline
|
||||||
|
|
||||||
|
### Part 1: Crop continuity
|
||||||
|
|
||||||
|
#### Slide 01 - Overview
|
||||||
|
|
||||||
|
- **Audience move**: See separate source views as one editable image system.
|
||||||
|
- **Layout**: Ordinary crop, shaped crop, and one native directional preset.
|
||||||
|
- **Title**: Overview crop state
|
||||||
|
- **Core message**: One source can support independent native picture objects.
|
||||||
|
- **Content**: Show the wide crop and shaped detail together.
|
||||||
|
- **Images**: Use `scene.png` for both visible crop objects.
|
||||||
|
- **Native shape suggestion**: Use the PowerPoint `rightArrow` preset to indicate continuation.
|
||||||
|
- **Motion suggestion**: Continue the primary crop into Slide 02 as one deterministic Morph object.
|
||||||
|
|
||||||
|
#### Slide 02 - Detail
|
||||||
|
|
||||||
|
- **Audience move**: Recognize the same image after a controlled crop and position change.
|
||||||
|
- **Layout**: Enlarged primary crop plus a second shaped detail.
|
||||||
|
- **Title**: Detail crop state
|
||||||
|
- **Core message**: Morph identity is independent from the picture crop geometry.
|
||||||
|
- **Content**: Move and zoom the primary crop while preserving the shared source.
|
||||||
|
- **Images**: Reuse `scene.png`; do not generate or replace the source.
|
||||||
|
- **Motion suggestion**: Pair the primary crop with Slide 01 and use Morph by object.
|
||||||
|
|
||||||
|
## X. Speaker Notes Requirements
|
||||||
|
|
||||||
|
- Notes are disabled for this maintenance smoke.
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
(project / "spec_lock.md").write_text(
|
||||||
|
"""<!-- ppt-master-schema: spec-lock/v1 -->
|
||||||
|
# Execution Lock
|
||||||
|
|
||||||
|
## canvas
|
||||||
|
- viewBox: 0 0 1280 720
|
||||||
|
- format: ppt169
|
||||||
|
## communication
|
||||||
|
- audience: PPT Master maintainers
|
||||||
|
- objective: Verify advanced image and motion contracts end to end.
|
||||||
|
- core_message: One raster remains editable across native crops and Morph.
|
||||||
|
## mode
|
||||||
|
- mode: showcase
|
||||||
|
## visual_style
|
||||||
|
- visual_style: swiss-minimal
|
||||||
|
## colors
|
||||||
|
- bg: #FFFFFF
|
||||||
|
- primary: #2563EB
|
||||||
|
- accent: #F97316
|
||||||
|
- text: #0F172A
|
||||||
|
## typography
|
||||||
|
- font_family: Arial, sans-serif
|
||||||
|
- title_family: Arial, sans-serif
|
||||||
|
- body_family: Arial, sans-serif
|
||||||
|
- title: 36
|
||||||
|
- body: 20
|
||||||
|
## icons
|
||||||
|
- library: none
|
||||||
|
- inventory: none
|
||||||
|
## images
|
||||||
|
- scene: images/scene.png | source=user | pattern=#M1-11 same-source independent crops with a shaped detail | crop=adaptive
|
||||||
|
## page_rhythm
|
||||||
|
- P01: dense
|
||||||
|
- P02: dense
|
||||||
|
## pptx_structure
|
||||||
|
- mode: flat
|
||||||
|
## forbidden
|
||||||
|
- Unsupported SVG constructs
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
slide_1 = f"""<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 1280 720" data-pptx-page-role="content">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="overview-shape" clipPathUnits="userSpaceOnUse">
|
||||||
|
<ellipse cx="0.725" cy="0.5" rx="0.275" ry="0.5"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect id="background" data-pptx-role="background"
|
||||||
|
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
|
||||||
|
<g id="hero-overview" data-pptx-bounds="80 140 448 360">
|
||||||
|
<svg id="overview-crop" x="80" y="140" width="448" height="360"
|
||||||
|
viewBox="0 0 0.7 1" preserveAspectRatio="none" overflow="hidden">
|
||||||
|
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||||
|
preserveAspectRatio="none"/>
|
||||||
|
</svg>
|
||||||
|
</g>
|
||||||
|
<g id="shaped-overview" data-pptx-bounds="760 170 330 340">
|
||||||
|
<svg id="overview-shaped-crop" data-pptx-crop="1"
|
||||||
|
x="760" y="170" width="330" height="340"
|
||||||
|
viewBox="0.45 0 0.55 1" preserveAspectRatio="none" overflow="hidden">
|
||||||
|
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||||
|
preserveAspectRatio="none" clip-path="url(#overview-shape)"/>
|
||||||
|
</svg>
|
||||||
|
</g>
|
||||||
|
{preset}
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
|
||||||
|
slide_2 = """<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 1280 720" data-pptx-page-role="content">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="detail-shape" clipPathUnits="userSpaceOnUse">
|
||||||
|
<path d="M 0.5 1 L 0.75 0 L 1 1 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect id="background" data-pptx-role="background"
|
||||||
|
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
|
||||||
|
<g id="hero-detail" data-pptx-bounds="0 0 1280 720">
|
||||||
|
<svg id="detail-crop" x="0" y="0" width="1280" height="720"
|
||||||
|
viewBox="0.15 0.1 0.36 0.36"
|
||||||
|
preserveAspectRatio="none" overflow="hidden">
|
||||||
|
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||||
|
preserveAspectRatio="none"/>
|
||||||
|
</svg>
|
||||||
|
</g>
|
||||||
|
<g id="shaped-detail" data-pptx-bounds="820 160 320 360">
|
||||||
|
<svg id="detail-shaped-crop" data-pptx-crop="1"
|
||||||
|
x="820" y="160" width="320" height="360"
|
||||||
|
viewBox="0.5 0 0.5 1" preserveAspectRatio="none" overflow="hidden">
|
||||||
|
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||||
|
preserveAspectRatio="none" clip-path="url(#detail-shape)"/>
|
||||||
|
</svg>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
|
||||||
|
(svg_output / "01_overview.svg").write_text(slide_1, encoding="utf-8")
|
||||||
|
(svg_output / "02_detail.svg").write_text(slide_2, encoding="utf-8")
|
||||||
|
(project / "animations.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"slides": {
|
||||||
|
"02_detail": {
|
||||||
|
"transition": {
|
||||||
|
"effect": "morph",
|
||||||
|
"effect_options": {"morph_by": "object"},
|
||||||
|
"duration": 0.8,
|
||||||
|
},
|
||||||
|
"morph": {
|
||||||
|
"from": "01_overview",
|
||||||
|
"pairs": {
|
||||||
|
"hero-image": {
|
||||||
|
"from": "hero-overview",
|
||||||
|
"to": "hero-detail",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
run_tool("project_manager.py", "validate", project)
|
||||||
|
run_tool("svg_quality_checker.py", project, "--format", "ppt169")
|
||||||
|
run_tool("animation_config.py", "validate", project)
|
||||||
|
|
||||||
|
|
||||||
|
def finalized_image_sizes():
|
||||||
|
sizes = []
|
||||||
|
for svg_path in sorted((project / "svg_final").glob("*.svg")):
|
||||||
|
root = ET.parse(svg_path).getroot()
|
||||||
|
for image in root.iter("{http://www.w3.org/2000/svg}image"):
|
||||||
|
href = image.get("href") or image.get(
|
||||||
|
"{http://www.w3.org/1999/xlink}href"
|
||||||
|
)
|
||||||
|
assert href and href.startswith("data:image/"), href
|
||||||
|
payload = base64.b64decode(href.split(",", 1)[1], validate=True)
|
||||||
|
with Image.open(io.BytesIO(payload)) as embedded:
|
||||||
|
sizes.append(embedded.size)
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
|
||||||
|
finalized_sizes = []
|
||||||
|
for finalize_args in ((), ("--no-compress",)):
|
||||||
|
run_tool("finalize_svg.py", project, *finalize_args)
|
||||||
|
embedded_sizes = finalized_image_sizes()
|
||||||
|
assert len(embedded_sizes) == 4, embedded_sizes
|
||||||
|
assert all(width > 100 and height > 100 for width, height in embedded_sizes)
|
||||||
|
finalized_sizes.append(embedded_sizes)
|
||||||
|
|
||||||
|
pptx = project / "advanced-image-motion.pptx"
|
||||||
|
run_tool("svg_to_pptx.py", project, "--no-notes", "-o", pptx)
|
||||||
|
with zipfile.ZipFile(pptx) as archive:
|
||||||
|
slide_names = sorted(
|
||||||
|
name
|
||||||
|
for name in archive.namelist()
|
||||||
|
if re.fullmatch(r"ppt/slides/slide[12]\.xml", name)
|
||||||
|
)
|
||||||
|
slide_xml = [archive.read(name) for name in slide_names]
|
||||||
|
assert len(slide_xml) == 2, slide_names
|
||||||
|
assert all(b'!!hero-image' in xml for xml in slide_xml)
|
||||||
|
assert b'morph' in slide_xml[1]
|
||||||
|
assert sum(xml.count(b"<a:srcRect") for xml in slide_xml) >= 4
|
||||||
|
assert b'<a:prstGeom prst="ellipse">' in slide_xml[0]
|
||||||
|
assert b'<a:prstGeom prst="rightArrow">' in slide_xml[0]
|
||||||
|
assert b"<a:custGeom>" in slide_xml[1]
|
||||||
|
|
||||||
|
readback = project / "readback"
|
||||||
|
run_tool(
|
||||||
|
"pptx_to_svg.py",
|
||||||
|
pptx,
|
||||||
|
"-o",
|
||||||
|
readback,
|
||||||
|
"--inheritance-mode",
|
||||||
|
"flat",
|
||||||
|
"--strict",
|
||||||
|
)
|
||||||
|
readback_svg = "\n".join(
|
||||||
|
path.read_text(encoding="utf-8")
|
||||||
|
for path in sorted((readback / "svg").glob("slide_*.svg"))
|
||||||
|
)
|
||||||
|
assert readback_svg.count('data-pptx-crop="1"') >= 2
|
||||||
|
assert readback_svg.count('data-pptx-shape-name="!!hero-image"') == 2
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Advanced image and motion smoke: passed "
|
||||||
|
f"(embedded sizes={finalized_sizes}; project={project})"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Every command must exit successfully. The finalized data URIs must retain
|
||||||
|
useful source pixels rather than collapsing to the nested child’s unit
|
||||||
|
coordinates; the package must contain four native picture crops, the authored
|
||||||
|
`rightArrow`, one Morph transition, and exactly one `!!hero-image` target on
|
||||||
|
each adjacent slide. The strict PPTX-to-SVG readback must restore both shaped
|
||||||
|
crop markers and both forced-Morph object names.
|
||||||
|
|
||||||
|
The printed project is intentionally retained for manual Microsoft PowerPoint
|
||||||
|
inspection. Morph must continue the primary picture without a visible
|
||||||
|
cross-fade, sharpness jump, or media re-decoding flash. That Office-only visual
|
||||||
|
check is evidence gathering; to stress differing media payloads, replace
|
||||||
|
`scene.png` with a `7680×4320` source and re-export. Do not trigger media
|
||||||
|
deduplication changes from package structure alone.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Legacy Chart Recall Compatibility
|
||||||
|
|
||||||
|
`chart_recall.py` preserves the historical broad visualization-recall CLI for
|
||||||
|
existing projects and external callers. It delegates to
|
||||||
|
`visualization_recall.py`, reads the same live Chart/Table registries, and emits
|
||||||
|
the legacy bare-key JSON shape.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/chart_recall.py recall \
|
||||||
|
--page P03 \
|
||||||
|
--tag "time series" \
|
||||||
|
--tag "three metrics" \
|
||||||
|
--tag "direction over time"
|
||||||
|
python3 skills/ppt-master/scripts/chart_recall.py validate process_flow
|
||||||
|
```
|
||||||
|
|
||||||
|
The validation example intentionally uses the legacy bare Structure intent `process_flow`;
|
||||||
|
the shared resolver recognizes it as a Structure intent without an SVG path.
|
||||||
|
The wrapper retains the original string in its historical `valid` list and adds
|
||||||
|
a `resolved` item with `family`, `key`, and `kind`; a Structure intent omits
|
||||||
|
`reference` and `path`. New planning describes qualitative relationships in §IX
|
||||||
|
or Quick active context; it does not write a Structure catalog key.
|
||||||
|
|
||||||
|
Do not use this wrapper in new planning prompts. See
|
||||||
|
[`visualization-recall.md`](./visualization-recall.md) for the canonical
|
||||||
|
`family/key` workflow and `page_visualizations` contract.
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# Confirm UI — Strategist and Template Confirmation Page
|
||||||
|
|
||||||
|
> The interactive surface for [`generate-pptx`](../../workflows/generate-pptx.md)
|
||||||
|
> Step 4. Stage 1 shows the template-independent communication recommendation
|
||||||
|
> and the template/free-design choice on one page and confirms both with one
|
||||||
|
> submission. That submission writes the Strategist contract to `result.json`
|
||||||
|
> and the selection sidecar to `template_selection.json`. The agent then installs
|
||||||
|
> any selected workspace and writes `template_handoff.json`; only afterward does
|
||||||
|
> final Stage 2 read installed template state and confirm the coordinated deck
|
||||||
|
> solution plus production mechanics. The chat path mirrors these boundaries
|
||||||
|
> without fabricating UI receipts.
|
||||||
|
|
||||||
|
## Authority and Scope
|
||||||
|
|
||||||
|
| Concern | Owner |
|
||||||
|
|---|---|
|
||||||
|
| Stage-1 combined confirmation and post-confirmation installation order | [`generate-pptx.md`](../../workflows/generate-pptx.md) |
|
||||||
|
| Template option/selection schema and page transport | This document |
|
||||||
|
| Step 4 gate and pipeline order | [`generate-pptx.md`](../../workflows/generate-pptx.md) |
|
||||||
|
| Confirm UI schema | This document |
|
||||||
|
| Stage 1 / final Stage 2 field membership | This document |
|
||||||
|
| Server launch / wait / shutdown behavior | This document |
|
||||||
|
| Port and lock behavior | This document |
|
||||||
|
| Chat fallback equivalence | This document |
|
||||||
|
| Confirmed-value precedence | [`generate-pptx.md`](../../workflows/generate-pptx.md) plus this document's `result.json` contract |
|
||||||
|
|
||||||
|
**Hard rule**: Keep detailed Confirm UI behavior here. The Generate route may summarize orchestration, but it should not duplicate the full JSON schema, catalog behavior, or launcher lifecycle.
|
||||||
|
|
||||||
|
**Mandatory surface decision — before any UI command**: Resolve the most recent
|
||||||
|
explicit confirmation-surface instruction for this run before running
|
||||||
|
`--daemon` or `--wait-only`. Unrelated later messages do not reset the selected
|
||||||
|
branch. A new explicit selection may change it before launch; once confirmation
|
||||||
|
starts in chat or UI switches to chat, keep chat for the rest of this run.
|
||||||
|
|
||||||
|
| Most recent explicit surface instruction | Branch |
|
||||||
|
|---|---|
|
||||||
|
| The user explicitly delegates confirmation | Make the combined Stage-1 communication/template decision, install it, then present one complete final summary. Do not launch the page or fabricate UI receipts. |
|
||||||
|
| Otherwise, the user asks for or agrees to personally confirm in chat, or declines the confirmation page | Use chat for both Strategist stages; Stage 1 includes the template/free-design choice. Do not launch the page, run `--wait-only`, or require UI-authored receipts. |
|
||||||
|
| No explicit confirmation-surface instruction exists for this run | Use the page as the default. |
|
||||||
|
|
||||||
|
Interpret the instruction semantically: “confirm here”, “use the chat window”, or
|
||||||
|
“do not open the confirmation page” are sufficient; no literal `chat-only`
|
||||||
|
keyword is required. Invoking a chat-question tool by itself does not select the
|
||||||
|
chat branch—the user's instruction does. Both branches preserve the same
|
||||||
|
Stage-1 communication/template decision, installation handoff, and template-aware
|
||||||
|
final Stage 2.
|
||||||
|
|
||||||
|
**Chat/delegated Stage-1 listing**: Author the communication recommendation
|
||||||
|
before reading the four indexes, then present that recommendation together with
|
||||||
|
an explicit free-design/template-mode choice. Only template mode expands the
|
||||||
|
registered candidates and supplied exact roots, and it requires at least one
|
||||||
|
selection. Ordinary requests initialize free design; explicit template intent
|
||||||
|
or any supplied exact root initializes template mode. Exactly one supplied root
|
||||||
|
may also seed that candidate, while multiple roots remain unselected. Under
|
||||||
|
explicit delegation, make the same decision, install
|
||||||
|
it, and only then derive Stage 2. Do not launch the page or fabricate receipts.
|
||||||
|
|
||||||
|
**Fallback rule**: When no surface was selected before launch, the page is the
|
||||||
|
default. Use chat when the user answers either always-on handoff in chat, or
|
||||||
|
after launch failure/timeout and one re-check of `result.json` plus the
|
||||||
|
Stage-1-sidecar `template_selection.json` when Stage 1 is active. A chat-question
|
||||||
|
tool alone is not a launch failure. Preserve the combined Stage-1 decision and
|
||||||
|
keep communication prompts open-ended.
|
||||||
|
|
||||||
|
**In-run UI → chat switch — any phase or stage**: If the user explicitly selects chat
|
||||||
|
after the UI server has launched—while `--wait-only` is active, before that wait
|
||||||
|
starts, or after it times out while the server remains live:
|
||||||
|
|
||||||
|
1. If a wait is active, interrupt it and confirm that its process has exited.
|
||||||
|
Only the return code from this deliberate wait interruption is expected to
|
||||||
|
be non-zero.
|
||||||
|
2. Run `server.py <project_path> --shutdown` and require that cleanup to
|
||||||
|
succeed. The browser tab may remain open, but its stopped server makes it
|
||||||
|
inactive.
|
||||||
|
3. Re-check the active receipt once. During Stage 1, require both `result.json`
|
||||||
|
and `template_selection.json` from the same submission; during Stage 2, check
|
||||||
|
`result.json`. Retain only values persisted before shutdown; an unsubmitted
|
||||||
|
browser draft is not confirmed.
|
||||||
|
4. Continue the unresolved current phase/stage and everything remaining in chat. Do
|
||||||
|
not call `--wait-only` again, recover the server, or relaunch the page during
|
||||||
|
this run.
|
||||||
|
|
||||||
|
**Always-on Stage-1 chat handoff**: After writing `template_options.json` and
|
||||||
|
template-independent `recommendations.stage1.json`, launch the healthy daemon
|
||||||
|
without `--wait`. Immediately post its actual URL plus one compact localized
|
||||||
|
summary of the current communication recommendation and template choice state:
|
||||||
|
audience, communication intent, audience outcome, core message, delivery
|
||||||
|
context, artifact afterlife, `content_divergence`, canvas, and whether the
|
||||||
|
default is free design or template mode, including any sole preselected root. Explain
|
||||||
|
that template mode expands the registered-kind and supplied-root selectors.
|
||||||
|
Show a blank prose value as “not specified” without changing it. End with an
|
||||||
|
explicit localized line saying that, if the page did not open, the user may
|
||||||
|
confirm or revise the same communication and template choices in chat. Only
|
||||||
|
then run `--wait-only --wait-stage stage1`. A chat
|
||||||
|
reply to that handoff applies the in-run switch above without waiting for
|
||||||
|
timeout. The handoff is context, not confirmation, and silence confirms
|
||||||
|
nothing. After launch failure/timeout and the required result re-check, present
|
||||||
|
the same combined Stage-1 items as open chat questions and wait explicitly.
|
||||||
|
|
||||||
|
## `confirm_ui/server.py`
|
||||||
|
|
||||||
|
The following launch and wait commands belong to the **UI branch only**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --daemon # launch combined Stage 1
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage1 # communication + template selection
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --complete-template-selection # after Stage-1 free-design closure / template install
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --wait-only # current final Stage 2
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --no-browser
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle auto-shutdown
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --reset-template-selection # clear prior template sidecars before a fresh UI run
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 cleanup (idempotent)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Without `--port`, binds the first free port from `127.0.0.1:5050`; the launch log prints the actual URL. `--port N` is exact and fails when unavailable. Auto-open is suppressed by `--no-browser`.
|
||||||
|
- In `--daemon` mode the launcher starts the child with browser opening suppressed, then accepts readiness only when `GET /api/health` identifies this confirm service, project, and child process. It opens the printed `http://127.0.0.1:<port>` URL only after that check.
|
||||||
|
- Confirm UI and live preview prefer the same memorable base port but keep separate processes and project-local locks (`.confirm_ui.lock` vs `live_preview/lock.json`). Normal Step 4 cleanup releases the confirm port before Step 6; concurrent projects may use different ports.
|
||||||
|
- `--daemon` starts the Flask process in the background and returns after the health check. Every Default UI run launches directly into combined Stage 1 and keeps the same process live through final Stage 2. The wait budget defaults to **590 s** (`--wait-timeout`); on timeout the detached server remains live, and the caller re-checks both Stage-1 receipts before chat fallback.
|
||||||
|
- `--wait-only` attaches to the page opened by `--daemon` and blocks until the requested receipt. If it is already persisted, the command returns before recovery, so a fast submit between launch, chat handoff, and wait is not lost. Otherwise, if the recorded server died, it restarts on the recorded/default port. Use `stage1` for the combined communication/template submission and the default/final wait for Stage 2.
|
||||||
|
- `--complete-template-selection` is agent-only. It validates the Stage-1 sidecar and writes the bound `template_handoff.json`; template mode additionally requires project-local `templates/design_spec.md`. Run it after installation/free-design closure and before writing Stage 2. `--reset-template-selection` removes exactly `template_options.json`, `template_selection.json`, and `template_handoff.json`; it does not alter Strategist files, installed template content, or `result.json`. The old `--*-template-phase` names are not aliases.
|
||||||
|
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path so the selected port is released before live preview starts.
|
||||||
|
- Every fresh UI run starts with `--reset-template-selection`, then writes valid `<project_path>/confirm_ui/template_options.json` and a newer `recommendations.stage1.json`; `explicit_workspace_roots` is an empty array when no exact root was supplied. Stage 1 writes the bound selection and communication result together. Stage 2 is exposed only when the matching handoff is newer than that selection and its recommendation is newer than the handoff. `--shutdown` needs neither input.
|
||||||
|
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
||||||
|
- Idle auto-shutdown after 900 s by default; `/api/shutdown` exits gracefully and releases the lock.
|
||||||
|
- Stage-1 `GET /api/recommendations` embeds the server-built candidate catalog
|
||||||
|
as top-level `template_options`. Its `/api/confirm` submission validates
|
||||||
|
current candidate keys and writes `template_selection.json` beside the pure
|
||||||
|
Strategist `result.json`; there is no independent template-options endpoint,
|
||||||
|
template-confirm endpoint, or template wait stage. The same APIs later serve
|
||||||
|
Stage 2 and strip legacy `template_reuse_scope` / `template_adherence`
|
||||||
|
fields. The completed template handoff is authoritative: `free_design`
|
||||||
|
strips a stray `template_application`, while `templates` exposes that
|
||||||
|
editable natural-language field in Stage 2.
|
||||||
|
|
||||||
|
Dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install flask
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage-1 template-selection sidecar contract
|
||||||
|
|
||||||
|
Template selection shares the Stage-1 page and submit action but remains a
|
||||||
|
separate artifact from the Strategist contract. Its files live under
|
||||||
|
`<project_path>/confirm_ui/`; selection keys never enter `result.json`.
|
||||||
|
|
||||||
|
### Input — `template_options.json` (created before launch)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "template",
|
||||||
|
"lang": "zh",
|
||||||
|
"default_mode": "free_design",
|
||||||
|
"explicit_workspace_roots": [
|
||||||
|
"/absolute/path/to/a/project-or-template-workspace"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `schema_version` is exactly `1`; `phase` is exactly `template`.
|
||||||
|
- `lang` is optional, but when present it is a non-empty UI-language string.
|
||||||
|
- `default_mode` is required and is exactly `free_design` or `templates`.
|
||||||
|
Ordinary requests use `free_design`; explicit template intent or any supplied
|
||||||
|
exact root uses `templates`. It initializes the UI but never locks the user.
|
||||||
|
- `explicit_workspace_roots` is required even when empty. Every item is a
|
||||||
|
unique absolute path resolving to an existing directory with
|
||||||
|
`templates/design_spec.md` or compatible legacy `design_spec.md`.
|
||||||
|
- The array supplies candidates for the one specified-root dropdown; it does
|
||||||
|
not authorize selecting several explicit roots in one confirmation.
|
||||||
|
- Do not write library entries into this file. The server reads only
|
||||||
|
`templates/brands/brands_index.json`,
|
||||||
|
`templates/styles/styles_index.json`,
|
||||||
|
`templates/layouts/layouts_index.json`, and
|
||||||
|
`templates/decks/decks_index.json`, derives each direct-child workspace root,
|
||||||
|
and validates that it exists with `templates/design_spec.md`. It never scans
|
||||||
|
kind directories.
|
||||||
|
|
||||||
|
Stage-1 `GET /api/recommendations` embeds this browser catalog as top-level
|
||||||
|
`template_options`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "template",
|
||||||
|
"lang": "zh",
|
||||||
|
"default_mode": "free_design",
|
||||||
|
"library": {
|
||||||
|
"brand": [],
|
||||||
|
"style": [],
|
||||||
|
"layout": [],
|
||||||
|
"deck": []
|
||||||
|
},
|
||||||
|
"explicit": [],
|
||||||
|
"preselected_keys": [],
|
||||||
|
"options_sha256": "<64 lowercase hex characters>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A library candidate has `key`, `source: "library"`, `kind`, `id`, `label`,
|
||||||
|
`summary`, and canonical absolute `workspace_root`. An unregistered explicit
|
||||||
|
candidate has `key`, `source: "explicit"`, parsed `kind`, `label`, and
|
||||||
|
canonical absolute `workspace_root`. If a supplied root exactly equals a registered canonical
|
||||||
|
root, the server reuses the library candidate/key instead of duplicating it as
|
||||||
|
explicit. Candidate keys are server-owned. The Stage-1 submit payload carries
|
||||||
|
the current `{ "mode": "free_design"|"templates", "selection_keys": [...] }`
|
||||||
|
beside the Strategist fields; the server validates that selection and writes it
|
||||||
|
to the sidecar rather than copying keys into `result.json`.
|
||||||
|
|
||||||
|
When the input supplies exactly one root, `preselected_keys` contains its
|
||||||
|
resolved candidate key as a convenience default, including when exact equality
|
||||||
|
reclassifies it as library. When several roots are supplied, all remain
|
||||||
|
candidates but none is preselected; one specified-root dropdown cannot encode
|
||||||
|
an instruction to use all of them.
|
||||||
|
|
||||||
|
**Page selection model**: Stage 1 first asks the user to choose `Free design` or
|
||||||
|
`Use templates`, initialized from `default_mode` but always switchable. Exactly
|
||||||
|
one supplied root may initialize its candidate as an editable convenience
|
||||||
|
default; multiple roots remain unselected. Only `Use templates` expands the
|
||||||
|
candidate controls: Brand, Style, Layout, and Deck each have one registered
|
||||||
|
single-select dropdown, and Specified has one explicit-root single-select
|
||||||
|
dropdown. Every dropdown includes `None`; template mode cannot submit until at
|
||||||
|
least one is non-empty. Free design clears all dropdowns. Registered kinds may
|
||||||
|
be combined, but each contributes at most one root and the specified channel at
|
||||||
|
most one. Because an explicit candidate carries its parsed kind, it may coexist
|
||||||
|
with one registered root of that kind and enter the two-workspace same-kind
|
||||||
|
conflict gate. Source provenance never grants priority.
|
||||||
|
|
||||||
|
### Output — `template_selection.json` (written with Stage 1)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "template",
|
||||||
|
"status": "confirmed",
|
||||||
|
"mode": "templates",
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"source": "library",
|
||||||
|
"kind": "style",
|
||||||
|
"id": "example_style",
|
||||||
|
"workspace_root": "/canonical/library/root/example_style"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "explicit",
|
||||||
|
"kind": "deck",
|
||||||
|
"workspace_root": "/canonical/unregistered/workspace/root"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options_sha256": "<64 lowercase hex characters>",
|
||||||
|
"selection_sha256": "<64 lowercase hex characters>",
|
||||||
|
"confirmed_at": "2026-08-04T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`mode: "free_design"` requires `selections: []`; `mode: "templates"` requires
|
||||||
|
at least one selection. Roots are unique canonical absolute paths. A library
|
||||||
|
selection contains exactly `source`, `kind`, `id`, and `workspace_root`; an
|
||||||
|
explicit selection contains exactly `source`, `kind`, and `workspace_root`.
|
||||||
|
There is at most one library selection per kind and at most one explicit
|
||||||
|
selection overall; cross-kind composition remains valid, and one explicit plus
|
||||||
|
one library selection may share a kind. The browser
|
||||||
|
cannot submit arbitrary paths because the server resolves posted keys against
|
||||||
|
the catalog it just built. `options_sha256` binds the receipt to the current
|
||||||
|
input, four index files, and resolved candidates. `selection_sha256` binds the
|
||||||
|
mode and canonical sorted selections to that option hash. Every receipt read
|
||||||
|
rebuilds the catalog and rejects option/index drift.
|
||||||
|
|
||||||
|
The Stage-1 submission writes this receipt and the Stage-1 `result.json`
|
||||||
|
together. Generate reads both exactly once after `--wait-only --wait-stage
|
||||||
|
stage1` returns. Free design skips installation. Template mode runs
|
||||||
|
`apply-template-workspace` against all selected roots and waits for complete
|
||||||
|
project-local installation. Only then does the agent complete the
|
||||||
|
handoff below. Installation copies each selected spec separately; Stage 2
|
||||||
|
resolves segment ownership and current-project fit from the installed set. Strategist never
|
||||||
|
reads the source roots.
|
||||||
|
|
||||||
|
### Agent handoff — `template_handoff.json`
|
||||||
|
|
||||||
|
After free design closes or template installation succeeds, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/confirm_ui/server.py <project_path> --complete-template-selection
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes, and agents must not hand-author:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "template",
|
||||||
|
"status": "ready",
|
||||||
|
"mode": "templates",
|
||||||
|
"selection_sha256": "<64 lowercase hex characters>",
|
||||||
|
"completed_at": "2026-08-04T12:01:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The handoff must match the current valid selection. Template mode also requires
|
||||||
|
`<project_path>/templates/design_spec.md`; free design requires no installed
|
||||||
|
spec. Write `recommendations.stage2.json` only after this command succeeds, so
|
||||||
|
its file time is newer than the handoff.
|
||||||
|
|
||||||
|
## Field shapes
|
||||||
|
|
||||||
|
The following fields belong to the Strategist stages, not to the
|
||||||
|
template-selection receipt.
|
||||||
|
|
||||||
|
- **Enumerable + custom** — canvas / icons retain blank manual inputs. Mode and visual style first show three project-specific `custom` values projected from the complete directions, then the full fixed base catalog as conservative single-select alternatives. Selecting a projected card expands its behavior editor in place; edits change only the current value, while the adjusted active whole-direction card exposes an explicit restore action for the authored text.
|
||||||
|
- **Visual examples for hard-to-name choices** — the full-screen confirmation page loads real SVG page samples from `static/style_previews/` for fixed `visual_style` catalog choices, and renders real sample SVGs from `templates/icons` for `icons`. Project-specific `custom` direction cards show their authored summary instead of requesting a nonexistent preset asset. These thumbnails and summaries make style and icon-library choices visually comparable before the user locks them. Preview copy is fixed role text (big title / section title / body / points), not project content from recommendation files, so users compare visual treatment rather than copywriting. These previews are a confirmation aid only: they do not add fields to recommendation stage files or `result.json`, and they do not replace the later Step 6 live preview.
|
||||||
|
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. A confirmed non-`none` set is the allowed acquisition-source boundary, not a requirement to use every selected source; only explicit `image_notes` wording can require a source, asset, or page role. Recommendation and result values may be a legacy single string, but new files should use an array. When several sources are recommended, write the source ids to `recommend.image_usage` and write the actual usage strategy to `image_notes`, not a custom prose value.
|
||||||
|
- **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option.
|
||||||
|
- **Proactive execution booleans** — Final Stage 2 carries top-level `proactive_speaker_notes`, `proactive_custom_animations`, and `proactive_narration_audio` values. Defaults are `true`, `false`, and `false`, respectively. They control what the Agent does proactively only when the user has not explicitly instructed otherwise; the latest explicit user instruction always wins. These three values are raw confirmation evidence: the UI and server neither couple nor rewrite them, and every boolean combination is valid. When narration audio is enabled, Strategist later resolves the effective Speaker Notes outcome to enabled and records `Narration Audio dependency` as its Design Spec provenance. Disabling proactive custom animation does not suppress the Strategist's advisory motion recommendations.
|
||||||
|
- **Open prose** — `audience`, `communication_intent`, `audience_outcome`, `core_message`, `delivery_context`, `artifact_afterlife`, `content_divergence`, and `page_count`. `communication_intent` may carry several purposes plus priority / sequence; common paths appear only as help text. `delivery_context` states one primary presenter-led / reader-led / hybrid / recorded-self-running context plus optional secondary use; a hybrid recommendation names which context leads. `content_divergence` is the source-treatment axis. `page_count` may be a range here; Strategist resolves the exact §IX roster, leaving Executor no pagination latitude.
|
||||||
|
- **Coordinated generative directions** — `design_directions` carries exactly three complete candidates authored top-down from the project contract. Each has a unique stable id and bundles `custom` mode, `custom` visual style, color, typography, icon id, and `custom` generated-image rendering regardless of recommended image source. Its localized note is a compact, user-facing style summary. It may reuse localized display labels from `catalogs.visual_styles` when they describe the result concisely, but those labels are optional vocabulary rather than a selection constraint or required mapping. Otherwise it uses concise natural language and never forces the nearest label. The summary stays within one or two short sentences and does not expose catalog ids or reference mechanics. All three must be viable as whole solutions; they do not need different catalog bases or forced safe / shifted / bold archetypes. After completing all three bundles, Strategist compares them against the confirmed contract and source, then writes the strongest overall fit's zero-based index to `selected`; array position does not determine preference. That bundle becomes the initial default and applies its three custom projections coherently. The page can still render legacy top-level `color`, `typography`, and `image_strategy` candidates, but new staged recommendations use the coordinated bundle.
|
||||||
|
|
||||||
|
Direction-local custom projections apply to mode, visual style, and generated-image rendering; all three are editable after selection and a selected custom value cannot be blank. The original recommendation remains immutable so the active whole-direction card can explicitly restore every edited component without making an ordinary card click destructive. Legacy standalone `custom_candidates` remain readable but are optional and are not authored in new files. Color / typography keep their existing manual Custom cards. Image usage uses source ids plus `image_notes`; closed sets have no Custom path.
|
||||||
|
|
||||||
|
**Stage-2 catalog read gate.** Before choosing component bases, Strategist reads only `modes/_index.md`, `visual-styles/_index.md`, and `image-renderings/_index.md`. It authors the three whole solution intents first, freezes every basis id from those indexes, and only then reads the deduplicated selected detail files before completing the custom behaviors. Unselected sibling files never enter context; a novel custom reads none.
|
||||||
|
|
||||||
|
**Stage-1 current-value contract.** Each editable prose box starts with the Strategist's recommendation, if one exists. The user may retain, revise, or clear it; no Stage-1 prose field has a non-empty validation gate. On confirmation, the browser submits the current strings and the server preserves them through every later stage and the final `result.json`, including `""`. Blank means no explicit user constraint and may cause downstream default judgment, but it never causes the initial recommendation to be restored. A profile-declared `locked: true` field is read-only and remains the sole exception.
|
||||||
|
|
||||||
|
`image_ai_path` is conditional: the page shows it and writes it to `result.json` only when `image_usage` includes `ai`. Web-sourced / User-provided / Placeholder / No images paths do not carry an AI backend choice.
|
||||||
|
|
||||||
|
## Catalogs — `static/catalogs.json` (the finite option universe)
|
||||||
|
|
||||||
|
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while four-language labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_zh_tw", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_zh_tw` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_zh_tw` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all four languages (zh / zh-TW / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Simplified Chinese, Traditional Chinese, and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_zh_tw", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the four-language labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
|
||||||
|
|
||||||
|
## Round-trip data contract
|
||||||
|
|
||||||
|
In the UI branch, round-trip and session files live under
|
||||||
|
`<project_path>/confirm_ui/`. `template_options.json` is prepared beside the
|
||||||
|
Stage-1 recommendation. The Stage-1 submit writes `result.json` and
|
||||||
|
`template_selection.json` together; after installation/free-design closure,
|
||||||
|
`template_handoff.json` unlocks Stage 2. The chat/delegated branch preserves the
|
||||||
|
same logical order without fabricating these UI receipts.
|
||||||
|
|
||||||
|
### Current two-stage flow
|
||||||
|
|
||||||
|
The page runs a **two-stage Strategist wizard in one browser session**. Stage 1
|
||||||
|
contains the communication contract and template/free-design controls. Each stage
|
||||||
|
has its own Strategist-authored file and top-level `"stage"` selector. The active,
|
||||||
|
unconfirmed stage may be overwritten any number of times when the user asks for
|
||||||
|
a better recommendation; refresh the page to load the replacement. Once the
|
||||||
|
user confirms it, normal progression writes the next stage file rather than
|
||||||
|
repurposing the previous one. The server derives the active Strategist filename
|
||||||
|
from `result.json`; the bound template handoff is the prerequisite for Stage 2.
|
||||||
|
|
||||||
|
Confirm UI is a one-run surface, not a migration layer. It accepts only the
|
||||||
|
current Stage-1/Stage-2 files. If a project starts UI confirmation again, run
|
||||||
|
`--reset-template-selection`, write fresh `template_options.json`, then author a
|
||||||
|
fresh `recommendations.stage1.json`. Those two inputs start the new UI
|
||||||
|
lifecycle; neither a standalone newer Stage-1 file nor a standalone option file
|
||||||
|
does so.
|
||||||
|
The completed result cannot be reopened or overwritten, and a standalone newer
|
||||||
|
Stage-1 file cannot start its replacement. After Stage 1 writes both receipts,
|
||||||
|
the agent completes a newer bound handoff; `recommendations.stage2.json` must be
|
||||||
|
newer than that handoff and the Stage-1 result. A Stage-2 file left by an earlier
|
||||||
|
run remains inactive. An existing `result.json` outside the current `stage1` /
|
||||||
|
`final` contract also fails closed unless fresh paired inputs start a new run.
|
||||||
|
|
||||||
|
| Recommendation file | Declared stage | Page renders | Button | On submit |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `recommendations.stage1.json` + `template_options.json` | `"stage1"` | communication contract — content language; audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs/completes the handoff |
|
||||||
|
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, formula policy, proactive notes/custom-animation/narration-audio toggles, generation mode, and Design Spec review toggle | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
||||||
|
|
||||||
|
In the UI branch, the AI authors Stage 1 without reading template candidates,
|
||||||
|
then launches the combined page. In chat/delegated confirmation it authors the
|
||||||
|
same communication recommendation before listing template candidates. After
|
||||||
|
the one Stage-1 confirmation, the AI installs any selection, completes the
|
||||||
|
handoff/equivalent state, then inspects only the project-local template and
|
||||||
|
authors the complete final Stage-2 solution plus production mechanics once from
|
||||||
|
the user's actual communication contract. An edit inside
|
||||||
|
the current stage never requests another recommendation. The page preserves
|
||||||
|
earlier answers across transitions. `GET /api/session` reports `phase:
|
||||||
|
"strategist"` with current Stage 1 from launch; after Stage-1 submission it
|
||||||
|
reports `waiting_agent` until the bound handoff and fresh Stage-2 file exist.
|
||||||
|
`GET /api/recommendations`
|
||||||
|
is `no-store`, and the server folds confirmed earlier-stage choices back into
|
||||||
|
the final Stage-2 payload so an in-run refresh preserves the confirmed Stage-1
|
||||||
|
communication contract. Unsubmitted Stage-2 edits are browser-local and a
|
||||||
|
completed final result is never reopened.
|
||||||
|
|
||||||
|
**Progression guard.** Stage 1 requires current `template_options.json` and a
|
||||||
|
fresh `recommendations.stage1.json`; it does not require a prior selection or
|
||||||
|
handoff. Its one submit must persist a valid Stage-1 result and selection bound
|
||||||
|
to those options. Stage 2 requires a newer `template_handoff.json` bound to that
|
||||||
|
selection and a fresh Stage-2 recommendation. This ordering prevents receipts
|
||||||
|
from an earlier one-run UI from satisfying a new run. Strategist confirms Stage
|
||||||
|
1 → installation/free-design handoff → final Stage 2.
|
||||||
|
`/api/confirm` accepts only the submit stage matching the
|
||||||
|
active filename and its required predecessor; the declared `stage` must also
|
||||||
|
match the filename. A confirmed templates-mode workspace does not exempt final
|
||||||
|
Stage 2: its recommendations must include `template_application.value`.
|
||||||
|
|
||||||
|
### Input — `recommendations.stage1.json` (created beside template options)
|
||||||
|
|
||||||
|
Author this file before reading candidate index summaries in chat and without
|
||||||
|
reading any template spec, prototype, asset, segment owner, or template
|
||||||
|
canvas. `template_options.json` supplies display state only and never changes
|
||||||
|
the communication recommendation.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "stage1",
|
||||||
|
"lang": "zh",
|
||||||
|
"primary_language": "zh-CN",
|
||||||
|
"recommend": {
|
||||||
|
"canvas": "ppt169"
|
||||||
|
},
|
||||||
|
"audience": { "value": "公司管理层,包括财务与产品负责人" },
|
||||||
|
"communication_intent": {
|
||||||
|
"value": "先汇报进展并暴露交付风险,再推动管理层决定下一阶段投入"
|
||||||
|
},
|
||||||
|
"audience_outcome": {
|
||||||
|
"value": "管理层能比较三个选项、接受风险判断,并选定一条获得预算的路径"
|
||||||
|
},
|
||||||
|
"core_message": {
|
||||||
|
"value": "现在为方案 B 增加投入,能以可接受的成本守住发布时间"
|
||||||
|
},
|
||||||
|
"delivery_context": {
|
||||||
|
"value": "主要为有主讲的 20 分钟管理层现场评审;次要为会后独立阅读的审批材料"
|
||||||
|
},
|
||||||
|
"artifact_afterlife": {
|
||||||
|
"value": "作为审批记录、项目交接依据和季度审计材料"
|
||||||
|
},
|
||||||
|
"content_divergence": { "value": "" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All seven Stage-1 prose values may be blank. `primary_language` is required canonical BCP-47. The server normalizes legacy English / Chinese / Japanese / Korean aliases, rejects `und` and Chinese without script/region, and carries it forward; `lang` is UI-only. Prose submits verbatim, including `""`. A profile's `{ "locked": true }` value is read-only, persisted, and stripped of that marker in final `result.json`.
|
||||||
|
|
||||||
|
The common paths — inform / explain / persuade / decide / align / teach / report and account / mobilize / record and hand off — appear only as help text for `communication_intent`. They are not catalog ids and must not be emitted as a `primary_job` field.
|
||||||
|
|
||||||
|
After Stage 1 is confirmed, create `recommendations.stage2.json` with the complete solution; leave Stage 1 unchanged (the server folds confirmed communication fields back in when serving the page):
|
||||||
|
|
||||||
|
**Stage-2 production contract**: the server rejects the recommendation file
|
||||||
|
unless `recommend.formula_policy`, `recommend.generation_mode`, and boolean
|
||||||
|
`refine_spec.value` are present; `recommend.image_ai_path` is additionally
|
||||||
|
required when `image_usage` includes `ai`. Final submission must retain the
|
||||||
|
corresponding direct values (`formula_policy`, `generation_mode`, boolean
|
||||||
|
`refine_spec`, and conditional `image_ai_path`) or confirmation is rejected.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "stage2",
|
||||||
|
"lang": "zh",
|
||||||
|
"recommend": {
|
||||||
|
"delivery_purpose": "balanced",
|
||||||
|
"mode": "custom",
|
||||||
|
"visual_style": "custom",
|
||||||
|
"image_strategy": "custom",
|
||||||
|
"image_usage": ["ai", "provided"],
|
||||||
|
"image_ai_path": "auto",
|
||||||
|
"formula_policy": "mixed",
|
||||||
|
"generation_mode": "continuous"
|
||||||
|
},
|
||||||
|
"page_count": { "value": "12-15" },
|
||||||
|
"image_notes": { "value": "封面和章节页用 AI 主视觉;产品页优先用户素材。" },
|
||||||
|
"proactive_speaker_notes": { "value": true },
|
||||||
|
"proactive_custom_animations": { "value": false },
|
||||||
|
"proactive_narration_audio": { "value": false },
|
||||||
|
"refine_spec": { "value": false },
|
||||||
|
"design_directions": {
|
||||||
|
"selected": 1,
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"id": "executive-clarity",
|
||||||
|
"name_zh": "稳妥专业",
|
||||||
|
"note_zh": "以瑞士极简为主,融合柔和圆角与编辑出版风格。",
|
||||||
|
"mode": "custom",
|
||||||
|
"mode_behavior_zh": "以 pyramid 作为唯一目录基底,为当前风险决策材料定制两次结论闸门;标题保持判断句,每章先给判断,再用证据展开并以可执行结论收束。",
|
||||||
|
"visual_style": "custom",
|
||||||
|
"visual_style_behavior_zh": "由 swiss-minimal 负责精确栅格和大留白,soft-rounded 负责少量关键容器的轮廓与轻微抬升,editorial 负责细规则、边注与证据层级;标题锐利,正文中性,装饰只标记推理关系。",
|
||||||
|
"icons": "tabler-outline",
|
||||||
|
"color": { "name_zh": "冷静专业", "palette": {
|
||||||
|
"background": "#FFFFFF", "secondary_bg": "#F4F6F8",
|
||||||
|
"primary": "#1A3A6B", "accent": "#E8A317",
|
||||||
|
"secondary_accent": "#4A7BB5", "body_text": "#1D2430"
|
||||||
|
} },
|
||||||
|
"typography": {
|
||||||
|
"name_zh": "微软雅黑 + Arial",
|
||||||
|
"heading": { "primary": "Microsoft YaHei", "english": "Arial", "css": "sans-serif" },
|
||||||
|
"body": { "primary": "Microsoft YaHei", "english": "Arial", "css": "sans-serif" },
|
||||||
|
"body_size": 24
|
||||||
|
},
|
||||||
|
"image_strategy": {
|
||||||
|
"name_zh": "编辑式证据图",
|
||||||
|
"rendering": "custom",
|
||||||
|
"visual_zh": "简化矢量主体配合编辑式注释与局部材质对比",
|
||||||
|
"mood_zh": "审慎、可信,像调查报道中的证据插图",
|
||||||
|
"behavior_zh": "由 vector-illustration 负责清晰轮廓,minimalist-swiss 负责留白构图,screen-print 负责克制的半调纹理,warm-scene 负责暖光与可信氛围;四者服从同一平面主体和当前演示文稿颜色角色,避免写实景深与装饰性渐变。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The example shows one candidate's complete shape; the actual array repeats that shape for exactly three candidates. Every candidate requires a unique stable `id`, literal `custom` mode/style/rendering with localized behavior, an icon value, complete six-role palette, complete heading/body stack, and complete image strategy even when `recommend.image_usage` excludes AI. The server rejects any fixed mode/style/rendering inside a new direction and any explicit `design_directions.selected` outside integer `0` through `2`; omission remains readable as a legacy index-`0` fallback. Legacy grids remain readable only with three complete palettes and complete typography.
|
||||||
|
|
||||||
|
- `design_directions.selected` owns the initial complete bundle and MUST be the actual zero-based index (`0`, `1`, or `2`) that Strategist chooses only after all three bundles are complete. The chosen card is marked Recommended and initially applies its matching mode, visual-style, and generated-image custom candidates together; array order carries no recommendation meaning. New direction mode/style/rendering values are always literal `custom`; prose stays in the required behavior sibling. `recommend.*` remains a compatibility hint and may mirror `custom`, but it never replaces the selected direction. Legacy aliases remain accepted; new files write canonical ids.
|
||||||
|
- The three proactive-execution fields are top-level boolean `{ "value": ... }` objects, not catalog ids. Omitted fields use `true / false / false` for notes / custom animation / narration audio. These are absence-of-instruction defaults, not permission to override the user's latest explicit request. Preserve all three raw values independently through `result.json`; do not couple or rewrite them. Strategist derives effective Speaker Notes as enabled when audio is `true` and records `Narration Audio dependency` as provenance in the Design Spec. `proactive_custom_animations: false` leaves Strategist animation suggestions unchanged; it only prevents unrequested custom-animation execution.
|
||||||
|
- `custom_candidates` is an optional legacy recommendation-only shape. New files place all three project-specific variants inside `design_directions`; each remains separately selectable and becomes editable in place. A custom behavior may use zero, one, or many exact catalog bases. Reference count has no fixed cap: every named id must contribute a distinct executable job, the behavior omits any id whose contribution it cannot state, and one basis never requires a decorative second. The UI rejects a selected blank and submits only the edited current value. Template-backed variants obey inherited identity, prototype capacity, and `template_application`.
|
||||||
|
- Seed `audience`, `communication_intent`, `audience_outcome`, and `delivery_context` when evidence supports them; users need not supply them, and every Stage-1 prose field may end blank. The contract and `primary_language` stay in `result.json` and `design_spec.md`; `spec_lock.md communication` receives `primary_language`, compact `audience` / `objective` / `core_message`, and reading mode. `communication_intent` may preserve multiple purposes and priority/sequence; never add a `primary_job` enum.
|
||||||
|
- Do not write `recommend.template_reuse_scope` or `recommend.template_adherence`. Strategist records those internal exporter values later in `spec_lock.md` after inspecting the actual template and current content.
|
||||||
|
- For a confirmed templates-mode handoff, write one editable prose field as top-level `template_application.value`. It summarizes **how to use** the already selected project-local template: actual page/prototype use and preservation/reorganization decisions. It never chooses, changes, or reinstalls a workspace. Omit it for free design. The UI returns the current string through final Stage 2; Strategist then persists the final effective plan as `- **Template Application**: ...` in `design_spec.md §I`, which Executor reads from the retained Design Spec. Never replace it with internal reuse/adherence ids or a fixed option menu.
|
||||||
|
|
||||||
|
Template-mode-only Stage-2 fragment:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"template_application": {
|
||||||
|
"value": "选用封面、章节页和数据页原型;跳过示例内容页。品牌标识和页脚保留,正文可按当前材料重组。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `recommend.image_usage` should be an array of source ids when more than one source applies, e.g. `["ai", "provided"]`. A single string is still accepted for backward compatibility. Do not write bare `"custom"` and do not encode a mixed-source plan as prose here; write the prose to top-level `image_notes.value`.
|
||||||
|
- `image_notes` is the initial strategy note shown under the image source chips. Use it for page-role guidance and constraints: which source applies where, what to avoid, which user assets are authoritative, how realistic / abstract the imagery should be, and what can remain as placeholders. It is intent guidance, not a separate finite option.
|
||||||
|
- Final Stage 2 shows and submits `recommend.image_ai_path` as one of `auto` / `api` / `host-native` / `manual` only while its current `image_usage` includes `ai`; changing sources refreshes that production control on the same page.
|
||||||
|
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders every role as a labelled swatch with its HEX value visible, and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers while writing `design_spec.md`, then projects the machine values to `spec_lock.md`; those are not user-facing confirmation choices.
|
||||||
|
- **Candidate display text may be multilingual**: color / typography candidates can provide `name_zh` / `name_zh_tw` / `name_en` / `name_ja` and `note_zh` / `note_zh_tw` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note` when present. Otherwise localized fields resolve in the page language first, then by the locale's fallback order: zh → en → ja → zh_tw, en → zh → ja → zh_tw, ja → en → zh → zh_tw, or zh_tw → zh → en → ja. Include all four variants in newly authored multilingual candidates so a persisted UI-language choice never hides recommendation text.
|
||||||
|
- **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Bundles differ overall; font pairs may repeat without blocking. Fixed pairs require `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. [`canvas-formats.md`](../../references/canvas-formats.md) § "Typography Scale Start" is the single owner of initial body anchors and sanity bands; the browser mirrors that rule, and submitted values remain px.
|
||||||
|
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes editable inputs for `title` / `subtitle` / `annotation`. The browser applies one documented deterministic dependency chain: PPT uses `reading mode → body baseline`, non-PPT uses `canvas → body baseline`, then every canvas uses `body baseline → unpinned role sizes` (role ramp: `body ×` the §g ratios). Changing reading mode updates a PPT body and all unpinned roles locally; changing body updates unpinned roles locally. Editing body or a role pins that value, so later reading-mode changes do not overwrite it. A font-only selection preserves current sizes; applying a different complete direction, or using the active card's explicit restore action, restores that direction's typography baseline and derived unpinned sizes. This is a browser-only state update: it performs no fetch and asks the backend to author no new recommendations. Each role input is labelled as px and shows an approximate pt equivalent (`1px = 0.75pt`) for orientation. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. These confirmed values are Strategist input anchors: the completed page plan may add recurring roles, and downstream execution owns bounded per-occurrence treatment. Candidate `sizes` remain accepted for compatibility; fresh Stage 2 preserves a candidate `body_size` as its baseline and derives only missing or unpinned role sizes from the same local ramp before first render.
|
||||||
|
- **`delivery_purpose` compatibility key / Reading mode** (enumerable, PPT only) decides where meaning is carried, not merely how large type is: `text` makes pages self-contained with complete sentences, short prose, captions, tables, and necessary detail; `balanced` shares explanation between page and presenter; `presentation` uses one idea, concise claims, and visual evidence while speech / notes carry the detail. It therefore governs page grammar, granularity, density / rhythm, and note burden. Reading-mode cards intentionally show **no px value**; the typography section owns the separately visible body / role sizes and applies any local default. It is surfaced in Stage 2 beside the visual system, separate from communication intent. `recommend.delivery_purpose` pre-selects one; `result.json` retains the key, while `spec_lock.md` uses canonical `consumption_mode`. Non-PPT canvases omit it.
|
||||||
|
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
|
||||||
|
- **Generated-image direction** appears only for current `image_usage: ai`, but all three custom project candidates already exist in `design_directions` before that toggle. Turning AI on reveals those candidates immediately without a backend rerun, followed by the 20 fixed system styles. Selecting a project candidate expands its behavior editor in that card; a fixed preset submits its id, while a project candidate submits `rendering: "custom"` + edited non-empty `behavior`. Turning AI off omits `image_strategy` from the final result without deleting the authored recommendation candidates. Catalog-based custom behavior names exact ids for optional `image_rendering_references`; a novel behavior has none. The left preview follows selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored.
|
||||||
|
- **`design_directions`** is the canonical Stage-2 starting set: exactly three top-down, project-fit bundles with stable ids, localized copy, custom mode/style/rendering, icons, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. The `selected` card carries the persistent Recommended marker and is applied first. A custom direction card uses its localized style-summary note—or the required behavior fallback—instead of requesting a preset-style preview. Newly authored notes may borrow localized catalog display labels where useful or use concise natural language freely; they never force an approximate label or expose internal catalog ids. Clicking an inactive card applies every field it owns; projected custom fields can then be edited in place and all lower controls may diverge. The active card shows an adjusted state and exposes an explicit restore action for its immutable authored bundle. `result.json` stores the edited current components, never a direction id.
|
||||||
|
- `recommend.generation_mode` and `refine_spec` mirror [`generate-pptx`](../../workflows/generate-pptx.md) Step 4. `split` / `true` are explicit opt-ins. Refinement adds no UI stage: after Gate 1 it stops before the lock for unrestricted chat revision until approval.
|
||||||
|
- `content_divergence` is a **free-text** Stage-1 source-treatment field. Blank means a balanced default; facts stay sourced at every level. Strategist consumes it while authoring §IX and records it in `design_spec.md §I`; it is not written to `spec_lock.md`. Beautify sends `{ "value": "keep source wording and page structure verbatim", "locked": true }`, so the UI displays it read-only and the server restores it on every staged submit. Template-fill does not use this confirmation flow and does not surface it.
|
||||||
|
- `lang` is the soft UI-language default (`zh` / `zh-TW` / `en` / `ja`); the persisted user choice wins. It never sets `primary_language`.
|
||||||
|
|
||||||
|
### Output — `result.json` (written on submit, read by the AI)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primary_language": "zh-CN",
|
||||||
|
"canvas": "ppt169",
|
||||||
|
"page_count": "12-15",
|
||||||
|
"audience": "...",
|
||||||
|
"communication_intent": "Report progress and expose risk first; then obtain an investment decision",
|
||||||
|
"audience_outcome": "The committee compares the options and chooses one funded path",
|
||||||
|
"core_message": "Fund option B now to protect the launch date at acceptable incremental cost",
|
||||||
|
"delivery_context": "Primary: presenter-led 20-minute leadership review; secondary: reader-led approval copy shared afterward",
|
||||||
|
"artifact_afterlife": "Approval record, hand-off reference, and audit trail",
|
||||||
|
"content_divergence": "freely restructure and expand within the source",
|
||||||
|
"mode": "custom",
|
||||||
|
"mode_behavior": "...",
|
||||||
|
"visual_style": "custom",
|
||||||
|
"visual_style_behavior": "...",
|
||||||
|
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
|
||||||
|
"icons": "tabler-outline",
|
||||||
|
"typography": { "name": "...", "heading": { "primary": "...", "english": "...", "css": "..." }, "body": { "primary": "...", "english": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
|
||||||
|
"delivery_purpose": "balanced",
|
||||||
|
"formula_policy": "mixed",
|
||||||
|
"image_usage": ["ai", "provided"],
|
||||||
|
"image_notes": "封面和章节页用 AI 主视觉;产品页优先用户素材,缺口页可用占位符。",
|
||||||
|
"image_ai_path": "auto",
|
||||||
|
"image_strategy": { "name": "方案 A", "rendering": "custom", "visual": "...", "mood": "...", "behavior": "..." },
|
||||||
|
"proactive_speaker_notes": true,
|
||||||
|
"proactive_custom_animations": false,
|
||||||
|
"proactive_narration_audio": false,
|
||||||
|
"generation_mode": "continuous",
|
||||||
|
"refine_spec": false,
|
||||||
|
"stage": "final",
|
||||||
|
"status": "confirmed",
|
||||||
|
"confirmed_at": "2026-06-15T11:44:44"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shape above is final for Strategist confirmation. It intentionally contains
|
||||||
|
no template-selection field: `template_selection.json`, its agent handoff, and
|
||||||
|
the installed project-local state own the parallel Stage-1 decision. The proactive-execution values
|
||||||
|
are independent flat booleans in `result.json`; old recommendations and results
|
||||||
|
that omit them resolve to `true / false / false`. They remain raw evidence even
|
||||||
|
when `proactive_speaker_notes` is `false` and `proactive_narration_audio` is
|
||||||
|
`true`; Strategist owns the effective dependency resolution described above.
|
||||||
|
Selected custom values use `mode: custom` + `mode_behavior`, `visual_style:
|
||||||
|
custom` + `visual_style_behavior`, or `image_strategy.rendering: custom` +
|
||||||
|
`behavior`. During Design Spec and lock authoring, Strategist projects optional
|
||||||
|
`mode_references`, `visual_style_references`, or
|
||||||
|
`image_rendering_references` only when that confirmed behavior actually uses
|
||||||
|
named catalog sources. These lists have no fixed item limit; each item must own
|
||||||
|
a distinct executable contribution, while genuinely novel custom behavior has
|
||||||
|
no reference list.
|
||||||
|
The Stage-1 intermediate write retains the communication contract for Stage 2.
|
||||||
|
|
||||||
|
**Final-result consumption contract.** A final result is the user-confirmed input contract for the Strategist's Design Spec, not another recommendation input. After the final wait, Generate Step 4 reads the complete final object exactly once and retains it while Strategist writes and audits `design_spec.md` against every explicitly present field. Normal lock authoring and downstream execution do not reopen `result.json`; the completed Design Spec is the durable authority. Only after that audit passes does Strategist author `spec_lock.md` from the Design Spec plus current execution context, selecting stable anchors and routing rather than copying every field or enumerating every legal color/font. Every value must be consumed at the semantic type owned by [`strategist.md`](../../references/strategist.md) §1 and its field owner: do not omit or substitute it, and do not silently strengthen or weaken its type. If a confirmed requirement cannot be honored, the owning workflow reports or pauses under failure recovery; it never deletes the requirement to keep the pipeline moving.
|
||||||
|
|
||||||
|
- Bespoke mode / style prose lives only in the required behavior sibling; image custom prose lives in `image_strategy.behavior`. Canvas / icons retain free-text edge cases, color / typography retain `name: "custom"`, and image usage remains a source-id array plus `image_notes`.
|
||||||
|
- `image_ai_path` and `image_strategy` appear only with `image_usage: ai` and remain confirmed downstream. The page is default; explicit/failure chat fallback keeps identical fields. `image_ai_path` selects the Step 5 path, and [`strategist-image.md`](../../references/strategist-image.md) §2 retains the selected rendering or custom behavior as the deck-level image identity anchor; individual prompts still adapt subject, composition, and atmosphere within it.
|
||||||
|
- Stage-1 **Confirm contract & template choice** writes the Stage-1 `result.json` and `template_selection.json` together, then keeps the page open while the agent installs the selection. A successful Stage-1 wait is explicitly intermediate and cannot end the task. In the same active run, the agent runs `--complete-template-selection`, writes fresh Stage 2 only after that handoff, and enters the final wait while the page keeps polling. Stage-2 **Confirm final plan** saves the final `result.json` and shuts the server down (auto-close). The AI reads each receipt at its owning boundary; chat fallback mirrors the same decisions without UI artifacts. Either way, Step 4 ends with `--shutdown` so a never-confirmed page cannot retain its selected port ahead of Step 6 live preview.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Confirmation surface only — Strategist authors every recommendation; the page never generates deck content.
|
||||||
|
- No SVG / layout preview here — that is the live preview server's job (`workflows/stages/live-preview.md`, Step 6).
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
# Conversion Tools
|
||||||
|
|
||||||
|
> **Design boundary**: use native-Python converters for supported formats,
|
||||||
|
> invoke Pandoc only for explicit fallback formats, and let web conversion use
|
||||||
|
> `curl_cffi` when available for sites that reject Python's default TLS
|
||||||
|
> fingerprint.
|
||||||
|
|
||||||
|
Source conversion tools turn PDFs, documents, slide decks, and web pages into Markdown before project creation.
|
||||||
|
|
||||||
|
Default workflow entry: use `source_to_md.py` unless a backend-specific
|
||||||
|
diagnostic or forced route is needed.
|
||||||
|
|
||||||
|
## Shared Output Contract
|
||||||
|
|
||||||
|
All `source_to_md` converters keep their existing Markdown output behavior and
|
||||||
|
now also write a lightweight sidecar profile when conversion succeeds:
|
||||||
|
|
||||||
|
| Output | Convention |
|
||||||
|
|---|---|
|
||||||
|
| Markdown | `<stem>.md` beside the local source unless `-o` selects another path |
|
||||||
|
| Asset directory | `<stem>_files/` when the backend extracts images or media |
|
||||||
|
| Image manifest | `<stem>_files/image_manifest.json` when image metadata is available |
|
||||||
|
| Conversion profile | `<stem>.conversion_profile.json` beside the Markdown output |
|
||||||
|
|
||||||
|
The conversion profile is metadata only. It records the converter, source path,
|
||||||
|
Markdown structure counts, asset directory, image manifest path, and image
|
||||||
|
count. Downstream PPT workflows still use the Markdown and image manifest as the
|
||||||
|
content/asset contract; the profile is for inspection and debugging.
|
||||||
|
|
||||||
|
## `source_to_md.py`
|
||||||
|
|
||||||
|
Unified dispatcher for ad hoc explicit-source conversion. It auto-detects each
|
||||||
|
listed input file or URL and calls the existing backend converter, so backend
|
||||||
|
behavior remains the source of truth.
|
||||||
|
|
||||||
|
Routing is centralized in `source_to_md/_dispatcher.py` and reused by
|
||||||
|
`project_manager.py import-sources`; do not add a second type-to-backend table.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md.py paper.pdf
|
||||||
|
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
|
||||||
|
python3 scripts/source_to_md.py ./sources
|
||||||
|
python3 scripts/source_to_md.py ./pdfs/*.pdf
|
||||||
|
python3 scripts/source_to_md.py ./decks/*.pptx
|
||||||
|
python3 scripts/source_to_md.py report.docx -o report.md
|
||||||
|
python3 scripts/source_to_md.py ./sources -o ./markdown # explicit separate output directory
|
||||||
|
python3 scripts/source_to_md.py workbook.xlsx --json
|
||||||
|
python3 scripts/source_to_md.py deck.pptx
|
||||||
|
python3 scripts/source_to_md.py https://example.com/article -o article.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful options:
|
||||||
|
- `-t pdf|doc|excel|pptx|web|markdown|text` forces a route when extension
|
||||||
|
detection is not enough.
|
||||||
|
- `--json` prints a compact machine-readable result after success when the
|
||||||
|
output path is known. With multiple inputs, each successful conversion prints
|
||||||
|
its own JSON line after that source finishes.
|
||||||
|
- `--images all|filtered|none`, `--no-images`, and `--filter-images` map to the
|
||||||
|
existing PDF image mode. They are intentionally PDF-only until other backends
|
||||||
|
expose the same behavior natively.
|
||||||
|
- Unknown backend-specific flags are passed through to each selected converter.
|
||||||
|
- `-o/--output` selects one Markdown file for one input, or an output directory
|
||||||
|
for multiple inputs / directory inputs.
|
||||||
|
|
||||||
|
For multi-source project intake, use `project_manager.py import-sources` with
|
||||||
|
all source paths / URLs. For local files, the default is to keep generated
|
||||||
|
Markdown/profile outputs beside the original source. `source_to_md.py` and the
|
||||||
|
backend converters support single files, explicit multi-file inputs, and
|
||||||
|
non-recursive directory inputs.
|
||||||
|
|
||||||
|
## `source_to_md/pdf_to_md.py`
|
||||||
|
|
||||||
|
Recommended first choice for native PDFs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py book.pdf
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py book.pdf -o output.md
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py book.pdf appendix.pdf
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py ./pdfs
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py ./pdfs -o ./markdown # explicit separate output directory
|
||||||
|
|
||||||
|
# Image extraction control (default: filtered)
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images filtered # size/quality filters applied
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images all # extract all images, no filtering
|
||||||
|
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images none # skip all images (text only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use cases:
|
||||||
|
- Native PDFs exported from Word, PowerPoint, LaTeX, or similar tools
|
||||||
|
- Privacy-sensitive documents that should stay local
|
||||||
|
- Fast first-pass extraction before falling back to OCR-heavy tools
|
||||||
|
|
||||||
|
Prefer MinerU or another OCR/layout tool when:
|
||||||
|
- The PDF is scanned or image-based
|
||||||
|
- Multi-column layout parsing is poor
|
||||||
|
- Encoding is garbled
|
||||||
|
|
||||||
|
Dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install PyMuPDF
|
||||||
|
```
|
||||||
|
|
||||||
|
## `source_to_md/doc_to_md.py`
|
||||||
|
|
||||||
|
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
|
||||||
|
|
||||||
|
Native path (no external binary required):
|
||||||
|
- `.docx` — via `mammoth`; text-only tables are preserved as pipe Markdown, and OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
|
||||||
|
- `.html` / `.htm` — via `markdownify` + `beautifulsoup4`
|
||||||
|
- `.epub` — via `ebooklib` + `markdownify`
|
||||||
|
- `.ipynb` — via `nbconvert`
|
||||||
|
|
||||||
|
Pandoc fallback (only if you need these):
|
||||||
|
- `.doc`, `.odt`, `.rtf`, `.tex`/`.latex`, `.rst`, `.org`, `.typ`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py lecture.docx
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py lecture.docx -o output.md
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py lecture.docx notes.html
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py ./docs
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py ./docs -o ./markdown # explicit separate output directory
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py notes.epub
|
||||||
|
python3 scripts/source_to_md/doc_to_md.py paper.tex -o paper.md # uses pandoc
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Native path — always required
|
||||||
|
pip install mammoth markdownify ebooklib nbconvert beautifulsoup4
|
||||||
|
|
||||||
|
# Fallback path — only for .doc/.odt/.rtf/.tex/.rst/.org/.typ
|
||||||
|
# macOS: brew install pandoc
|
||||||
|
# Ubuntu: sudo apt install pandoc
|
||||||
|
# Windows: https://pandoc.org/installing.html
|
||||||
|
```
|
||||||
|
|
||||||
|
All paths produce the same output convention: `<input>.md` plus a sibling `<input>_files/` directory containing extracted images with relative references.
|
||||||
|
On success, a sibling `<input>.conversion_profile.json` is also written.
|
||||||
|
|
||||||
|
## `source_to_md/excel_to_md.py`
|
||||||
|
|
||||||
|
Excel workbook converter for presentation source intake.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
- `.xlsx`
|
||||||
|
- `.xlsm`
|
||||||
|
|
||||||
|
Unsupported by default:
|
||||||
|
- `.xls` — resave as `.xlsx` first
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py report.xlsx
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py report.xlsx -o output.md
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py report.xlsx budget.xlsm
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py ./workbooks
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py ./workbooks -o ./markdown # explicit separate output directory
|
||||||
|
python3 scripts/source_to_md/excel_to_md.py report.xlsm --max-rows 200 --max-cols 40
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- preserves workbook and sheet structure in Markdown
|
||||||
|
- exports visible sheets only
|
||||||
|
- trims empty outer rows and columns
|
||||||
|
- propagates merged-cell labels for readable Markdown tables
|
||||||
|
- exports formula cells as cached values; it does not recalculate formulas
|
||||||
|
- writes `<input>.conversion_profile.json` after successful conversion
|
||||||
|
|
||||||
|
Dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
CSV/TSV files are already plain-text table sources and do not require this converter.
|
||||||
|
|
||||||
|
## `source_to_md/ppt_to_md.py`
|
||||||
|
|
||||||
|
Structured PowerPoint-to-Markdown converter for Open XML slide decks.
|
||||||
|
|
||||||
|
Supported formats include:
|
||||||
|
- `.pptx`, `.pptm`
|
||||||
|
- `.ppsx`, `.ppsm`
|
||||||
|
- `.potx`, `.potm`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx -o output.md
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx appendix.pptx
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py ./decks
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py ./decks -o ./markdown # explicit separate output directory
|
||||||
|
python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- extracts slide text in reading order
|
||||||
|
- converts PowerPoint tables to Markdown tables; cell-internal line breaks become `<br>` so they cannot break the pipe-table row structure
|
||||||
|
- transcribes category charts as category × series tables and scatter/bubble charts as typed X/Y[/size] point tables
|
||||||
|
- preserves every readable chart dimension or series and emits `[Chart data warning: <reason>]` for missing caches/count mismatches; `[Chart data unavailable: <reason>]` is reserved for charts with no readable points (and unsupported ChartEx), so XY data is never flattened into a misleading category table
|
||||||
|
- transcribes SmartArt semantic nodes as hierarchical Markdown; unreadable diagram data emits an explicit placeholder and conversion warning
|
||||||
|
- exports embedded pictures to a sibling `_files/` directory
|
||||||
|
- appends speaker notes when present
|
||||||
|
- writes `<input>.conversion_profile.json` after successful conversion
|
||||||
|
|
||||||
|
Dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install python-pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy `.ppt` is not parsed directly. Resave it as `.pptx` or export it to PDF first.
|
||||||
|
|
||||||
|
## `pptx_intake.py`
|
||||||
|
|
||||||
|
Standard enrichment layer for PPTX sources. It complements `ppt_to_md.py` rather
|
||||||
|
than replacing it: Markdown remains the normalized content source, while intake
|
||||||
|
artifacts provide source facts for Strategist and standalone PPTX workflows.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs (per source deck, prefixed by file stem):
|
||||||
|
- `<stem>.identity.json` — canvas size/aspect, theme palette/fonts, observed colors/fonts
|
||||||
|
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections
|
||||||
|
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers
|
||||||
|
|
||||||
|
`project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: importing several PPTX files gives each its own `<stem>.*` artifacts and a `decks[]` entry in the shared `source_profile.json` index (re-importing the same stem replaces its entry). The beautify profile and Fill Native PPTX route stay single-deck and read one chosen deck's `<stem>.*` artifacts.
|
||||||
|
|
||||||
|
Usage boundary:
|
||||||
|
- Standard generation uses these fields as facts and recommendation candidates; it does not inherit source slide coordinates or page order by default.
|
||||||
|
- Beautify promotes selected identity/content fields into locked constraints after confirmation and redraws SmartArt meaning with ordinary editable shapes.
|
||||||
|
- Template-fill uses the slide library as the native PPTX fill contract; SmartArt is inventory-only and remains unchanged.
|
||||||
|
|
||||||
|
## `pptx_to_svg.py`
|
||||||
|
|
||||||
|
Reconstruct a PPTX package as editable SVG views by reading OOXML directly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode both
|
||||||
|
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode layered
|
||||||
|
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode flat
|
||||||
|
python3 scripts/pptx_to_svg.py deck.pptx --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mode | Output |
|
||||||
|
|---|---|
|
||||||
|
| `both` (default) | Layered master/layout/slide SVGs under `svg/`, plus self-contained slides under `svg-flat/` |
|
||||||
|
| `layered` | Only the layered `svg/` view and inheritance metadata |
|
||||||
|
| `flat` | One self-contained slide SVG per page under `svg/` |
|
||||||
|
|
||||||
|
For Office pictures that carry both a raster compatibility preview on
|
||||||
|
`a:blip` and an editable SVG relationship in `asvg:svgBlip`, import resolves
|
||||||
|
the SVG relationship first. The raster relationship is used only when the SVG
|
||||||
|
relationship or media part cannot be read. The template manifest uses the same
|
||||||
|
relationship preference for asset identity; its existing missing-media gate
|
||||||
|
remains strict rather than silently treating the raster preview as the
|
||||||
|
template's canonical asset.
|
||||||
|
|
||||||
|
### Import compatibility and recovery boundary
|
||||||
|
|
||||||
|
Import is tolerant by default because the source deck is user-owned or comes
|
||||||
|
from third-party authoring tools. Recovery happens at the narrowest safe
|
||||||
|
boundary: first omit only an unsupported property or feature; if that is not
|
||||||
|
possible, replace only the affected object with a visible diagnostic
|
||||||
|
placeholder; omit a background without discarding its page. Corrupt ZIP/XML or
|
||||||
|
missing required package structure remains fatal because no safe local recovery
|
||||||
|
exists. Pass `--strict` for parser development or contract verification when
|
||||||
|
the first unsupported/malformed source construct should stop conversion.
|
||||||
|
|
||||||
|
Every successful run writes `<output>/conversion-report.json`. Its stable
|
||||||
|
top-level fields are `schemaVersion`, `source`, `mode`, `summary`, and
|
||||||
|
`diagnostics`. Each diagnostic records a reason `code`, source `message`, chosen
|
||||||
|
`fallback`, package `part_path`, and—when available—`slide_index`, `shape_id`,
|
||||||
|
`shape_name`, and `shape_kind`. The command also prints a bounded warning
|
||||||
|
summary instead of a raw Python traceback.
|
||||||
|
|
||||||
|
In the detailed native-object notes below, “fails closed” or “error” describes
|
||||||
|
the native replacement claim or strict mode. Default tolerant deck import
|
||||||
|
retains the usable fallback/object and records the degradation; it does not
|
||||||
|
discard unrelated shapes, pages, or the entire deck.
|
||||||
|
|
||||||
|
### Native table and chart import claims
|
||||||
|
|
||||||
|
Supported text-grid tables and conservative classic-chart caches carry a
|
||||||
|
`data-pptx-replace-with` claim beside their SVG fallback, with the replacement
|
||||||
|
payload in a child `<metadata type="application/json">`. The parent claim
|
||||||
|
selects the table or chart schema. Table import requires
|
||||||
|
exact physical row/grid topology and accepts canonical rectangular merges,
|
||||||
|
safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed
|
||||||
|
run-rich paragraph schema.
|
||||||
|
Each run requires `text` and may use only `bold`, `italic`, `underline`,
|
||||||
|
`strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`.
|
||||||
|
Presentation-only source run XML without a non-empty `effectLst` / `effectDag`
|
||||||
|
normalizes. A table-cell run effect disables native replacement and adds a
|
||||||
|
blocking effect diagnostic. Relationship-bearing text, extensions,
|
||||||
|
noncanonical/overlapping merges, nonblank merge slaves, unsafe border XML,
|
||||||
|
non-solid fills, structural line breaks/fields/tabs/bullets, and broken text
|
||||||
|
topology remain fallback-only.
|
||||||
|
Markers remain dormant
|
||||||
|
unless a later export uses `--native-charts-and-tables`. That opt-in is
|
||||||
|
data-object-first: the default fallback still exports as editable DrawingML
|
||||||
|
shapes, while the opt-in supplies a data source and PowerPoint's
|
||||||
|
chart/table-specific object model.
|
||||||
|
The native-object route may normalize styling or omit marker-local details not represented by the
|
||||||
|
payload, and export reports that risk without disabling an otherwise supported
|
||||||
|
active marker. Unsupported tables keep their
|
||||||
|
rendered SVG table; unsupported charts keep a baked preview when one exists.
|
||||||
|
For the currently supported parsed classic families (column/bar/line/area,
|
||||||
|
pie/doughnut, scatter, and bubble), a chart without a baked preview receives a
|
||||||
|
deterministic readable fallback marked
|
||||||
|
`data-pptx-fallback-kind="normalized"`. Unknown style XML disables the native
|
||||||
|
replacement claim or falls back to a diagnostic object in tolerant mode;
|
||||||
|
common solid/no-fill/line/marker forms and scheme colors are normalized for the
|
||||||
|
SVG fallback and core payload colors, while native opt-in may still normalize
|
||||||
|
unmodeled alpha, line, marker, or no-fill details. Common General, decimal,
|
||||||
|
grouped, percent, and simple currency-prefix data-label formats render
|
||||||
|
deterministically; an unknown Excel format program keeps the active payload but
|
||||||
|
does not claim a normalized fallback. Active types outside the current renderer
|
||||||
|
continue to use an explicit placeholder marked
|
||||||
|
`data-pptx-fallback-kind="placeholder"`. Validation and export report that
|
||||||
|
reconstruction-only fallback as a warning. Default export keeps the
|
||||||
|
placeholder; when the same group has a valid active chart replacement payload,
|
||||||
|
`--native-charts-and-tables` may still reconstruct the PowerPoint-native chart.
|
||||||
|
Invalid or contradictory fallback declarations remain errors. Fallback-only
|
||||||
|
replacement capability uses `data-pptx-replacement-status` and remains a
|
||||||
|
warning when the SVG fallback itself is complete. Imported table/chart groups
|
||||||
|
under this contract carry `data-pptx-import-source="pptx"`, whether active or
|
||||||
|
fallback-only; generated authoring omits this provenance attribute.
|
||||||
|
|
||||||
|
Active imported markers also carry `data-pptx-fallback-sha256`, computed over
|
||||||
|
their canonical fallback plus reachable document-level SVG fragment definitions.
|
||||||
|
A later visible edit, reachable definition change, local reference-target
|
||||||
|
change, or marker transform makes the replacement metadata stale. The mandatory
|
||||||
|
quality checker reports the mismatch; default export keeps the edited fallback,
|
||||||
|
while `--native-charts-and-tables` fails before replacement so it cannot discard that edit.
|
||||||
|
`visibility:hidden` content, marker-local unused definitions, and explicitly
|
||||||
|
referenced document-level target roots (even when hidden) are included
|
||||||
|
conservatively; marker-local `display:none` subtrees are excluded, and external
|
||||||
|
file bytes are not read.
|
||||||
|
Generated authoring and reusable templates omit import provenance and do not
|
||||||
|
preseed a static fallback hash; that state is normal and does not warn. A legacy
|
||||||
|
imported marker that still carries PPTX import provenance but lacks the hash
|
||||||
|
remains native-compatible and warns in the checker/native route that stale
|
||||||
|
detection is unavailable.
|
||||||
|
|
||||||
|
Legacy `data-pptx-native*`, `data-pptx-visual-status`, and
|
||||||
|
`data-pptx-route-status` spellings remain read-compatible. New importer output
|
||||||
|
and generated SVG use the replacement/fallback names above. The old
|
||||||
|
`--native-objects` option remains a compatibility alias for
|
||||||
|
`--native-charts-and-tables`.
|
||||||
|
|
||||||
|
For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the importer resolves
|
||||||
|
the normalized `wholeTbl`, `firstRow`, `band1H`/`band2H`, theme color/font, and
|
||||||
|
direct-format override subset. Other built-in/custom style families remain
|
||||||
|
outside this guarantee.
|
||||||
|
|
||||||
|
The chart importer also accepts the verified column/line/area combo subset,
|
||||||
|
canonical four-series OHLC stock charts with shared numeric date caches, area
|
||||||
|
charts with numeric date axes, and verified scatter/bubble charts with a closed
|
||||||
|
pair of `axes.x` / `axes.y` value axes. Combo primary/secondary plots may retain
|
||||||
|
independent category caches and workbook ranges. Both the category/value and XY
|
||||||
|
contracts read back the supported kind/position/visibility, label-position,
|
||||||
|
number-format, min/max/major-unit, reverse, and major-gridline fields; the native
|
||||||
|
writer emits every field in those closed contracts. Scatter style is derived
|
||||||
|
from uniform effective series line/marker/smooth state. The normalized XY
|
||||||
|
fallback newly consumes only the two major-gridline flags, not the remaining
|
||||||
|
axis fields. The importer also accepts radar, safe `of_pie` `serLines`, the
|
||||||
|
closed axis/title/legend normalization cases, and bar/column `gapWidth` /
|
||||||
|
`overlap`. `gapWidth` must be one integer in `0..500` and `overlap` one integer
|
||||||
|
in `-100..100`; both normalize in native output, while malformed, duplicate, or
|
||||||
|
out-of-range values disable the native replacement claim in tolerant mode and
|
||||||
|
stop strict import. These additions do not expand the normalized
|
||||||
|
renderer.
|
||||||
|
Safe stock series style may pass the structural gate, while stock series,
|
||||||
|
`hiLowLines`, and up-down bar local styling can still normalize under the
|
||||||
|
data-object-first contract.
|
||||||
|
ChartEx import accepts exactly the validated treemap, sunburst, histogram,
|
||||||
|
pareto, box-whisker, waterfall, and funnel data models. Their supported
|
||||||
|
hierarchy/category/value/series/subtotal topology round-trips to native output.
|
||||||
|
Numeric caches must be non-empty and finite, with canonical non-negative counts
|
||||||
|
and indexes and exact contiguous point topology. Source style, axes, labels,
|
||||||
|
and binning details may normalize. This is not full `AxisSpec`, arbitrary
|
||||||
|
ChartEx import or presentation fidelity, arbitrary stock variants, other
|
||||||
|
date-axis chart families, or unlisted axis semantics. ChartEx native output
|
||||||
|
still consumes valid payload colors in its color-style part.
|
||||||
|
|
||||||
|
Exporter-canonical charts recover canonical solid series/slice colors and exact
|
||||||
|
one- or two-paragraph title styling; two paragraphs retain their `title` /
|
||||||
|
`subtitle` roles. This is not a general source-chart style round-trip guarantee.
|
||||||
|
|
||||||
|
Concrete slide SVGs resolve `<a:fld type="slidenum">` using the presentation's
|
||||||
|
`firstSlideNum` display numbering. Standalone master/layout SVGs keep the
|
||||||
|
literal field fallback because one shared part can serve multiple slides.
|
||||||
|
|
||||||
|
### Maintenance smoke checks
|
||||||
|
|
||||||
|
Run these checks from the repository root after changing `pptx_to_svg/` or its
|
||||||
|
CLI. They generate every required input under `/tmp`; do not replace them with
|
||||||
|
a committed `test_*.py` suite.
|
||||||
|
|
||||||
|
#### Healthy generated deck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE
|
||||||
|
from pptx.util import Inches
|
||||||
|
|
||||||
|
presentation = Presentation()
|
||||||
|
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||||
|
shape = slide.shapes.add_shape(
|
||||||
|
MSO_SHAPE.RECTANGLE,
|
||||||
|
Inches(1),
|
||||||
|
Inches(1),
|
||||||
|
Inches(3),
|
||||||
|
Inches(1),
|
||||||
|
)
|
||||||
|
shape.text = "PPTX import smoke check"
|
||||||
|
presentation.save("/tmp/ppt-master-smoke-healthy.pptx")
|
||||||
|
PY
|
||||||
|
|
||||||
|
python3 "skills/ppt-master/scripts/pptx_to_svg.py" \
|
||||||
|
"/tmp/ppt-master-smoke-healthy.pptx" \
|
||||||
|
--inheritance-mode flat \
|
||||||
|
-o "/tmp/ppt-master-smoke-healthy"
|
||||||
|
python3 -c "import json; from pathlib import Path; report = json.loads(Path('/tmp/ppt-master-smoke-healthy/conversion-report.json').read_text()); assert report['summary'] == {'slides': 1, 'warnings': 0}, report['summary']; print('OK: 1 slide, 0 warnings')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands exit `0`; the assertion prints
|
||||||
|
`OK: 1 slide, 0 warnings`.
|
||||||
|
|
||||||
|
#### Tolerant/strict color-structure probe
|
||||||
|
|
||||||
|
Generate a two-shape PPTX, then add one foreign attribute to the first shape's
|
||||||
|
valid `a:srgbClr` node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c '
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.dml.color import RGBColor
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE
|
||||||
|
from pptx.util import Inches
|
||||||
|
|
||||||
|
base = Path("/tmp/ppt-master-color-smoke-base.pptx")
|
||||||
|
target = Path("/tmp/ppt-master-color-smoke.pptx")
|
||||||
|
presentation = Presentation()
|
||||||
|
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||||
|
for left, color in ((1, (0x44, 0x72, 0xC4)), (4, (0xED, 0x7D, 0x31))):
|
||||||
|
shape = slide.shapes.add_shape(
|
||||||
|
MSO_SHAPE.RECTANGLE,
|
||||||
|
Inches(left),
|
||||||
|
Inches(1),
|
||||||
|
Inches(2),
|
||||||
|
Inches(1),
|
||||||
|
)
|
||||||
|
shape.fill.solid()
|
||||||
|
shape.fill.fore_color.rgb = RGBColor(*color)
|
||||||
|
presentation.save(base)
|
||||||
|
|
||||||
|
with ZipFile(base) as source, ZipFile(target, "w", ZIP_DEFLATED) as destination:
|
||||||
|
patched = False
|
||||||
|
for member in source.infolist():
|
||||||
|
payload = source.read(member)
|
||||||
|
if member.filename == "ppt/slides/slide1.xml":
|
||||||
|
old = b"<a:srgbClr val=\"4472C4\"/>"
|
||||||
|
new = b"<a:srgbClr val=\"4472C4\" legacy=\"1\"/>"
|
||||||
|
if old not in payload:
|
||||||
|
raise RuntimeError("probe color node was not generated")
|
||||||
|
payload = payload.replace(old, new, 1)
|
||||||
|
patched = True
|
||||||
|
destination.writestr(member, payload)
|
||||||
|
if not patched:
|
||||||
|
raise RuntimeError("slide XML was not patched")
|
||||||
|
print(target)
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tolerant import and verify both the recovery report and the visible SVG:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 "skills/ppt-master/scripts/pptx_to_svg.py" \
|
||||||
|
"/tmp/ppt-master-color-smoke.pptx" \
|
||||||
|
--inheritance-mode flat \
|
||||||
|
-o "/tmp/ppt-master-smoke-color-tolerant"
|
||||||
|
python3 -c '
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
output = Path("/tmp/ppt-master-smoke-color-tolerant")
|
||||||
|
report = json.loads((output / "conversion-report.json").read_text())
|
||||||
|
diagnostics = report["diagnostics"]
|
||||||
|
svg = (output / "svg" / "slide_01.svg").read_text()
|
||||||
|
assert report["summary"] == {"slides": 1, "warnings": 1}, report["summary"]
|
||||||
|
assert len(diagnostics) == 1, diagnostics
|
||||||
|
assert diagnostics[0]["code"] == "color-structure-normalized", diagnostics[0]
|
||||||
|
assert diagnostics[0]["fallback"] == "retain recognized color attributes and modifiers", diagnostics[0]
|
||||||
|
assert diagnostics[0]["slide_index"] == 1, diagnostics[0]
|
||||||
|
assert diagnostics[0]["shape_name"] == "Rectangle 1", diagnostics[0]
|
||||||
|
assert "#4472C4" in svg and "#ED7D31" in svg
|
||||||
|
print("OK: tolerant import recovered #4472C4 and preserved #ED7D31")
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands exit `0`; the importer reports one
|
||||||
|
`color-structure-normalized` warning and the assertion prints
|
||||||
|
`OK: tolerant import recovered #4472C4 and preserved #ED7D31`.
|
||||||
|
|
||||||
|
Run the same probe in strict mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 "skills/ppt-master/scripts/pptx_to_svg.py" \
|
||||||
|
"/tmp/ppt-master-color-smoke.pptx" \
|
||||||
|
--inheritance-mode flat \
|
||||||
|
--strict \
|
||||||
|
-o "/tmp/ppt-master-smoke-color-strict"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit `1`, no traceback, and one error line:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error: PPTX-to-SVG conversion failed: Invalid DrawingML sRGB color structure
|
||||||
|
```
|
||||||
|
|
||||||
|
## `source_to_md/web_to_md.py`
|
||||||
|
|
||||||
|
Convert web pages to Markdown and download images locally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/source_to_md/web_to_md.py https://example.com/article
|
||||||
|
python3 scripts/source_to_md/web_to_md.py https://url1.com https://url2.com
|
||||||
|
python3 scripts/source_to_md/web_to_md.py -f urls.txt
|
||||||
|
python3 scripts/source_to_md/web_to_md.py https://example.com -o output.md
|
||||||
|
python3 scripts/source_to_md/web_to_md.py https://example.com --emit-result /tmp/result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
When `curl_cffi` is installed (included in `requirements.txt`), this script
|
||||||
|
automatically impersonates a modern Chrome TLS fingerprint, which lets it
|
||||||
|
fetch WeChat Official Accounts (`mp.weixin.qq.com`) and other sites that
|
||||||
|
block Python's default TLS fingerprint. No extra flags needed. If
|
||||||
|
`curl_cffi` is not available, it falls back to plain `requests`.
|
||||||
|
|
||||||
|
On success, the converter writes `<output>.conversion_profile.json` beside the
|
||||||
|
Markdown output.
|
||||||
|
`--emit-result` is for wrapper scripts that need the actual saved Markdown path
|
||||||
|
when the converter derives a title-based filename.
|
||||||
|
|
||||||
|
|
||||||
|
## Image Orientation Review
|
||||||
|
|
||||||
|
Run this review when the user requests orientation correction, converted text
|
||||||
|
asks the reader to rotate the device, or a downloaded asset is visibly
|
||||||
|
sideways. EXIF and dimensions may trigger review, but they cannot determine the
|
||||||
|
semantic direction of pixels that are already stored sideways.
|
||||||
|
|
||||||
|
Generate a labeled static contact sheet. This command previews the first frame
|
||||||
|
after EXIF normalization and does not modify source images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${SKILL_DIR}/scripts/rotate_images.py sheet <images_directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
The default output is
|
||||||
|
`<images_directory>/../analysis/<directory>_orientation_contact_sheet.jpg`.
|
||||||
|
Inspect it with the current multimodal agent, identify only visually confirmed
|
||||||
|
rotations, and write a temporary JSON list. `rotation` is clockwise degrees and
|
||||||
|
must be `90`, `180`, or `270`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"path": "/absolute/path/to/sideways.jpg", "rotation": 270}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the confirmed fixes and regenerate image facts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${SKILL_DIR}/scripts/rotate_images.py fix /tmp/orientation_fixes.json
|
||||||
|
python3 ${SKILL_DIR}/scripts/analyze_images.py <images_directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
GIF files are excluded: `sheet` does not list them, and `fix` rejects a batch
|
||||||
|
that references one so all GIF files remain unchanged.
|
||||||
|
|
||||||
|
Do not infer a rotation from prose, EXIF, or aspect ratio alone, and do not
|
||||||
|
launch the HTML `gen` command in source intake. `auto` remains an in-place EXIF
|
||||||
|
normalizer. `gen` is a compatibility UI that runs the same normalization before
|
||||||
|
writing HTML; neither belongs to source intake.
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# Image Tools
|
||||||
|
|
||||||
|
> **Design boundary**: keep provider credentials explicit, keep in-pipeline
|
||||||
|
> acquisition manifest-driven, and treat external image references as authoring
|
||||||
|
> inputs while delivery writes self-contained SVG previews and native PPTX
|
||||||
|
> media.
|
||||||
|
|
||||||
|
Image tools cover formula rendering, prompt-based AI generation, web image search, image inspection, and Gemini watermark removal.
|
||||||
|
|
||||||
|
## `latex_render.py`
|
||||||
|
|
||||||
|
Manifest-driven LaTeX formula renderer. Default Generate has Strategist write
|
||||||
|
`images/formula_manifest.json` after Typography confirmation; Quick Generate
|
||||||
|
has the current agent write the same resource manifest without confirmation.
|
||||||
|
This script renders only those declared formulas to transparent PNGs and writes
|
||||||
|
dimensions back into the manifest.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/latex_render.py <project_path>
|
||||||
|
python3 scripts/latex_render.py <project_path> --dry-run
|
||||||
|
python3 scripts/latex_render.py <project_path> --providers codecogs,quicklatex,mathpad,wikimedia
|
||||||
|
```
|
||||||
|
|
||||||
|
Manifest shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": ["codecogs", "quicklatex", "mathpad", "wikimedia"],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "formula_001",
|
||||||
|
"latex": "E = mc^2",
|
||||||
|
"display": "block",
|
||||||
|
"color": "#1D1D1F",
|
||||||
|
"background": "#FFFFFF",
|
||||||
|
"transparent": true,
|
||||||
|
"dpi": 300,
|
||||||
|
"filename": "formula_001.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output files land directly under `project/images/`. Formula filenames should use a shared `formula_` prefix, e.g. `formula_001.png`. The default provider chain is `codecogs,quicklatex,mathpad,wikimedia`; each provider is tried automatically until one succeeds, and the winning provider is recorded back into the manifest. `--providers` or manifest-level `providers` may override the order, but all four are available as no-key fallbacks. Formula PNGs are transparent by default. `background` is the temporary render matte and local background-removal reference; set `transparent: false` only when an opaque final formula asset is intentional. The script does not scan `spec_lock.md` or source documents for `$...$`; formula selection belongs to the active resource owner.
|
||||||
|
|
||||||
|
## `image_gen.py`
|
||||||
|
|
||||||
|
Unified image generation entry point.
|
||||||
|
|
||||||
|
This script is the **Path A** API/proxy executor for generated images. Default
|
||||||
|
Generate checks `design_spec.md §I / AI Image Acquisition Path` before manifest
|
||||||
|
mode: only `api` / `auto` permits Path A; a missing or unknown value fails
|
||||||
|
closed and returns to Step 4 recovery. Quick Generate has no Design Spec: use
|
||||||
|
the explicit active-context path when supplied, otherwise `auto` selects the
|
||||||
|
A → B → C chain defined in
|
||||||
|
[`image-generator.md`](../../references/image-generator.md) §7 without asking.
|
||||||
|
In either profile, `host-native` uses the host image tool directly and `manual`
|
||||||
|
uses the read-only Markdown sidecar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_gen.py "A modern futuristic workspace"
|
||||||
|
python3 scripts/image_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K
|
||||||
|
python3 scripts/image_gen.py "Concept car" -o projects/demo/images
|
||||||
|
python3 scripts/image_gen.py --list-backends
|
||||||
|
```
|
||||||
|
|
||||||
|
Backends are grouped into Core / Extended / Experimental tiers. Run `python3 scripts/image_gen.py --list-backends` for the current list.
|
||||||
|
|
||||||
|
Backend selection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_gen.py "A cat" --backend openai
|
||||||
|
python3 scripts/image_gen.py "A cinematic portrait" --backend minimax
|
||||||
|
python3 scripts/image_gen.py "A product launch hero image" --backend qwen
|
||||||
|
python3 scripts/image_gen.py "科技感背景图" --backend zhipu
|
||||||
|
python3 scripts/image_gen.py "A product KV in cinematic style" --backend volcengine
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration sources:
|
||||||
|
|
||||||
|
1. Current process environment variables
|
||||||
|
2. First `.env` found in this order:
|
||||||
|
- Current working directory
|
||||||
|
- Skill directory (e.g. `~/.agents/skills/ppt-master/.env`)
|
||||||
|
- Clone repo root
|
||||||
|
- `~/.ppt-master/.env`
|
||||||
|
|
||||||
|
The active backend must always be selected explicitly via `IMAGE_BACKEND`.
|
||||||
|
|
||||||
|
Example `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
IMAGE_BACKEND=openai
|
||||||
|
OPENAI_API_KEY=sk-xxx
|
||||||
|
OPENAI_MODEL=gpt-image-2
|
||||||
|
# Optional proxy
|
||||||
|
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1
|
||||||
|
# OpenAI-compatible provider knobs:
|
||||||
|
# OPENAI_SIZE_PRESET=auto
|
||||||
|
# OPENAI_RESPONSE_FORMAT=auto
|
||||||
|
# OPENAI_QUALITY=auto
|
||||||
|
# Allowed values: png / jpeg / webp
|
||||||
|
# OPENAI_OUTPUT_FORMAT=png
|
||||||
|
# jpeg/webp only, 0-100
|
||||||
|
# OPENAI_OUTPUT_COMPRESSION=80
|
||||||
|
# gpt-image-2: auto / opaque
|
||||||
|
# OPENAI_BACKGROUND=auto
|
||||||
|
# auto / low
|
||||||
|
# OPENAI_MODERATION=auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Example process environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export IMAGE_BACKEND=openai
|
||||||
|
export OPENAI_API_KEY=sk-xxx
|
||||||
|
export OPENAI_MODEL=gpt-image-2
|
||||||
|
export OPENAI_OUTPUT_FORMAT=png
|
||||||
|
```
|
||||||
|
|
||||||
|
Current process environment wins over `.env`.
|
||||||
|
|
||||||
|
OpenAI backend notes:
|
||||||
|
- `gpt-image-2` is the default OpenAI model.
|
||||||
|
- Requests are sent with plain `requests.post()` to improve compatibility with
|
||||||
|
OpenAI-compatible proxies that block the OpenAI SDK's `httpx` transport.
|
||||||
|
- For `gpt-image-2`, `image_size=512px` means a low-quality draft preset, not a literal 512px edge. The model requires both edges to be multiples of 16px, a long:short ratio no greater than 3:1, and total pixels between 655,360 and 8,294,400.
|
||||||
|
- `OPENAI_BACKGROUND=transparent` is not supported by `gpt-image-2`; use `auto` or `opaque`.
|
||||||
|
- If `OPENAI_OUTPUT_FORMAT=jpeg` or `webp`, generated files use `.jpg` or `.webp` extensions instead of `.png`.
|
||||||
|
- OpenAI-compatible providers that reject OpenAI-specific fields can use `OPENAI_RESPONSE_FORMAT=omit`, `OPENAI_QUALITY=omit`, and `OPENAI_SIZE_PRESET=<preset>`. Valid response formats are `auto`, `b64_json`, `url`, and `omit`; valid size presets are `auto`, `legacy`, `gpt-image`, `gpt-image-2`, and `dall-e-2`.
|
||||||
|
|
||||||
|
Example `.env` for Agnes AI through the OpenAI-compatible backend:
|
||||||
|
|
||||||
|
```env
|
||||||
|
IMAGE_BACKEND=openai
|
||||||
|
OPENAI_API_KEY=your-agnes-key
|
||||||
|
OPENAI_MODEL=agnes-image-2.1-flash
|
||||||
|
OPENAI_BASE_URL=https://apihub.agnes-ai.com/v1
|
||||||
|
OPENAI_SIZE_PRESET=gpt-image-2
|
||||||
|
OPENAI_RESPONSE_FORMAT=omit
|
||||||
|
OPENAI_QUALITY=omit
|
||||||
|
```
|
||||||
|
|
||||||
|
Use provider-specific keys only (e.g. `GEMINI_API_KEY`, `OPENAI_API_KEY`). See `.env.example` in clone mode or `${SKILL_DIR}/.env.example` in skill-install mode for the full list per backend.
|
||||||
|
|
||||||
|
`IMAGE_API_KEY`, `IMAGE_MODEL`, and `IMAGE_BASE_URL` are intentionally unsupported.
|
||||||
|
|
||||||
|
If you keep multiple providers in one `.env` or environment, `IMAGE_BACKEND` must explicitly select the active provider.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
- Default to the Core tier for routine PPT work
|
||||||
|
- Use Extended only when you need a specific model style
|
||||||
|
- Treat Experimental backends as opt-in
|
||||||
|
|
||||||
|
Example `.env` for MiniMax image backend:
|
||||||
|
|
||||||
|
```env
|
||||||
|
IMAGE_BACKEND=minimax
|
||||||
|
MINIMAX_API_KEY=your-api-key
|
||||||
|
# Optional: override base URL (defaults to https://api.minimaxi.com, domestic China endpoint)
|
||||||
|
# Use https://api.minimax.io for overseas access
|
||||||
|
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||||
|
# MINIMAX_MODEL=image-01
|
||||||
|
```
|
||||||
|
|
||||||
|
## `image_treat.py`
|
||||||
|
|
||||||
|
Create a non-destructive PNG derivative from one bitmap already prepared under
|
||||||
|
`<project_path>/images/`. Use this only when a slide needs a baked bitmap effect;
|
||||||
|
crop, mask, rotation, mirror, opacity, shadow, scrim, outline, and overlap remain
|
||||||
|
native SVG/PPT treatments. This tool does not perform semantic background
|
||||||
|
removal: use `slice_images.py --alpha` for flat-color keys, an already prepared
|
||||||
|
RGBA asset or the active host image editor for a standalone cutout, and
|
||||||
|
[`image-generator.md`](../../references/image-generator.md) §4.4 only for
|
||||||
|
registered subject/base layers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_treat.py projects/demo hero.jpg \
|
||||||
|
--output hero_soft.png --brightness 0.9 --contrast 1.1 --blur 12
|
||||||
|
|
||||||
|
python3 scripts/image_treat.py projects/demo hero.jpg \
|
||||||
|
--output hero_duotone.png --duotone "#14213D" "#FCA311"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported operations are brightness, contrast, desaturation/grayscale,
|
||||||
|
duotone, and Gaussian blur. They compose in a fixed order: brightness →
|
||||||
|
contrast → tone treatment → blur. Desaturation, grayscale, and duotone are
|
||||||
|
mutually exclusive. At least one option must produce a real change; animated
|
||||||
|
or multi-frame sources are rejected rather than reduced to one frame.
|
||||||
|
|
||||||
|
Both input and output are bare filenames directly under `images/`; output must
|
||||||
|
be a new `.png` file. The tool keeps the EXIF-corrected display dimensions,
|
||||||
|
leaves any alpha mask unchanged, and never overwrites the source or an existing
|
||||||
|
derivative. If `images/image_sources.json` contains the source filename, the
|
||||||
|
new record inherits that legal provenance and records `derived_from` plus the
|
||||||
|
ordered `treatments`. Run `analyze_images.py` after all planned derivatives are
|
||||||
|
ready so the inventory reflects the files that SVG authoring will consume.
|
||||||
|
|
||||||
|
## `analyze_images.py`
|
||||||
|
|
||||||
|
Analyze objective image-file facts in a project directory before writing the
|
||||||
|
design spec or authoring SVG.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/analyze_images.py <project_path>/images
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool does not resolve a canvas or recommend a left/right, top/bottom, or
|
||||||
|
other slide layout. Its atomic CSV records EXIF-corrected native dimensions and
|
||||||
|
`AspectRatio`, the objective aspect-ratio category, optional source
|
||||||
|
`SourceDisplayRatio`, format, actual transparent-pixel presence, usage count,
|
||||||
|
and bitmap/vector capability facts. An empty folder rewrites a header-only
|
||||||
|
report; unreadable supported files still refresh the report and produce a
|
||||||
|
non-zero exit.
|
||||||
|
|
||||||
|
Use this as the default factual inventory; it does not perform semantic image
|
||||||
|
understanding or choose composition. Generate planning follows the Strategist's
|
||||||
|
context-first boundary: source context, captions / alt text / titles, filenames,
|
||||||
|
user notes, and existing resource records come first. Only an already-selected
|
||||||
|
provided/web asset whose focal-safe crop, overlay contrast, or quiet region
|
||||||
|
remains materially ambiguous may be inspected for that placement; this never
|
||||||
|
reopens selection or provenance, never bulk-opens the image folder, and never
|
||||||
|
restores routine readback of AI-generated images.
|
||||||
|
|
||||||
|
## `image_search.py`
|
||||||
|
|
||||||
|
Zero-config web image search across openly-licensed providers. Sister tool to `image_gen.py` — used when the resource list row has `Acquire Via: web`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_search.py "offshore wind farm" \
|
||||||
|
--filename cover_bg.jpg --slide 01_cover \
|
||||||
|
--orientation landscape -o projects/demo/images
|
||||||
|
```
|
||||||
|
|
||||||
|
For multiple web rows, `--batch images/image_queries.json` searches them concurrently (modest default, `--concurrency N` / `IMAGE_SEARCH_CONCURRENCY` to tune) instead of one call per row — the web sister of `image_gen.py --manifest`. Schema and status semantics: [`image-searcher.md`](../../references/image-searcher.md) §5.
|
||||||
|
|
||||||
|
Providers (Pexels / Pixabay are tried first when keyed; Openverse and Wikimedia are zero-config fallbacks):
|
||||||
|
|
||||||
|
| Provider | Config | Strength |
|
||||||
|
|---|---|---|
|
||||||
|
| `pexels` | recommended: `PEXELS_API_KEY` | modern stock photography, people, workplace, lifestyle |
|
||||||
|
| `pixabay` | recommended: `PIXABAY_API_KEY` | broad type coverage including photos and illustrations |
|
||||||
|
| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
|
||||||
|
| `wikimedia` | zero-config | educational, scientific, geographic, historical |
|
||||||
|
|
||||||
|
Default search chain (when `--provider` is unset): configured Pexels, configured Pixabay, Openverse, then Wikimedia. Missing keyed credentials are silently skipped. Keyed providers broaden stock-photo coverage but are optional; zero-config providers remain valid.
|
||||||
|
|
||||||
|
`image_search.py` uses the same `.env` lookup order as `image_gen.py`, so skill installs can keep `PEXELS_API_KEY` / `PIXABAY_API_KEY` in `~/.ppt-master/.env`.
|
||||||
|
|
||||||
|
Query guidance:
|
||||||
|
|
||||||
|
Keep the Design Spec §VIII `Reference` as the full visual/crop intent; write a separate concise provider query for this CLI. Start with the shortest phrase that preserves identity, but retain exact multi-word names and necessary disambiguators beyond four words.
|
||||||
|
|
||||||
|
| Case | Pattern |
|
||||||
|
|---|---|
|
||||||
|
| Generic stock concept | `boardroom meeting` |
|
||||||
|
| China-specific landmark | Precise official place/identity name plus necessary geography |
|
||||||
|
| Avoid | Negative prompt wording such as `not tourist snapshot` |
|
||||||
|
|
||||||
|
License filter:
|
||||||
|
|
||||||
|
- **Default**: search all providers with `cc0,pdm,pexels,pixabay,cc by,cc by-sa` allowed together. The chosen image may be `no-attribution` or `attribution-required`; Executor adds an inline credit only when needed.
|
||||||
|
- `--strict-no-attribution` restricts the search to `cc0,pdm,pexels,pixabay` — useful for full-bleed hero images or templates that cannot host a credit element.
|
||||||
|
|
||||||
|
Pin a provider, refuse attribution, or override the manifest path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pin Wikimedia
|
||||||
|
python3 scripts/image_search.py "Olympics opening ceremony" \
|
||||||
|
--filename event.jpg --provider wikimedia \
|
||||||
|
--orientation landscape -o projects/demo/images
|
||||||
|
|
||||||
|
# Strict mode — refuse CC BY / CC BY-SA
|
||||||
|
python3 scripts/image_search.py "abstract gradient" \
|
||||||
|
--filename hero.jpg --strict-no-attribution \
|
||||||
|
-o projects/demo/images
|
||||||
|
```
|
||||||
|
|
||||||
|
Suitability & manual replacement (a web top hit is metadata-relevant, not guaranteed visually right):
|
||||||
|
|
||||||
|
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution).
|
||||||
|
- For exact subjects (landmarks, people, companies, products), use `--require-terms` or batch `required_terms` so visually plausible but wrong metadata is rejected before ranking. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"`. Keep proper-name / geography anchors; do not broaden to generic terms like `canyon`, `stone pillar`, or `ancient town` just to improve coverage.
|
||||||
|
- `--save-candidates` (with `--max-candidates`, default 4) keeps an opt-in escalation pool under `candidates/<stem>/`; review it, then `--promote candidate_03.jpg --filename <name>.jpg`.
|
||||||
|
- `--from-url <url> --filename <name>.jpg` downloads a user-chosen image URL and replaces the target (recorded `license_tier: manual`) — the model-agnostic manual path; works even without a multimodal model.
|
||||||
|
|
||||||
|
Full review / escalation flow: [`image-searcher.md`](../../references/image-searcher.md) §5.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
|
||||||
|
- `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text)
|
||||||
|
- Manifest is idempotent on `filename` and written atomically; damaged existing provenance blocks replacement
|
||||||
|
|
||||||
|
Allowed licenses (default): CC0, Public Domain, Pexels License, Pixabay Content License, CC BY, CC BY-SA. Auto-rejected: CC BY-NC, CC BY-ND, CC BY-NC-SA, CC BY-NC-ND, all rights reserved, unknown.
|
||||||
|
|
||||||
|
The full role-level reference (intent → query translation, on-slide attribution contract) is in [`references/image-searcher.md`](../../references/image-searcher.md).
|
||||||
|
|
||||||
|
## `gemini_watermark_remover.py`
|
||||||
|
|
||||||
|
Remove Gemini watermark assets after manual download.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gemini_watermark_remover.py <image_path>
|
||||||
|
python3 scripts/gemini_watermark_remover.py <image_path> -o output_path.png
|
||||||
|
python3 scripts/gemini_watermark_remover.py <image_path> -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Requires `scripts/assets/bg_48.png` and `scripts/assets/bg_96.png`
|
||||||
|
- Best used after downloading “full size” Gemini images
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install Pillow numpy
|
||||||
|
```
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Mask and Gradient Maintenance Smoke
|
||||||
|
|
||||||
|
Run this manual smoke from the repository root after changing gradient
|
||||||
|
validation, gradient import/export, native background promotion, icon
|
||||||
|
expansion, or mask rejection. It keeps XML in memory except for temporary SVG
|
||||||
|
fixtures; do not turn it into a test framework or example deck.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
scripts = Path("skills/ppt-master/scripts").resolve()
|
||||||
|
sys.path.insert(0, str(scripts))
|
||||||
|
|
||||||
|
from pptx_to_svg.color_resolver import ColorPalette
|
||||||
|
from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints, resolve_fill
|
||||||
|
from svg_quality_checker import SVGQualityChecker
|
||||||
|
from svg_to_pptx.drawingml.converter import (
|
||||||
|
SvgNativeConversionError,
|
||||||
|
convert_svg_to_slide_shapes,
|
||||||
|
)
|
||||||
|
from svg_to_pptx.drawingml.styles import build_gradient_fill
|
||||||
|
from svg_to_pptx.drawingml.utils import (
|
||||||
|
parse_project_linear_gradient_coordinate,
|
||||||
|
project_gradient_errors,
|
||||||
|
project_mask_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
SVG_NS = "http://www.w3.org/2000/svg"
|
||||||
|
|
||||||
|
|
||||||
|
def svg(fragment):
|
||||||
|
return ET.fromstring(
|
||||||
|
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" '
|
||||||
|
f'data-pptx-page-role="content">{fragment}</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
valid = svg(
|
||||||
|
"""<defs>
|
||||||
|
<linearGradient id="linear">
|
||||||
|
<stop offset="0" stop-color="#2563EB"/>
|
||||||
|
<stop offset="100%" stop-color="#F97316" stop-opacity="0.4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="radial" cx="0.25" cy="0.7" r="0.8">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#0F172A"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect id="bg" x="0" y="0" width="1280" height="720"
|
||||||
|
fill="url(#linear)"/>"""
|
||||||
|
)
|
||||||
|
assert not project_gradient_errors(valid)
|
||||||
|
linear, radial = list(valid.find(f"{{{SVG_NS}}}defs"))
|
||||||
|
linear_xml = build_gradient_fill(linear)
|
||||||
|
radial_xml = build_gradient_fill(radial)
|
||||||
|
assert '<a:lin ang="0" scaled="1"/>' in linear_xml
|
||||||
|
assert '<a:alpha val="40000"/>' in linear_xml
|
||||||
|
assert '<a:path path="circle">' in radial_xml
|
||||||
|
assert (
|
||||||
|
'<a:fillToRect l="25000" t="70000" r="75000" b="30000"/>'
|
||||||
|
in radial_xml
|
||||||
|
)
|
||||||
|
radial.set("fx", "0.8")
|
||||||
|
radial.set("fy", "0.2")
|
||||||
|
focused_xml = build_gradient_fill(radial)
|
||||||
|
assert (
|
||||||
|
'<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>'
|
||||||
|
in focused_xml
|
||||||
|
)
|
||||||
|
native_gradient = ET.fromstring(
|
||||||
|
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
|
||||||
|
f'{focused_xml}</root>'
|
||||||
|
)[0]
|
||||||
|
restored = resolve_fill(native_gradient, None)
|
||||||
|
assert 'cx="0.5" cy="0.5" r="0.5" fx="0.8" fy="0.2"' in restored.defs[0]
|
||||||
|
assert '<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>' in (
|
||||||
|
build_gradient_fill(ET.fromstring(restored.defs[0]))
|
||||||
|
)
|
||||||
|
radial.set("fx", "0")
|
||||||
|
radial.set("fy", "0")
|
||||||
|
outside_focus_errors = project_gradient_errors(valid)
|
||||||
|
assert any(
|
||||||
|
"must lie within the canonical circle" in error
|
||||||
|
for error in outside_focus_errors
|
||||||
|
), outside_focus_errors
|
||||||
|
try:
|
||||||
|
build_gradient_fill(radial)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "must lie within the canonical circle" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("outside radial focus reached DrawingML")
|
||||||
|
radial.set("fx", "0.8")
|
||||||
|
radial.set("fy", "0.2")
|
||||||
|
|
||||||
|
diagnostics = []
|
||||||
|
palette = ColorPalette(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
strict=False,
|
||||||
|
diagnostic_sink=lambda code, message, fallback: diagnostics.append(
|
||||||
|
(code, message, fallback)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outside_native_xml = focused_xml.replace(
|
||||||
|
'l="80000" t="20000" r="20000" b="80000"',
|
||||||
|
'l="0" t="0" r="100000" b="100000"',
|
||||||
|
)
|
||||||
|
outside_native_gradient = ET.fromstring(
|
||||||
|
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
|
||||||
|
f"{outside_native_xml}</root>"
|
||||||
|
)[0]
|
||||||
|
normalized = resolve_fill(outside_native_gradient, palette)
|
||||||
|
assert " fx=" not in normalized.defs[0]
|
||||||
|
assert " fy=" not in normalized.defs[0]
|
||||||
|
assert any(
|
||||||
|
code == "path-gradient-focus-normalized"
|
||||||
|
for code, _message, _fallback in diagnostics
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp:
|
||||||
|
source = Path(tmp) / "gradient.svg"
|
||||||
|
source.write_text(
|
||||||
|
ET.tostring(valid, encoding="unicode"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
trace = []
|
||||||
|
slide_xml, *_ = convert_svg_to_slide_shapes(source, trace_out=trace)
|
||||||
|
assert slide_xml.count("<p:bg>") == 1
|
||||||
|
assert "<a:gradFill>" in slide_xml
|
||||||
|
assert trace[0]["summary"]["promoted_backgrounds"] == 1
|
||||||
|
assert any(
|
||||||
|
event.get("decision") == "native-background"
|
||||||
|
for event in trace[0]["events"]
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid_gradients = [
|
||||||
|
(
|
||||||
|
"""<linearGradient id="single">
|
||||||
|
<stop offset="0" stop-color="#2563EB"/>
|
||||||
|
</linearGradient>""",
|
||||||
|
"requires at least two direct <stop> children",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"""<linearGradient id="descending">
|
||||||
|
<stop offset="1" stop-color="#2563EB"/>
|
||||||
|
<stop offset="0" stop-color="#F97316"/>
|
||||||
|
</linearGradient>""",
|
||||||
|
"offsets must be non-decreasing",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"""<linearGradient id="zero" x1="0.5" y1="0.5" x2="0.5" y2="0.5">
|
||||||
|
<stop offset="0" stop-color="#2563EB"/>
|
||||||
|
<stop offset="1" stop-color="#F97316"/>
|
||||||
|
</linearGradient>""",
|
||||||
|
"linear gradient axis must not collapse to one point",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for definition, expected in invalid_gradients:
|
||||||
|
errors = project_gradient_errors(svg(f"<defs>{definition}</defs>"))
|
||||||
|
assert any(expected in error for error in errors), errors
|
||||||
|
|
||||||
|
mask_cases = [
|
||||||
|
"""<defs><mask id="fade"><rect width="1" height="1"/></mask></defs>""",
|
||||||
|
"""<rect width="100" height="100" mask="url(#fade)"/>""",
|
||||||
|
"""<rect width="100" height="100" style="mask: url(#fade)"/>""",
|
||||||
|
]
|
||||||
|
for fragment in mask_cases:
|
||||||
|
errors = project_mask_errors(svg(fragment))
|
||||||
|
assert any("unsupported SVG mask" in error for error in errors), errors
|
||||||
|
|
||||||
|
checker = SVGQualityChecker()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ppt-master-mask-smoke-") as tmp:
|
||||||
|
for index, fragment in enumerate(mask_cases, start=1):
|
||||||
|
source = Path(tmp) / f"mask-{index}.svg"
|
||||||
|
source.write_text(
|
||||||
|
ET.tostring(svg(fragment), encoding="unicode"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
checked = checker.check_file(str(source))
|
||||||
|
assert any("mask" in error.lower() for error in checked["errors"])
|
||||||
|
try:
|
||||||
|
convert_svg_to_slide_shapes(source)
|
||||||
|
except SvgNativeConversionError as exc:
|
||||||
|
assert "invalid project mask" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"native export accepted {source.name}")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ppt-master-icon-mask-smoke-") as tmp:
|
||||||
|
project = Path(tmp)
|
||||||
|
icon_dir = project / "icons" / "imported"
|
||||||
|
icon_dir.mkdir(parents=True)
|
||||||
|
(icon_dir / "masked.svg").write_text(
|
||||||
|
f"""<svg xmlns="{SVG_NS}" viewBox="0 0 24 24">
|
||||||
|
<defs>
|
||||||
|
<mask id="fade">
|
||||||
|
<rect x="0" y="0" width="24" height="24" fill="#FFFFFF"/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="24" height="24"
|
||||||
|
fill="#000000" mask="url(#fade)"/>
|
||||||
|
</svg>""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
source = project / "icon-mask.svg"
|
||||||
|
source.write_text(
|
||||||
|
ET.tostring(
|
||||||
|
svg(
|
||||||
|
"""<use data-icon="imported/masked"
|
||||||
|
x="20" y="20" width="24" height="24"/>"""
|
||||||
|
),
|
||||||
|
encoding="unicode",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
checked = checker.check_file(str(source))
|
||||||
|
assert any(
|
||||||
|
"Icon imported/masked" in error and "mask" in error.lower()
|
||||||
|
for error in checked["errors"]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
convert_svg_to_slide_shapes(source)
|
||||||
|
except SvgNativeConversionError as exc:
|
||||||
|
assert "invalid project mask" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("native export accepted a masked icon")
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = _angle_to_unit_endpoints(30)
|
||||||
|
assert any(value < 0 or value > 1 for value in (x1, y1, x2, y2))
|
||||||
|
for value in (x1, y1, x2, y2):
|
||||||
|
assert math.isclose(
|
||||||
|
parse_project_linear_gradient_coordinate(str(value)),
|
||||||
|
value,
|
||||||
|
abs_tol=1e-9,
|
||||||
|
)
|
||||||
|
roundtrip = svg(
|
||||||
|
f"""<defs>
|
||||||
|
<linearGradient id="roundtrip"
|
||||||
|
x1="{x1:.9f}" y1="{y1:.9f}" x2="{x2:.9f}" y2="{y2:.9f}">
|
||||||
|
<stop offset="0" stop-color="#2563EB"/>
|
||||||
|
<stop offset="1" stop-color="#F97316"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>"""
|
||||||
|
)
|
||||||
|
assert not project_gradient_errors(roundtrip)
|
||||||
|
roundtrip_gradient = roundtrip.find(
|
||||||
|
f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient"
|
||||||
|
)
|
||||||
|
angle = int(
|
||||||
|
re.search(
|
||||||
|
r'<a:lin ang="(\d+)"',
|
||||||
|
build_gradient_fill(roundtrip_gradient),
|
||||||
|
).group(1)
|
||||||
|
) / 60000
|
||||||
|
assert math.isclose(angle, 30, abs_tol=0.01), angle
|
||||||
|
|
||||||
|
print("Mask and gradient smoke: passed")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
The three invalid-gradient cases, the outside-circle radial focus, all three
|
||||||
|
direct mask forms, and a mask hidden inside a `data-icon` asset must produce
|
||||||
|
the named shared-validator errors in both Checker and direct export. The legal
|
||||||
|
cases must retain stop alpha, round-trip an in-circle focus, center an imported
|
||||||
|
outside-circle focus with a diagnostic, promote the full-canvas gradient to
|
||||||
|
one native `p:bg`, default an unpositioned linear gradient to horizontal, and
|
||||||
|
recover approximately 30 degrees from the importer's out-of-unit-box endpoint
|
||||||
|
form.
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Multilingual Text Maintenance Smoke
|
||||||
|
|
||||||
|
Run this manual smoke from the repository root after changing Confirm UI
|
||||||
|
language handling, DrawingML text export, native tables/charts, notes, or
|
||||||
|
document metadata. It uses only temporary files and does not add an automated
|
||||||
|
test suite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH="skills/ppt-master/scripts:skills/ppt-master/scripts/confirm_ui" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from pptx import Presentation
|
||||||
|
|
||||||
|
from confirm_ui.server import create_app
|
||||||
|
from language_tags import (
|
||||||
|
LanguageTagError,
|
||||||
|
language_uses_rtl,
|
||||||
|
normalize_language_tag,
|
||||||
|
)
|
||||||
|
from svg_to_pptx.drawingml.context import ConvertContext
|
||||||
|
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
|
||||||
|
from svg_to_pptx.native_objects import _build_native_chart
|
||||||
|
from svg_to_pptx.native_objects.table import _build_native_table
|
||||||
|
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg
|
||||||
|
from svg_to_pptx.pptx_package.cli import _declared_primary_language
|
||||||
|
from svg_to_pptx.pptx_package.notes import create_notes_slide_xml
|
||||||
|
|
||||||
|
|
||||||
|
def reject_language(value):
|
||||||
|
try:
|
||||||
|
normalize_language_tag(value)
|
||||||
|
except LanguageTagError:
|
||||||
|
return
|
||||||
|
raise AssertionError(f"invalid language accepted: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
canonical = {
|
||||||
|
"ES_mx": "es-MX",
|
||||||
|
"RU_ru": "ru-RU",
|
||||||
|
"AR_sa": "ar-SA",
|
||||||
|
"HE_il": "he-IL",
|
||||||
|
"HI_in": "hi-IN",
|
||||||
|
"TH_th": "th-TH",
|
||||||
|
"KO_kr": "ko-KR",
|
||||||
|
"fil_ph": "fil-PH",
|
||||||
|
"zh_hans": "zh-Hans",
|
||||||
|
"de-CH-1901": "de-CH-1901",
|
||||||
|
"en-u-nu-latn": "en-u-nu-latn",
|
||||||
|
}
|
||||||
|
for raw, expected in canonical.items():
|
||||||
|
assert normalize_language_tag(raw) == expected
|
||||||
|
for raw in ("und", "zh", "en--US", "Arabic", "x-private"):
|
||||||
|
reject_language(raw)
|
||||||
|
assert language_uses_rtl("ar-Arab-SA")
|
||||||
|
assert not language_uses_rtl("ar-Latn-SA")
|
||||||
|
assert language_uses_rtl("en-Arab-US")
|
||||||
|
|
||||||
|
samples = {
|
||||||
|
"en-US": "Summary 2026",
|
||||||
|
"zh-Hans": "年度总结 2026",
|
||||||
|
"ja-JP": "年間まとめ 2026",
|
||||||
|
"ko-KR": "연간 요약 2026",
|
||||||
|
"es-ES": "Resumen 2026",
|
||||||
|
"ru-RU": "Итоги 2026",
|
||||||
|
"ar-SA": "ملخص 2026",
|
||||||
|
"he-IL": "סיכום 2026",
|
||||||
|
"hi-IN": "सारांश 2026",
|
||||||
|
"th-TH": "สรุป 2026",
|
||||||
|
}
|
||||||
|
rtl_languages = {"ar-SA", "he-IL"}
|
||||||
|
|
||||||
|
with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
|
||||||
|
# Confirm UI canonicalizes Stage 1 and persists the same project language.
|
||||||
|
project = root / "confirm-project"
|
||||||
|
confirm = project / "confirm_ui"
|
||||||
|
confirm.mkdir(parents=True)
|
||||||
|
recommendation = {
|
||||||
|
"stage": "stage1",
|
||||||
|
"lang": "en",
|
||||||
|
"primary_language": "AR_sa",
|
||||||
|
"audience": {"value": "Team"},
|
||||||
|
"communication_intent": {"value": "Explain"},
|
||||||
|
"audience_outcome": {"value": "Understand"},
|
||||||
|
"core_message": {"value": "Result"},
|
||||||
|
"delivery_context": {"value": "Meeting"},
|
||||||
|
"artifact_afterlife": {"value": ""},
|
||||||
|
"content_divergence": {"value": ""},
|
||||||
|
"recommend": {"canvas": "ppt169"},
|
||||||
|
}
|
||||||
|
(confirm / "recommendations.stage1.json").write_text(
|
||||||
|
json.dumps(recommendation),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
app = create_app(str(project), idle_timeout=0)
|
||||||
|
app.testing = True
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.get("/api/recommendations")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()["primary_language"] == "ar-SA"
|
||||||
|
response = client.post(
|
||||||
|
"/api/confirm",
|
||||||
|
json={
|
||||||
|
"stage": "stage1",
|
||||||
|
"primary_language": "ar-SA",
|
||||||
|
"canvas": "ppt169",
|
||||||
|
"audience": "Team",
|
||||||
|
"communication_intent": "Explain",
|
||||||
|
"audience_outcome": "Understand",
|
||||||
|
"core_message": "Result",
|
||||||
|
"delivery_context": "Meeting",
|
||||||
|
"artifact_afterlife": "",
|
||||||
|
"content_divergence": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = json.loads((confirm / "result.json").read_text(encoding="utf-8"))
|
||||||
|
assert result["primary_language"] == "ar-SA"
|
||||||
|
|
||||||
|
# The execution lock is the only export-time project-language source.
|
||||||
|
lock_project = root / "lock-project"
|
||||||
|
lock_project.mkdir()
|
||||||
|
lock_template = """# Execution Lock
|
||||||
|
|
||||||
|
## communication
|
||||||
|
{language}- audience: team
|
||||||
|
- objective: explain
|
||||||
|
- core_message: result
|
||||||
|
"""
|
||||||
|
lock = lock_project / "spec_lock.md"
|
||||||
|
lock.write_text(
|
||||||
|
lock_template.format(language="- primary_language: ES_mx\n"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert _declared_primary_language(lock_project) == "es-MX"
|
||||||
|
lock.write_text(lock_template.format(language=""), encoding="utf-8")
|
||||||
|
assert _declared_primary_language(lock_project) is None
|
||||||
|
|
||||||
|
for index, (language, sample) in enumerate(samples.items(), 1):
|
||||||
|
stem = f"slide-{index}"
|
||||||
|
svg = root / f"{stem}.svg"
|
||||||
|
svg.write_text(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
'viewBox="0 0 1280 720">'
|
||||||
|
'<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>'
|
||||||
|
f'<text x="100" y="140" font-size="42" '
|
||||||
|
f'font-family="Arial" fill="#111111">{sample}</text>'
|
||||||
|
"</svg>",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
slide_xml, *_ = convert_svg_to_slide_shapes(
|
||||||
|
svg,
|
||||||
|
index,
|
||||||
|
verbose=False,
|
||||||
|
primary_language=language,
|
||||||
|
)
|
||||||
|
assert f'lang="{language}"' in slide_xml
|
||||||
|
assert ("rtl=\"1\"" in slide_xml) == (language in rtl_languages)
|
||||||
|
assert ("<a:rtl val=\"1\"/>" in slide_xml) == (
|
||||||
|
language in rtl_languages
|
||||||
|
)
|
||||||
|
|
||||||
|
ascii_svg = root / f"{stem}-ascii.svg"
|
||||||
|
ascii_svg.write_text(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
'viewBox="0 0 1280 720">'
|
||||||
|
'<text x="100" y="140" font-size="42">2026 AI</text>'
|
||||||
|
"</svg>",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ascii_xml, *_ = convert_svg_to_slide_shapes(
|
||||||
|
ascii_svg,
|
||||||
|
index + 20,
|
||||||
|
verbose=False,
|
||||||
|
primary_language=language,
|
||||||
|
)
|
||||||
|
assert f'lang="{language}"' in ascii_xml
|
||||||
|
assert 'rtl="1"' not in ascii_xml
|
||||||
|
assert "<a:rtl val=\"1\"/>" not in ascii_xml
|
||||||
|
|
||||||
|
context = ConvertContext(primary_language=language)
|
||||||
|
marker = ET.Element("g")
|
||||||
|
table = _build_native_table(
|
||||||
|
marker,
|
||||||
|
context,
|
||||||
|
{
|
||||||
|
"x": 10,
|
||||||
|
"y": 10,
|
||||||
|
"width": 600,
|
||||||
|
"height": 180,
|
||||||
|
"columns": [sample, "2026 AI"],
|
||||||
|
"rows": [["A", "B"]],
|
||||||
|
"style": {"font_family": "Arial"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert f'lang="{language}"' in table.xml
|
||||||
|
assert all(slot in table.xml for slot in ("<a:latin ", "<a:ea ", "<a:cs "))
|
||||||
|
|
||||||
|
chart_context = ConvertContext(primary_language=language)
|
||||||
|
_build_native_chart(
|
||||||
|
marker,
|
||||||
|
chart_context,
|
||||||
|
{
|
||||||
|
"x": 10,
|
||||||
|
"y": 220,
|
||||||
|
"width": 600,
|
||||||
|
"height": 300,
|
||||||
|
"type": "column",
|
||||||
|
"title": sample,
|
||||||
|
"categories": ["A", "B"],
|
||||||
|
"series": [{"name": sample, "values": [1, 2]}],
|
||||||
|
"style": {"font_family": "Arial"},
|
||||||
|
"show_legend": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
chart_xml = next(
|
||||||
|
value.decode("utf-8")
|
||||||
|
for part, value in chart_context.package_files.items()
|
||||||
|
if part.startswith("ppt/charts/chart") and not part.endswith(".rels")
|
||||||
|
)
|
||||||
|
assert f'<c:lang val="{language}"/>' in chart_xml
|
||||||
|
assert f'lang="{language}"' in chart_xml
|
||||||
|
|
||||||
|
notes_xml = create_notes_slide_xml(
|
||||||
|
1,
|
||||||
|
sample + "\n2026 AI",
|
||||||
|
language,
|
||||||
|
)
|
||||||
|
assert f'lang="{language}"' in notes_xml
|
||||||
|
assert ("rtl=\"1\"" in notes_xml) == (language in rtl_languages)
|
||||||
|
|
||||||
|
output = root / f"{index}.pptx"
|
||||||
|
assert create_pptx_with_native_svg(
|
||||||
|
[svg],
|
||||||
|
output,
|
||||||
|
canvas_format="ppt169",
|
||||||
|
verbose=False,
|
||||||
|
transition=None,
|
||||||
|
notes={stem: sample + "\n2026 AI"},
|
||||||
|
pptx_structure="flat",
|
||||||
|
structure_name="multilingual-smoke",
|
||||||
|
primary_language=language,
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(output) as archive:
|
||||||
|
assert archive.testzip() is None
|
||||||
|
packaged_slide = archive.read(
|
||||||
|
"ppt/slides/slide1.xml"
|
||||||
|
).decode("utf-8")
|
||||||
|
packaged_notes = archive.read(
|
||||||
|
"ppt/notesSlides/notesSlide1.xml"
|
||||||
|
).decode("utf-8")
|
||||||
|
core = archive.read("docProps/core.xml").decode("utf-8")
|
||||||
|
assert f'lang="{language}"' in packaged_slide
|
||||||
|
assert f'lang="{language}"' in packaged_notes
|
||||||
|
assert f"<dc:language>{language}</dc:language>" in core
|
||||||
|
assert len(Presentation(str(output)).slides) == 1
|
||||||
|
|
||||||
|
# A legacy lock with no language field keeps the previous per-run path.
|
||||||
|
legacy_svg = root / "legacy.svg"
|
||||||
|
legacy_svg.write_text(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
'viewBox="0 0 1280 720">'
|
||||||
|
'<text x="100" y="140" font-size="42">한국어 2026</text>'
|
||||||
|
"</svg>",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
legacy_output = root / "legacy.pptx"
|
||||||
|
assert create_pptx_with_native_svg(
|
||||||
|
[legacy_svg],
|
||||||
|
legacy_output,
|
||||||
|
canvas_format="ppt169",
|
||||||
|
verbose=False,
|
||||||
|
transition=None,
|
||||||
|
pptx_structure="flat",
|
||||||
|
structure_name="legacy-smoke",
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(legacy_output) as archive:
|
||||||
|
assert archive.testzip() is None
|
||||||
|
legacy_slide = archive.read(
|
||||||
|
"ppt/slides/slide1.xml"
|
||||||
|
).decode("utf-8")
|
||||||
|
assert 'lang="ko-KR"' in legacy_slide
|
||||||
|
|
||||||
|
print("Multilingual text smoke: passed")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Multilingual text smoke: passed
|
||||||
|
```
|
||||||
|
|
||||||
|
The RTL contract is paragraph `a:pPr rtl="1"` plus run-level `a:rtl` only
|
||||||
|
when the run contains strong RTL characters. Do not use `rtlCol`; it controls
|
||||||
|
column order, not paragraph direction.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# PPTX Animation Core
|
||||||
|
|
||||||
|
The shared animation core owns the object-effect vocabulary, trigger
|
||||||
|
semantics, OOXML timing writer, semantic read-back, and package validation for
|
||||||
|
PowerPoint OOXML. Per-element animation remains opt-in: generated PPTX export
|
||||||
|
defaults to `none`, exactly as before this validation upgrade.
|
||||||
|
|
||||||
|
## 1. Ownership
|
||||||
|
|
||||||
|
| Concern | Owner |
|
||||||
|
|---|---|
|
||||||
|
| Effect registry, timing writer, and read-back | `scripts/pptx_animations.py` |
|
||||||
|
| Sidecar parsing and SVG target discovery | `svg_to_pptx/animation_config.py` |
|
||||||
|
| SVG group-to-shape mapping | `svg_to_pptx/drawingml/converter.py` |
|
||||||
|
| Generated PPTX resolution and validation | `svg_to_pptx/pptx_package/builder.py` |
|
||||||
|
| Narration timing merge | `svg_to_pptx/pptx_package/narration.py` |
|
||||||
|
| Public authoring contract | `references/animations.md` |
|
||||||
|
| Customization stage | `workflows/stages/customize-animations.md` |
|
||||||
|
|
||||||
|
**Hard rule**: only the generated SVG-to-PPTX route writes object
|
||||||
|
animations. Direct-PPTX routes preserve source animations and run structural
|
||||||
|
package validation; they do not resolve or author animation effects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Domain Model
|
||||||
|
|
||||||
|
`groups.<id>` accepts either one backward-compatible effect object or one
|
||||||
|
non-empty `effects[]` array; the forms are exclusive and every array row names
|
||||||
|
`effect`. Both expand into the same row model, so repeated shape targets are
|
||||||
|
valid. Legacy rows also accept `trigger`; omitted row settings inherit the
|
||||||
|
resolved slide animation.
|
||||||
|
|
||||||
|
One resolved row contains these fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| Target | Positive PowerPoint shape id written to `p:spTgt@spid` |
|
||||||
|
| Effect | One canonical PowerPoint-authored preset class / id / subtype / behavior-tree signature |
|
||||||
|
| Trigger | Row-specific `on-click`, `with-previous`, or `after-previous`; omitted values inherit the resolved slide Start mode |
|
||||||
|
| Trigger shape | Optional different top-level group; maps to PowerPoint `On Click of` |
|
||||||
|
| Duration | Finite positive schedule duration; scalable native behavior trees preserve their internal timing ratios |
|
||||||
|
| Delay | Finite non-negative row offset; shape-trigger rows use it as `TriggerDelayTime` |
|
||||||
|
| Order | Positive integer sidecar order; ties retain stable SVG group order, then `effects[]` index |
|
||||||
|
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name` (one installed PowerPoint face, required for Change Font; not a CSS list), `relative`, or `size` values from PowerPoint `EffectParameters` |
|
||||||
|
| Timing options | Repeat count/span, auto-reverse, rewind, accelerate/decelerate, bounce-end ratio, and restart policy |
|
||||||
|
| Completion / cue | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound |
|
||||||
|
|
||||||
|
Modes resolve before XML writing:
|
||||||
|
|
||||||
|
| Mode | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| `auto` | Generic entrance only: deterministic semantic mapping from the SVG group id |
|
||||||
|
| `mixed` | Generic entrance only: deterministic cycle over canonical PowerPoint entrance presets |
|
||||||
|
| `random` | Generic entrance only: stable seeded choice from the same canonical entrance pool |
|
||||||
|
| `none` | No object-animation sequence |
|
||||||
|
|
||||||
|
The same effective input produces the same `random` choices. When enabled,
|
||||||
|
`--conversion-trace` records each resolved row and effect, so a generated deck
|
||||||
|
can be audited without replaying the resolver.
|
||||||
|
|
||||||
|
`animation_config.py scaffold` is neutral: object defaults are `none`, and
|
||||||
|
empty `{}` group placeholders inherit no motion until populated.
|
||||||
|
|
||||||
|
Bundled sound discovery/materialization is a workflow concern, not part of the
|
||||||
|
animation core. After the SVG and object-motion solution are complete,
|
||||||
|
`sound_sync.py` may copy only selected namespaced ids into
|
||||||
|
`<project>/sounds/`; new sidecars then reference those project-relative WAV
|
||||||
|
paths. Existing low-level project-relative/absolute `.m4a`, `.mp3`, and `.wav`
|
||||||
|
inputs remain compatible. The core never resolves a library id or reads
|
||||||
|
`templates/sounds/` directly; see [`animations.md`](../../references/animations.md)
|
||||||
|
§2.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Canonical Registry and Compatibility Inputs
|
||||||
|
|
||||||
|
The canonical registry contains 203 PowerPoint-authored presets:
|
||||||
|
|
||||||
|
| Category | Key prefix | Count | Example |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Entrance | `entrance_*` | 53 | `entrance_bounce` |
|
||||||
|
| Emphasis | `emphasis_*` | 33 | `emphasis_spin` |
|
||||||
|
| Motion path | `path_*` | 64 | `path_circle` |
|
||||||
|
| Exit | `exit_*` | 53 | `exit_faded_zoom` |
|
||||||
|
|
||||||
|
The 29 established short names remain valid only as compatibility inputs.
|
||||||
|
Normalization resolves them to canonical PowerPoint-authored presets before
|
||||||
|
selection, XML writing, read-back, tracing, or validation.
|
||||||
|
|
||||||
|
| Compatibility input | Canonical preset |
|
||||||
|
|---|---|
|
||||||
|
| `appear`, `cut` | `entrance_appear` |
|
||||||
|
| `fade` | `entrance_fade` |
|
||||||
|
| `fly`, `fly_left`, `fly_right`, `fly_top` | `entrance_fly` |
|
||||||
|
| `zoom` | `entrance_zoom` |
|
||||||
|
| `wipe`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down` | `entrance_wipe` |
|
||||||
|
| `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek` | matching `entrance_*` preset |
|
||||||
|
| `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel` | matching `entrance_*` preset |
|
||||||
|
|
||||||
|
`cut` maps to `entrance_appear` because current PowerPoint exposes no separate
|
||||||
|
Cut object-animation preset. Old Fly/Wipe names desugar to the canonical effect
|
||||||
|
plus `effect_options.direction`; legacy `wheel` desugars to
|
||||||
|
`entrance_wheel` plus `amount: 4`. New output never writes those aliases.
|
||||||
|
|
||||||
|
Together with the 29 accepted compatibility names, the public input surface
|
||||||
|
contains 232 keys. New selections, generated sidecars, conversion traces,
|
||||||
|
writers, and documentation examples use canonical keys; short names exist only
|
||||||
|
at compatibility input boundaries.
|
||||||
|
|
||||||
|
The shipped `pptx_animation_presets.json` contains the PowerPoint-authored
|
||||||
|
`p:cTn` row for every native effect. Complex effects use combinations of
|
||||||
|
`p:set`, `p:anim`, `p:animClr`, `p:animEffect`, `p:animMotion`, `p:animRot`,
|
||||||
|
and `p:animScale`; reducing them to one filter would silently change the
|
||||||
|
effect. `pptx_animations.py --list` prints the full categorized public
|
||||||
|
registry; `pptx_animations.py --describe <effect>` prints that effect's exact
|
||||||
|
option values and shared timing/completion contract.
|
||||||
|
|
||||||
|
Native presets map to the object-capable `MsoAnimEffect` values. Media play,
|
||||||
|
pause, stop, and play-from-bookmark are excluded because they require a
|
||||||
|
media/bookmark target rather than an SVG-derived shape. Exit effects use the
|
||||||
|
same entrance-capable `MsoAnimEffect` identity with PowerPoint's exit flag and
|
||||||
|
serialize as `presetClass="exit"`.
|
||||||
|
|
||||||
|
Paragraph/text-range build controls are likewise outside this writer: generated
|
||||||
|
targets are top-level SVG groups, not paragraph ranges. For that target model,
|
||||||
|
the public contract covers all PowerPoint effect parameters, timing modifiers,
|
||||||
|
completion controls, sound, and object-trigger linkage; Speed and smooth
|
||||||
|
start/end remain derived rather than duplicated.
|
||||||
|
|
||||||
|
**Hard rule — no downgrade**:
|
||||||
|
|
||||||
|
- Keep all 29 established short names accepted as compatibility inputs.
|
||||||
|
- Reject an unknown effect, mode, or trigger; never substitute another value.
|
||||||
|
- Reject booleans and non-finite, out-of-range, or invalidly ordered values.
|
||||||
|
- Reject a missing slide, missing group, or structural-layer target.
|
||||||
|
- Keep the generated-route default at `none`; validation does not opt a deck in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Target Resolution
|
||||||
|
|
||||||
|
Generated object animation targets top-level SVG content groups. Explicit SVG
|
||||||
|
semantics are authoritative; the group-id chrome heuristic is only a fallback
|
||||||
|
for marker-free legacy SVGs.
|
||||||
|
|
||||||
|
| Target state | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Ordinary content group | Animatable; a legacy block resolves one row and `effects[]` may resolve several rows against the same final shape |
|
||||||
|
| Legacy chrome-like id | Skipped unless explicitly named in `animations.json` |
|
||||||
|
| Explicit sidecar group override | May override only the legacy chrome-name heuristic |
|
||||||
|
| `data-pptx-layer` or explicit static role/placeholder | Structural and never animatable |
|
||||||
|
|
||||||
|
An explicit sidecar entry cannot turn a Master/Layout/Slide structural layer or
|
||||||
|
an explicitly marked static page-frame role/placeholder into an animation
|
||||||
|
target. This boundary preserves PPTX structure even when a legacy id resembles
|
||||||
|
content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. OOXML Rules
|
||||||
|
|
||||||
|
The writer emits animation timing after `p:transition` and before `p:extLst`.
|
||||||
|
Normally this is one root `p:timing`; nonzero `bounce_end` uses PowerPoint's
|
||||||
|
native `mc:AlternateContent` with a p14 Choice and non-bounce Fallback. Each
|
||||||
|
branch contains a `tmRoot`, a `mainSeq` when ordinary Start rows exist, one
|
||||||
|
`interactiveSeq` per trigger-shape row, unique branch-local `p:cTn@id` values,
|
||||||
|
and same-slide `p:spTgt` references.
|
||||||
|
|
||||||
|
Trigger mapping:
|
||||||
|
|
||||||
|
| Public trigger | Object row `p:cTn@nodeType` |
|
||||||
|
|---|---|
|
||||||
|
| `on-click` | `clickEffect` |
|
||||||
|
| `with-previous` | `withEffect` |
|
||||||
|
| `after-previous` | `afterEffect` |
|
||||||
|
|
||||||
|
A row-level `trigger_shape` resolves to a different shape id and writes
|
||||||
|
PowerPoint's native `interactiveSeq` with `onClick` shape conditions. Its row
|
||||||
|
remains `clickEffect`; row `delay` becomes `TriggerDelayTime`. Ordinary rows
|
||||||
|
remain in `mainSeq` and keep the slide Start mode.
|
||||||
|
|
||||||
|
Row `trigger` overrides slide Start in both forms. `trigger_shape` implies
|
||||||
|
`on-click` and conflicts with an explicit non-`on-click` Start. Repeated
|
||||||
|
`p:spTgt@spid` values are valid distinct Animation Pane rows. Ordinary rows
|
||||||
|
retain page-wide `order`; trigger-shape rows retain their relative order in
|
||||||
|
separate `interactiveSeq` branches and do not interleave with `mainSeq`.
|
||||||
|
|
||||||
|
The writer does not emit `p:bldP` for grouped content or pictures. Microsoft
|
||||||
|
defines `p:bldP@spid` for a text-bearing `p:sp`; using it for `p:grpSp` or
|
||||||
|
`p:pic` creates an invalid build reference. Package validation still accepts a
|
||||||
|
valid source `p:bldP` that targets a text-bearing shape.
|
||||||
|
|
||||||
|
Direct-PPTX preserve mode also tolerates an unchanged legacy `p:bldP` that
|
||||||
|
targets an existing group/picture. Earlier PPT Master exports wrote this form;
|
||||||
|
the direct routes fingerprint and preserve it instead of blocking those decks.
|
||||||
|
New generated output never writes it, and generated-package validation remains
|
||||||
|
strict.
|
||||||
|
|
||||||
|
`entrance_appear` is the visibility-flip exception: its `p:set` behavior is
|
||||||
|
always 1ms. The configured positive duration remains the row's scheduling span
|
||||||
|
used when computing the next `after-previous` offset; read-back verifies the
|
||||||
|
1ms behavior and the resulting timeline offset separately. The compatibility
|
||||||
|
inputs `appear` and `cut` normalize to this canonical preset.
|
||||||
|
|
||||||
|
Other native presets with a
|
||||||
|
finite duration scale every finite behavior duration and start delay
|
||||||
|
proportionally, preserving multi-step timing such as bounce and teeter.
|
||||||
|
PowerPoint-authored instantaneous emphasis presets keep their `indefinite`
|
||||||
|
behavior duration; their configured duration remains the scheduling span for
|
||||||
|
the next `after-previous` row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validation and Read-Back
|
||||||
|
|
||||||
|
Before export, `animation_config.py validate` uses the writer's effect-behavior
|
||||||
|
test for `bounce_end` and resolves declared sound paths against the project
|
||||||
|
root. Missing paths, non-files, and unsupported audio extensions fail this
|
||||||
|
project-level preflight; field-only validation remains filesystem-independent.
|
||||||
|
|
||||||
|
Generated export reads every slide back before packaging and compares each
|
||||||
|
requested row with the serialized result:
|
||||||
|
|
||||||
|
- row count and row order, including stable repeated-target rows;
|
||||||
|
- trigger, optional trigger shape, and shape target;
|
||||||
|
- resolved effect key, preset class, filter, `presetID`, and `presetSubtype`;
|
||||||
|
- exact effect options, repeat/reverse/rewind/acceleration/bounce/restart
|
||||||
|
semantics, completion behavior, sound relationship, and playback span;
|
||||||
|
- native behavior-tree signature, serialized behavior duration, and computed
|
||||||
|
timeline offset (`entrance_appear` and instantaneous native presets use the
|
||||||
|
exceptions above).
|
||||||
|
|
||||||
|
After packaging, validation scans every slide part for root timing placement,
|
||||||
|
duplicate or malformed `p:cTn` ids, missing `p:spTgt` shapes, invalid build
|
||||||
|
targets, and unsupported generated effect tuples. A mismatch fails export
|
||||||
|
before the requested output file replaces an existing deck.
|
||||||
|
|
||||||
|
Narration injection parses and merges the slide DOM. It adds audio timing under
|
||||||
|
the existing `tmRoot`, allocates fresh ids, and preserves object animation.
|
||||||
|
For bounce timing it updates both p14 Choice and Fallback; unsupported nested
|
||||||
|
timing containers still fail safely instead of being duplicated.
|
||||||
|
|
||||||
|
Direct-PPTX routes run the structural package validator with generated-effect
|
||||||
|
enforcement disabled. This permits preservation of source/extension effects and
|
||||||
|
legacy group build rows while still rejecting corrupt timing IDs or missing
|
||||||
|
targets. Template fill and native enhancement fingerprint the source
|
||||||
|
object-animation tree before and after their allowed edits; any semantic change
|
||||||
|
fails. These routes have no object-animation write ownership.
|
||||||
|
|
||||||
|
The conversion trace is also the authoritative input for downstream video
|
||||||
|
motion. `video_motion_plan.py` preserves the resolved effect/options, direction,
|
||||||
|
row order, base and repeat-aware playback duration, absolute offset, object
|
||||||
|
bounds, and narration-derived slide advance while adding only renderer-specific enhancement parameters. Video
|
||||||
|
renderers must not bypass this read-back result and infer motion from sidecar
|
||||||
|
delay values alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Compatibility Scope
|
||||||
|
|
||||||
|
The compatibility contract covers PowerPoint OOXML and PowerPoint read-back.
|
||||||
|
Other presentation applications may interpret timing trees or filter values
|
||||||
|
differently; the exporter does not make an unconditional Keynote guarantee.
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- [Microsoft `MsoAnimEffect` enumeration](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.msoanimeffect)
|
||||||
|
- [Microsoft `Sequence.AddEffect`](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.sequence.addeffect)
|
||||||
|
- [Microsoft `Effect.Exit`](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.effect.exit)
|
||||||
|
- [Microsoft animation-filter implementation notes](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/a96dab70-2e72-4319-928d-0eb4b275ce58)
|
||||||
|
- [Microsoft `p:bldP` implementation restrictions](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/40d17b6d-30c0-4c10-b042-b2597824a820)
|
||||||
|
- [Open XML SDK time-node values](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.timenodevalues?view=openxml-3.0.1)
|
||||||
|
- [Open XML SDK shape target](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.shapetarget?view=openxml-3.0.1)
|
||||||
|
|
||||||
|
See [`pptx-transitions.md`](./pptx-transitions.md) for the symmetric page-motion
|
||||||
|
core, MCE handling, and slide-advance contract.
|
||||||
|
See [`video-motion-plan.md`](./video-motion-plan.md) for the downstream
|
||||||
|
animation-to-video contract.
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
# PPTX Transition Core
|
||||||
|
|
||||||
|
The shared transition core owns page-transition vocabulary, slide-advance
|
||||||
|
semantics, OOXML mutation, MCE preservation, package timing enablement, and
|
||||||
|
read-back validation for every PPTX route.
|
||||||
|
|
||||||
|
## 1. Ownership
|
||||||
|
|
||||||
|
| Concern | Owner |
|
||||||
|
|---|---|
|
||||||
|
| Page transition registry | scripts/pptx_transitions.py |
|
||||||
|
| In-slide object animation | scripts/pptx_animations.py |
|
||||||
|
| Generated PPTX adapter | svg_to_pptx/pptx_package/builder.py |
|
||||||
|
| Template Fill adapter | template_fill_pptx/transitions.py |
|
||||||
|
| Native Enhance adapter | native_enhance_pptx_core.py |
|
||||||
|
| Public workflow | references/animations.md |
|
||||||
|
|
||||||
|
**Hard rule**: adapters resolve route policy, then call the shared core. They
|
||||||
|
must not build, replace, or patch a transition with route-local XML or regex.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Domain Model
|
||||||
|
|
||||||
|
| Layer | Meaning | OOXML |
|
||||||
|
|---|---|---|
|
||||||
|
| Enter | How the current slide appears from the preceding slide | Native effect, Effect Options, and duration |
|
||||||
|
| Sound | Optional cue played with the current slide's transition | `p:sndAc/p:stSnd/p:snd` with an embedded WAV relationship |
|
||||||
|
| Advance | How the current slide leaves for the next slide | advClick and advTm |
|
||||||
|
|
||||||
|
Enter policy:
|
||||||
|
|
||||||
|
| Policy | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| preserve | Keep the source visual transition, including unknown extensions |
|
||||||
|
| replace | Write the requested supported effect |
|
||||||
|
| none | Write no visual effect |
|
||||||
|
|
||||||
|
Advance mode:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| preserve | Keep source advClick and advTm |
|
||||||
|
| click | Click advance only |
|
||||||
|
| after | Timed advance only |
|
||||||
|
| both | Click or timed advance, whichever occurs first |
|
||||||
|
| narration | Timed advance from narration lead-in, audio duration, and page-tail padding; click disabled |
|
||||||
|
|
||||||
|
**Hard rule**: enter=none may coexist with a sound and/or timed advance. The
|
||||||
|
valid result is a non-visual `p:transition` with `p:sndAc`, advance attributes,
|
||||||
|
or both, and no visual-effect child.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Compatibility Contract
|
||||||
|
|
||||||
|
The native registry covers the complete current PowerPoint transition gallery:
|
||||||
|
12 Subtle effects, 29 Exciting effects, and 7 Dynamic Content effects. New
|
||||||
|
selection, sidecars, plans, conversion traces, help, and writers use only these
|
||||||
|
48 native keys.
|
||||||
|
|
||||||
|
Eight established low-level names remain valid at input boundaries. They
|
||||||
|
normalize to one native effect plus native `effect_options`; they are not a
|
||||||
|
second transition registry:
|
||||||
|
|
||||||
|
| Compatibility input | Native request |
|
||||||
|
|---|---|
|
||||||
|
| `strips` | `wipe` with `direction: right` |
|
||||||
|
| `circle` | `shape` with `shape: circle` |
|
||||||
|
| `diamond` | `shape` with `shape: diamond` |
|
||||||
|
| `plus` | `shape` with `shape: plus` |
|
||||||
|
| `newsflash` | `flash` |
|
||||||
|
| `pull` | `uncover` |
|
||||||
|
| `wedge` | `clock` with `style: wedge` |
|
||||||
|
| `wheel` | `clock` with `style: clockwise` |
|
||||||
|
|
||||||
|
Standard PresentationML effects use a direct `p:transition` carrier:
|
||||||
|
|
||||||
|
| Effect | Required primary child and attributes |
|
||||||
|
|---|---|
|
||||||
|
| fade | p:fade |
|
||||||
|
| push | p:push dir=r |
|
||||||
|
| wipe | p:wipe dir=r |
|
||||||
|
| split | p:split |
|
||||||
|
| cut | p:cut |
|
||||||
|
| random_bars | p:randomBar dir=vert |
|
||||||
|
| shape | p:circle |
|
||||||
|
| uncover | p:pull dir=r |
|
||||||
|
| cover | p:cover dir=r |
|
||||||
|
| dissolve | p:dissolve |
|
||||||
|
| checkerboard | p:checker |
|
||||||
|
| blinds | p:blinds dir=vert |
|
||||||
|
| clock | p:wheel spokes=1 |
|
||||||
|
| random | p:random |
|
||||||
|
| box | p:zoom |
|
||||||
|
| comb | p:comb |
|
||||||
|
|
||||||
|
Office 2010 effects use a `p14` Choice with a `p:fade` Fallback:
|
||||||
|
|
||||||
|
| Effect | Required primary child and attributes |
|
||||||
|
|---|---|
|
||||||
|
| reveal | p14:reveal dir=r |
|
||||||
|
| flash | p14:flash |
|
||||||
|
| ripple | p14:ripple |
|
||||||
|
| honeycomb | p14:honeycomb |
|
||||||
|
| glitter | p14:glitter |
|
||||||
|
| vortex | p14:vortex dir=r |
|
||||||
|
| shred | p14:shred dir=out |
|
||||||
|
| switch | p14:switch dir=r |
|
||||||
|
| flip | p14:flip dir=r |
|
||||||
|
| gallery | p14:gallery dir=r |
|
||||||
|
| cube | p14:prism dir=r |
|
||||||
|
| doors | p14:doors dir=vert |
|
||||||
|
| zoom | p14:warp dir=in |
|
||||||
|
| pan | p14:pan dir=r |
|
||||||
|
| ferris_wheel | p14:ferris dir=r |
|
||||||
|
| conveyor | p14:conveyor dir=r |
|
||||||
|
| rotate | p14:prism dir=r isContent=1 |
|
||||||
|
| window | p14:window |
|
||||||
|
| orbit | p14:prism dir=r isContent=1 isInverted=1 |
|
||||||
|
| fly_through | p14:flythrough |
|
||||||
|
|
||||||
|
Office 2012 effects use a `p15` Choice with a `p:fade` Fallback:
|
||||||
|
|
||||||
|
| Effect | Required primary child and attributes |
|
||||||
|
|---|---|
|
||||||
|
| fall_over | p15:prstTrans prst=fallOver invX=1 |
|
||||||
|
| drape | p15:prstTrans prst=drape invX=1 |
|
||||||
|
| curtains | p15:prstTrans prst=curtains |
|
||||||
|
| wind | p15:prstTrans prst=wind |
|
||||||
|
| prestige | p15:prstTrans prst=prestige |
|
||||||
|
| fracture | p15:prstTrans prst=fracture |
|
||||||
|
| crush | p15:prstTrans prst=crush |
|
||||||
|
| peel_off | p15:prstTrans prst=peelOff invX=1 |
|
||||||
|
| page_curl | p15:prstTrans prst=pageCurlSingle invX=1 |
|
||||||
|
| airplane | p15:prstTrans prst=airplane |
|
||||||
|
| origami | p15:prstTrans prst=origami |
|
||||||
|
|
||||||
|
`morph` uses `p159:morph option=byObject` in an Office 2015 Choice with a
|
||||||
|
`p:fade` Fallback. `none` is the explicit no-visual-effect input and therefore
|
||||||
|
is not a registry entry.
|
||||||
|
|
||||||
|
### 3.1 Native Effect Options
|
||||||
|
|
||||||
|
Use `effect_options` only with an explicit native `effect`. Omitted options use
|
||||||
|
the PowerPoint-authored `default` reported by `--describe-transition`:
|
||||||
|
|
||||||
|
| Effect | Supported options |
|
||||||
|
|---|---|
|
||||||
|
| `morph` | `morph_by`: `object`, `word`, `character` |
|
||||||
|
| `fade` | `style`: `smoothly`, `through_black` |
|
||||||
|
| `push`, `wipe`, `vortex`, `cube`, `pan`, `rotate`, `orbit` | `direction`: `left`, `right`, `up`, `down` |
|
||||||
|
| `split` | `orientation`: `horizontal`, `vertical`; `direction`: `out`, `in` |
|
||||||
|
| `reveal` | `direction`: `right`, `left`; `through_black`: boolean |
|
||||||
|
| `cut` | `through_black`: boolean |
|
||||||
|
| `random_bars`, `blinds`, `doors` | `orientation`: `vertical`, `horizontal` |
|
||||||
|
| `checkerboard` | `direction`: `across`, `down` |
|
||||||
|
| `comb`, `window` | `orientation`: `horizontal`, `vertical` |
|
||||||
|
| `shape` | `shape`: `circle`, `diamond`, `plus` |
|
||||||
|
| `uncover`, `cover` | `direction`: `left`, `right`, `up`, `down`, `up_left`, `up_right`, `down_left`, `down_right` |
|
||||||
|
| `fall_over`, `drape`, `wind`, `peel_off`, `airplane`, `origami` | `direction`: `right`, `left` |
|
||||||
|
| `page_curl` | `direction`: `right`, `left`; `pages`: `single`, `double` |
|
||||||
|
| `clock` | `style`: `clockwise`, `counterclockwise`, `wedge` |
|
||||||
|
| `ripple` | `origin`: `center`, `up_left`, `up_right`, `down_left`, `down_right` |
|
||||||
|
| `glitter` | `shape`: `diamond`, `hexagon`; `direction`: `right`, `left`, `up`, `down` |
|
||||||
|
| `shred` | `pattern`: `strips`, `rectangle`; `direction`: `out`, `in` |
|
||||||
|
| `switch`, `flip`, `gallery`, `ferris_wheel`, `conveyor` | `direction`: `right`, `left` |
|
||||||
|
| `box`, `zoom` | `direction`: `out`, `in` |
|
||||||
|
| `fly_through` | `direction`: `in`, `out`; `bounce`: boolean |
|
||||||
|
| All other native effects | No Effect Options |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
~~~json
|
||||||
|
{
|
||||||
|
"transition": {
|
||||||
|
"effect": "page_curl",
|
||||||
|
"effect_options": {
|
||||||
|
"direction": "left",
|
||||||
|
"pages": "double"
|
||||||
|
},
|
||||||
|
"duration": 0.6,
|
||||||
|
"sound": "sounds/bigsoundbank/<file>.wav"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
`transition.sound` is optional and accepts a `.wav` path resolved by the
|
||||||
|
generated-project adapter. A bundled-library choice is synced on demand only
|
||||||
|
after the visual transition plan is complete, then referenced by its
|
||||||
|
project-relative `sounds/<namespace>/<file>.wav` path. The adapter packages the
|
||||||
|
file and passes the shared core an embedded relationship id/name; the core
|
||||||
|
never reads `templates/sounds/` or resolves library ids.
|
||||||
|
|
||||||
|
Inspect the exact contract, including compatibility desugaring:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition page_curl
|
||||||
|
python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition diamond
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Read-back reports the canonical native effect, its complete effective options,
|
||||||
|
the raw OOXML child, and raw attributes. This makes option loss a validation
|
||||||
|
failure rather than a silent downgrade.
|
||||||
|
|
||||||
|
**Hard rule — no downgrade**:
|
||||||
|
|
||||||
|
- Never rename or remove an established effect.
|
||||||
|
- Never omit its established direction or split attributes.
|
||||||
|
- Reject an unknown requested effect; never substitute fade.
|
||||||
|
- Preserve an unknown source effect when the route selects preserve.
|
||||||
|
- An extension counts as successful only when the primary Choice contains the
|
||||||
|
requested effect. A fallback alone is not success.
|
||||||
|
|
||||||
|
### 3.2 Deterministic Morph Identity
|
||||||
|
|
||||||
|
The generated route may add an explicit `slides.<destination>.morph` block to
|
||||||
|
bind direct-root SVG groups across adjacent slides. The sidecar stable key is
|
||||||
|
lowered to the same top-level `p:cNvPr@name="!!<key>"` on both final
|
||||||
|
Slide-local objects. This does not create an Animation Pane row and does not
|
||||||
|
change either object's numeric shape id.
|
||||||
|
|
||||||
|
The full plan is resolved before any SVG conversion so a source group named by
|
||||||
|
the following slide remains a stable top-level target. Names are written only
|
||||||
|
after flat/structured/preserve processing has finished; structured slide-shape
|
||||||
|
roster expectations are then refreshed. Package read-back requires:
|
||||||
|
|
||||||
|
- the declared source to be the immediately preceding public slide;
|
||||||
|
- exactly one `!!<key>` object on each side;
|
||||||
|
- the same OOXML object container type on both sides;
|
||||||
|
- Morph by object on the destination; and
|
||||||
|
- no structural target, same-slide name collision, group/key conflict, or
|
||||||
|
undeclared shared `!!` name on a Morph edge.
|
||||||
|
|
||||||
|
Morph without an explicit pair block retains PowerPoint's automatic matching
|
||||||
|
behavior. Explicit pairing is generated-route authoring; direct-PPTX routes
|
||||||
|
continue to preserve existing object names and transition XML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Route Mapping
|
||||||
|
|
||||||
|
| Route | Default enter | Default advance | Compatibility note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Generated PPTX CLI | fade, 0.4s; no sound | click | auto-advance maps to both; an optional sidecar sound is project-local |
|
||||||
|
| Recorded narration | Preserve resolved enter | narration | none remains visually none |
|
||||||
|
| Template Fill | preserve source | preserve source | explicit effects replace; legacy advance_after maps to both |
|
||||||
|
| Native Enhance | Confirmed global/per-slide plan effect | Confirmed timing module | With audio off, an enabled global transition or explicit global `none` applies to all pages; with audio on, the scope flag controls non-narrated pages |
|
||||||
|
|
||||||
|
Template Fill changes source transitions only when its CLI or per-slide plan
|
||||||
|
selects a replacement, removal, or timed advance. Native Enhance uses its
|
||||||
|
confirmed plan. The public `create_pptx_with_native_svg` Python API retains its
|
||||||
|
legacy 0.5s default; the generated-deck CLI explicitly passes 0.4s.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. OOXML Rules
|
||||||
|
|
||||||
|
**Slide child order**:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
p:cSld
|
||||||
|
p:clrMapOvr
|
||||||
|
p:transition or transition mc:AlternateContent
|
||||||
|
p:timing
|
||||||
|
p:extLst
|
||||||
|
~~~
|
||||||
|
|
||||||
|
One slide may contain at most one logical transition carrier:
|
||||||
|
|
||||||
|
- one direct p:transition; or
|
||||||
|
- one root-level mc:AlternateContent whose Choice/Fallback branches contain
|
||||||
|
p:transition.
|
||||||
|
|
||||||
|
Mutation rules:
|
||||||
|
|
||||||
|
| Operation | Direct transition | AlternateContent |
|
||||||
|
|---|---|---|
|
||||||
|
| preserve | Leave unchanged | Leave wrapper and branches unchanged |
|
||||||
|
| advance-only | Patch direct attributes | Patch Choice and Fallback identically |
|
||||||
|
| replace | Replace the direct carrier | Remove the whole wrapper, then write one carrier |
|
||||||
|
| none | Remove visual effect; retain a non-visual carrier when sound or timing is needed | Remove the whole wrapper; write a non-visual carrier when sound or timing is needed |
|
||||||
|
|
||||||
|
**Sound placement**: for a direct transition, append `p:sndAc` inside
|
||||||
|
`p:transition` after the visual-effect child, when present. For
|
||||||
|
`mc:AlternateContent`, write the same sound action into both Choice and
|
||||||
|
Fallback transition carriers so older Office consumers do not lose the cue.
|
||||||
|
The slide relationship targets one packaged WAV part; replacing or removing a
|
||||||
|
requested generated sound must not leave a dangling relationship.
|
||||||
|
|
||||||
|
**MCE prefix rule**: Requires and Ignorable values contain textual prefix
|
||||||
|
names. Serialization must retain bindings for those exact names. Renaming an
|
||||||
|
effect prefix without updating these attributes corrupts compatibility.
|
||||||
|
|
||||||
|
**Package timing rule**: when a route writes advTm, set
|
||||||
|
ppt/presProps.xml p:presentationPr/p:showPr useTimings=1. Do not write showPr
|
||||||
|
into ppt/presentation.xml.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validation and Read-Back
|
||||||
|
|
||||||
|
Reject:
|
||||||
|
|
||||||
|
- unknown effect names;
|
||||||
|
- options without an explicit native effect;
|
||||||
|
- unknown option fields or values for the selected effect;
|
||||||
|
- non-finite values, including NaN and Infinity;
|
||||||
|
- duration less than or equal to zero;
|
||||||
|
- a missing/non-file transition sound, a non-WAV transition sound, or an
|
||||||
|
unresolved/dangling sound relationship;
|
||||||
|
- negative advance or narration padding;
|
||||||
|
- booleans passed as numeric API values;
|
||||||
|
- multiple logical transition carriers;
|
||||||
|
- unresolved MCE Requires or Ignorable prefixes.
|
||||||
|
- invalid forced-Morph adjacency, identity uniqueness, object type, or
|
||||||
|
destination effect.
|
||||||
|
|
||||||
|
Read-back must report the canonical native effect and complete effective
|
||||||
|
options, while keeping the primary Choice child separate from the fallback. It
|
||||||
|
must also report raw effect attributes, carrier type, duration, click mode, and
|
||||||
|
automatic advance time, plus the transition sound relationship/name when
|
||||||
|
present. Package validation must run after writing, not only before mutation.
|
||||||
|
|
||||||
|
Use inline smoke commands and gitignored projects/_smoke_* artifacts. Do not
|
||||||
|
add a tests directory or test_*.py files.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Project Tools
|
||||||
|
|
||||||
|
> **Import boundary**: move only sources already under the repository's
|
||||||
|
> `projects/` tree. Copy every other local path, even when `--move` is supplied.
|
||||||
|
> Use `--copy` to preserve a projects-local source.
|
||||||
|
|
||||||
|
Project tools create, validate, and inspect the standard PPT Master workspace.
|
||||||
|
|
||||||
|
## `project_manager.py`
|
||||||
|
|
||||||
|
Main entry point for project setup and validation.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/project_manager.py init <project_name> --format ppt169
|
||||||
|
python3 scripts/project_manager.py import-sources <project_path> <source1_or_dir> [<source2_or_dir> ...]
|
||||||
|
python3 scripts/project_manager.py scaffold-spec <project_path> # optional manual helper
|
||||||
|
python3 scripts/project_manager.py scaffold-lock <project_path> # optional manual helper
|
||||||
|
python3 scripts/project_manager.py validate <project_path>
|
||||||
|
python3 scripts/project_manager.py info <project_path>
|
||||||
|
python3 scripts/project_manager.py page-context <project_path> P07 [--pretty] [--record-usage]
|
||||||
|
python3 scripts/project_manager.py page-context-report <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `init --quick-generate`: `svg_output/` plus
|
||||||
|
`validation/workflow.log`; no README
|
||||||
|
- Files outside `projects/` are always copied into `sources/`
|
||||||
|
- `--move` applies only to sources under the repository's `projects/` tree
|
||||||
|
- A directly supplied supported bitmap is also copied into `images/` with a
|
||||||
|
collision-safe basename while its original remains archived in `sources/`
|
||||||
|
- Directory inputs are expanded non-recursively. After Step 1 conversion,
|
||||||
|
pass the source file/directory once when generated Markdown lives beside the
|
||||||
|
original source. If Step 1 used `-o` to write Markdown elsewhere, pass both
|
||||||
|
the original source path/directory and the Markdown output path/directory.
|
||||||
|
- A projects-local supplied source directory left strictly empty after import
|
||||||
|
(or empty from the start) is removed; every directory outside `projects/`
|
||||||
|
remains untouched. `--copy` never removes directories.
|
||||||
|
- Files already under `projects/` move into `sources/` by default. Pass `--copy`
|
||||||
|
to preserve them in place.
|
||||||
|
- `--move` and `--copy` are mutually exclusive.
|
||||||
|
- Normal Generate authoring reads `templates/design_spec_reference.md`, writes
|
||||||
|
the complete `design_spec.md` from scratch, then reads
|
||||||
|
`templates/spec_lock_reference.md` and writes the complete lock projection.
|
||||||
|
It does not call either scaffold command.
|
||||||
|
- Optional `scaffold-spec` creates `design_spec.md` from
|
||||||
|
`templates/scaffolds/design_spec.md`; `scaffold-lock` creates `spec_lock.md`
|
||||||
|
from `templates/scaffolds/spec_lock.md`. Both substitute project/canvas
|
||||||
|
metadata deterministically and refuse to overwrite an existing artifact.
|
||||||
|
- `validate` parses the existing Markdown artifacts against
|
||||||
|
`templates/schemas/design_spec.schema.json` and
|
||||||
|
`templates/schemas/spec_lock.schema.json`. It reports missing sections and
|
||||||
|
fields, illegal enums, malformed page keys, and unmet conditional sections.
|
||||||
|
When optional custom reference lists are present, it also requires every id
|
||||||
|
to resolve to the matching mode, visual-style, or image-rendering catalog,
|
||||||
|
rejects duplicates, and rejects reference rows on non-custom selections;
|
||||||
|
it does not rewrite either artifact or compare their values for textual
|
||||||
|
equality. It also does not prove final-confirmation → Design Spec fidelity or
|
||||||
|
Design Spec/context → lock semantic fidelity; Generate Step 4 owns those two
|
||||||
|
gates before this structural validation. Validation reads the planning
|
||||||
|
artifacts only and never reopens `confirm_ui/result.json`; the final result is
|
||||||
|
consumed once into the Design Spec before validation begins. The design schema is structural lint for
|
||||||
|
the human-readable brief; the lock schema owns machine execution values. For
|
||||||
|
structured template use, strict input prototypes must match their assigned
|
||||||
|
Master/Layout; adaptive input prototypes retain the assigned Master while a
|
||||||
|
new output Layout already declared by Strategist is cross-validated after its
|
||||||
|
generated SVG exists. Versioned
|
||||||
|
Direct-authored current artifacts and optional scaffolds carry the schema
|
||||||
|
marker. Markerless legacy artifacts are left on
|
||||||
|
their prior validation path with a warning;
|
||||||
|
malformed or unsupported markers are errors.
|
||||||
|
- PPTX-family inputs are enriched automatically under `analysis/` with
|
||||||
|
per-deck `<stem>.identity.json` / `<stem>.slide_library.json` plus the shared
|
||||||
|
multi-deck index `source_profile.json` (`decks[]`).
|
||||||
|
Multi-deck per project: several PPTX imports each get their own `<stem>.*`
|
||||||
|
artifacts and a `decks[]` entry; re-importing the same stem replaces its entry.
|
||||||
|
|
||||||
|
### On-demand page execution view
|
||||||
|
|
||||||
|
`page-context` projects `design_spec.md` and `spec_lock.md` into one compact
|
||||||
|
current-page view on stdout. The default command is read-only; `--pretty`
|
||||||
|
changes JSON formatting only. Before projection it revalidates the machine lock
|
||||||
|
and selected template-root identities; design-brief values are not treated as
|
||||||
|
a second lock. Slide headings at H3–H6 remain readable by the projector. Normal
|
||||||
|
generation retains the complete planning artifacts once per valid execution
|
||||||
|
context and does not invoke this command before every page; use it only for an
|
||||||
|
explicit diagnostic, routing check, or context-usage measurement.
|
||||||
|
|
||||||
|
Each invocation deliberately includes the bounded `global` anchor set as a
|
||||||
|
cross-page continuity view, not a color/font allowlist. `lock_source` binds that projection to the current
|
||||||
|
`spec_lock.md` SHA. `page_context` contains the current §IX brief, rhythm,
|
||||||
|
resources, and conditional template/chart assignment. `reference_set` contains
|
||||||
|
`kind`, scoped path, SHA, and `once-per-execution-context` policy for the
|
||||||
|
project/template Design Specs and selected prototype/chart SVGs. The project
|
||||||
|
Design Spec additionally carries
|
||||||
|
`same_context_edit_policy: targeted-readback-and-rebind`: when the current main
|
||||||
|
agent makes a bounded repair in a valid uncompacted context that preserves
|
||||||
|
roster/order/identity/communication, it reads back only the exact changed
|
||||||
|
fragments and validates them before continuing. Fresh, compacted, external,
|
||||||
|
unknown, or mismatched changes require one complete Design Spec and lock read.
|
||||||
|
|
||||||
|
The deprecated `--bundle` flag remains accepted as a compatibility no-op. It
|
||||||
|
never appends a Design Spec, prototype SVG, chart SVG, manifest, or text-slot
|
||||||
|
sidecar to stdout.
|
||||||
|
|
||||||
|
The projection keeps project-specific forbidden rules; universal SVG and icon
|
||||||
|
rules remain in the always-loaded execution core. Image rows are selected from
|
||||||
|
the current §IX brief, explicit §VIII page assignments, and mirror prototype
|
||||||
|
references. When those sources assign images elsewhere but not to the current
|
||||||
|
page, the view excludes those assigned images. Any still-unassigned legacy
|
||||||
|
image remains in a compatibility subset; `confirmed-none` is emitted only when
|
||||||
|
all locked images have a deterministic assignment elsewhere.
|
||||||
|
|
||||||
|
Mirror materialization may publish deterministic
|
||||||
|
`ppt-master.template-text-slots.v2-min` diagnostics. They are not page-context
|
||||||
|
or model inputs. The complete SVG remains the sole template authority; checker
|
||||||
|
and structured export validate output attributes, text/tspan topology, and
|
||||||
|
referenced-resource hashes against it internally.
|
||||||
|
|
||||||
|
`--record-usage` writes a derived snapshot to
|
||||||
|
`analysis/page-context/P<NN>.usage.json`. It hashes every input, measures the
|
||||||
|
exact compact stdout, and records the reference fingerprints. `tiktoken` is
|
||||||
|
loaded lazily with `o200k_base`; when unavailable, the command still succeeds
|
||||||
|
and records bytes, characters, hashes, and `tokens: null`.
|
||||||
|
`page-context-report` summarizes only fresh snapshots and identifies stale or
|
||||||
|
token-unavailable pages plus unique referenced files. Telemetry may be partial;
|
||||||
|
it does not measure once-loaded references, source reads, or other session
|
||||||
|
context.
|
||||||
|
|
||||||
|
Common formats:
|
||||||
|
- `ppt169`
|
||||||
|
- `ppt43`
|
||||||
|
- `xiaohongshu`
|
||||||
|
- `moments`
|
||||||
|
- `story`
|
||||||
|
- `banner`
|
||||||
|
- `a4`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/project_manager.py init my_presentation --format ppt169
|
||||||
|
python3 scripts/project_manager.py scaffold-spec projects/my_presentation_ppt169_20251116 # optional
|
||||||
|
python3 scripts/project_manager.py scaffold-lock projects/my_presentation_ppt169_20251116 # optional
|
||||||
|
python3 scripts/project_manager.py validate projects/my_presentation_ppt169_20251116
|
||||||
|
python3 scripts/project_manager.py info projects/my_presentation_ppt169_20251116
|
||||||
|
python3 scripts/project_manager.py page-context projects/my_presentation_ppt169_20251116 P07 --record-usage
|
||||||
|
python3 scripts/project_manager.py page-context-report projects/my_presentation_ppt169_20251116
|
||||||
|
```
|
||||||
|
|
||||||
|
## `workflow_transcript.py` and `workflow_log.py`
|
||||||
|
|
||||||
|
Project initialization creates `validation/workflow.log` and records its own
|
||||||
|
milestone. Run later project-scoped Python tools normally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/<tool>.py <project_path> <args...>
|
||||||
|
```
|
||||||
|
|
||||||
|
Their shared CLI bootstrap discovers the existing project log from the working
|
||||||
|
directory or command arguments. `workflow_transcript.py` records a UTC command
|
||||||
|
envelope plus explicit error/failure and receipt/report lines, bounded
|
||||||
|
warning/OK/stderr samples, limited summary context, and per-run omission counts;
|
||||||
|
no outer launcher or second Python process is used. It leaves full output on
|
||||||
|
the original console instead of copying it into the audit log. Commands before
|
||||||
|
project initialization are not backfilled. Binary-buffer writes, hidden child
|
||||||
|
output, and detached service activity are not recorded; Confirm UI and live
|
||||||
|
preview retain detailed output in their component `server.log` files. Their
|
||||||
|
shared detached-process launcher disables automatic workflow recording in the
|
||||||
|
long-running child while preserving the short foreground launcher's own record.
|
||||||
|
|
||||||
|
For a Python helper whose arguments and working directory do not identify the
|
||||||
|
active project, set the routing signal on the same command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PPT_MASTER_PROJECT_PATH="<project_path>" python3 scripts/<helper>.py <args...>
|
||||||
|
```
|
||||||
|
|
||||||
|
This variable selects only the destination transcript; it does not authorize
|
||||||
|
the helper to read project artifacts or change its ownership.
|
||||||
|
|
||||||
|
Append a manual note only when an important audit detail has no owning command
|
||||||
|
output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/workflow_log.py <project_path> "<material audit detail>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Suitable notes include a material stage handoff or rework reason, a
|
||||||
|
user-approved exception, or a manual recovery choice. Do not duplicate
|
||||||
|
artifact contents, routine page progress, or private reasoning.
|
||||||
|
|
||||||
|
The log is append-only audit evidence. It is not a complete console transcript,
|
||||||
|
stage, quality, or artifact authority and is not read during normal generation
|
||||||
|
or resume. Inspect it only when the user explicitly requests a run review. An
|
||||||
|
automatic recording failure emits a warning but does not change the Python
|
||||||
|
tool's result; an explicit manual entry that cannot be written exits non-zero.
|
||||||
|
|
||||||
|
## `project_utils.py`
|
||||||
|
|
||||||
|
Shared helper module used by other scripts.
|
||||||
|
|
||||||
|
Typical use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from project_utils import get_project_info, validate_project_structure
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also run it directly for quick checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/project_utils.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
## `batch_validate.py`
|
||||||
|
|
||||||
|
Batch-check project structure and compliance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/batch_validate.py examples
|
||||||
|
python3 scripts/batch_validate.py examples projects
|
||||||
|
python3 scripts/batch_validate.py --all
|
||||||
|
python3 scripts/batch_validate.py examples --export
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this for repository-wide health checks before release or cleanup.
|
||||||
|
|
||||||
|
## `generate_examples_index.py`
|
||||||
|
|
||||||
|
Rebuild `examples/README.md` automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/generate_examples_index.py
|
||||||
|
python3 scripts/generate_examples_index.py examples
|
||||||
|
```
|
||||||
|
|
||||||
|
## `pptx_template_import.py`
|
||||||
|
|
||||||
|
Unified PPTX preparation entry point for `/create-template`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx>
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> -o <output_dir>
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --manifest-only
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --skip-manifest
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --embed-images
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode both
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode flat
|
||||||
|
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode layered
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Extracts reusable media assets from `ppt/media/`
|
||||||
|
- Summarizes slide size, theme colors, font metadata, and per-master theme metadata
|
||||||
|
- Resolves slide / layout / master relationships from OOXML relationships; every master and layout is included even when no sample slide currently references it
|
||||||
|
- Generates `manifest.json` (single source of truth for slide size, theme, per-master themes, assets, layouts, masters, placeholders, slides, SVG file paths, and page-type candidates), `native_structure.json`, `source_template.pptx`, `assets/`, `conversion-report.json`, and shape-level SVGs under `svg/`
|
||||||
|
- **SVG output defaults to the layered authoring source** (`--inheritance-mode layered`):
|
||||||
|
- `svg/` — layered template view for designers: every master and layout in the deck rendered once as `svg/master_*.svg` / `svg/layout_*.svg` (including ones no sample slide currently references); `svg/slide_NN.svg` contains only that slide's own shapes; `svg/inheritance.json` records parentage plus source-owned `showInheritedShapes` / `showMasterShapes` booleans.
|
||||||
|
- `svg-flat/` — optional verification view emitted only by `--inheritance-mode both`: each `slide_NN.svg` is self-contained (the effective visible Master/Layout contributions plus Slide-local content painted into one file), so opening any slide in isolation shows the full page like PowerPoint would. Background inheritance remains independent of inherited-shape visibility. Useful for previews, screenshots, and "did this slide actually render correctly" sanity checks.
|
||||||
|
- `manifest.json` records `svgFile` for slides / layouts / masters, `flatSvgFile` for slides when `svg-flat/` exists, placeholder type / index / geometry / base style, an asset map used by SVG `href` values, and common assets reused through slide / layout / master inheritance. Placeholder semantics keep `subTitle`, `obj`, `media`, and `dt` distinct as `subtitle`, `object`, `media`, and `date`.
|
||||||
|
- `conversion-report.json` owns tolerant source-recovery diagnostics; it is not a cache or a duplicate of the structural manifests
|
||||||
|
- Layered slide SVGs keep only the slide's own background; inherited master / layout backgrounds stay in the corresponding master / layout SVGs
|
||||||
|
- Placeholder guides are intentionally lightweight in `svg/` master / layout files; `svg-flat/` hides those guides and is the visual preview source
|
||||||
|
- Charts, SmartArt, diagrams, and OLE objects become typed placeholders in `svg/`; `svg-flat/` shows a preview image with a corner badge when one exists, otherwise a visible placeholder. Tables are converted into real SVG content.
|
||||||
|
- Pass `--inheritance-mode both` to add `svg-flat/`, or `--inheritance-mode flat` for the legacy round-trip view (single self-contained `svg/` tree without master/layout/inheritance files).
|
||||||
|
- SVG export reads OOXML directly via `pptx_to_svg` — no PowerPoint or Keynote dependency, runs on any platform
|
||||||
|
- `<image>` elements in `svg/` reference files in `assets/` directly; pass `--embed-images` to inline as data URIs instead
|
||||||
|
- External linked images and missing media are strict failures. Office vector media such as EMF / WMF are converted to PNG previews when the local toolchain can do so; otherwise the import fails instead of silently dropping content.
|
||||||
|
- Required in `/create-template` whenever the reference source is `.pptx`
|
||||||
|
- Default output directory is `<pptx_stem>_template_import/`
|
||||||
|
- Use `--manifest-only` when you explicitly want only the lightweight import output without slide SVG export
|
||||||
|
- Intended for template reference preparation, not for final 1:1 template delivery
|
||||||
|
|
||||||
|
Implementation note:
|
||||||
|
- Internal helpers for this workflow live under `scripts/template_import/`
|
||||||
|
|
||||||
|
## `error_helper.py`
|
||||||
|
|
||||||
|
Show standardized fixes for common project errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/error_helper.py
|
||||||
|
python3 scripts/error_helper.py missing_readme
|
||||||
|
python3 scripts/error_helper.py missing_readme project_path=my_project
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Prompt Audit — Budget and Governance Lint
|
||||||
|
|
||||||
|
> Maintainer-only, read-only. Audits the agent-facing Markdown corpus without modifying it and is intentionally not wired into CI or pre-commit hooks. Generation roles never load this doc, the tool, or its manifest.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/prompt_audit.py # text summary
|
||||||
|
python3 skills/ppt-master/scripts/prompt_audit.py --json # stable JSON report
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `tiktoken` (not part of `requirements.txt` — end users never need it):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'tiktoken>=0.7.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code `1` on any deterministic error. An unaccepted exact duplicate or schema projection is an actionable warning; heuristic near-duplicate candidates remain report-only information and do not enter `findings`. With `--json`, setup failures also use a stable `AUDIT_SETUP_ERROR` JSON envelope instead of a traceback or plain-text error.
|
||||||
|
|
||||||
|
## What It Checks
|
||||||
|
|
||||||
|
| Area | Failure class |
|
||||||
|
|---|---|
|
||||||
|
| Corpus and hot-file token ceilings | error on budget overflow |
|
||||||
|
| Declared load sets (route/stage scenarios) | error on budget overflow, unknown files, selector/registry drift |
|
||||||
|
| Load coverage | error when a corpus file is in no load set and has no `coverage.exempt` entry |
|
||||||
|
| Registry claims (layout patterns, modes, styles, renderings, types, charts) | error on ID/count/index drift |
|
||||||
|
| Markdown references and declared authority edges | error on broken links or unreferenced edges |
|
||||||
|
| Cross-file exact duplicates | warning until adjudicated via `duplicates.accepted` |
|
||||||
|
| Cross-file near duplicates | informational candidates in the report; no finding |
|
||||||
|
| Schema multi-definition | warning for each unaccepted owner-field / projection-path pair; accepted projections stay visible in the report |
|
||||||
|
| Accepted duplicate/schema drift | error when an accepted source or projection no longer matches |
|
||||||
|
|
||||||
|
## Manifest Maintenance — `prompt_audit_manifest.json`
|
||||||
|
|
||||||
|
The manifest is audit-only (`audit_only: true`, `runtime_consumed: false`); it is a lint fixture, never prompt context. It hand-transcribes the load rules stated in `SKILL.md` and the role/workflow docs, so **every change to read instructions in those docs must update the matching load set in the same change** — the coverage check catches unclassified files, but only humans can catch a changed read rule for an existing file.
|
||||||
|
|
||||||
|
- **New corpus file** → when no existing category exemption matches it, the audit fails with `LOAD_COVERAGE_GAP` until you add it to the load sets that read it or exempt it with a one-line reason. Exempt only material that never enters role context (for example, a legacy tombstone, generated maintenance asset, maintainer-only doc, or license notice); represent conditional runtime reads as incremental load sets.
|
||||||
|
- **Intentional exact duplicate** → run `--json`, copy the candidate's `kind`, `fingerprint`, and `paths` into `duplicates.accepted` with a reason. The acceptance identity is all three values, so separate path pairs with identical prose remain independently reviewable. Editing either reported raw block changes its fingerprint; stale acceptance fails with `DUPLICATE_ACCEPTED_STALE`.
|
||||||
|
- **Near duplicate** → inspect `duplicates.near` as heuristic maintenance information. It produces no warning. A maintainer may still record a stable intentional pair in `duplicates.accepted`; `--skip-near-duplicates` deliberately leaves accepted near pairs unchecked because that scan did not run.
|
||||||
|
- **Schema owner** → every configured field must have a field-local definition signal in its declared owner. Split fields into separate owner entries when they belong to different artifacts. Generic `key` / `value` prose elsewhere on a long line is not a grammar signal; explicit assignments, field-local grammar/format language, and forms such as `field ... one of ...` are.
|
||||||
|
- **Intentional schema projection** → run `--json`, then copy the field's `owner_fingerprint` and each reviewed projection's `path` / `fingerprint` into that `schema_grammars[]` entry. Classify every projection as `producer`, `consumer`, `reference`, or `compatibility`, and record a one-line reason:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "skills/ppt-master/templates/schemas/spec_lock.schema.json",
|
||||||
|
"fields": ["page_rhythm"],
|
||||||
|
"scan": ["skills/ppt-master/**/*.md"],
|
||||||
|
"accepted": [
|
||||||
|
{
|
||||||
|
"field": "page_rhythm",
|
||||||
|
"owner_fingerprint": "0123456789ab",
|
||||||
|
"projections": [
|
||||||
|
{
|
||||||
|
"path": "skills/ppt-master/references/executor-base.md",
|
||||||
|
"role": "consumer",
|
||||||
|
"fingerprint": "abcdef012345",
|
||||||
|
"reason": "Executor needs the selected page-rhythm key and closed values."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance is exact, not a path exemption. The owner fingerprint covers the configured field's owner contract; a projection fingerprint covers every current grammar-like line for that field in that path. Owner edits, projection edits, or projection removal fail with `SCHEMA_ACCEPTED_STALE`; a new field/path projection remains `SCHEMA_MULTIDEF_CANDIDATE`. The JSON report separates `schema_grammars[].open`, `.accepted`, and `.stale`, so adjudicated projections and drift remain auditable without turning stale accepted paths into duplicate warnings.
|
||||||
|
- **Budget ceilings** (`budget_policy: fixed_upper_bound`): budgets are stable, deliberately rounded limits rather than mirrors of the current token count. Establish a new ceiling with roughly 10% working headroom and round it up in 250-token increments below 10k, 1k increments below 100k, or 5k increments from 100k upward; the manifest loader enforces those increments. Once set, do not raise or lower a passing ceiling, including to restore headroom after prompt growth. Raise it only after the current audit reports `BUDGET_CORPUS`, `BUDGET_FILE`, or `BUDGET_LOAD_SET` against that exact ceiling; then choose the next rounded limit with comparable headroom, record the overflow-triggering scope in the same change, and leave it unchanged until another actual overflow.
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
# SVG Pipeline Tools
|
||||||
|
|
||||||
|
> **Maintenance boundary**: post-processing modules serve both the on-disk
|
||||||
|
> `svg_final/` preview and in-memory native PPTX conversion. Check both
|
||||||
|
> consumers before changing or removing a step.
|
||||||
|
|
||||||
|
These tools cover post-processing, SVG validation, speaker notes, recorded narration, and PPTX export.
|
||||||
|
|
||||||
|
The supported delivery contract has one PPTX path: `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The mandatory `finalize_svg.py` step separately creates self-contained `svg_final/` visual previews, which may be opened directly or inserted into PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's manual Convert-to-Shape operation is unsupported.
|
||||||
|
|
||||||
|
## `svg_authoring_view.py`
|
||||||
|
|
||||||
|
Create a lightweight editable authoring IR bundle from one PPTX-imported SVG or
|
||||||
|
a directory of imported SVGs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_authoring_view.py <svg-file-or-directory> -o <output-dir> \
|
||||||
|
--projection-kind layered
|
||||||
|
```
|
||||||
|
|
||||||
|
The operation is non-destructive and refuses existing output files unless
|
||||||
|
`--force` is explicit. It never writes back to the source SVG. The JSON report
|
||||||
|
on stdout records original/projected byte counts and removals by category. The
|
||||||
|
output directory contains the editable SVGs, one model-readable
|
||||||
|
`authoring_summary.json`, and one tool-only `authoring_manifest.json`.
|
||||||
|
|
||||||
|
The projected copy:
|
||||||
|
|
||||||
|
- removes embedded `txbody` metadata;
|
||||||
|
- removes hidden native geometry carriers while retaining and unwrapping their
|
||||||
|
visible preview geometry;
|
||||||
|
- removes source-object identity/style/hash attributes that are only useful to
|
||||||
|
an exact import round trip;
|
||||||
|
- keeps visible paths, text, images, stable ids, Master/Layout root markers,
|
||||||
|
selected native-shape intent, and a document-local `data-pptx-source-ref` on
|
||||||
|
each imported logical object;
|
||||||
|
- rewrites relative local asset references for the projection's new location;
|
||||||
|
- compacts imported model-facing frames and safe transform page coordinates to
|
||||||
|
at most two decimals.
|
||||||
|
|
||||||
|
The summary stores the current SVG roster plus compact per-file canvas, size,
|
||||||
|
text, image, vector, placeholder, icon, and source-ref counts. Models read the
|
||||||
|
summary and editable SVGs; they do not read the machine manifest. The manifest
|
||||||
|
stores relative source/authoring filenames, source and initial authoring hashes,
|
||||||
|
and source element paths. It deliberately does not copy the opaque payload.
|
||||||
|
The authoring bundle is the editable source for template creation; the complete
|
||||||
|
imported SVG remains immutable native-payload backing. Final
|
||||||
|
`templates/*.svg` files are materialized and validated from that pair. The IR
|
||||||
|
directory itself is not a supported direct input to `svg_to_pptx.py`.
|
||||||
|
|
||||||
|
Regenerate the summary after direct edits that do not pass through one of the
|
||||||
|
in-place normalization tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_authoring_view.py <authoring-dir> --refresh-summary
|
||||||
|
```
|
||||||
|
|
||||||
|
This projection is separate from canonical preset authoring. New project SVGs
|
||||||
|
and project-owned templates use the compact authored form: one atomic
|
||||||
|
`<g data-pptx-authoring="preset">` owns the preset intent and base paint, with
|
||||||
|
the registry-generated visible `<path>` layers as direct children. Quality
|
||||||
|
check and export rerender the locked registry to validate that group, so the
|
||||||
|
compact form has no hidden carrier, preview wrapper, or serialized preview
|
||||||
|
fingerprint. `pptx_to_svg.py` continues to emit the expanded carrier/preview
|
||||||
|
evidence required for import and round-trip decisions. The normative boundary
|
||||||
|
is owned by [`shared-standards-core.md`](../../references/shared-standards-core.md) §1.5, with
|
||||||
|
authoring guidance in
|
||||||
|
[`native-shape-authoring.md`](../../references/native-shape-authoring.md).
|
||||||
|
|
||||||
|
## Shape Boolean maintenance smoke
|
||||||
|
|
||||||
|
Run this manual smoke from the repository root after changing
|
||||||
|
`shape_boolean_svg.py`, preset geometry, path conversion, or custom-geometry
|
||||||
|
import/export. It uses only a gitignored `projects/_smoke_*` workspace and the
|
||||||
|
inline-smoke convention from [`code-style.md`](../../../../docs/rules/code-style.md)
|
||||||
|
§11; do not turn it into a test file or example deck.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import pathops
|
||||||
|
|
||||||
|
project = Path(tempfile.mkdtemp(prefix="_smoke_shape_boolean_", dir="projects"))
|
||||||
|
scripts = Path("skills/ppt-master/scripts")
|
||||||
|
svg_output = project / "svg_output"
|
||||||
|
svg_output.mkdir()
|
||||||
|
(project / "spec_lock.md").write_text(
|
||||||
|
"""<!-- ppt-master-schema: spec-lock/v1 -->
|
||||||
|
# Execution Lock
|
||||||
|
|
||||||
|
## canvas
|
||||||
|
- viewBox: 0 0 1280 720
|
||||||
|
- format: ppt169
|
||||||
|
## communication
|
||||||
|
- audience:
|
||||||
|
- objective:
|
||||||
|
- core_message:
|
||||||
|
## mode
|
||||||
|
- mode: briefing
|
||||||
|
## visual_style
|
||||||
|
- visual_style: Boolean maintenance smoke
|
||||||
|
## colors
|
||||||
|
- bg: #FFFFFF
|
||||||
|
- primary: #2563EB
|
||||||
|
- accent: #F97316
|
||||||
|
- text: #0F172A
|
||||||
|
## typography
|
||||||
|
- font_family: Arial, sans-serif
|
||||||
|
- title_family: Arial, sans-serif
|
||||||
|
- body_family: Arial, sans-serif
|
||||||
|
- title: 36
|
||||||
|
- body: 20
|
||||||
|
## icons
|
||||||
|
- library: none
|
||||||
|
- inventory: none
|
||||||
|
## page_rhythm
|
||||||
|
- P01: dense
|
||||||
|
## pptx_structure
|
||||||
|
- mode: flat
|
||||||
|
## forbidden
|
||||||
|
- Unsupported SVG constructs
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_tool(script, *args):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(scripts / script), *map(str, args)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
preset = run_tool(
|
||||||
|
"preset_shape_svg.py", "render", "rightArrow",
|
||||||
|
"--id", "preset-source", "--frame", "500", "120", "240", "120",
|
||||||
|
"--fill", "#2563EB", "--stroke", "none",
|
||||||
|
)
|
||||||
|
source = project / "operands.svg"
|
||||||
|
source.write_text(
|
||||||
|
f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||||
|
<defs><clipPath id="clip"><rect width="80" height="80"/></clipPath></defs>
|
||||||
|
<g transform="translate(100 80) scale(1.2)">
|
||||||
|
<rect id="body" x="40" y="40" width="400" height="240" rx="20"
|
||||||
|
fill="#2563EB" stroke="#0F172A" stroke-width="5"
|
||||||
|
stroke-dasharray="10 4"/>
|
||||||
|
<circle id="cutout" cx="240" cy="160" r="70" fill="#F97316"/>
|
||||||
|
<text id="text-cutout" x="240" y="235" text-anchor="middle"
|
||||||
|
font-family="sans-serif" font-size="180" font-weight="700">01</text>
|
||||||
|
<text id="missing-font" x="240" y="235"
|
||||||
|
font-family="Definitely Missing Font" font-size="80">X</text>
|
||||||
|
<text id="nested-text" x="240" y="235"
|
||||||
|
font-family="sans-serif" font-size="80"><tspan>X</tspan></text>
|
||||||
|
<path id="open" d="M 40 320 L 260 320 L 260 420" fill="#2563EB"/>
|
||||||
|
<rect id="clipped" x="40" y="320" width="160" height="100"
|
||||||
|
clip-path="url(#clip)" fill="#2563EB"/>
|
||||||
|
<path id="imported" d="M 240 320 H 400 V 420 H 240 Z"
|
||||||
|
data-pptx-geometry-kind="custom" fill="#2563EB"/>
|
||||||
|
<rect id="dashoffset" x="440" y="320" width="120" height="100"
|
||||||
|
fill="#2563EB" stroke="#0F172A" stroke-dashoffset="2"/>
|
||||||
|
<rect id="non-scaling" x="500" y="40" width="150" height="120"
|
||||||
|
fill="#2563EB" stroke="#0F172A" stroke-width="5"
|
||||||
|
stroke-dasharray="10 4" vector-effect="non-scaling-stroke"/>
|
||||||
|
<circle id="non-scaling-cut" cx="625" cy="100" r="45" fill="#F97316"/>
|
||||||
|
<rect id="far" x="800" y="320" width="100" height="80" fill="#2563EB"/>
|
||||||
|
</g>
|
||||||
|
{preset}
|
||||||
|
<circle id="preset-cut" cx="690" cy="180" r="52" fill="#F97316"/>
|
||||||
|
</svg>
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
("union", "union", "preset-source", "preset-cut"),
|
||||||
|
("combine", "combine", "body", "cutout"),
|
||||||
|
("fragment", "fragment", "body", "cutout"),
|
||||||
|
("intersect", "intersect", "body", "cutout"),
|
||||||
|
("subtract", "subtract", "body", "cutout"),
|
||||||
|
("text-subtract", "subtract", "body", "text-cutout"),
|
||||||
|
]
|
||||||
|
expected_custom_shapes = 0
|
||||||
|
for index, (name, operation, first, second) in enumerate(operations, start=1):
|
||||||
|
fragment = run_tool(
|
||||||
|
"shape_boolean_svg.py", "render", source, "--operation", operation,
|
||||||
|
"--source", first, "--source", second, "--id", f"result-{name}",
|
||||||
|
)
|
||||||
|
paths = list(
|
||||||
|
ET.fromstring(
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg">{fragment}</svg>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert paths and all(path.tag.endswith("}path") for path in paths)
|
||||||
|
assert all(
|
||||||
|
token not in fragment
|
||||||
|
for token in ("clip-path=", "fill-rule=", "mask=", "transform=")
|
||||||
|
)
|
||||||
|
if operation == "fragment":
|
||||||
|
assert len(paths) > 1
|
||||||
|
assert [path.get("id") for path in paths] == [
|
||||||
|
f"result-{name}-{piece}"
|
||||||
|
for piece in range(1, len(paths) + 1)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
assert len(paths) == 1
|
||||||
|
assert paths[0].get("id") == f"result-{name}"
|
||||||
|
if operation == "combine":
|
||||||
|
assert all(path.get("stroke-width") == "6" for path in paths)
|
||||||
|
assert all(path.get("stroke-dasharray") == "12 4.8" for path in paths)
|
||||||
|
if operation == "subtract":
|
||||||
|
assert (paths[0].get("d") or "").count("M ") >= 2
|
||||||
|
|
||||||
|
expected_custom_shapes += len(paths)
|
||||||
|
(svg_output / f"{index:02d}_{name}.svg").write_text(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
|
||||||
|
'data-pptx-page-role="content">'
|
||||||
|
'<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>'
|
||||||
|
f"{fragment}</svg>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
non_scaling = ET.fromstring(
|
||||||
|
run_tool(
|
||||||
|
"shape_boolean_svg.py", "render", source, "--operation", "union",
|
||||||
|
"--source", "non-scaling", "--source", "non-scaling-cut",
|
||||||
|
"--id", "result-non-scaling",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert non_scaling.get("stroke-width") == "5"
|
||||||
|
assert non_scaling.get("stroke-dasharray") == "10 4"
|
||||||
|
assert non_scaling.get("vector-effect") == "non-scaling-stroke"
|
||||||
|
|
||||||
|
rejections = [
|
||||||
|
("union", "body", "open", "open subpath"),
|
||||||
|
("union", "body", "clipped", "uses clip-path"),
|
||||||
|
("union", "body", "imported", "PPTX import/round-trip metadata"),
|
||||||
|
("union", "body", "dashoffset", "stroke-dashoffset"),
|
||||||
|
("intersect", "body", "far", "produced no filled area"),
|
||||||
|
("subtract", "body", "missing-font", "cannot resolve an installed font"),
|
||||||
|
("subtract", "body", "nested-text", "child content is unsupported"),
|
||||||
|
]
|
||||||
|
for operation, first, second, expected_error in rejections:
|
||||||
|
rejected = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(scripts / "shape_boolean_svg.py"),
|
||||||
|
"render", str(source), "--operation", operation,
|
||||||
|
"--source", first, "--source", second,
|
||||||
|
"--id", f"reject-{second}",
|
||||||
|
],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert rejected.returncode != 0
|
||||||
|
assert expected_error in rejected.stderr, rejected.stderr
|
||||||
|
|
||||||
|
run_tool(
|
||||||
|
"svg_quality_checker.py", project,
|
||||||
|
"--quick-generate", "--format", "ppt169",
|
||||||
|
"--stage", "final", "--json",
|
||||||
|
)
|
||||||
|
pptx = project / "boolean-smoke.pptx"
|
||||||
|
run_tool("svg_to_pptx.py", project, "--quick-generate", "-o", pptx)
|
||||||
|
with zipfile.ZipFile(pptx) as archive:
|
||||||
|
slides = [
|
||||||
|
name
|
||||||
|
for name in archive.namelist()
|
||||||
|
if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
|
||||||
|
]
|
||||||
|
custom_shapes = sum(
|
||||||
|
archive.read(name).count(b"<a:custGeom>")
|
||||||
|
for name in slides
|
||||||
|
)
|
||||||
|
assert len(slides) == len(operations), slides
|
||||||
|
assert custom_shapes == expected_custom_shapes
|
||||||
|
|
||||||
|
readback = project / "readback"
|
||||||
|
run_tool(
|
||||||
|
"pptx_to_svg.py", pptx, "-o", readback,
|
||||||
|
"--inheritance-mode", "flat", "--strict",
|
||||||
|
)
|
||||||
|
slides = sorted((readback / "svg").glob("slide_*.svg"))
|
||||||
|
readback_custom_shapes = sum(
|
||||||
|
slide.read_text(encoding="utf-8").count('data-pptx-custgeom="')
|
||||||
|
for slide in slides
|
||||||
|
)
|
||||||
|
assert len(slides) == len(operations), slides
|
||||||
|
assert readback_custom_shapes == expected_custom_shapes
|
||||||
|
print(
|
||||||
|
f"Shape Boolean smoke: passed "
|
||||||
|
f"({expected_custom_shapes} custom shapes; {project})"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
The seven inline negative cases must return nonzero and match their expected
|
||||||
|
errors; every other command must pass. Open the printed
|
||||||
|
`boolean-smoke.pptx` path in PowerPoint: both shape-cut and text-cut Subtract
|
||||||
|
results must have real holes, and every Fragment sibling must remain separately
|
||||||
|
selectable.
|
||||||
|
|
||||||
|
## `compact_svg_coordinates.py`
|
||||||
|
|
||||||
|
Compact safe model-facing page-space coordinates without rewriting unrelated
|
||||||
|
SVG formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/compact_svg_coordinates.py <svg-file-or-directory>
|
||||||
|
python3 scripts/compact_svg_coordinates.py <template-directory> \
|
||||||
|
--inplace --keep-native-frames
|
||||||
|
```
|
||||||
|
|
||||||
|
The default run is a dry-run JSON report. `--inplace` atomically replaces only
|
||||||
|
changed SVG files. The shared create-template final pass uses
|
||||||
|
`--keep-native-frames`: it compacts `data-pptx-bounds`, translation values,
|
||||||
|
rotation centers, and
|
||||||
|
matrix `e/f`, while preserving canonical
|
||||||
|
authored-preset or inline native frames. `svg_authoring_view.py` separately
|
||||||
|
compacts imported model-facing frames because unchanged mirror refs can recover
|
||||||
|
their exact coordinates from immutable lossless backing.
|
||||||
|
|
||||||
|
The compactor never rounds path/points geometry, normalized crop or nested
|
||||||
|
`viewBox` ratios, gradient offsets, opacity, scale arguments, rotation angles,
|
||||||
|
or matrix `a/b/c/d` coefficients. Type A mirror materialization invokes the
|
||||||
|
same compactor before native-record externalization; `standard` and `fidelity`
|
||||||
|
use the shared final pass before template validation.
|
||||||
|
|
||||||
|
## `extract_svg_assets.py`
|
||||||
|
|
||||||
|
Factor large vector subtrees out of lightweight authoring IR documents and
|
||||||
|
replace them with compact `<use data-icon>` references:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/extract_svg_assets.py <layered_svg_dir> \
|
||||||
|
--icons-dir <icons_dir> --icon-namespace imported \
|
||||||
|
--inplace --id-prefix layered
|
||||||
|
python3 scripts/extract_svg_assets.py <flat_svg_dir> \
|
||||||
|
--icons-dir <icons_dir> --icon-namespace imported \
|
||||||
|
--reuse-inventory <layered_inventory.json> \
|
||||||
|
--inplace --id-prefix flat
|
||||||
|
```
|
||||||
|
|
||||||
|
The first pass records a source fingerprint before namespacing each extracted
|
||||||
|
asset's internal ids. The second pass reuses a fingerprint-matched asset and
|
||||||
|
writes no duplicate SVG file. Unmatched flat-only subtrees still extract
|
||||||
|
normally. Use `--clean-stale` on both import-workspace passes to remove stale
|
||||||
|
generated files for their respective prefixes. In create-template workspaces,
|
||||||
|
`imported` is the fixed namespace: assets live once under `icons/imported/`, and
|
||||||
|
the working SVGs reference them as `data-icon="imported/<name>"`. Inventory
|
||||||
|
entries retain source refs from each extracted subtree, allowing expansion to
|
||||||
|
reconnect the authoring-manifest mapping. A rerun on an
|
||||||
|
already rewritten namespaced projection inventories those references and does
|
||||||
|
not progressively extract their remaining parent or sibling geometry. An
|
||||||
|
in-place pass over an authoring bundle refreshes `authoring_summary.json`
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## `mirror_template_materialize.py`
|
||||||
|
|
||||||
|
Compile one Type A PPTX import workspace into a deterministic structured mirror
|
||||||
|
template after the layered authoring IR has been reviewed and edited:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/mirror_template_materialize.py \
|
||||||
|
<import_workspace> <empty_template_workspace>
|
||||||
|
```
|
||||||
|
|
||||||
|
The command treats `<import_workspace>/authoring-svg/` as the sole editable
|
||||||
|
source. It reads the tool-only layered authoring manifest internally and
|
||||||
|
validates it against immutable lossless SVG
|
||||||
|
hashes, source PPTX hash, complete Master/Layout/Slide graph, inheritance
|
||||||
|
visibility facts, source-ref closure, and extracted-vector inventory before it
|
||||||
|
writes anything. It refuses a non-empty destination and stages the whole result
|
||||||
|
before atomic publication, so a failed preflight cannot leave a partial
|
||||||
|
template.
|
||||||
|
|
||||||
|
Materialization preserves source page order and emits one definition-only
|
||||||
|
`layout_<layout_key>.svg` for every source Layout unused by all source Slides.
|
||||||
|
It mechanically expands fixed Master/Layout group wrappers into direct atoms,
|
||||||
|
rehydrates only unchanged converter-supported Slide-local/slot refs, keeps the
|
||||||
|
current SVG fallback for edited refs, preserves explicit text hard breaks, and
|
||||||
|
removes every IR-only source ref. Imported axis-flipped groups retain their
|
||||||
|
geometry reflection while descendant SVG text receives a matching
|
||||||
|
counter-reflection, preserving PowerPoint's upright glyph appearance in browser
|
||||||
|
previews. Supported opaque `p:txBody`,
|
||||||
|
relationship-free `p:style`, and `a:custGeom` payloads are deduplicated into
|
||||||
|
`templates/native_payloads.json.gz`. Repeated native restoration attributes
|
||||||
|
are stored there as short `data-pptx-native-ref` records; page and
|
||||||
|
imported-vector SVGs retain only those record ids and content-hash payload
|
||||||
|
references. The native record referenced by an imported text placeholder
|
||||||
|
carrier owns its authoritative source frame, so the Slide-local frame can
|
||||||
|
differ from reusable Layout bounds without restoring long exact coordinates
|
||||||
|
inline. Structural Master/Layout, placeholder, layer, and editable-object
|
||||||
|
fields remain inline. Source `p:sldLayout@showMasterSp` and
|
||||||
|
`p:sld@showMasterSp` facts become canonical root
|
||||||
|
`data-pptx-show-master-shapes` and
|
||||||
|
`data-pptx-show-inherited-shapes` booleans.
|
||||||
|
|
||||||
|
Checker, template-structure validation, and export hydrate both store layers in
|
||||||
|
memory; legacy inline payload and v1 payload-only stores remain readable.
|
||||||
|
|
||||||
|
The published `ppt-master.template-execution-manifest.v1` roster points to one
|
||||||
|
compact `ppt-master.template-text-slots.v2-min` sidecar per prototype. Each text
|
||||||
|
slot contains only `selector`, `role`, `current_text`, `text_segments`, and
|
||||||
|
`tspan_count`; a top-level tool hash covers its selectors and immutable
|
||||||
|
text/tspan topology and attributes. These records are deterministic tool
|
||||||
|
diagnostics, not page-authoring inputs. Page-context emits only the complete
|
||||||
|
prototype's path and SHA for that reference, so the model reads the SVG once
|
||||||
|
per execution context and reuses it until the SHA changes. The model chooses
|
||||||
|
semantics and edits only existing visible text values, while checker and
|
||||||
|
structured export validate output attributes, text/tspan topology, and
|
||||||
|
referenced-resource hashes against
|
||||||
|
the prototype.
|
||||||
|
|
||||||
|
The output routes reusable vectors once to `icons/imported/`, bitmaps to
|
||||||
|
`images/`, and other referenced files to `templates/assets/`. The JSON report
|
||||||
|
reports payload occurrence, native-record, unique-byte, and compressed-store
|
||||||
|
counts and is written to stdout only. The command intentionally does not create
|
||||||
|
`templates/design_spec.md`; Template_Designer writes the package-specific rules
|
||||||
|
and page roster after materialization. This compiler is for Type A mirror materialization,
|
||||||
|
not `standard` / `fidelity`, loose Type B SVGs, ordinary generation, finalize,
|
||||||
|
or export.
|
||||||
|
|
||||||
|
## `extract_svg_pictures.py`
|
||||||
|
|
||||||
|
Normalize one deliberately selected complex SVG object into one PowerPoint
|
||||||
|
picture. The command accepts exact `<g id>` values only, writes each group as a
|
||||||
|
tight standalone SVG asset, embeds its local image/CSS dependencies, and
|
||||||
|
replaces the source group at the same parent index with one `<image>`. Native
|
||||||
|
export therefore emits one `p:pic` backed by SVG media.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/extract_svg_pictures.py \
|
||||||
|
"<workspace>/authoring-svg/<layered_svg_file>.svg" \
|
||||||
|
--select "<group_id>" \
|
||||||
|
--resource-root "<workspace>" \
|
||||||
|
--images-dir "<workspace>/picture-assets" \
|
||||||
|
--inplace
|
||||||
|
```
|
||||||
|
|
||||||
|
Imported PowerPoint groups normally provide `data-pptx-frame`, which is used
|
||||||
|
as the picture bounds. For a large standalone SVG without frame metadata, the
|
||||||
|
tool measures the selected group with Playwright; use repeated
|
||||||
|
`--bounds ID=x,y,width,height` values when browser measurement is unavailable
|
||||||
|
or when effect overflow needs an explicit frame. `--padding` expands the
|
||||||
|
chosen bounds. The generated `*_picture_asset_inventory.json` records the
|
||||||
|
bounds source, asset hash, copied definition ids, and embedded local resources.
|
||||||
|
Nested selections are accepted only through metadata-only `<g>` ancestors.
|
||||||
|
When an ancestor carries a transform, style, clip, opacity, or other visual
|
||||||
|
attribute, select that outer group instead; this prevents applying the ancestor
|
||||||
|
effect once inside the SVG asset and again to the replacement `<image>`.
|
||||||
|
Scripts, `foreignObject`, SVG animation, remote resources, and external SVG
|
||||||
|
fragment references fail closed; local image/CSS resources must stay inside
|
||||||
|
the declared `--resource-root` and are embedded into the asset.
|
||||||
|
An in-place rewrite inside an authoring bundle refreshes
|
||||||
|
`authoring_summary.json` automatically.
|
||||||
|
|
||||||
|
This operation belongs only to an explicit `create-template` normalization
|
||||||
|
decision in `standard` or `fidelity` mode. It does not choose groups, detect
|
||||||
|
repetition, infer a Master/Layout, or run during ordinary import, free
|
||||||
|
generation, mirror materialization, finalize, or export. Placeholder, native
|
||||||
|
single-shape, table/chart, icon-placeholder, and authored-preset groups are
|
||||||
|
rejected because they already own a different semantic route.
|
||||||
|
|
||||||
|
Do not confuse this tool with `extract_svg_assets.py`:
|
||||||
|
|
||||||
|
- `extract_svg_assets.py` is a model-readability optimization. It replaces
|
||||||
|
heuristic vector runs with `<use data-icon>`, then re-inlines them before
|
||||||
|
export so the PPTX still contains native shapes.
|
||||||
|
- `extract_svg_pictures.py` is an explicit representation change. It replaces
|
||||||
|
only named groups with `<image>`, so each result intentionally remains one
|
||||||
|
editable PowerPoint picture rather than individually editable paths.
|
||||||
|
|
||||||
|
## Recommended Pipeline
|
||||||
|
|
||||||
|
Run these steps one at a time. Wait for each command to exit successfully before
|
||||||
|
starting the next command.
|
||||||
|
|
||||||
|
When the effective Speaker Notes outcome in `design_spec.md §I` is enabled, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
After `total_md_split.py` exits successfully, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/finalize_svg.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
After `finalize_svg.py` exits successfully, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
When Speaker Notes is disabled, skip `total_md_split.py` and use
|
||||||
|
`python3 scripts/svg_to_pptx.py <project_path> --no-notes` for the final
|
||||||
|
command. This prevents stale files under `notes/` from being embedded.
|
||||||
|
|
||||||
|
Do not start another post-processing command while the current command is still
|
||||||
|
running. The canonical gates and success criteria are owned by
|
||||||
|
[`generate-pptx.md`](../../workflows/generate-pptx.md) Step 7.
|
||||||
|
|
||||||
|
## `finalize_svg.py`
|
||||||
|
|
||||||
|
Unified post-processing entry point. This is the preferred way to run SVG cleanup.
|
||||||
|
|
||||||
|
It aggregates:
|
||||||
|
- `embed_icons.py`
|
||||||
|
- static same-document `<use>` expansion from `svg_to_pptx/use_expander.py`
|
||||||
|
- `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here)
|
||||||
|
- `flatten_tspan.py`
|
||||||
|
|
||||||
|
`svg_final/` remains a required Step 7.2 artifact even though the native exporter reads `svg_output/`. It is the self-contained visual reference and may be manually inserted as an SVG picture.
|
||||||
|
|
||||||
|
## `svg_to_pptx.py`
|
||||||
|
|
||||||
|
Convert project SVGs into PPTX.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
|
# Explicit compact image export:
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --image-sizing display --image-scale 2 --image-quality 85
|
||||||
|
# Force original image bytes:
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --no-image-optimize
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --native-charts-and-tables
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure structured # deck/layout template override
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # free-design/brand-only override
|
||||||
|
# Template-import visual round-trip diagnostic only:
|
||||||
|
python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat
|
||||||
|
# Post-processed-source comparison diagnostic only (never a release export):
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> -s final
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --no-notes
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> -t none
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --auto-advance 3
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --animation mixed --animation-duration 0.8
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --reflow-text # opt-in PowerPoint reflow
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --no-merge # one text frame per visual line
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --animation-config animations.json
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --no-animations
|
||||||
|
```
|
||||||
|
|
||||||
|
Native image export defaults to `--image-sizing cap`: it preserves source bytes
|
||||||
|
when no resize or EXIF geometry normalization is required, and re-encodes only
|
||||||
|
images that require one of those transformations. The `display` command above
|
||||||
|
is an explicit compact export; `--no-image-optimize` disables all native image
|
||||||
|
optimization and forces original bytes.
|
||||||
|
|
||||||
|
The normal command reads `pptx_structure.mode` from `spec_lock.md`. For legacy
|
||||||
|
projects whose lock exists but predates that field, export emits one compatibility
|
||||||
|
warning and uses `flat`; no SVG regeneration is required. A missing `spec_lock.md`,
|
||||||
|
an explicit legacy/unknown mode, or a requested `structured` export without an
|
||||||
|
explicit current structured contract remains blocking.
|
||||||
|
|
||||||
|
Explicit direct generation may use the
|
||||||
|
[`quick-generate`](../../workflows/profiles/quick-generate.md) profile after the
|
||||||
|
current agent has converted/read sources, researched identified factual gaps,
|
||||||
|
and prepared the required images, icons, formulas, and resource manifests as
|
||||||
|
needed. That profile skips Strategist, Confirm UI, `design_spec.md`, and
|
||||||
|
`spec_lock.md`; it does not skip the resources required by the authored pages.
|
||||||
|
After the complete SVG roster exists, run its lockless final checker, then
|
||||||
|
export:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_quality_checker.py <project_path> \
|
||||||
|
--quick-generate --stage final --json
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --quick-generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This direct-export flag takes `svg_output/` as its authored page source, resolves
|
||||||
|
valid project-local resources referenced by those pages, infers one consistent
|
||||||
|
canvas, uses flat converter-default package scaffolding, and does not read or
|
||||||
|
require `spec_lock.md`. Notes, motion, narration, native objects, conversion
|
||||||
|
trace, and other ordinary exporter capabilities remain available; notes,
|
||||||
|
custom object animation, and narration start off in Quick and may be enabled
|
||||||
|
when needed. The exporter refuses a missing, blocking, non-final, or stale
|
||||||
|
Quick final report before PPTX creation. Default-path output retains the normal
|
||||||
|
postflight report and `backup/` snapshot; explicit `-o` retains the ordinary
|
||||||
|
no-backup behavior. Existing source, analysis, image/icon/formula, and
|
||||||
|
resource-manifest artifacts remain untouched.
|
||||||
|
|
||||||
|
For generated-project narration, follow the
|
||||||
|
[`generate-audio`](../../workflows/stages/generate-audio.md) stage. It owns voice
|
||||||
|
selection, audio generation, and the narrated re-export workflow.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Default output (either Generate profile, no `-o`):
|
||||||
|
- `exports/<project_name>_<timestamp>.pptx` — native editable pptx (canonical output)
|
||||||
|
- `validation/<project_name>_<timestamp>.report.json` — package postflight, quality-gate linkage, unresolved resource audit, and published part counts
|
||||||
|
- `backup/<timestamp>/svg_output/` — copy of authored SVG source for re-export without re-running the LLM
|
||||||
|
- `exports/` contains only final PPTX deliverables; machine-readable quality and postflight reports belong in `validation/`.
|
||||||
|
- The default Generate flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-generate deliberately skips it.
|
||||||
|
- In both Generate profiles, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; the postflight report still uses the output stem under the project `validation/` directory.
|
||||||
|
- Postflight reruns ZIP integrity and published Slide count. Internal relationships,
|
||||||
|
structured-package validation, transitions, and animations are enforced before the
|
||||||
|
builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated
|
||||||
|
postflight checks.
|
||||||
|
- `font_portability` warns when a complete font stack has no concrete family or when
|
||||||
|
the converter resolves its Latin / East Asian role to a typeface that normally
|
||||||
|
requires a custom installation. A recommended stack such as
|
||||||
|
`"Microsoft YaHei", Arial, sans-serif` does not warn merely because it ends with a
|
||||||
|
generic fallback.
|
||||||
|
- Multiline text export modes:
|
||||||
|
- Default: one editable frame retains authored breaks and disables PowerPoint wrapping. An ordinary generated frame uses PowerPoint's native resize-shape-to-fit-text behavior, so deleting a retained break expands the frame instead of leaving text outside it; imported exact frames and structured multiline placeholder carriers retain fixed-size behavior.
|
||||||
|
- `--reflow-text`: eligible same-size lines become flowing prose that PowerPoint may rewrap; a font-size change, list marker, or accepted larger gap remains a paragraph boundary. Legacy `--merge-paragraphs` aliases this mode.
|
||||||
|
- `--no-merge`: each dy-stacked line becomes an independent frame with its own placement.
|
||||||
|
- Detection is conservative: mixed-layout `<text>` falls back to per-line frames. Use `--reflow-text` only for resizable body copy and `--no-merge` only for independent line objects or absolute line positions.
|
||||||
|
- Native release export reads `svg_output/`. `-s final` is an explicit diagnostic override for comparing conversion behavior against post-processed SVGs; it does not change artifact ownership or create a supported release path.
|
||||||
|
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
|
||||||
|
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
|
||||||
|
page-design source. Templates and locks may guide authoring, but finalize/export
|
||||||
|
never use them to overlay visible content missing from the SVG. Notes, animation,
|
||||||
|
narration, transitions, and direct native-PPTX workflows keep their separate
|
||||||
|
inputs and package-level processing.
|
||||||
|
- For PPTX template-import workspaces, use `-s svg-flat` when you need a visual round-trip check. The layered `svg/` tree is the machine-readable template source and intentionally does not inline inherited master / layout decoration into each slide.
|
||||||
|
- Native mode is strict about unsupported visual SVG elements: if a visual element cannot be represented or safely preserved, export fails with the SVG file, element tag, and position instead of silently dropping content.
|
||||||
|
- Omitting `--pptx-structure` reads `spec_lock.md`. Free-design, brand-only, and `template_reuse_scope: style` releases declare `mode: flat`, omit Master/Layout mappings and SVG structure metadata, and materialize one clean project-owned Master plus one Blank Layout from the current lock. Deck/layout templates use `mode: structured` only for `template_reuse_scope: mirror|layout`, with complete unique `pptx_masters` / `pptx_layouts` rosters and one `page_pptx_layouts` assignment per page. A template-backed Layout definition may remain unused by pages and still register in the final package.
|
||||||
|
- On structured template routes, every page root repeats Master/Layout keys and picker names. Master/Layout fixed visuals are direct semantic atoms. Ordinary layer `<g>` elements are invalid; one validated compact authored-preset `<g>` emitted by `preset_shape_svg.py` is the sole group exception because it compiles to one native shape.
|
||||||
|
- Every visible direct root `<g>` requires root-coordinate `data-pptx-bounds`; nested bounds are ignored. Frame/native metadata never replaces it; placeholder bounds also define the slot frame. Checker compares root bounds with `viewBox`, descendant text with its module using DrawingML wrapping headroom, and every estimable visible text carrier directly with the root `viewBox` before that headroom. Images, shapes, paths, `<use>`, effects, and object frames are excluded from module containment. Per side, ≤`1px` is ignored; module overflow ≤`5%` warns and >`5%` fails, while larger page text overflow always fails. Bounds never clip/reflow; unestimable visible text warns. A wholly off-canvas direct-root Morph endpoint may opt out of page containment with `data-pptx-morph-staging="true"`; it still needs valid module bounds, retained Morph uses an explicit pair, and partial overflow remains blocking.
|
||||||
|
- Missing root bounds fails on final pages/templates and under `--template-mode`; references warn until adapted.
|
||||||
|
- On structured template routes, each normal slot is a direct root `<g id>` with semantic type, positive design-zone bounds, and exactly one compatible carrier. Composite `object` slots use explicit proxy binding; zero-slot Layouts are valid. Flat pages keep all SVG objects Slide-local.
|
||||||
|
- Flat export maps locked typography/colors into a clean project-owned theme/Master, removes stock content placeholders and unused built-in Layouts, retains only the standard date/footer/slide-number capability hooks, and keeps one Blank Layout without promoting Slide content. Structured export additionally creates one reusable Layout per declared key and reopens the package to verify the full Presentation → Master → Layout → Slide graph, fixed-object order, placeholder identities/bounds, carrier bindings, hidden proxies, and zero-slot Layouts.
|
||||||
|
- Template `page_layouts` remains input provenance. Strict preserves the prototype contract; adaptive retains its Master and may use a new Layout identity only when Strategist declared it in the plan and lock. Construction cannot allocate or mutate Layout identity downstream.
|
||||||
|
- Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete Master identity are rejected with a pointer to [`create-template`](../../workflows/create-template.md). Create a new workspace and generate new structured SVG pages; do not upgrade the existing project in place. Explicit flat free-design/brand-only projects intentionally omit Master identity.
|
||||||
|
- Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename.
|
||||||
|
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
|
||||||
|
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
|
||||||
|
- `--conversion-trace` without a path writes `validation/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested.
|
||||||
|
- Formal default and `--quick-generate` release export compute the exact SVG source fingerprint and refuse a missing, unreadable, unsupported, non-final, blocking, stale, or unverifiable final quality report before PPTX creation. A project without `validation/svg_quality_report.json` exits nonzero with the `not-provided` gate status; run the final checker against its current `svg_output/` first. An explicit non-`output` `--source` remains a diagnostic override and bypasses this release gate; postflight still records any verifiable report linkage.
|
||||||
|
- After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
|
||||||
|
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
|
||||||
|
- Before publishing structured template output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication. Flat release instead validates its single referenced Master/Layout shell and exact date/footer/slide-number hook roster before packaging.
|
||||||
|
- Authored SVG clip-path restrictions remain. Crop wrappers use an
|
||||||
|
overflow-hidden viewport; preview-safe shape clips target the inner image in
|
||||||
|
viewBox coordinates, while legacy imported wrapper clips remain compatible.
|
||||||
|
Both map to native picture crop/geometry when possible.
|
||||||
|
- The default Generate flow embeds speaker notes automatically unless `--no-notes` is used; Quick Generate defaults them off and enables them with `--with-notes`
|
||||||
|
- Recorded narration is opt-in:
|
||||||
|
- `notes_to_audio.py` uses `edge-tts` by default, or a configured cloud TTS provider (`elevenlabs`, `minimax`, `qwen`, `cosyvoice`), and generates one audio file per slide into `audio/`
|
||||||
|
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
|
||||||
|
- `--recorded-narration audio` prepares PowerPoint's "recorded timings and narrations": every slide must have matching `m4a` / `mp3` / `wav` audio, `ffprobe` must read every duration, and `--animation-trigger on-click` is rejected
|
||||||
|
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from page-start lead-in + audio duration + page-tail padding. `--narration-start-floor` and `--narration-padding` are independent optional seconds; their defaults are `0.8` and `0.5`, and the post-transition lead-in is `max(0, start floor - transition duration)`
|
||||||
|
- While motion remains enabled, narrated export without an explicit `--animation-config` selects `<project>/narration_animations.json` when either animation sidecar exists; canonical-only cue synchronization therefore blocks until the derived file exists. Narration-independent custom motion explicitly passes `--animation-config animations.json`, even when a derived sidecar also exists
|
||||||
|
- Without animation sidecars, Generate narration may inherit base-report deck motion via `--inherit-motion-from`; direct low-level omission keeps legacy `fade` / no object builds. Use `--no-animations` to remove object/page motion while retaining narration timings
|
||||||
|
- Non-narrated export keeps the existing optional `<project>/animations.json` default
|
||||||
|
- Narration timing merges into the existing slide timing DOM. While motion remains enabled, object-animation rows and the resolved page transition are preserved rather than regenerated; inherited `-a none` suppresses object rows, and `--no-animations` removes both motion layers
|
||||||
|
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
|
||||||
|
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
|
||||||
|
- This is intended for direct PowerPoint video export with "Use recorded timings and narrations"
|
||||||
|
- Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level
|
||||||
|
- Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices`
|
||||||
|
- Page transitions are controlled by `-t/--transition`; per-element object animations are controlled by `-a/--animation`
|
||||||
|
- Per-element animation applies to ordinary top-level SVG `<g id="...">` groups; each group is a PowerPoint shape-target anchor, not necessarily one Animation Pane row. Use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent
|
||||||
|
- An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker
|
||||||
|
- Start mode is set globally by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), or `with-previous` (all together on slide entry). A sidecar row may override it with `trigger`; the slide value is only the inherited Start mode
|
||||||
|
- `on-click` is for live presentations only; recorded narration rejects every row that resolves to it, including a row with `trigger_shape`, because the tool does not generate object-level click timings
|
||||||
|
- Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide
|
||||||
|
- Per-element animation defaults to `none`. `auto` is opt-in (`-a auto`) and maps
|
||||||
|
generic entrance effects from the group's SVG id: information-dense elements
|
||||||
|
get a stable entrance (chart→wipe, card-/step-/pillar-→fly,
|
||||||
|
title/takeaway→fade); image-like and unmatched ids rotate through bounded
|
||||||
|
entrance pools.
|
||||||
|
- `mixed` (legacy) deterministically rotates through the canonical entrance pool; `random` selects from the same entrance pool with a stable seed from the effective deck input. `auto`, `mixed`, and `random` never choose emphasis, motion-path, or exit effects; select an explicit canonical `entrance_*`, `emphasis_*`, `path_*`, or `exit_*` key for those authored duties. `--conversion-trace` records each resolved effect when enabled
|
||||||
|
- `--animation-duration` controls the inherited per-row schedule length (default
|
||||||
|
`0.4`); scalable native effects preserve internal timing ratios, while
|
||||||
|
instantaneous presets keep their authored duration. `--animation-stagger`
|
||||||
|
supplies the default gap between successive non-trigger-shape rows in
|
||||||
|
`after-previous` mode (default `0.5`)
|
||||||
|
- Optional object-level overrides live in `<project>/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`. The scaffold is neutral (`defaults.animation.effect: none`, untouched groups `{}`). A populated group uses either the fully compatible legacy single-effect fields or a non-empty `effects[]`, never both; every `effects[]` row names an explicit effect
|
||||||
|
- Transition/object sound remains off by default. After SVG and visual motion are complete, discover bundled ids with `sound_sync.py list [--query <term>]` and copy only selected ids with `sound_sync.py <project> <namespace>/<sound_id> [...]`. `transition.sound` references a project-relative `.wav`; object-animation `sound` accepts the existing `.m4a`/`.mp3`/`.wav` path contract, while bundled selections use the synced project-relative `.wav`. With no selected cue, do not create `<project>/sounds/`. Export never resolves ids or reads `templates/sounds/` directly
|
||||||
|
- One `effects[]` row becomes one Animation Pane record on the group's shape target. Each row may independently set sequence `order`, `delay`, `duration`, `trigger`, and `trigger_shape`; ordinary rows use page-wide order, while `trigger_shape` rows keep relative order in separate interactive sequences and imply `on-click`
|
||||||
|
- Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission
|
||||||
|
- Generated export reads every slide back and verifies animation row order, including repeated rows on one shape target, trigger, shape target, resolved effect tuple and native behavior signature, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication
|
||||||
|
- The animation writer does not emit paragraph/text-range builds (`p:bldP`), custom freeform motion paths, native Chart/SmartArt build sequences, or media playback commands for grouped SVG content. Direct-PPTX routes preserve source object animation and perform structural package validation only; they do not author effects
|
||||||
|
- The full registry, OOXML rules, and compatibility boundary are documented in [`pptx-animations.md`](./pptx-animations.md)
|
||||||
|
|
||||||
|
Dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install python-pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
## `total_md_split.py`
|
||||||
|
|
||||||
|
Split `total.md` into per-slide note files.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
python3 scripts/total_md_split.py <project_path> -o <output_directory>
|
||||||
|
python3 scripts/total_md_split.py <project_path> -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Each section begins with `# `
|
||||||
|
- Heading text matches the SVG filename
|
||||||
|
- Sections are separated by `---`
|
||||||
|
|
||||||
|
## `svg_quality_checker.py`
|
||||||
|
|
||||||
|
Validate SVG technical compliance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project/svg_output/01_cover.svg
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project/svg_output
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project --stage first-page
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project --stage final --json
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project --format ppt169
|
||||||
|
python3 scripts/svg_quality_checker.py --all examples
|
||||||
|
python3 scripts/svg_quality_checker.py examples/project --export
|
||||||
|
python3 scripts/svg_quality_checker.py path/to/template/templates --template-mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks include:
|
||||||
|
- `viewBox`
|
||||||
|
- banned elements
|
||||||
|
- paint compatibility: unsupported values error; supported non-default spellings such as `rgba()` receive non-blocking recommendations for `#RRGGBB` plus explicit alpha
|
||||||
|
- line-break structure
|
||||||
|
- explicit Master/Layout/slot structure for reusable templates
|
||||||
|
- duplicate empty Layout contracts under different keys
|
||||||
|
|
||||||
|
Warnings are advisory: they require no modification or acknowledgement and do
|
||||||
|
not affect the command's zero exit status. Only errors block the quality gate.
|
||||||
|
|
||||||
|
`--stage first-page` resolves only the first authored SVG and permits an incomplete
|
||||||
|
future page roster. `--stage final` checks the complete project. With `--json`,
|
||||||
|
the final stage writes `validation/svg_quality_report.json`, while the first-page
|
||||||
|
stage writes `validation/svg_quality_first_page_report.json` so it cannot overwrite
|
||||||
|
the release gate (or use `--json-output`). The report separates
|
||||||
|
release failures (`blocking`), changed/new advisories (`introduced`),
|
||||||
|
prototype-identical diagnostics (`inherited`), and source-conversion losses
|
||||||
|
(`source-import`). It also fingerprints every checked SVG so postflight cannot
|
||||||
|
mistake a stale report for the current export gate. On a successful run, use the
|
||||||
|
checker exit status and terminal summary; do not load the complete JSON unless a
|
||||||
|
failure investigation or explicit audit requires targeted fields.
|
||||||
|
|
||||||
|
Template mode accepts the same compact canonical preset groups as generated
|
||||||
|
pages: one atomic `<g data-pptx-authoring="preset">` with direct visible paths.
|
||||||
|
It validates those paths dynamically against the locked registry and does not
|
||||||
|
require an import-style carrier, preview wrapper, fingerprint, or a separate
|
||||||
|
source-payload opt-in marker. Exact syntax remains owned by the linked
|
||||||
|
standards rather than this pipeline overview.
|
||||||
|
|
||||||
|
## `svg_position_calculator.py`
|
||||||
|
|
||||||
|
Analyze and review supported chart coordinates after SVG generation.
|
||||||
|
|
||||||
|
Use this after `svg_quality_checker.py` passes, and only for chart types supported by this script: `bar`, `pie` / `donut`, `radar`, `line` / `area` / `scatter`, and `grid`. Area charts do not have a separate calculator mode: use `calc line` for the upper boundary points, then close the filled region to the plot area's bottom baseline (`y_max`) in the SVG.
|
||||||
|
|
||||||
|
### Calculate expected coordinates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_position_calculator.py calc bar --data "A:185,B:142" --area "130,155,1200,480" --bar-width 120
|
||||||
|
python3 scripts/svg_position_calculator.py calc line --data "0:50,10:80,20:120" --area "120,120,1200,600" --y-range "0,150"
|
||||||
|
python3 scripts/svg_position_calculator.py calc pie --data "A:35,B:25,C:20" --center "420,400" --radius 200
|
||||||
|
python3 scripts/svg_position_calculator.py calc grid --rows 2 --cols 3 --area "50,150,1230,670"
|
||||||
|
```
|
||||||
|
|
||||||
|
For an area chart, use the line output as the top boundary:
|
||||||
|
|
||||||
|
```svg
|
||||||
|
M first_x,first_y ... L last_x,last_y L last_x,y_max L first_x,y_max Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Manually compare the calculator output with the coordinates already present in the generated SVG. If coordinates differ, update the SVG from the `calc` output, rerun `svg_quality_checker.py`, then repeat the coordinate review. The tool intentionally does not rewrite SVG files automatically.
|
||||||
|
|
||||||
|
### Analyze (inspect existing SVG)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_position_calculator.py analyze <svg_file>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this after SVG generation to inspect existing SVG geometry when manual comparison needs more context.
|
||||||
|
|
||||||
|
## Advanced Standalone Tools
|
||||||
|
|
||||||
|
### `flatten_tspan.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_finalize/flatten_tspan.py examples/<project>/svg_output
|
||||||
|
python3 scripts/svg_finalize/flatten_tspan.py path/to/input.svg path/to/output.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
### `align_embed_images.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_finalize/align_embed_images.py path/to/slide.svg
|
||||||
|
python3 scripts/svg_finalize/align_embed_images.py --dry-run path/to/slide.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for rare single-file diagnostics when image `slice` / `meet` alignment and
|
||||||
|
Base64 embedding must be inspected outside `finalize_svg.py`. In normal project
|
||||||
|
runs, use `python3 scripts/finalize_svg.py <project_path>`; the old
|
||||||
|
`crop-images`, `fix-aspect`, and `embed-images` names remain accepted only as
|
||||||
|
`finalize_svg.py --only` aliases for the merged `align-images` step.
|
||||||
|
|
||||||
|
### `embed_icons.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_finalize/embed_icons.py output.svg
|
||||||
|
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
|
||||||
|
python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
Replaces `<use data-icon="chunk-filled/name" .../>`, `<use data-icon="tabler-filled/name" .../>` and `<use data-icon="tabler-outline/name" .../>` placeholders with actual SVG path elements. Use for manual icon embedding checks outside `finalize_svg.py`.
|
||||||
|
|
||||||
|
## SVG Compatibility Contract
|
||||||
|
|
||||||
|
The always-on SVG authoring contract lives in
|
||||||
|
[`shared-standards-core.md`](../../references/shared-standards-core.md), with
|
||||||
|
advanced effects, native data objects, and structured PPTX metadata owned by
|
||||||
|
their conditionally loaded modules. This tool guide does not repeat accepted
|
||||||
|
syntax, rejected constructs, or conditional limits.
|
||||||
|
|
||||||
|
`svg_quality_checker.py` validates source SVG before finalization.
|
||||||
|
`finalize_svg.py` and native export apply the preprocessing required by that
|
||||||
|
contract, while native conversion fails on unsupported visual elements rather
|
||||||
|
than silently dropping them.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Validation Failed
|
||||||
|
|
||||||
|
1. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/project_manager.py validate <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Fix missing files or invalid directories reported by the validator.
|
||||||
|
3. Re-run validation before post-processing or export.
|
||||||
|
|
||||||
|
## SVG Preview Looks Wrong
|
||||||
|
|
||||||
|
1. Check the file path and filename.
|
||||||
|
2. Confirm naming conventions are consistent.
|
||||||
|
3. Run the mandatory post-processing step when you need a self-contained preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/finalize_svg.py <project_path>
|
||||||
|
python3 -m http.server --directory <project_path>/svg_final 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
`svg_final/` is the visual-preview artifact and may be inserted into PowerPoint as an SVG picture. Fix authored content in `svg_output/`, then regenerate `svg_final/`; do not edit the derived preview as the source.
|
||||||
|
|
||||||
|
## Speaker Notes Do Not Split
|
||||||
|
|
||||||
|
Check `total.md`:
|
||||||
|
- headings must start with `# `
|
||||||
|
- heading text must match SVG filenames
|
||||||
|
- sections must be separated by `---`
|
||||||
|
|
||||||
|
Then rerun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
## PPT Export Quality Issues
|
||||||
|
|
||||||
|
Preferred sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
python3 scripts/finalize_svg.py <project_path>
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep all three steps even though they have different consumers: Step 7.2 creates the mandatory `svg_final/` visual preview, while the supported native PPTX exporter reads `svg_output/` directly. Do not pass `-s final` for a release export; that override is diagnostic-only.
|
||||||
|
|
||||||
|
## Inserted SVG Does Not Convert Cleanly to Shapes
|
||||||
|
|
||||||
|
PowerPoint's manual Convert-to-Shape behavior is unsupported. `svg_final/` is supported only as a visual preview / SVG picture. Use the native PPTX generated from `svg_output/` when editable DrawingML shapes are required.
|
||||||
|
|
||||||
|
## Recorded Narration Missing
|
||||||
|
|
||||||
|
1. Generate audio after `total_md_split.py`, so filenames in `audio/` can match split `notes/*.md` files.
|
||||||
|
2. Export with the project-relative audio directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
|
||||||
|
```
|
||||||
|
|
||||||
|
`--recorded-narration` prepares PowerPoint recorded timings and narrations. If it fails, check:
|
||||||
|
- every slide has a matching `m4a`, `mp3`, or `wav` file in `audio/`
|
||||||
|
- `ffprobe` is installed and can read each audio duration
|
||||||
|
- the deck is not using `--animation-trigger on-click`
|
||||||
|
|
||||||
|
Use `--narration-audio-dir audio` only when you intentionally want lower-level, partial audio embedding instead of PowerPoint recorded timings.
|
||||||
|
|
||||||
|
## Dependency Checklist
|
||||||
|
|
||||||
|
Most tools use the standard library. Install extra dependencies only when needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Important optional packages:
|
||||||
|
- `python-pptx` for PPTX export
|
||||||
|
- `edge-tts` for `notes_to_audio.py` recorded narration audio
|
||||||
|
- `Pillow` for image utilities
|
||||||
|
- `numpy` for watermark removal
|
||||||
|
- `PyMuPDF` for PDF conversion
|
||||||
|
- `google-genai` for Gemini image generation
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# update_spec.py
|
||||||
|
|
||||||
|
> **Scope boundary**: this tool updates only deterministic global color and font
|
||||||
|
> substitutions, writes the authoritative `spec_lock.md` only after the SVG
|
||||||
|
> updates succeed, and relies on version control for rollback instead of
|
||||||
|
> creating parallel backups.
|
||||||
|
|
||||||
|
Propagate a `spec_lock.md` value change to both the lock file and every `svg_output/*.svg`. The single edit surface for bulk style tweaks after generation.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/update_spec.py <project_path> <section>.<key>=<value>
|
||||||
|
```
|
||||||
|
|
||||||
|
Bare `<key>=<value>` (no dot) is treated as `colors.<key>=<value>` for backward compat.
|
||||||
|
|
||||||
|
One invocation = one change. The tool:
|
||||||
|
|
||||||
|
1. Reads the old value from `<project_path>/spec_lock.md`
|
||||||
|
2. Plans and propagates the change into every `.svg` under `svg_output/`
|
||||||
|
3. Writes the new value into `spec_lock.md`; a global font replacement updates
|
||||||
|
every existing `typography.*_family` row together
|
||||||
|
4. Prints the list of files touched
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# swap the primary color deck-wide (bare key → colors.primary)
|
||||||
|
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 primary=#0066AA
|
||||||
|
|
||||||
|
# explicit section.key form
|
||||||
|
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 colors.accent=#FF6B35
|
||||||
|
|
||||||
|
# change the deck-wide font family
|
||||||
|
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 \
|
||||||
|
'typography.font_family=Arial, "Microsoft YaHei", sans-serif'
|
||||||
|
```
|
||||||
|
|
||||||
|
## v2 scope
|
||||||
|
|
||||||
|
- **Supported**:
|
||||||
|
- `colors.*` — HEX value replacement across `svg_output/*.svg` (case-insensitive).
|
||||||
|
- `typography.font_family` — replaces the inner value of every
|
||||||
|
`font-family="..."` / `font-family='...'` attribute and sets all existing
|
||||||
|
`typography.*_family` lock rows to that universal family.
|
||||||
|
- **Not supported**: typography sizes, icons, images, canvas, forbidden — these involve attribute-scoped or semantic replacements whose risk/benefit does not warrant bulk propagation. Edit `spec_lock.md` and the affected SVGs by hand, or re-author the pages.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- "Change the primary color across the whole deck" → one `update_spec.py` call
|
||||||
|
- "Switch the deck-wide font family" → one `update_spec.py` call
|
||||||
|
- "Switch an individual page's accent" → just edit that page's SVG directly
|
||||||
|
- "Re-design the palette / type system" → update `spec_lock.md` manually, then the Executor can regenerate affected pages
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- HEX values (e.g. `#005587`) are unique enough in SVG content that literal replacement is safe
|
||||||
|
- `font-family` substitution is scoped to the attribute; the outer quote character is preserved, and switched automatically if the new value contains the same quote
|
||||||
|
- a global font substitution rewrites all existing family-role lock rows in one
|
||||||
|
file write, so the universal SVG result cannot leave stale title/body roles
|
||||||
|
- The tool refuses non-HEX inputs, unknown keys, and unsupported sections
|
||||||
|
- No backups are created — the project folder should be under git so you can diff / revert
|
||||||
|
|
||||||
|
### Note on first `font-family` update
|
||||||
|
|
||||||
|
The script writes the `spec_lock.md` value verbatim into every SVG's `font-family` attribute. If the Executor generated SVGs with quote-flattened font names (e.g. `font-family="Microsoft YaHei, Arial, sans-serif"`) while `spec_lock.md` holds the quoted form (`"Microsoft YaHei", Arial, sans-serif`), the **first** substitution will normalize every SVG to match the `spec_lock.md` literal (e.g. `font-family='"Microsoft YaHei", Arial, sans-serif'`). The two forms are semantically equivalent (CSS and DrawingML parse them identically), but the normalization produces byte-level diffs across every SVG that contains text. Subsequent updates only touch files where the value actually changes.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Video Motion Plan
|
||||||
|
|
||||||
|
`video_motion_plan.py` converts a resolved SVG-to-PPTX conversion trace into a
|
||||||
|
renderer-neutral motion plan. It is the handoff between PPT Master's canonical
|
||||||
|
custom animation and any SVG-native or post-production video renderer.
|
||||||
|
|
||||||
|
The planner exists to prevent a video path from reducing animation to delay
|
||||||
|
values. The conversion trace is authoritative because it already contains the
|
||||||
|
resolved object target, effect, direction tuple, order, duration, offset, slide
|
||||||
|
advance, and native object bounds after sidecar inheritance and narration
|
||||||
|
synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Generate a Resolved Source Trace
|
||||||
|
|
||||||
|
For a narrated deck, generate the trace from the narrated export so its offsets
|
||||||
|
and slide advances include the final audio timing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
|
||||||
|
--recorded-narration audio \
|
||||||
|
--conversion-trace \
|
||||||
|
-o <project_path>/validation/video_motion_source.pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build the motion plan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/video_motion_plan.py \
|
||||||
|
<project_path>/validation/video_motion_source.trace.json \
|
||||||
|
-o <project_path>/validation/video_motion_plan.json \
|
||||||
|
--style adaptive \
|
||||||
|
--force
|
||||||
|
```
|
||||||
|
|
||||||
|
`restrained` and `dynamic` are explicit intensity alternatives. `adaptive` is
|
||||||
|
the default and adjusts enhancement strength from page role, animated-object
|
||||||
|
count, and object area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authority and Locks
|
||||||
|
|
||||||
|
The downstream renderer must preserve:
|
||||||
|
|
||||||
|
- object identity and SVG group id;
|
||||||
|
- object order;
|
||||||
|
- source effect;
|
||||||
|
- semantic direction;
|
||||||
|
- resolved start time and slide timing anchor.
|
||||||
|
|
||||||
|
The video layer may optimize only the declared `optimizer_scope` parameters:
|
||||||
|
easing, travel distance, opacity, scale, mask feather, blur, motion blur, and
|
||||||
|
overshoot. These additions can make motion feel more cinematic without
|
||||||
|
rewriting the presentation's choreography.
|
||||||
|
|
||||||
|
Do not derive a video plan directly from raw `animations.json` or
|
||||||
|
`narration_animations.json`. Raw sidecars may still contain inheritance,
|
||||||
|
`auto`, timing modes, or narration-relative values. Always use the resolved
|
||||||
|
conversion trace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Effect Mapping
|
||||||
|
|
||||||
|
Each object keeps `source_effect` and receives a compatible video family:
|
||||||
|
|
||||||
|
| Source effect | Video family |
|
||||||
|
|---|---|
|
||||||
|
| `entrance_appear` | `hard_reveal` |
|
||||||
|
| `entrance_fade` | `soft_fade` |
|
||||||
|
| `entrance_dissolve` | `grain_dissolve` |
|
||||||
|
| `entrance_fly`, `entrance_ascend` | `directional_slide` |
|
||||||
|
| `entrance_wipe`, `entrance_peek` | `soft_mask_reveal` |
|
||||||
|
| `entrance_zoom`, `entrance_expand`, `entrance_stretch` | `focus_scale` |
|
||||||
|
| `entrance_split` | `split_mask` |
|
||||||
|
| `entrance_box`, `entrance_circle`, `entrance_diamond`, `entrance_plus` | `shape_mask` |
|
||||||
|
| `entrance_blinds`, `entrance_checkerboard`, `entrance_random_bars`, `entrance_strips`, `entrance_wedge`, `entrance_wheel` | `pattern_reveal` |
|
||||||
|
| `entrance_swivel` | `soft_swivel` |
|
||||||
|
|
||||||
|
This mapping is an enhancement contract, not permission to substitute an
|
||||||
|
unrelated effect. A directional slide remains directional; a wipe remains a
|
||||||
|
mask reveal. The retired short effect names remain readable in older
|
||||||
|
conversion traces, but new traces record canonical PowerPoint keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Output Contract
|
||||||
|
|
||||||
|
The output schema is `ppt-master.video-motion-plan.v1`. Each slide records:
|
||||||
|
|
||||||
|
- SVG source and canvas size;
|
||||||
|
- page role;
|
||||||
|
- slide duration and its source;
|
||||||
|
- resolved transition;
|
||||||
|
- ordered animated objects.
|
||||||
|
|
||||||
|
Each object records:
|
||||||
|
|
||||||
|
- `group_id`, native `shape_id`, and order;
|
||||||
|
- source effect and trigger;
|
||||||
|
- absolute `start_ms` and `duration_ms`;
|
||||||
|
- native `bounds_emu` and normalized area;
|
||||||
|
- renderer parameters under `video`.
|
||||||
|
|
||||||
|
The planner rejects click-triggered animation because a rendered video has no
|
||||||
|
interactive click event. Re-export with click-free `after-previous` or
|
||||||
|
`with-previous` timing first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Current Boundary
|
||||||
|
|
||||||
|
This script owns the semantic handoff and deterministic enhancement policy. It
|
||||||
|
does not encode video by itself. A renderer that consumes the plan must report
|
||||||
|
unsupported families instead of silently falling back to delay-only fades.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Visualization Candidate Recall
|
||||||
|
|
||||||
|
`visualization_recall.py` gives Default Strategist or the Quick Generate main
|
||||||
|
agent one bounded deterministic shortlist across the live Chart and Table
|
||||||
|
family registries. It exposes the selected full catalog only when the
|
||||||
|
caller explicitly requests semantic review. The tool reads these indexes on
|
||||||
|
every invocation and maintains no second category or keyword index:
|
||||||
|
|
||||||
|
- `templates/charts/charts_index.json`
|
||||||
|
- `templates/tables/tables_index.json`
|
||||||
|
|
||||||
|
Qualitative Structure does not enter recall. Default records its relationship
|
||||||
|
model in §IX; Quick keeps the same decision in active context. Both load
|
||||||
|
`executor-structure.md` and compose the shapes for the current page.
|
||||||
|
|
||||||
|
## Recall candidates
|
||||||
|
|
||||||
|
Describe one page's information shape with 3-8 concise English semantic tags.
|
||||||
|
Translate source-language or industry terms into structural meaning first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/visualization_recall.py recall \
|
||||||
|
--page P03 \
|
||||||
|
--tag "time series" \
|
||||||
|
--tag "three metrics" \
|
||||||
|
--tag "direction over time" \
|
||||||
|
--limit 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--family chart|table` only when the page semantics already make that
|
||||||
|
boundary certain; the default `all` preserves unified Chart/Table recall.
|
||||||
|
`--limit` accepts 3-8 and defaults to 6. Read the returned JSON unfiltered:
|
||||||
|
`tail`, `head`, `grep`, or another truncator can discard higher-ranked
|
||||||
|
candidates. `confidence` reports lexical strength only and never decides fit.
|
||||||
|
|
||||||
|
At `high` / `medium`, retain `no-template-match` when none fits. At `low` /
|
||||||
|
`none`, select a fitting bounded candidate directly; otherwise rerun the same
|
||||||
|
command once with `--semantic-fallback`, compare the returned selection rules
|
||||||
|
semantically, and only then retain `no-template-match`. The full-catalog review
|
||||||
|
is a narrow low-confidence no-match gate, not a routine recall step.
|
||||||
|
|
||||||
|
| Field | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `page` | Input `P<NN>` page key |
|
||||||
|
| `family_filter` | Requested family or `all` |
|
||||||
|
| `semantic_tags` | Deduplicated input tags |
|
||||||
|
| `confidence` | Lexical recall strength; never a selection decision |
|
||||||
|
| `candidates` | Ranked family/key references, SVG paths, summaries, scores, and matched tags |
|
||||||
|
| `semantic_fallback` | Selected live catalogs, present only with `--semantic-fallback` |
|
||||||
|
| `no_template_match` | Explicit fallback; blocked at low/none until semantic fallback review |
|
||||||
|
|
||||||
|
The scorer treats the key and summary Pick clause as positive evidence and the
|
||||||
|
Skip clause as negative evidence. A term found only in Skip cannot make a
|
||||||
|
candidate eligible. Unicode input is NFKC-normalized before matching. The
|
||||||
|
active profile owner still applies semantic judgment and prefers the most
|
||||||
|
specific valid information structure.
|
||||||
|
|
||||||
|
## Validate selected references
|
||||||
|
|
||||||
|
Validate every selected reference before Default writes Design Spec §VII and
|
||||||
|
`spec_lock.md page_visualizations`, or before Quick opens it for immediate use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/visualization_recall.py validate \
|
||||||
|
chart/line_chart table/record_table
|
||||||
|
```
|
||||||
|
|
||||||
|
The command is read-only. It exits `0` when every supplied reference resolves
|
||||||
|
to a registered SVG and `1` otherwise. New planning supplies canonical
|
||||||
|
`family/key`. When validating an existing legacy mapping, opt into bare-key
|
||||||
|
resolution explicitly; every key must resolve uniquely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/visualization_recall.py validate \
|
||||||
|
--legacy-bare pros_cons_chart
|
||||||
|
```
|
||||||
|
|
||||||
|
A Default `no-template-match` page appears in neither §VII nor
|
||||||
|
`page_visualizations`; record its custom fallback in §IX. A qualitative
|
||||||
|
Structure is not a no-match case: describe its relationships in §IX and build
|
||||||
|
it without catalog lookup.
|
||||||
|
|
||||||
|
## Selection boundary
|
||||||
|
|
||||||
|
- Default records `Page | Family | Template | Usage` for each positive
|
||||||
|
selection and projects `family/key` into `page_visualizations`.
|
||||||
|
- Usage is one concise page-local purpose; detailed adaptation remains in §IX.
|
||||||
|
- Quick keeps the selected reference and purpose only in active context.
|
||||||
|
- Structure is a separate runtime information model, never a catalog candidate
|
||||||
|
or `page_visualizations` entry.
|
||||||
|
- Never serialize `no-template-match`, empty tables, summaries, paths, or
|
||||||
|
runners-up into planning artifacts.
|
||||||
|
- Open only the selected SVG for its mapped page. It is a flexible reference,
|
||||||
|
not a type, geometry, style, or native-replacement lock.
|
||||||
|
|
||||||
|
## Legacy compatibility
|
||||||
|
|
||||||
|
`chart_recall.py` remains a compatibility wrapper for existing callers. It
|
||||||
|
uses the same scorer and live Chart/Table registries, preserves bare-key
|
||||||
|
validation and the historical JSON shape, and resolves live candidates to their
|
||||||
|
current family paths. New prompts and automation use `visualization_recall.py`.
|
||||||
|
|
||||||
|
When `validate --legacy-bare` receives one of the 36 retired canonical Structure
|
||||||
|
bare keys, the shared resolver returns `kind=legacy-structure-intent`,
|
||||||
|
`family=structure`, and the original key without `path` or `reference`. This
|
||||||
|
compatibility result is a semantic hint for old `page_charts`, not a live catalog
|
||||||
|
entry, and never participates in recall.
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Error Message Helper
|
||||||
|
|
||||||
|
Provides user-friendly error messages and specific fix suggestions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorHelper:
|
||||||
|
"""Error message helper."""
|
||||||
|
|
||||||
|
# Error types and their corresponding fix suggestions
|
||||||
|
ERROR_SOLUTIONS = {
|
||||||
|
'missing_readme': {
|
||||||
|
'message': 'Missing README.md file',
|
||||||
|
'solutions': [
|
||||||
|
'Create a README.md file with project description, usage instructions, etc.',
|
||||||
|
'Document the project goal, source material, canvas, generated artifacts, and export path',
|
||||||
|
'Keep project-specific instructions local instead of copying a repository example'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'missing_spec': {
|
||||||
|
'message': 'Missing design specification file',
|
||||||
|
'solutions': [
|
||||||
|
'Create a design_spec.md file',
|
||||||
|
'Include: canvas specs, color scheme, font specs, layout specs, content outline',
|
||||||
|
'Refer to the design specification generated by the Strategist role'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
},
|
||||||
|
'missing_svg_output': {
|
||||||
|
'message': 'Missing svg_output directory',
|
||||||
|
'solutions': [
|
||||||
|
'Create the svg_output directory: mkdir svg_output',
|
||||||
|
'Place generated SVG files in this directory',
|
||||||
|
'Ensure SVG files follow naming convention: slide_XX_name.svg'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'empty_svg_output': {
|
||||||
|
'message': 'svg_output directory is empty',
|
||||||
|
'solutions': [
|
||||||
|
'Use the AI role (Executor) to generate SVG files',
|
||||||
|
'Save SVG files to the svg_output directory',
|
||||||
|
'Ensure file naming format: slide_01_cover.svg, slide_02_content.svg, etc.'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
},
|
||||||
|
'invalid_svg_naming': {
|
||||||
|
'message': 'Non-standard SVG file naming',
|
||||||
|
'solutions': [
|
||||||
|
'Rename SVG files using format: slide_XX_name.svg',
|
||||||
|
'XX should be a two-digit number (01, 02, ...)',
|
||||||
|
'name should use English or pinyin, separated by underscores',
|
||||||
|
'Example: slide_01_cover.svg, slide_02_overview.svg'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
},
|
||||||
|
'missing_project_date': {
|
||||||
|
'message': 'Project directory missing date suffix',
|
||||||
|
'solutions': [
|
||||||
|
'Rename the project directory to add a date suffix: _YYYYMMDD',
|
||||||
|
'Format: {project_name}_{format}_{YYYYMMDD}',
|
||||||
|
'Example: my_project_ppt169_20251116',
|
||||||
|
'Command: mv old_name new_name_ppt169_20251116'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
},
|
||||||
|
'viewbox_mismatch': {
|
||||||
|
'message': 'SVG viewBox differs from the recorded canvas format',
|
||||||
|
'solutions': [
|
||||||
|
'Check the viewBox attribute of SVG files',
|
||||||
|
'Treat the root viewBox as the actual canvas size',
|
||||||
|
'If the project metadata is stale, export will use the SVG viewBox',
|
||||||
|
'PPT 16:9 should be: viewBox="0 0 1280 720"',
|
||||||
|
'PPT 4:3 should be: viewBox="0 0 1024 768"',
|
||||||
|
'Reference: references/canvas-formats.md'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
},
|
||||||
|
'multiple_viewboxes': {
|
||||||
|
'message': 'Multiple different viewBox settings detected',
|
||||||
|
'solutions': [
|
||||||
|
'Unify the viewBox across all SVG files',
|
||||||
|
'All pages in the same project should use the same canvas size',
|
||||||
|
'Use find-and-replace tools for batch correction',
|
||||||
|
'Reference the viewBox setting of the first page'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
},
|
||||||
|
'no_viewbox': {
|
||||||
|
'message': 'SVG file missing viewBox attribute',
|
||||||
|
'solutions': [
|
||||||
|
'Add the viewBox attribute to the SVG root element',
|
||||||
|
'Format: <svg viewBox="0 0 1280 720" ...>',
|
||||||
|
'Root width/height are optional compatibility attributes',
|
||||||
|
'The root viewBox is mandatory for SVG generation'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'foreignobject_detected': {
|
||||||
|
'message': 'Forbidden <foreignObject> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <foreignObject> elements',
|
||||||
|
'Use <text> + <tspan> for manual line wrapping',
|
||||||
|
'This is a project technical specification requirement',
|
||||||
|
'Reference: references/shared-standards-core.md'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'clippath_on_non_image': {
|
||||||
|
'message': 'clip-path is only allowed on <image> elements',
|
||||||
|
'solutions': [
|
||||||
|
'Remove clip-path from shapes / groups / text',
|
||||||
|
'Draw the target geometry directly with the matching native element: <circle> / <ellipse> / <rect rx="..."> / <polygon> / <path>. A rect clipped to a circle is just a <circle>.',
|
||||||
|
'clip-path on <image> is conditionally allowed — see references/shared-standards-core.md §1.2'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'clippath_def_missing': {
|
||||||
|
'message': 'clip-path references a <clipPath> id that does not exist in <defs>',
|
||||||
|
'solutions': [
|
||||||
|
'Define the referenced <clipPath id="..."> inside <defs>',
|
||||||
|
'The clipPath must contain exactly one shape child (circle / ellipse / rect with rx,ry / path / polygon)',
|
||||||
|
'Reference: references/shared-standards-core.md §1.2'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'mask_detected': {
|
||||||
|
'message': 'Forbidden <mask> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <mask> elements',
|
||||||
|
'PPT does not support SVG masks',
|
||||||
|
'Use opacity (opacity/fill-opacity) as an alternative'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'style_element_detected': {
|
||||||
|
'message': 'Forbidden <style> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <style> elements',
|
||||||
|
'Convert CSS styles to inline attributes',
|
||||||
|
'Example: fill="#000" instead of class="text-black"'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'class_attribute_detected': {
|
||||||
|
'message': 'Forbidden class attribute detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove all class attributes',
|
||||||
|
'Use inline styles instead',
|
||||||
|
'Example: fill="#000" stroke="#333" directly on the element'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'id_attribute_detected': {
|
||||||
|
'message': 'Forbidden CSS selector usage with id detected',
|
||||||
|
'solutions': [
|
||||||
|
'Keep IDs for local references or documented semantic/animation groups',
|
||||||
|
'Remove <style> rules and CSS selectors',
|
||||||
|
'Use inline presentation attributes instead'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'external_css_detected': {
|
||||||
|
'message': 'Forbidden external CSS reference detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <?xml-stylesheet?> declarations',
|
||||||
|
'Remove <link rel="stylesheet"> references',
|
||||||
|
'Remove @import external styles',
|
||||||
|
'Convert styles to inline attributes'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
# Note: <marker> and marker-end are NO LONGER forbidden — they are
|
||||||
|
# conditionally allowed (see references/shared-standards-core.md §1.1).
|
||||||
|
# The converter maps qualifying markers to native DrawingML arrow heads.
|
||||||
|
'marker_orphan_ref': {
|
||||||
|
'message': 'marker-start/marker-end references a marker id, but no <marker> element is defined',
|
||||||
|
'solutions': [
|
||||||
|
'Define the <marker> inside <defs>',
|
||||||
|
'Or remove the marker-start/marker-end attribute',
|
||||||
|
'See shared-standards-core.md §1.1 for marker constraints',
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'event_attribute_detected': {
|
||||||
|
'message': 'Forbidden event attribute detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove onclick/onload and other event attributes',
|
||||||
|
'Scripts and event handling are forbidden in SVG',
|
||||||
|
'Implement interactivity in PPT instead'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'set_detected': {
|
||||||
|
'message': 'Forbidden <set> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <set> elements',
|
||||||
|
'SVG animations will not be exported to PPT',
|
||||||
|
'Use PPT native animations for animation effects'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'iframe_detected': {
|
||||||
|
'message': 'Forbidden <iframe> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <iframe> elements',
|
||||||
|
'External pages should not be embedded in SVG'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'textpath_detected': {
|
||||||
|
'message': 'Forbidden <textPath> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <textPath> elements',
|
||||||
|
'Text on path is not compatible with PPT',
|
||||||
|
'Use regular <text> elements and adjust position manually'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'webfont_detected': {
|
||||||
|
'message': 'Forbidden web font (@font-face) detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove @font-face declarations',
|
||||||
|
'Use font-family stacks that export PPT-safe pre-installed typefaces',
|
||||||
|
'Example: font-family: "Microsoft YaHei", Arial, sans-serif'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'animation_detected': {
|
||||||
|
'message': 'Forbidden SMIL animation element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove all <animate>, <animateMotion>, <animateTransform> and similar elements',
|
||||||
|
'SVG animations will not be exported to PPT',
|
||||||
|
'Use PPT native animations for animation effects'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'script_detected': {
|
||||||
|
'message': 'Forbidden <script> element detected',
|
||||||
|
'solutions': [
|
||||||
|
'Remove <script> elements',
|
||||||
|
'Scripts and event handling are forbidden',
|
||||||
|
'JavaScript in SVG will not execute in PPT'
|
||||||
|
],
|
||||||
|
'severity': 'error'
|
||||||
|
},
|
||||||
|
'invalid_font': {
|
||||||
|
'message': 'Font stack exports non-PPT-safe typefaces to PPTX',
|
||||||
|
'solutions': [
|
||||||
|
'Use stacks whose exported Latin / EA typefaces are pre-installed',
|
||||||
|
'CJK: "Microsoft YaHei", sans-serif | SimSun, serif',
|
||||||
|
'Latin: Arial, sans-serif | "Times New Roman", serif',
|
||||||
|
'Mono: Consolas, "Courier New", monospace',
|
||||||
|
'See strategist.md §g for the full PPT-safe discipline'
|
||||||
|
],
|
||||||
|
'severity': 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_solution(cls, error_type: str, context: Optional[Dict] = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Get the solution for an error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_type: Error type
|
||||||
|
context: Context information (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing message, solutions, severity
|
||||||
|
"""
|
||||||
|
if error_type in cls.ERROR_SOLUTIONS:
|
||||||
|
solution = cls.ERROR_SOLUTIONS[error_type].copy()
|
||||||
|
|
||||||
|
# Customize message based on context
|
||||||
|
if context:
|
||||||
|
solution = cls._customize_solution(solution, context)
|
||||||
|
|
||||||
|
return solution
|
||||||
|
|
||||||
|
# Unknown error type
|
||||||
|
return {
|
||||||
|
'message': 'Unknown error',
|
||||||
|
'solutions': ['Please check the documentation or contact the maintainer'],
|
||||||
|
'severity': 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _customize_solution(cls, solution: Dict, context: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Customize solution based on context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
solution: Original solution
|
||||||
|
context: Context information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customized solution
|
||||||
|
"""
|
||||||
|
customized = solution.copy()
|
||||||
|
|
||||||
|
# Customize based on project path
|
||||||
|
if 'project_path' in context:
|
||||||
|
project_path = context['project_path']
|
||||||
|
customized['solutions'] = [
|
||||||
|
s.replace('<project_path>', project_path).replace(
|
||||||
|
'<your_project>', project_path)
|
||||||
|
for s in customized['solutions']
|
||||||
|
]
|
||||||
|
|
||||||
|
# Customize based on filename
|
||||||
|
if 'file_name' in context:
|
||||||
|
file_name = context['file_name']
|
||||||
|
customized['message'] = f"{customized['message']}: {file_name}"
|
||||||
|
|
||||||
|
# Customize based on expected/actual values
|
||||||
|
if 'expected' in context and 'actual' in context:
|
||||||
|
customized['message'] += f" (expected: {context['expected']}, actual: {context['actual']})"
|
||||||
|
|
||||||
|
return customized
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def format_error_message(cls, error_type: str, context: Optional[Dict] = None) -> str:
|
||||||
|
"""
|
||||||
|
Format error message (for terminal output).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_type: Error type
|
||||||
|
context: Context information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted error message string
|
||||||
|
"""
|
||||||
|
solution = cls.get_solution(error_type, context)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Error message
|
||||||
|
severity_icon = "[ERROR]" if solution['severity'] == 'error' else "[WARN]"
|
||||||
|
lines.append(f"{severity_icon} {solution['message']}")
|
||||||
|
|
||||||
|
# Solutions
|
||||||
|
if solution['solutions']:
|
||||||
|
lines.append("\nSuggested fixes:")
|
||||||
|
for i, sol in enumerate(solution['solutions'], 1):
|
||||||
|
lines.append(f" {i}. {sol}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def print_error(cls, error_type: str, context: Optional[Dict] = None):
|
||||||
|
"""
|
||||||
|
Print formatted error message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_type: Error type
|
||||||
|
context: Context information
|
||||||
|
"""
|
||||||
|
print(cls.format_error_message(error_type, context))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all_error_types(cls) -> List[str]:
|
||||||
|
"""Get all supported error types."""
|
||||||
|
return list(cls.ERROR_SOLUTIONS.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def print_help(cls):
|
||||||
|
"""Print all error types and solutions."""
|
||||||
|
print("PPT Master - Error Types and Solutions\n")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
for error_type, info in cls.ERROR_SOLUTIONS.items():
|
||||||
|
print(f"\n[{error_type}]")
|
||||||
|
print(f"Message: {info['message']}")
|
||||||
|
print(f"Severity: {info['severity']}")
|
||||||
|
print("Solutions:")
|
||||||
|
for i, sol in enumerate(info['solutions'], 1):
|
||||||
|
print(f" {i}. {sol}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the command-line parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Look up PPT Master error messages and suggested fixes.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"error_type",
|
||||||
|
nargs="?",
|
||||||
|
choices=sorted(ErrorHelper.ERROR_SOLUTIONS),
|
||||||
|
help="Error type to explain",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"context",
|
||||||
|
nargs="*",
|
||||||
|
metavar="key=value",
|
||||||
|
help="Optional context values used by templates",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run the CLI entry point for error lookup."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not args.error_type:
|
||||||
|
ErrorHelper.print_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
for item in args.context:
|
||||||
|
if '=' not in item:
|
||||||
|
parser.error(f"context values must use key=value syntax: {item}")
|
||||||
|
key, value = item.split('=', 1)
|
||||||
|
context[key] = value
|
||||||
|
|
||||||
|
print(ErrorHelper.format_error_message(args.error_type, context))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Large Vector Asset Extractor
|
||||||
|
|
||||||
|
Factor large inline vector groups (complex illustrations) out of working SVGs
|
||||||
|
into project icon assets, leaving a one-line `<use data-icon="namespace/id"/>`
|
||||||
|
placeholder behind — so the working SVG stays readable (structure, not a wall of
|
||||||
|
`<path>`). Visually lossless and reversible: the existing icon embedding path
|
||||||
|
re-inlines each asset before export, so the exported PPTX remains native shapes,
|
||||||
|
not an embedded picture.
|
||||||
|
|
||||||
|
Because re-inlining restores the extracted vector subtree, the detection
|
||||||
|
threshold is a readability convenience — it changes which blobs are factored
|
||||||
|
out, not whether the export stays editable.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/extract_svg_assets.py <svg_dir> [options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/extract_svg_assets.py import_ws/authoring-svg \
|
||||||
|
--icons-dir import_ws/icons --icon-namespace imported \
|
||||||
|
--inplace --id-prefix layered --clean-stale
|
||||||
|
python3 scripts/extract_svg_assets.py import_ws/authoring-svg-flat \
|
||||||
|
--icons-dir import_ws/icons --icon-namespace imported \
|
||||||
|
--reuse-inventory import_ws/authoring-svg_vector_asset_inventory.json \
|
||||||
|
--inplace --id-prefix flat --clean-stale
|
||||||
|
python3 scripts/extract_svg_assets.py project/svg_output --inplace --min-drawables 40
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (standard library only).
|
||||||
|
|
||||||
|
See workflows/create-template.md and svg_finalize/embed_icons.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
from svg_authoring_view import (
|
||||||
|
AUTHORING_MANIFEST_NAME,
|
||||||
|
write_authoring_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
SVG_NS = "http://www.w3.org/2000/svg"
|
||||||
|
DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"}
|
||||||
|
SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"}
|
||||||
|
DEFAULT_MIN_DRAWABLES = 20
|
||||||
|
DEFAULT_MIN_BYTES = 3000
|
||||||
|
DEFAULT_MIN_DECORATION_BYTES = 3000
|
||||||
|
SOURCE_REF_ATTRIBUTE = "data-pptx-source-ref"
|
||||||
|
ICON_NAMESPACE_RE = re.compile(r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$")
|
||||||
|
URL_REF_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"]\S*?)\1\s*\)")
|
||||||
|
|
||||||
|
|
||||||
|
def _local(tag: object) -> str:
|
||||||
|
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _drawable_count(elem: ET.Element) -> int:
|
||||||
|
return sum(1 for e in elem.iter() if _local(e.tag) in DRAWABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_size(elem: ET.Element) -> int:
|
||||||
|
if not any(item.get(SOURCE_REF_ATTRIBUTE) for item in elem.iter()):
|
||||||
|
return len(ET.tostring(elem, encoding="utf-8"))
|
||||||
|
measured = copy.deepcopy(elem)
|
||||||
|
for item in measured.iter():
|
||||||
|
item.attrib.pop(SOURCE_REF_ATTRIBUTE, None)
|
||||||
|
return len(ET.tostring(measured, encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _large_enough(elem: ET.Element, min_drawables: int, min_bytes: int) -> bool:
|
||||||
|
return _drawable_count(elem) >= min_drawables or _xml_size(elem) >= min_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _has_semantic_content(elem: ET.Element) -> bool:
|
||||||
|
"""Text-bearing groups must stay readable/editable in the working SVG."""
|
||||||
|
return any(_local(e.tag) in SEMANTIC_CONTENT for e in elem.iter())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_existing_placeholder(elem: ET.Element) -> bool:
|
||||||
|
return _local(elem.tag) == "use" and elem.get("data-icon") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_icon_placeholder(elem: ET.Element) -> bool:
|
||||||
|
return any(_is_existing_placeholder(item) for item in elem.iter())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_extractable_subtree(elem: ET.Element) -> bool:
|
||||||
|
"""Pure vector subtrees can be moved; semantic content must stay inline."""
|
||||||
|
if _has_icon_placeholder(elem) or _is_chart_group(elem) or _has_semantic_content(elem):
|
||||||
|
return False
|
||||||
|
return _drawable_count(elem) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_chart_group(elem: ET.Element) -> bool:
|
||||||
|
"""Charts are handled separately (data + calibration) — never extract them."""
|
||||||
|
gid = (elem.get("id") or "").lower()
|
||||||
|
if "chart" in gid:
|
||||||
|
return True
|
||||||
|
return any("chart" in (e.get("id") or "").lower() for e in elem.iter())
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_histogram(elem: ET.Element) -> dict[str, int]:
|
||||||
|
hist: dict[str, int] = {}
|
||||||
|
for e in elem.iter():
|
||||||
|
name = _local(e.tag)
|
||||||
|
if name in DRAWABLE:
|
||||||
|
hist[name] = hist.get(name, 0) + 1
|
||||||
|
return hist
|
||||||
|
|
||||||
|
|
||||||
|
def _source_references(elem: ET.Element) -> list[str]:
|
||||||
|
return sorted({
|
||||||
|
source_ref
|
||||||
|
for item in elem.iter()
|
||||||
|
if (source_ref := item.get(SOURCE_REF_ATTRIBUTE))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_descendant(container: ET.Element, candidate: ET.Element) -> bool:
|
||||||
|
return any(elem is candidate for elem in container.iter())
|
||||||
|
|
||||||
|
|
||||||
|
def _id_index(root: ET.Element) -> dict[str, ET.Element]:
|
||||||
|
return {elem_id: elem for elem in root.iter() if (elem_id := elem.get("id"))}
|
||||||
|
|
||||||
|
|
||||||
|
def _referenced_ids(elem: ET.Element) -> set[str]:
|
||||||
|
refs: set[str] = set()
|
||||||
|
for item in elem.iter():
|
||||||
|
for attr_name, value in item.attrib.items():
|
||||||
|
refs.update(match.group(2) for match in URL_REF_RE.finditer(value))
|
||||||
|
if _local(attr_name) == "href" and value.startswith("#") and len(value) > 1:
|
||||||
|
refs.add(value[1:])
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_elements(root: ET.Element, asset_group: ET.Element) -> list[ET.Element]:
|
||||||
|
"""
|
||||||
|
Return external definition elements referenced by the extracted subtree.
|
||||||
|
|
||||||
|
Gradients, patterns, filters, clip paths, and markers often live in the
|
||||||
|
source SVG root <defs>. The extracted asset must carry these dependencies
|
||||||
|
itself; otherwise the standalone icon and later re-inline can lose styling.
|
||||||
|
"""
|
||||||
|
by_id = _id_index(root)
|
||||||
|
dependencies: list[ET.Element] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
queue = sorted(_referenced_ids(asset_group))
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
ref_id = queue.pop(0)
|
||||||
|
if ref_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ref_id)
|
||||||
|
|
||||||
|
target = by_id.get(ref_id)
|
||||||
|
if target is None or _is_descendant(asset_group, target):
|
||||||
|
continue
|
||||||
|
|
||||||
|
dependencies.append(target)
|
||||||
|
for nested_ref in sorted(_referenced_ids(target)):
|
||||||
|
if nested_ref not in seen:
|
||||||
|
queue.append(nested_ref)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_id_mapping(asset_id: str, group: ET.Element, dependencies: list[ET.Element]) -> dict[str, str]:
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for root in [group, *dependencies]:
|
||||||
|
for elem in root.iter():
|
||||||
|
elem_id = elem.get("id")
|
||||||
|
if elem_id and elem_id not in mapping:
|
||||||
|
mapping[elem_id] = f"{asset_id}_{elem_id}"
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_references(elem: ET.Element, id_mapping: dict[str, str]) -> None:
|
||||||
|
def rewrite_url(match: re.Match[str]) -> str:
|
||||||
|
quote, ref_id = match.group(1), match.group(2)
|
||||||
|
new_id = id_mapping.get(ref_id, ref_id)
|
||||||
|
return f"url({quote}#{new_id}{quote})"
|
||||||
|
|
||||||
|
for item in elem.iter():
|
||||||
|
elem_id = item.get("id")
|
||||||
|
if elem_id in id_mapping:
|
||||||
|
item.set("id", id_mapping[elem_id])
|
||||||
|
|
||||||
|
for attr_name, value in list(item.attrib.items()):
|
||||||
|
rewritten = URL_REF_RE.sub(rewrite_url, value)
|
||||||
|
if _local(attr_name) == "href" and value.startswith("#") and value[1:] in id_mapping:
|
||||||
|
rewritten = f"#{id_mapping[value[1:]]}"
|
||||||
|
if rewritten != value:
|
||||||
|
item.set(attr_name, rewritten)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> list[ET.Element]:
|
||||||
|
"""Outermost <g> groups whose drawable count clears the threshold (no nesting)."""
|
||||||
|
found: list[ET.Element] = []
|
||||||
|
|
||||||
|
def walk(elem: ET.Element) -> None:
|
||||||
|
for child in list(elem):
|
||||||
|
if _local(child.tag) != "g":
|
||||||
|
walk(child)
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
not _is_chart_group(child)
|
||||||
|
and not _has_semantic_content(child)
|
||||||
|
and not _has_icon_placeholder(child)
|
||||||
|
and _large_enough(child, min_drawables, min_bytes)
|
||||||
|
):
|
||||||
|
found.append(child) # outermost qualifying — do not descend
|
||||||
|
else:
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
walk(root)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _find_extractable_runs(
|
||||||
|
root: ET.Element,
|
||||||
|
min_drawables: int,
|
||||||
|
min_bytes: int,
|
||||||
|
min_decoration_bytes: int,
|
||||||
|
) -> list[tuple[ET.Element, list[ET.Element]]]:
|
||||||
|
"""
|
||||||
|
Consecutive pure-vector children inside mixed groups.
|
||||||
|
|
||||||
|
PPT exports often flatten text and large vector decorations as siblings
|
||||||
|
under the same parent. Whole-group extraction would hide text, so only the
|
||||||
|
contiguous vector runs are factored out.
|
||||||
|
"""
|
||||||
|
found: list[tuple[ET.Element, list[ET.Element]]] = []
|
||||||
|
|
||||||
|
def flush(parent: ET.Element, run: list[ET.Element]) -> None:
|
||||||
|
byte_threshold = min_decoration_bytes if _has_semantic_content(parent) else min_bytes
|
||||||
|
if run and (
|
||||||
|
sum(_drawable_count(child) for child in run) >= min_drawables
|
||||||
|
or sum(_xml_size(child) for child in run) >= byte_threshold
|
||||||
|
):
|
||||||
|
found.append((parent, list(run)))
|
||||||
|
|
||||||
|
def walk(elem: ET.Element) -> None:
|
||||||
|
run: list[ET.Element] = []
|
||||||
|
for child in list(elem):
|
||||||
|
if _is_extractable_subtree(child):
|
||||||
|
run.append(child)
|
||||||
|
continue
|
||||||
|
flush(elem, run)
|
||||||
|
run = []
|
||||||
|
walk(child)
|
||||||
|
flush(elem, run)
|
||||||
|
|
||||||
|
walk(root)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_svg(
|
||||||
|
group: ET.Element,
|
||||||
|
dependencies: list[ET.Element],
|
||||||
|
view_box: str | None,
|
||||||
|
width: str | None,
|
||||||
|
height: str | None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Standalone, independently-viewable SVG carrying the group in page coords."""
|
||||||
|
svg = ET.Element(f"{{{SVG_NS}}}svg")
|
||||||
|
svg.set("data-icon-style", "preserve-color")
|
||||||
|
if view_box:
|
||||||
|
svg.set("viewBox", view_box)
|
||||||
|
if width:
|
||||||
|
svg.set("width", width)
|
||||||
|
if height:
|
||||||
|
svg.set("height", height)
|
||||||
|
if dependencies:
|
||||||
|
defs = ET.SubElement(svg, f"{{{SVG_NS}}}defs")
|
||||||
|
for dependency in dependencies:
|
||||||
|
defs.append(dependency)
|
||||||
|
svg.append(group)
|
||||||
|
return ET.tostring(svg, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_sha256(
|
||||||
|
group: ET.Element,
|
||||||
|
dependencies: list[ET.Element],
|
||||||
|
view_box: str | None,
|
||||||
|
width: str | None,
|
||||||
|
height: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Fingerprint an extracted subtree before asset-id namespacing."""
|
||||||
|
payload = _asset_svg(
|
||||||
|
copy.deepcopy(group),
|
||||||
|
[copy.deepcopy(dependency) for dependency in dependencies],
|
||||||
|
view_box,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_group(nodes: list[ET.Element]) -> ET.Element:
|
||||||
|
group = ET.Element(f"{{{SVG_NS}}}g")
|
||||||
|
for node in nodes:
|
||||||
|
group.append(node)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_id(svg_path: Path, index: int, id_prefix: str) -> str:
|
||||||
|
prefix = f"{id_prefix}_" if id_prefix else ""
|
||||||
|
return f"{prefix}{svg_path.stem}_ill{index:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_reference(icon_namespace: str, asset_id: str) -> str:
|
||||||
|
return f"{icon_namespace}/{asset_id}" if icon_namespace else asset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_relative_path(icon_namespace: str, asset_id: str) -> str:
|
||||||
|
return f"{_icon_reference(icon_namespace, asset_id)}.svg"
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_asset_for_namespace(icon_name: str, icon_namespace: str) -> str | None:
|
||||||
|
"""Map one local placeholder to its asset path, excluding other libraries."""
|
||||||
|
if icon_namespace:
|
||||||
|
prefix = f"{icon_namespace}/"
|
||||||
|
if not icon_name.startswith(prefix):
|
||||||
|
return None
|
||||||
|
asset_id = icon_name[len(prefix):]
|
||||||
|
if not asset_id or "/" in asset_id:
|
||||||
|
return None
|
||||||
|
return f"{icon_name}.svg"
|
||||||
|
if "/" in icon_name:
|
||||||
|
return None
|
||||||
|
return f"{icon_name}.svg"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_namespace_placeholder(root: ET.Element, icon_namespace: str) -> bool:
|
||||||
|
if not icon_namespace:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
_local(elem.tag) == "use"
|
||||||
|
and (icon_name := elem.get("data-icon")) is not None
|
||||||
|
and _icon_asset_for_namespace(icon_name, icon_namespace) is not None
|
||||||
|
for elem in root.iter()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_asset_re(svg_stems: list[str], id_prefix: str) -> re.Pattern[str] | None:
|
||||||
|
if not svg_stems:
|
||||||
|
return None
|
||||||
|
prefix = f"{re.escape(id_prefix)}_" if id_prefix else ""
|
||||||
|
stems = "|".join(re.escape(stem) for stem in svg_stems)
|
||||||
|
return re.compile(rf"^{prefix}(?:{stems})_ill\d+\.svg$")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_stale_assets(
|
||||||
|
icons_dir: Path,
|
||||||
|
icon_namespace: str,
|
||||||
|
svg_paths: list[Path],
|
||||||
|
id_prefix: str,
|
||||||
|
keep_assets: set[str],
|
||||||
|
) -> list[str]:
|
||||||
|
pattern = _generated_asset_re([path.stem for path in svg_paths], id_prefix)
|
||||||
|
if pattern is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
removed: list[str] = []
|
||||||
|
asset_dir = icons_dir / icon_namespace if icon_namespace else icons_dir
|
||||||
|
for asset_path in sorted(asset_dir.glob("*.svg")):
|
||||||
|
relative_asset = asset_path.relative_to(icons_dir).as_posix()
|
||||||
|
if relative_asset in keep_assets or not pattern.match(asset_path.name):
|
||||||
|
continue
|
||||||
|
asset_path.unlink()
|
||||||
|
removed.append(relative_asset)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _referenced_icon_assets(svg_paths: list[Path], icon_namespace: str) -> set[str]:
|
||||||
|
assets: set[str] = set()
|
||||||
|
for svg_path in svg_paths:
|
||||||
|
try:
|
||||||
|
root = ET.parse(svg_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
for elem in root.iter():
|
||||||
|
if _local(elem.tag) != "use":
|
||||||
|
continue
|
||||||
|
icon_name = elem.get("data-icon")
|
||||||
|
if icon_name and (asset := _icon_asset_for_namespace(icon_name, icon_namespace)):
|
||||||
|
assets.add(asset)
|
||||||
|
return assets
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_placeholder_entries(
|
||||||
|
svg_paths: list[Path],
|
||||||
|
icons_dir: Path,
|
||||||
|
icon_namespace: str,
|
||||||
|
known_assets: set[str],
|
||||||
|
) -> list[dict]:
|
||||||
|
by_asset: dict[str, dict] = {}
|
||||||
|
for svg_path in svg_paths:
|
||||||
|
try:
|
||||||
|
root = ET.parse(svg_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for elem in root.iter():
|
||||||
|
if _local(elem.tag) != "use":
|
||||||
|
continue
|
||||||
|
icon_name = elem.get("data-icon")
|
||||||
|
if not icon_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
asset = _icon_asset_for_namespace(icon_name, icon_namespace)
|
||||||
|
if asset is None:
|
||||||
|
continue
|
||||||
|
if asset in known_assets:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = by_asset.setdefault(
|
||||||
|
asset,
|
||||||
|
{
|
||||||
|
"svg": svg_path.name,
|
||||||
|
"svgs": [],
|
||||||
|
"id": icon_name,
|
||||||
|
"icon": icon_name,
|
||||||
|
"asset": asset,
|
||||||
|
"source": "existing-placeholder",
|
||||||
|
"asset_exists": (icons_dir / asset).exists(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if svg_path.name not in entry["svgs"]:
|
||||||
|
entry["svgs"].append(svg_path.name)
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
for asset, entry in sorted(by_asset.items()):
|
||||||
|
asset_path = icons_dir / asset
|
||||||
|
if asset_path.exists():
|
||||||
|
entry["asset_sha256"] = hashlib.sha256(
|
||||||
|
asset_path.read_bytes()
|
||||||
|
).hexdigest()
|
||||||
|
try:
|
||||||
|
root = ET.parse(asset_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
entry["drawable_count"] = _drawable_count(root)
|
||||||
|
entry["byte_count"] = _xml_size(root)
|
||||||
|
entry["elements"] = _tag_histogram(root)
|
||||||
|
entry["source_refs"] = _source_references(root)
|
||||||
|
entry["dependencies"] = sorted(
|
||||||
|
elem_id
|
||||||
|
for elem in root.iter()
|
||||||
|
if _local(elem.tag) == "defs"
|
||||||
|
for child in elem
|
||||||
|
if (elem_id := child.get("id"))
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _load_reusable_assets(inventory_path: Path, icons_dir: Path) -> dict[str, dict]:
|
||||||
|
"""Load fingerprinted assets from an earlier extraction inventory."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(inventory_path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ValueError(f"reuse inventory not found: {inventory_path}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"invalid reuse inventory JSON: {inventory_path}: {exc}") from exc
|
||||||
|
|
||||||
|
entries = payload.get("assets")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise ValueError(f"reuse inventory has no assets list: {inventory_path}")
|
||||||
|
|
||||||
|
reusable: dict[str, dict] = {}
|
||||||
|
fingerprinted = 0
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
source_sha256 = entry.get("source_sha256")
|
||||||
|
asset_sha256 = entry.get("asset_sha256")
|
||||||
|
asset = entry.get("asset")
|
||||||
|
icon = entry.get("icon")
|
||||||
|
if not all(
|
||||||
|
isinstance(value, str) and value
|
||||||
|
for value in (source_sha256, asset_sha256, asset, icon)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
fingerprinted += 1
|
||||||
|
asset_path = icons_dir / asset
|
||||||
|
if not asset_path.is_file():
|
||||||
|
raise ValueError(
|
||||||
|
f"reusable asset is missing from the target icons directory: {asset_path}"
|
||||||
|
)
|
||||||
|
actual_asset_sha256 = hashlib.sha256(asset_path.read_bytes()).hexdigest()
|
||||||
|
if actual_asset_sha256 != asset_sha256:
|
||||||
|
raise ValueError(
|
||||||
|
f"reusable asset hash does not match its inventory: {asset_path}"
|
||||||
|
)
|
||||||
|
current = reusable.get(source_sha256)
|
||||||
|
if current is None or asset < str(current["asset"]):
|
||||||
|
reusable[source_sha256] = entry
|
||||||
|
|
||||||
|
extracted_count = payload.get("extracted_count", 0)
|
||||||
|
if isinstance(extracted_count, int) and extracted_count > fingerprinted:
|
||||||
|
raise ValueError(
|
||||||
|
"reuse inventory predates source fingerprints; rerun the source extraction "
|
||||||
|
f"with the current tool: {inventory_path}"
|
||||||
|
)
|
||||||
|
return reusable
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_path(svg_path: Path, rewritten_dir: Path | None, inplace: bool) -> Path:
|
||||||
|
if inplace:
|
||||||
|
return svg_path
|
||||||
|
if rewritten_dir is None:
|
||||||
|
return svg_path.parent.parent / f"{svg_path.parent.name}-rewritten" / svg_path.name
|
||||||
|
return rewritten_dir / svg_path.name
|
||||||
|
|
||||||
|
|
||||||
|
def extract_file(
|
||||||
|
svg_path: Path,
|
||||||
|
icons_dir: Path,
|
||||||
|
icon_namespace: str,
|
||||||
|
min_drawables: int,
|
||||||
|
min_bytes: int,
|
||||||
|
min_decoration_bytes: int,
|
||||||
|
inplace: bool,
|
||||||
|
id_prefix: str = "",
|
||||||
|
rewritten_dir: Path | None = None,
|
||||||
|
reusable_assets: dict[str, dict] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Extract qualifying groups from one SVG. Returns inventory entries."""
|
||||||
|
ET.register_namespace("", SVG_NS)
|
||||||
|
tree = ET.parse(svg_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
view_box = root.get("viewBox")
|
||||||
|
width = root.get("width")
|
||||||
|
height = root.get("height")
|
||||||
|
|
||||||
|
# A namespaced projection is an all-at-once readability pass. Once it owns
|
||||||
|
# an asset reference, reruns inventory the existing placeholders instead of
|
||||||
|
# progressively factoring their remaining parent/sibling geometry.
|
||||||
|
if _has_namespace_placeholder(root, icon_namespace):
|
||||||
|
if not inplace:
|
||||||
|
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
|
||||||
|
rewritten.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
targets: list[tuple[ET.Element, list[ET.Element]]] = []
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
group_targets = _find_extractable(root, min_drawables, min_bytes)
|
||||||
|
selected_groups = set(group_targets)
|
||||||
|
|
||||||
|
def inside_selected_group(elem: ET.Element) -> bool:
|
||||||
|
current = elem
|
||||||
|
while current in parents:
|
||||||
|
current = parents[current]
|
||||||
|
if current in selected_groups:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for group in group_targets:
|
||||||
|
parent = parents.get(group)
|
||||||
|
if parent is not None:
|
||||||
|
targets.append((parent, [group]))
|
||||||
|
|
||||||
|
for parent, run in _find_extractable_runs(root, min_drawables, min_bytes, min_decoration_bytes):
|
||||||
|
if (
|
||||||
|
parent not in selected_groups
|
||||||
|
and not inside_selected_group(parent)
|
||||||
|
and not any(child in selected_groups for child in run)
|
||||||
|
and all(child in parent for child in run)
|
||||||
|
):
|
||||||
|
targets.append((parent, run))
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
if not inplace:
|
||||||
|
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
|
||||||
|
rewritten.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
asset_dir = icons_dir / icon_namespace if icon_namespace else icons_dir
|
||||||
|
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
for index, (parent, nodes) in enumerate(targets, start=1):
|
||||||
|
if not nodes or not all(node in parent for node in nodes):
|
||||||
|
continue
|
||||||
|
asset_id = _asset_id(svg_path, index, id_prefix)
|
||||||
|
icon_reference = _icon_reference(icon_namespace, asset_id)
|
||||||
|
asset = _asset_relative_path(icon_namespace, asset_id)
|
||||||
|
pos = list(parent).index(nodes[0])
|
||||||
|
group = nodes[0] if len(nodes) == 1 and _local(nodes[0].tag) == "g" else _asset_group(nodes)
|
||||||
|
|
||||||
|
dependencies = [copy.deepcopy(elem) for elem in _dependency_elements(root, group)]
|
||||||
|
dependency_source_ids = sorted({
|
||||||
|
elem_id
|
||||||
|
for dependency in dependencies
|
||||||
|
for elem in dependency.iter()
|
||||||
|
if (elem_id := elem.get("id"))
|
||||||
|
})
|
||||||
|
source_sha256 = _source_sha256(group, dependencies, view_box, width, height)
|
||||||
|
source_refs = _source_references(group)
|
||||||
|
reusable = (reusable_assets or {}).get(source_sha256)
|
||||||
|
if reusable is not None:
|
||||||
|
reused_icon = str(reusable["icon"])
|
||||||
|
reused_asset = str(reusable["asset"])
|
||||||
|
placeholder = ET.Element(f"{{{SVG_NS}}}use")
|
||||||
|
placeholder.set("data-icon", reused_icon)
|
||||||
|
for node in nodes:
|
||||||
|
if node in parent:
|
||||||
|
parent.remove(node)
|
||||||
|
parent.insert(pos, placeholder)
|
||||||
|
entries.append({
|
||||||
|
"svg": svg_path.name,
|
||||||
|
"id": reused_icon,
|
||||||
|
"icon": reused_icon,
|
||||||
|
"asset": reused_asset,
|
||||||
|
"source": "reused-inventory",
|
||||||
|
"source_sha256": source_sha256,
|
||||||
|
"asset_sha256": reusable["asset_sha256"],
|
||||||
|
"reused_from_svg": reusable.get("svg"),
|
||||||
|
"drawable_count": _drawable_count(group),
|
||||||
|
"byte_count": _xml_size(group),
|
||||||
|
"source_refs": source_refs,
|
||||||
|
"dependencies": dependency_source_ids,
|
||||||
|
"elements": _tag_histogram(group),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
id_mapping = _collect_id_mapping(asset_id, group, dependencies)
|
||||||
|
_rewrite_references(group, id_mapping)
|
||||||
|
for dependency in dependencies:
|
||||||
|
_rewrite_references(dependency, id_mapping)
|
||||||
|
|
||||||
|
# Asset keeps the group in original page coordinates and carries its defs.
|
||||||
|
asset_bytes = _asset_svg(group, dependencies, view_box, width, height)
|
||||||
|
(icons_dir / asset).write_bytes(asset_bytes)
|
||||||
|
|
||||||
|
placeholder = ET.Element(f"{{{SVG_NS}}}use")
|
||||||
|
placeholder.set("data-icon", icon_reference)
|
||||||
|
for node in nodes:
|
||||||
|
if node in parent:
|
||||||
|
parent.remove(node)
|
||||||
|
parent.insert(pos, placeholder)
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
"svg": svg_path.name,
|
||||||
|
"id": icon_reference,
|
||||||
|
"icon": icon_reference,
|
||||||
|
"asset": asset,
|
||||||
|
"source": "extracted",
|
||||||
|
"source_sha256": source_sha256,
|
||||||
|
"asset_sha256": hashlib.sha256(asset_bytes).hexdigest(),
|
||||||
|
"drawable_count": _drawable_count(group),
|
||||||
|
"byte_count": _xml_size(group),
|
||||||
|
"source_refs": source_refs,
|
||||||
|
"dependencies": [id_mapping.get(elem_id, elem_id) for elem_id in dependency_source_ids],
|
||||||
|
"elements": _tag_histogram(group),
|
||||||
|
})
|
||||||
|
|
||||||
|
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
|
||||||
|
rewritten.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Factor large inline vector groups out of SVGs into reusable assets.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("svg_dir", help="Directory of working SVGs (e.g. import_ws/svg or project/svg_output)")
|
||||||
|
parser.add_argument("-o", "--output", dest="icons_dir", help="Project icon dir (default: <svg_dir>/../icons)")
|
||||||
|
parser.add_argument("--icons-dir", dest="icons_dir", help="Project icon dir (default: <svg_dir>/../icons)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--icon-namespace",
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"Optional lower-case subdirectory and data-icon prefix for extracted "
|
||||||
|
"assets (create-template uses: imported)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rewritten-dir",
|
||||||
|
help="Directory for rewritten SVGs when not using --inplace (default: <svg_dir>/../<svg_dir-name>-rewritten)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inventory",
|
||||||
|
help="Inventory JSON path (default: <svg_dir>/../<svg_dir-name>_vector_asset_inventory.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reuse-inventory",
|
||||||
|
help=(
|
||||||
|
"Reuse fingerprint-matched assets from an earlier extraction inventory; "
|
||||||
|
"only unmatched vector subtrees create new assets"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--id-prefix",
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"Optional prefix for generated asset IDs, useful when processing "
|
||||||
|
"layered and flat SVG dirs into one icons dir"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-drawables", type=int, default=DEFAULT_MIN_DRAWABLES,
|
||||||
|
help=f"Min drawable elements for a group to be extracted (default: {DEFAULT_MIN_DRAWABLES})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-bytes", type=int, default=DEFAULT_MIN_BYTES,
|
||||||
|
help=f"Min XML bytes for a pure-vector group/run to be extracted (default: {DEFAULT_MIN_BYTES})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-decoration-bytes", type=int, default=DEFAULT_MIN_DECORATION_BYTES,
|
||||||
|
help=(
|
||||||
|
"Min XML bytes for pure-vector decoration runs inside text-bearing groups "
|
||||||
|
f"(default: {DEFAULT_MIN_DECORATION_BYTES})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inplace", action="store_true",
|
||||||
|
help="Rewrite the source SVGs in place instead of writing to --rewritten-dir",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--clean-stale",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Remove stale generated assets for the current svg filenames/id prefix "
|
||||||
|
"that are not referenced by this run's inventory"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
svg_dir = Path(args.svg_dir)
|
||||||
|
if not svg_dir.is_dir():
|
||||||
|
print(f"[ERROR] svg_dir not found: {svg_dir}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
icons_dir = Path(args.icons_dir) if args.icons_dir else svg_dir.parent / "icons"
|
||||||
|
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
icon_namespace = args.icon_namespace.strip()
|
||||||
|
if icon_namespace and not ICON_NAMESPACE_RE.fullmatch(icon_namespace):
|
||||||
|
print(
|
||||||
|
"[ERROR] --icon-namespace must be one lower-case ASCII directory name "
|
||||||
|
"using only letters, digits, '_' or '-'",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
reusable_assets: dict[str, dict] = {}
|
||||||
|
reuse_inventory_path = Path(args.reuse_inventory) if args.reuse_inventory else None
|
||||||
|
if reuse_inventory_path is not None:
|
||||||
|
try:
|
||||||
|
reusable_assets = _load_reusable_assets(reuse_inventory_path, icons_dir)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
rewritten_dir = Path(args.rewritten_dir) if args.rewritten_dir else None
|
||||||
|
inventory_path = (
|
||||||
|
Path(args.inventory)
|
||||||
|
if args.inventory
|
||||||
|
else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json"
|
||||||
|
)
|
||||||
|
svg_paths = sorted(svg_dir.glob("*.svg"))
|
||||||
|
|
||||||
|
inventory: list[dict] = []
|
||||||
|
for svg_path in svg_paths:
|
||||||
|
try:
|
||||||
|
inventory.extend(
|
||||||
|
extract_file(
|
||||||
|
svg_path,
|
||||||
|
icons_dir,
|
||||||
|
icon_namespace,
|
||||||
|
args.min_drawables,
|
||||||
|
args.min_bytes,
|
||||||
|
args.min_decoration_bytes,
|
||||||
|
args.inplace,
|
||||||
|
args.id_prefix,
|
||||||
|
rewritten_dir,
|
||||||
|
reusable_assets,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
print(f"[WARN] skip unparseable {svg_path.name}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
extracted_count = sum(entry.get("source") == "extracted" for entry in inventory)
|
||||||
|
reused_count = sum(entry.get("source") == "reused-inventory" for entry in inventory)
|
||||||
|
known_assets = {str(entry["asset"]) for entry in inventory}
|
||||||
|
inventory.extend(
|
||||||
|
_existing_placeholder_entries(
|
||||||
|
svg_paths,
|
||||||
|
icons_dir,
|
||||||
|
icon_namespace,
|
||||||
|
known_assets,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stale_removed: list[str] = []
|
||||||
|
if args.clean_stale:
|
||||||
|
keep_assets = {str(entry["asset"]) for entry in inventory}
|
||||||
|
keep_assets.update(_referenced_icon_assets(svg_paths, icon_namespace))
|
||||||
|
stale_removed = _clean_stale_assets(
|
||||||
|
icons_dir,
|
||||||
|
icon_namespace,
|
||||||
|
svg_paths,
|
||||||
|
args.id_prefix,
|
||||||
|
keep_assets,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"schema": "vector_asset_inventory.v1",
|
||||||
|
"svg_dir": str(svg_dir),
|
||||||
|
"icons_dir": str(icons_dir),
|
||||||
|
"icon_namespace": icon_namespace or None,
|
||||||
|
"rewritten_dir": (
|
||||||
|
None
|
||||||
|
if args.inplace
|
||||||
|
else str(_rewritten_path(svg_dir / "_sample.svg", rewritten_dir, False).parent)
|
||||||
|
),
|
||||||
|
"reuse_inventory": str(reuse_inventory_path) if reuse_inventory_path is not None else None,
|
||||||
|
"min_drawables": args.min_drawables,
|
||||||
|
"min_bytes": args.min_bytes,
|
||||||
|
"min_decoration_bytes": args.min_decoration_bytes,
|
||||||
|
"extracted_count": extracted_count,
|
||||||
|
"reused_count": reused_count,
|
||||||
|
"asset_count": len(inventory),
|
||||||
|
"stale_removed": stale_removed,
|
||||||
|
"assets": inventory,
|
||||||
|
}
|
||||||
|
inventory_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
inventory_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
summary_path: Path | None = None
|
||||||
|
if args.inplace and (svg_dir / AUTHORING_MANIFEST_NAME).is_file():
|
||||||
|
try:
|
||||||
|
summary_path = write_authoring_summary(svg_dir)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(
|
||||||
|
f"[ERROR] vector extraction succeeded but authoring summary "
|
||||||
|
f"refresh failed: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
f"[OK] extracted {extracted_count} new asset(s), reused {reused_count} asset(s), "
|
||||||
|
f"inventoried {len(inventory)} asset reference(s) -> "
|
||||||
|
f"{icons_dir / icon_namespace if icon_namespace else icons_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if stale_removed:
|
||||||
|
print(f"[OK] removed {len(stale_removed)} stale generated asset(s)", file=sys.stderr)
|
||||||
|
if summary_path is not None:
|
||||||
|
print(f"[OK] refreshed model-readable summary: {summary_path}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - SVG Post-processing Tool (Unified Entry Point)
|
||||||
|
|
||||||
|
Processes SVG files from svg_output/ and produces the visual preview in
|
||||||
|
svg_final/, embedding supported raster/SVG assets. Native PPTX export continues
|
||||||
|
to read svg_output/ by default; svg_final/ may be opened directly or inserted
|
||||||
|
as an SVG image. EMF/WMF assets retain their external-reference exception.
|
||||||
|
By default, all processing steps are executed. You can also specify individual
|
||||||
|
steps via arguments.
|
||||||
|
|
||||||
|
Architecture note: this module's outputs feed svg_final/ on disk AND its
|
||||||
|
sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
|
||||||
|
are memory-reused by svg_to_pptx during native conversion. Deleting any
|
||||||
|
step here may also break native pptx output, not just svg_final/.
|
||||||
|
See scripts/docs/svg-pipeline.md before modifying the shared pipeline.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Execute all processing steps (recommended)
|
||||||
|
python3 scripts/finalize_svg.py <project_directory>
|
||||||
|
|
||||||
|
# Execute only specific steps
|
||||||
|
python3 scripts/finalize_svg.py <project_directory> --only embed-icons align-images
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/finalize_svg.py projects/my_project
|
||||||
|
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
|
||||||
|
|
||||||
|
Processing options:
|
||||||
|
embed-icons - Expand project icons and static same-document <use>
|
||||||
|
align-images - Align (slice/meet) and Base64-embed all <image> in one pass.
|
||||||
|
Replaces the former crop-images + fix-aspect + embed-images
|
||||||
|
trio. The old names remain accepted as aliases for the
|
||||||
|
merged step, so existing --only invocations keep working.
|
||||||
|
flatten-text - Convert <tspan> to independent <text> (for special renderers)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TextIO
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
# Import finalize helpers from the internal package.
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from resource_paths import icon_search_dirs_for_project # noqa: E402
|
||||||
|
from svg_finalize.align_embed_images import (
|
||||||
|
align_and_embed_images_in_svg,
|
||||||
|
count_office_vector_refs_in_svg,
|
||||||
|
)
|
||||||
|
from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
|
||||||
|
from svg_to_pptx.geometry_properties import (
|
||||||
|
GeometryStyleError,
|
||||||
|
materialize_inline_geometry_in_file,
|
||||||
|
)
|
||||||
|
from svg_to_pptx.use_expander import (
|
||||||
|
UseExpansionError,
|
||||||
|
expand_local_use_references_in_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlattenTextResult(Enum):
|
||||||
|
"""Describe whether flattening changed a file, skipped it, or failed."""
|
||||||
|
|
||||||
|
CHANGED = "changed"
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_print(text: str, *, file: TextIO | None = None) -> None:
|
||||||
|
"""Print text while tolerating Windows terminal encoding limits."""
|
||||||
|
stream = file or sys.stdout
|
||||||
|
try:
|
||||||
|
print(text, file=stream)
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
replacements = {
|
||||||
|
chr(0x23F3): "[..]",
|
||||||
|
chr(0x2705): "[DONE]",
|
||||||
|
chr(0x274C): "[ERROR]",
|
||||||
|
chr(0x26A0) + chr(0xFE0F): "[WARN]",
|
||||||
|
chr(0x1F4C1): "[DIR]",
|
||||||
|
chr(0x1F4C4): "[FILE]",
|
||||||
|
chr(0x1F4E6): "[OK]",
|
||||||
|
}
|
||||||
|
for source, target in replacements.items():
|
||||||
|
text = text.replace(source, target)
|
||||||
|
print(text, file=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def process_flatten_text(
|
||||||
|
svg_file: Path,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> FlattenTextResult:
|
||||||
|
"""Flatten text in one SVG and report changed, unchanged, or error."""
|
||||||
|
try:
|
||||||
|
from svg_finalize.flatten_tspan import flatten_text_with_tspans
|
||||||
|
|
||||||
|
tree = ET.parse(str(svg_file))
|
||||||
|
changed = flatten_text_with_tspans(tree)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
|
||||||
|
if verbose:
|
||||||
|
safe_print(f" [OK] {svg_file.name}: text flattened")
|
||||||
|
return FlattenTextResult.CHANGED
|
||||||
|
return FlattenTextResult.UNCHANGED
|
||||||
|
except Exception as exc:
|
||||||
|
safe_print(
|
||||||
|
f" [ERROR] {svg_file.name}: text flattening failed: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return FlattenTextResult.ERROR
|
||||||
|
|
||||||
|
|
||||||
|
def _path_lexists(path: Path) -> bool:
|
||||||
|
"""Return whether a path or dangling symlink occupies the target name."""
|
||||||
|
return os.path.lexists(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_candidate_directory(candidate_dir: Path, output_dir: Path) -> None:
|
||||||
|
"""Publish one staged directory and restore the previous output on failure."""
|
||||||
|
if output_dir.is_symlink() or (
|
||||||
|
_path_lexists(output_dir) and not output_dir.is_dir()
|
||||||
|
):
|
||||||
|
raise RuntimeError(f"Output path must be a real directory: {output_dir}")
|
||||||
|
|
||||||
|
transaction_dir = Path(
|
||||||
|
tempfile.mkdtemp(
|
||||||
|
prefix=f".{output_dir.name}.publish-",
|
||||||
|
dir=output_dir.parent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
backup_dir = transaction_dir / "previous"
|
||||||
|
preserve_backup = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if output_dir.is_dir():
|
||||||
|
try:
|
||||||
|
os.replace(output_dir, backup_dir)
|
||||||
|
os.replace(candidate_dir, output_dir)
|
||||||
|
except BaseException as publish_error:
|
||||||
|
try:
|
||||||
|
if _path_lexists(backup_dir):
|
||||||
|
if _path_lexists(output_dir):
|
||||||
|
failed_output = transaction_dir / "failed-publish"
|
||||||
|
os.replace(output_dir, failed_output)
|
||||||
|
os.replace(backup_dir, output_dir)
|
||||||
|
except BaseException as restore_error:
|
||||||
|
if (
|
||||||
|
not _path_lexists(backup_dir)
|
||||||
|
and _path_lexists(output_dir)
|
||||||
|
):
|
||||||
|
raise publish_error
|
||||||
|
preserve_backup = _path_lexists(backup_dir)
|
||||||
|
raise RuntimeError(
|
||||||
|
"Failed to publish svg_final and restore the previous "
|
||||||
|
"directory; recovery directory: "
|
||||||
|
f"{transaction_dir}"
|
||||||
|
) from restore_error
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
os.replace(candidate_dir, output_dir)
|
||||||
|
finally:
|
||||||
|
if not preserve_backup:
|
||||||
|
shutil.rmtree(transaction_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_candidate_directory(
|
||||||
|
candidate_dir: Path,
|
||||||
|
*,
|
||||||
|
options: dict[str, bool],
|
||||||
|
quiet: bool,
|
||||||
|
compress: bool,
|
||||||
|
max_dimension: int | None,
|
||||||
|
image_scale: float,
|
||||||
|
icons_dir: Path,
|
||||||
|
icons_fallback_dir: Path | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Run every selected finalization pass against one unpublished candidate."""
|
||||||
|
# Core normalization: downstream image/rect processors read XML geometry.
|
||||||
|
geometry_count = 0
|
||||||
|
for svg_file in candidate_dir.glob('*.svg'):
|
||||||
|
try:
|
||||||
|
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||||
|
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Step 1: Expand project icons, then standard same-document use references.
|
||||||
|
if options.get('embed_icons'):
|
||||||
|
if not quiet:
|
||||||
|
safe_print("[1/3] Expanding icons + local use references...")
|
||||||
|
icons_count = 0
|
||||||
|
for svg_file in candidate_dir.glob('*.svg'):
|
||||||
|
count = embed_icons_in_file(
|
||||||
|
svg_file,
|
||||||
|
icons_dir,
|
||||||
|
dry_run=False,
|
||||||
|
verbose=False,
|
||||||
|
fallback_dir=icons_fallback_dir,
|
||||||
|
)
|
||||||
|
icons_count += count
|
||||||
|
for svg_file in candidate_dir.glob('*.svg'):
|
||||||
|
try:
|
||||||
|
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||||
|
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] {svg_file.name}: expanded icon geometry "
|
||||||
|
f"materialization failed: {exc}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
local_use_count = 0
|
||||||
|
for svg_file in candidate_dir.glob('*.svg'):
|
||||||
|
try:
|
||||||
|
local_use_count += expand_local_use_references_in_file(svg_file)
|
||||||
|
except (OSError, ET.ParseError, UseExpansionError) as exc:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if not quiet:
|
||||||
|
if icons_count > 0:
|
||||||
|
safe_print(f" {icons_count} icon(s) embedded")
|
||||||
|
else:
|
||||||
|
safe_print(" No icons")
|
||||||
|
if local_use_count > 0:
|
||||||
|
safe_print(f" {local_use_count} local use reference(s) expanded")
|
||||||
|
else:
|
||||||
|
safe_print(" No local use references")
|
||||||
|
|
||||||
|
if not quiet and geometry_count:
|
||||||
|
safe_print(
|
||||||
|
f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Align (slice/meet) and Base64-embed all <image> in one pass.
|
||||||
|
# Replaces the former crop-images / fix-aspect / embed-images trio: the
|
||||||
|
# spatial transform (slice → crop, meet → fit-box) and the asset embed
|
||||||
|
# are mutually exclusive branches per image, sequenced together so each
|
||||||
|
# SVG is only parsed and serialized once and each bitmap is only read
|
||||||
|
# from disk once.
|
||||||
|
if options.get('align_images'):
|
||||||
|
if not quiet:
|
||||||
|
safe_print("[2/3] Aligning + embedding images...")
|
||||||
|
img_count = 0
|
||||||
|
img_errors = 0
|
||||||
|
office_vector_count = 0
|
||||||
|
for svg_file in candidate_dir.glob('*.svg'):
|
||||||
|
office_vector_count += count_office_vector_refs_in_svg(svg_file)
|
||||||
|
count, errs = align_and_embed_images_in_svg(
|
||||||
|
svg_file,
|
||||||
|
dry_run=False,
|
||||||
|
verbose=False,
|
||||||
|
compress=compress,
|
||||||
|
max_dimension=max_dimension,
|
||||||
|
image_scale=image_scale,
|
||||||
|
)
|
||||||
|
img_count += count
|
||||||
|
img_errors += errs
|
||||||
|
if img_errors:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] Image alignment/embedding failed for "
|
||||||
|
f"{img_errors} image(s); svg_final was not published",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if not quiet:
|
||||||
|
if img_count > 0:
|
||||||
|
msg = f" {img_count} image(s) aligned + embedded"
|
||||||
|
safe_print(msg)
|
||||||
|
if office_vector_count:
|
||||||
|
safe_print(
|
||||||
|
f" {office_vector_count} Office vector(s) left external "
|
||||||
|
"for native PPTX passthrough"
|
||||||
|
)
|
||||||
|
elif office_vector_count:
|
||||||
|
safe_print(
|
||||||
|
f" {office_vector_count} Office vector(s) left external "
|
||||||
|
"for native PPTX passthrough"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
safe_print(" No images")
|
||||||
|
|
||||||
|
# Step 3: Flatten text.
|
||||||
|
if options.get('flatten_text'):
|
||||||
|
if not quiet:
|
||||||
|
safe_print("[3/3] Flattening text...")
|
||||||
|
flatten_count = 0
|
||||||
|
flatten_errors = 0
|
||||||
|
for svg_file in candidate_dir.glob('*.svg'):
|
||||||
|
result = process_flatten_text(svg_file, verbose=False)
|
||||||
|
if result is FlattenTextResult.CHANGED:
|
||||||
|
flatten_count += 1
|
||||||
|
elif result is FlattenTextResult.ERROR:
|
||||||
|
flatten_errors += 1
|
||||||
|
if flatten_errors:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] Text flattening failed for {flatten_errors} file(s); "
|
||||||
|
"svg_final was not published",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if not quiet:
|
||||||
|
if flatten_count > 0:
|
||||||
|
safe_print(f" {flatten_count} file(s) processed")
|
||||||
|
else:
|
||||||
|
safe_print(" No processing needed")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_project(
|
||||||
|
project_dir: Path,
|
||||||
|
options: dict[str, bool],
|
||||||
|
dry_run: bool = False,
|
||||||
|
quiet: bool = False,
|
||||||
|
compress: bool = True,
|
||||||
|
max_dimension: int | None = 2560,
|
||||||
|
image_scale: float = 2.0,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Finalize SVG files in the project
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_dir: Project directory path
|
||||||
|
options: Processing options dictionary
|
||||||
|
dry_run: Preview only, do not execute
|
||||||
|
quiet: Quiet mode, reduce output
|
||||||
|
compress: Compress images before embedding
|
||||||
|
max_dimension: Downscale images exceeding this dimension
|
||||||
|
image_scale: Target image pixels per SVG display pixel
|
||||||
|
"""
|
||||||
|
svg_output = project_dir / 'svg_output'
|
||||||
|
svg_final = project_dir / 'svg_final'
|
||||||
|
icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir)
|
||||||
|
|
||||||
|
# Check if svg_output exists
|
||||||
|
if not svg_output.exists():
|
||||||
|
safe_print(f"[ERROR] svg_output directory not found: {svg_output}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get list of SVG files
|
||||||
|
svg_files = list(svg_output.glob('*.svg'))
|
||||||
|
if not svg_files:
|
||||||
|
safe_print(f"[ERROR] No SVG files in svg_output")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print()
|
||||||
|
safe_print(f"[DIR] Project: {project_dir.name}")
|
||||||
|
safe_print(f"[FILE] {len(svg_files)} SVG file(s)")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
safe_print("[PREVIEW] Preview mode, no operations will be performed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
candidate_dir = Path(
|
||||||
|
tempfile.mkdtemp(
|
||||||
|
prefix=f".{svg_final.name}.candidate-",
|
||||||
|
dir=svg_final.parent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
shutil.copytree(svg_output, candidate_dir, dirs_exist_ok=True)
|
||||||
|
candidate_ready = _process_candidate_directory(
|
||||||
|
candidate_dir,
|
||||||
|
options=options,
|
||||||
|
quiet=quiet,
|
||||||
|
compress=compress,
|
||||||
|
max_dimension=max_dimension,
|
||||||
|
image_scale=image_scale,
|
||||||
|
icons_dir=icons_dir,
|
||||||
|
icons_fallback_dir=icons_fallback_dir,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] SVG finalization failed before publish: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not candidate_ready:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
_publish_candidate_directory(candidate_dir, svg_final)
|
||||||
|
except (OSError, RuntimeError) as exc:
|
||||||
|
safe_print(
|
||||||
|
f"[ERROR] svg_final publish failed: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(candidate_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
# Done
|
||||||
|
if not quiet:
|
||||||
|
print()
|
||||||
|
safe_print("[OK] Done!")
|
||||||
|
print()
|
||||||
|
print("Next steps:")
|
||||||
|
print(f" python scripts/svg_to_pptx.py \"{project_dir}\"")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='PPT Master - SVG Post-processing Tool',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog='''
|
||||||
|
Examples:
|
||||||
|
%(prog)s projects/my_project # Execute all processing (default)
|
||||||
|
%(prog)s projects/my_project --only embed-icons align-images
|
||||||
|
%(prog)s projects/my_project -q # Quiet mode
|
||||||
|
|
||||||
|
Processing options (for --only):
|
||||||
|
embed-icons Expand project icons and static same-document <use>
|
||||||
|
align-images Align (slice/meet) + Base64-embed all <image> (single pass)
|
||||||
|
flatten-text Flatten text
|
||||||
|
|
||||||
|
Aliases (still accepted):
|
||||||
|
crop-images, fix-aspect, embed-images → all map to align-images
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument('project_dir', type=Path, help='Project directory path')
|
||||||
|
parser.add_argument(
|
||||||
|
'--only', nargs='+', metavar='OPTION',
|
||||||
|
choices=[
|
||||||
|
'embed-icons',
|
||||||
|
'align-images',
|
||||||
|
# Backwards-compatible aliases — all three map to align-images now.
|
||||||
|
'crop-images', 'fix-aspect', 'embed-images',
|
||||||
|
'flatten-text',
|
||||||
|
],
|
||||||
|
help=('Execute only specified processing steps (default: all). '
|
||||||
|
'crop-images / fix-aspect / embed-images are accepted as '
|
||||||
|
'aliases for the merged align-images step.'),
|
||||||
|
)
|
||||||
|
parser.add_argument('--dry-run', '-n', action='store_true',
|
||||||
|
help='Preview only, do not execute')
|
||||||
|
parser.add_argument('--quiet', '-q', action='store_true',
|
||||||
|
help='Quiet mode, reduce output')
|
||||||
|
parser.add_argument('--compress', dest='compress', action='store_true', default=True,
|
||||||
|
help='Compress images before embedding (default)')
|
||||||
|
parser.add_argument('--no-compress', dest='compress', action='store_false',
|
||||||
|
help='Disable image compression before embedding')
|
||||||
|
parser.add_argument('--max-dimension', type=int, default=2560,
|
||||||
|
help='Downscale images exceeding this dimension on either axis (default: 2560)')
|
||||||
|
parser.add_argument('--image-scale', type=float, default=2.0,
|
||||||
|
help='Target image pixels per SVG display pixel (default: 2.0)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.project_dir.exists():
|
||||||
|
safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Aliases: any of crop-images / fix-aspect / embed-images implies the
|
||||||
|
# merged align-images step. Older invocations stay valid.
|
||||||
|
_ALIGN_ALIASES = {'align-images', 'crop-images', 'fix-aspect', 'embed-images'}
|
||||||
|
|
||||||
|
# Determine processing options
|
||||||
|
if args.only:
|
||||||
|
only = set(args.only)
|
||||||
|
options = {
|
||||||
|
'embed_icons': 'embed-icons' in only,
|
||||||
|
'align_images': bool(only & _ALIGN_ALIASES),
|
||||||
|
'flatten_text': 'flatten-text' in only,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Execute all by default
|
||||||
|
options = {
|
||||||
|
'embed_icons': True,
|
||||||
|
'align_images': True,
|
||||||
|
'flatten_text': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.max_dimension < 1:
|
||||||
|
safe_print("[ERROR] --max-dimension must be >= 1")
|
||||||
|
sys.exit(1)
|
||||||
|
if args.image_scale < 1:
|
||||||
|
safe_print("[ERROR] --image-scale must be >= 1")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
|
||||||
|
compress=args.compress,
|
||||||
|
max_dimension=args.max_dimension,
|
||||||
|
image_scale=args.image_scale)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Gemini Watermark Remover
|
||||||
|
|
||||||
|
Removes the watermark logo from the bottom-right corner of Gemini-generated images.
|
||||||
|
Uses a reverse blending algorithm to restore original pixels.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/gemini_watermark_remover.py <image_path>
|
||||||
|
python3 scripts/gemini_watermark_remover.py <image_path> -o output_path.png
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/gemini_watermark_remover.py projects/demo/images/bg_01.png
|
||||||
|
python3 scripts/gemini_watermark_remover.py image.jpg -o image_clean.jpg
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
pip install Pillow numpy
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Supports PNG, JPG, JPEG formats
|
||||||
|
- Automatically detects watermark size (48px or 96px)
|
||||||
|
- Output file defaults to adding an _unwatermarked suffix
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Import modules from the same directory
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Algorithm parameters
|
||||||
|
ALPHA_THRESHOLD = 0.002 # Alpha threshold; values below this are not processed
|
||||||
|
MAX_ALPHA = 0.99 # Maximum alpha value to prevent division by zero
|
||||||
|
LOGO_VALUE = 255 # Logo pixel value (white)
|
||||||
|
LARGE_IMAGE_THRESHOLD = 1024
|
||||||
|
LARGE_LOGO_SIZE = 96
|
||||||
|
SMALL_LOGO_SIZE = 48
|
||||||
|
LARGE_MARGIN = 64
|
||||||
|
SMALL_MARGIN = 32
|
||||||
|
|
||||||
|
# Watermark background image paths
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
BG_48_PATH = SCRIPT_DIR / "assets" / "bg_48.png"
|
||||||
|
BG_96_PATH = SCRIPT_DIR / "assets" / "bg_96.png"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_watermark_config(width: int, height: int) -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Detect watermark configuration based on image dimensions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Image width
|
||||||
|
height: Image height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configuration dict containing logo_size, margin_right, margin_bottom
|
||||||
|
"""
|
||||||
|
if width > LARGE_IMAGE_THRESHOLD and height > LARGE_IMAGE_THRESHOLD:
|
||||||
|
return {
|
||||||
|
"logo_size": LARGE_LOGO_SIZE,
|
||||||
|
"margin_right": LARGE_MARGIN,
|
||||||
|
"margin_bottom": LARGE_MARGIN,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"logo_size": SMALL_LOGO_SIZE,
|
||||||
|
"margin_right": SMALL_MARGIN,
|
||||||
|
"margin_bottom": SMALL_MARGIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_watermark_position(width: int, height: int, config: dict[str, int]) -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Calculate watermark position
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Image width
|
||||||
|
height: Image height
|
||||||
|
config: Watermark configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Position dict containing x, y, width, height
|
||||||
|
"""
|
||||||
|
logo_size = config["logo_size"]
|
||||||
|
return {
|
||||||
|
"x": width - config["margin_right"] - logo_size,
|
||||||
|
"y": height - config["margin_bottom"] - logo_size,
|
||||||
|
"width": logo_size,
|
||||||
|
"height": logo_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_alpha_map(bg_image: Image.Image) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Calculate the alpha channel map from the watermark background image
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bg_image: Watermark background PNG image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Alpha map array (range 0-1)
|
||||||
|
"""
|
||||||
|
bg_array = np.array(bg_image.convert("RGB"), dtype=np.float32)
|
||||||
|
max_channel = np.max(bg_array, axis=2)
|
||||||
|
return max_channel / 255.0
|
||||||
|
|
||||||
|
|
||||||
|
def remove_watermark(image: Image.Image, alpha_map: np.ndarray, position: dict) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Remove watermark using a reverse blending algorithm
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Original image
|
||||||
|
alpha_map: Alpha map array
|
||||||
|
position: Watermark position
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image with watermark removed
|
||||||
|
"""
|
||||||
|
img_array = np.array(image.convert("RGBA"), dtype=np.float32)
|
||||||
|
x, y, w, h = position["x"], position["y"], position["width"], position["height"]
|
||||||
|
|
||||||
|
for row in range(h):
|
||||||
|
for col in range(w):
|
||||||
|
alpha = alpha_map[row, col]
|
||||||
|
if alpha < ALPHA_THRESHOLD:
|
||||||
|
continue
|
||||||
|
alpha = min(alpha, MAX_ALPHA)
|
||||||
|
one_minus_alpha = 1.0 - alpha
|
||||||
|
|
||||||
|
img_y, img_x = y + row, x + col
|
||||||
|
for c in range(3):
|
||||||
|
watermarked = img_array[img_y, img_x, c]
|
||||||
|
original = (watermarked - alpha * LOGO_VALUE) / one_minus_alpha
|
||||||
|
img_array[img_y, img_x, c] = np.clip(original, 0, 255)
|
||||||
|
|
||||||
|
return Image.fromarray(img_array.astype(np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(input_path: Path, output_path: Path | None = None, verbose: bool = True) -> Path:
|
||||||
|
"""
|
||||||
|
Process a single image to remove its watermark
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: Input image path
|
||||||
|
output_path: Output image path (optional)
|
||||||
|
verbose: Whether to output detailed information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Output file path
|
||||||
|
"""
|
||||||
|
image = Image.open(input_path)
|
||||||
|
width, height = image.size
|
||||||
|
|
||||||
|
config = detect_watermark_config(width, height)
|
||||||
|
position = calculate_watermark_position(width, height, config)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" Image size: {width} x {height}")
|
||||||
|
print(f" Watermark size: {config['logo_size']} x {config['logo_size']}")
|
||||||
|
print(f" Watermark position: ({position['x']}, {position['y']})")
|
||||||
|
|
||||||
|
bg_path = BG_96_PATH if config["logo_size"] == 96 else BG_48_PATH
|
||||||
|
|
||||||
|
if not bg_path.exists():
|
||||||
|
print(f"Error: Watermark background image not found: {bg_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
bg_image = Image.open(bg_path)
|
||||||
|
alpha_map = calculate_alpha_map(bg_image)
|
||||||
|
|
||||||
|
result = remove_watermark(image, alpha_map, position)
|
||||||
|
|
||||||
|
if output_path is None:
|
||||||
|
stem = input_path.stem
|
||||||
|
suffix = input_path.suffix or ".png"
|
||||||
|
output_path = input_path.parent / f"{stem}_unwatermarked{suffix}"
|
||||||
|
|
||||||
|
if output_path.suffix.lower() in (".jpg", ".jpeg"):
|
||||||
|
result = result.convert("RGB")
|
||||||
|
|
||||||
|
result.save(output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='PPT Master - Gemini Watermark Remover',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog='''
|
||||||
|
Examples:
|
||||||
|
%(prog)s projects/demo/images/bg_01.png
|
||||||
|
%(prog)s image.jpg -o image_clean.jpg
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Automatically detects watermark size (96px for large images, 48px for small)
|
||||||
|
- Supports PNG, JPG, JPEG formats
|
||||||
|
- Output file defaults to adding an _unwatermarked suffix
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument('input', type=Path, help='Input image path')
|
||||||
|
parser.add_argument('-o', '--output', type=Path, default=None, help='Output image path')
|
||||||
|
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.input.exists():
|
||||||
|
print(f"Error: File not found: {args.input}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
verbose = not args.quiet
|
||||||
|
if verbose:
|
||||||
|
print("PPT Master - Gemini Watermark Remover")
|
||||||
|
print("=" * 40)
|
||||||
|
print(f" Input file: {args.input}")
|
||||||
|
|
||||||
|
output = process_image(args.input, args.output, verbose=verbose)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print()
|
||||||
|
print(f"[Done] Saved to: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Examples Index Generator
|
||||||
|
|
||||||
|
Automatically scans the examples directory and generates a README.md index file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/generate_examples_index.py
|
||||||
|
python3 scripts/generate_examples_index.py examples
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from project_utils import find_all_projects, get_project_info, CANVAS_FORMATS
|
||||||
|
except ImportError:
|
||||||
|
print("Error: Cannot import the project_utils module")
|
||||||
|
print("Please ensure project_utils.py is in the same directory")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_examples_index(examples_dir: str = 'examples') -> str:
|
||||||
|
"""
|
||||||
|
Generate a README.md index for the examples directory
|
||||||
|
|
||||||
|
Args:
|
||||||
|
examples_dir: Path to the examples directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated README.md content
|
||||||
|
"""
|
||||||
|
examples_path = Path(examples_dir)
|
||||||
|
skill_dir = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
if not examples_path.exists():
|
||||||
|
print(f"[ERROR] Directory not found: {examples_dir}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def skill_link(target: Path) -> str:
|
||||||
|
"""Return a link from the generated index to a packaged Skill resource."""
|
||||||
|
return Path(
|
||||||
|
os.path.relpath(target, start=examples_path.resolve())
|
||||||
|
).as_posix()
|
||||||
|
|
||||||
|
print(f"[SCAN] Scanning directory: {examples_dir}")
|
||||||
|
|
||||||
|
# Find all projects
|
||||||
|
projects = find_all_projects(examples_dir)
|
||||||
|
|
||||||
|
if not projects:
|
||||||
|
print("[WARN] No projects found")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
print(f"Found {len(projects)} project(s)")
|
||||||
|
|
||||||
|
# Collect project information
|
||||||
|
projects_info = []
|
||||||
|
for project_path in projects:
|
||||||
|
info = get_project_info(str(project_path))
|
||||||
|
projects_info.append(info)
|
||||||
|
|
||||||
|
# Sort by date (newest first)
|
||||||
|
projects_info.sort(key=lambda x: x['date'], reverse=True)
|
||||||
|
|
||||||
|
# Group by format
|
||||||
|
by_format = defaultdict(list)
|
||||||
|
for info in projects_info:
|
||||||
|
by_format[info['format']].append(info)
|
||||||
|
|
||||||
|
# Generate README content
|
||||||
|
content = []
|
||||||
|
content.append("# PPT Master Example Projects Index\n")
|
||||||
|
content.append("> This file is auto-generated by the packaged `scripts/generate_examples_index.py`\n")
|
||||||
|
content.append(f"> Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||||
|
|
||||||
|
# Overview statistics
|
||||||
|
content.append("## [Stats] Overview\n")
|
||||||
|
content.append(f"- **Total projects**: {len(projects_info)}")
|
||||||
|
content.append(f"- **Canvas formats**: {len(by_format)} type(s)")
|
||||||
|
|
||||||
|
total_svgs = sum(info['svg_count'] for info in projects_info)
|
||||||
|
content.append(f"- **SVG files**: {total_svgs}")
|
||||||
|
|
||||||
|
# Statistics by format
|
||||||
|
content.append("\n### Format Distribution\n")
|
||||||
|
for fmt_key in sorted(by_format.keys(), key=lambda x: len(by_format[x]), reverse=True):
|
||||||
|
count = len(by_format[fmt_key])
|
||||||
|
fmt_name = CANVAS_FORMATS.get(fmt_key, {}).get('name', fmt_key)
|
||||||
|
content.append(f"- **{fmt_name}**: {count} project(s)")
|
||||||
|
|
||||||
|
# Recently updated
|
||||||
|
content.append("\n## [New] Recently Updated\n")
|
||||||
|
for info in projects_info[:5]:
|
||||||
|
content.append(
|
||||||
|
f"- **{info['name']}** ({info['format_name']}) - {info['date_formatted']}")
|
||||||
|
|
||||||
|
# Project list by format
|
||||||
|
content.append("\n## [List] Project List\n")
|
||||||
|
|
||||||
|
# Define format display order
|
||||||
|
format_order = ['ppt169', 'ppt43', 'wechat',
|
||||||
|
'xiaohongshu', 'moments', 'story', 'banner', 'a4']
|
||||||
|
|
||||||
|
for fmt_key in format_order:
|
||||||
|
if fmt_key not in by_format:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fmt_info = CANVAS_FORMATS.get(fmt_key, {})
|
||||||
|
fmt_name = fmt_info.get('name', fmt_key)
|
||||||
|
dimensions = fmt_info.get('dimensions', '')
|
||||||
|
|
||||||
|
content.append(f"\n### {fmt_name} ({dimensions})\n")
|
||||||
|
|
||||||
|
projects_list = by_format[fmt_key]
|
||||||
|
# Sort by date
|
||||||
|
projects_list.sort(key=lambda x: x['date'], reverse=True)
|
||||||
|
|
||||||
|
for info in projects_list:
|
||||||
|
# Project name and link
|
||||||
|
project_link = f"./{info['dir_name']}"
|
||||||
|
|
||||||
|
# Build project entry
|
||||||
|
line = f"- **[{info['name']}]({project_link})**"
|
||||||
|
|
||||||
|
# Add date
|
||||||
|
line += f" - {info['date_formatted']}"
|
||||||
|
|
||||||
|
# Add SVG count
|
||||||
|
line += f" - {info['svg_count']} page(s)"
|
||||||
|
|
||||||
|
content.append(line)
|
||||||
|
|
||||||
|
# Other uncategorized formats
|
||||||
|
other_formats = set(by_format.keys()) - set(format_order)
|
||||||
|
if other_formats:
|
||||||
|
content.append("\n### Other Formats\n")
|
||||||
|
for fmt_key in sorted(other_formats):
|
||||||
|
projects_list = by_format[fmt_key]
|
||||||
|
for info in projects_list:
|
||||||
|
project_link = f"./{info['dir_name']}"
|
||||||
|
line = f"- **[{info['name']}]({project_link})**"
|
||||||
|
line += f" ({info['format_name']}) - {info['date_formatted']}"
|
||||||
|
line += f" - {info['svg_count']} page(s)"
|
||||||
|
content.append(line)
|
||||||
|
|
||||||
|
# Usage instructions
|
||||||
|
content.append("\n## [Docs] Usage Instructions\n")
|
||||||
|
content.append("### Preview Projects\n")
|
||||||
|
content.append("Each project contains the following files:\n")
|
||||||
|
content.append("- `README.md` - Project documentation")
|
||||||
|
content.append("- `Design Spec & Content Outline.md` - Full design specification")
|
||||||
|
content.append("- `svg_output/` - SVG output files\n")
|
||||||
|
|
||||||
|
content.append("**Method 1: Using an HTTP server (recommended)**\n")
|
||||||
|
content.append("```bash")
|
||||||
|
content.append(
|
||||||
|
"python3 -m http.server --directory examples/<project_name>/svg_output 8000")
|
||||||
|
content.append("# Visit http://localhost:8000")
|
||||||
|
content.append("```\n")
|
||||||
|
|
||||||
|
content.append("**Method 2: Open SVG directly**\n")
|
||||||
|
content.append("```bash")
|
||||||
|
content.append(
|
||||||
|
"open examples/<project_name>/svg_output/slide_01_cover.svg")
|
||||||
|
content.append("```\n")
|
||||||
|
|
||||||
|
# Create new project
|
||||||
|
content.append("### Create a New Project\n")
|
||||||
|
content.append("Refer to existing project structures, or use the project management tool:\n")
|
||||||
|
content.append("```bash")
|
||||||
|
content.append(
|
||||||
|
"python3 scripts/project_manager.py init my_project --format ppt169")
|
||||||
|
content.append("```\n")
|
||||||
|
|
||||||
|
# Contribution guidelines
|
||||||
|
content.append("## [Contribute] Contributing Example Projects\n")
|
||||||
|
content.append("We welcome you to share your projects in the examples directory!\n")
|
||||||
|
content.append("### Project Requirements\n")
|
||||||
|
content.append("1. Follow the standard project structure")
|
||||||
|
content.append("2. Include a complete README.md and design specification")
|
||||||
|
content.append("3. SVG files must comply with technical specifications")
|
||||||
|
content.append("4. Directory naming format: `{project_name}_{format}_{YYYYMMDD}`\n")
|
||||||
|
|
||||||
|
content.append("### Submission Process\n")
|
||||||
|
content.append("1. Create a project under the `examples/` directory")
|
||||||
|
content.append(
|
||||||
|
"2. Validate the project: `python3 scripts/project_manager.py validate examples/<project>`")
|
||||||
|
content.append("3. Update the index: `python3 scripts/generate_examples_index.py`")
|
||||||
|
content.append("4. Submit a Pull Request\n")
|
||||||
|
|
||||||
|
# Related resources
|
||||||
|
content.append("## [Resources] Related Resources\n")
|
||||||
|
content.append(f"- [Workflow]({skill_link(skill_dir / 'SKILL.md')})")
|
||||||
|
content.append(
|
||||||
|
f"- [Canvas Formats]({skill_link(skill_dir / 'references' / 'canvas-formats.md')})")
|
||||||
|
content.append(
|
||||||
|
f"- [Role Definitions]({skill_link(skill_dir / 'references')})")
|
||||||
|
content.append(
|
||||||
|
f"- [Visualization Templates]({skill_link(skill_dir / 'templates' / 'README.md')})\n")
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
content.append("---\n")
|
||||||
|
content.append(
|
||||||
|
f"*Auto-generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} by PPT Master*")
|
||||||
|
|
||||||
|
return "\n".join(content)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the CLI entry point."""
|
||||||
|
examples_dir = 'examples'
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
if sys.argv[1] in {'-h', '--help', 'help'}:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
examples_dir = sys.argv[1]
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("PPT Master - Examples Index Generator")
|
||||||
|
print("=" * 80 + "\n")
|
||||||
|
|
||||||
|
# Generate index content
|
||||||
|
content = generate_examples_index(examples_dir)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
print("\n[ERROR] Generation failed")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
output_file = Path(examples_dir) / 'README.md'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print(f"\n[OK] Index file generated: {output_file}")
|
||||||
|
print(f" Contains {len(content.splitlines())} lines")
|
||||||
|
|
||||||
|
# Display statistics
|
||||||
|
projects_count = content.count('- **[')
|
||||||
|
print(f" Indexed {projects_count} project(s)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] Failed to write file: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Icon Sync
|
||||||
|
|
||||||
|
Copy chosen library icons into `<project>/icons/<lib>/` when selected. Missing
|
||||||
|
names exit non-zero before export. Known basenames need no separate existence
|
||||||
|
check; search the chosen library only for unresolved concepts.
|
||||||
|
|
||||||
|
Project-local custom icons count as satisfied. In one resource-selection batch,
|
||||||
|
`simple-icons` may accompany one of the four stylistic libraries for
|
||||||
|
real brand marks.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/icon_sync.py <project_path> <lib/name> [<lib/name> ...]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/icon_sync.py projects/deck tabler-outline/home tabler-outline/chart
|
||||||
|
python3 scripts/icon_sync.py projects/deck tabler-outline/home simple-icons/github
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (standard library only).
|
||||||
|
|
||||||
|
See references/executor-base.md §4 and templates/icons/README.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
_LIB_ALIASES = {"chunk": "chunk-filled"}
|
||||||
|
_STYLISTIC_LIBRARIES = {
|
||||||
|
"chunk-filled",
|
||||||
|
"phosphor-duotone",
|
||||||
|
"tabler-filled",
|
||||||
|
"tabler-outline",
|
||||||
|
}
|
||||||
|
_GLOBAL_ICONS_DIR = Path(__file__).resolve().parent.parent / "templates" / "icons"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_name(icon_name: str) -> tuple[str, str]:
|
||||||
|
"""`lib/name` -> (lib, name), applying the chunk→chunk-filled alias."""
|
||||||
|
if "/" not in icon_name:
|
||||||
|
# legacy un-prefixed names live in chunk-filled/
|
||||||
|
return "chunk-filled", icon_name
|
||||||
|
lib, name = icon_name.split("/", 1)
|
||||||
|
return _LIB_ALIASES.get(lib, lib), name
|
||||||
|
|
||||||
|
|
||||||
|
def sync_icons(project_path: Path, icon_names: list[str], global_dir: Path = _GLOBAL_ICONS_DIR) -> tuple[list[str], list[str]]:
|
||||||
|
"""Copy each `lib/name` from the global library into `<project>/icons/`.
|
||||||
|
|
||||||
|
Returns (copied, missing). A name already present in the project (e.g. a
|
||||||
|
custom icon) counts as satisfied, not missing.
|
||||||
|
"""
|
||||||
|
project_icons = project_path / "icons"
|
||||||
|
copied: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
for raw in icon_names:
|
||||||
|
lib, name = _split_name(raw)
|
||||||
|
src = global_dir / lib / f"{name}.svg"
|
||||||
|
dst = project_icons / lib / f"{name}.svg"
|
||||||
|
if src.is_file():
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
copied.append(f"{lib}/{name}")
|
||||||
|
elif dst.is_file():
|
||||||
|
copied.append(f"{lib}/{name} (already in project)")
|
||||||
|
else:
|
||||||
|
missing.append(f"{lib}/{name}")
|
||||||
|
|
||||||
|
return copied, missing
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Copy chosen library icons into a project's icons/ folder; report missing ones.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("project_path", help="Project directory")
|
||||||
|
parser.add_argument("icons", nargs="+", help="Icon names to copy, e.g. chunk-filled/home")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
project = Path(args.project_path)
|
||||||
|
if not project.is_dir():
|
||||||
|
print(f"[ERROR] project not found: {project}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
requested_libraries = {_split_name(raw)[0] for raw in args.icons}
|
||||||
|
stylistic_libraries = sorted(requested_libraries & _STYLISTIC_LIBRARIES)
|
||||||
|
if len(stylistic_libraries) > 1:
|
||||||
|
print(
|
||||||
|
f"[ERROR] mixed stylistic icon libraries: {', '.join(stylistic_libraries)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Choose one of the four stylistic libraries per selection batch; "
|
||||||
|
"simple-icons may coexist for real brand marks.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
copied, missing = sync_icons(project, args.icons)
|
||||||
|
|
||||||
|
if copied:
|
||||||
|
print(f"[OK] {len(copied)} icon(s) in {project / 'icons'}:", file=sys.stderr)
|
||||||
|
for c in copied:
|
||||||
|
print(f" + {c}", file=sys.stderr)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(f"\n[MISSING] {len(missing)} icon(s) not in the library — re-pick before continuing:", file=sys.stderr)
|
||||||
|
for m in missing:
|
||||||
|
lib = m.split("/", 1)[0]
|
||||||
|
print(
|
||||||
|
f' ✗ {m} (search: rg --files "{_GLOBAL_ICONS_DIR / lib}" -g \'*<keyword>*.svg\')',
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Internal backend implementations for image_gen.py."""
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Black Forest Labs FLUX image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
BFL_API_KEY (required)
|
||||||
|
BFL_BASE_URL (optional)
|
||||||
|
BFL_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend bfl")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
poll_json,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = [
|
||||||
|
"1:1", "2:3", "3:2", "3:4", "4:3",
|
||||||
|
"4:5", "5:4", "9:16", "16:9", "21:9",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://api.bfl.ai"
|
||||||
|
DEFAULT_MODEL = "flux-pro-1.1-ultra"
|
||||||
|
|
||||||
|
MODEL_ENDPOINTS = {
|
||||||
|
"flux-pro-1.1": "/v1/flux-pro-1.1",
|
||||||
|
"flux-pro-1.1-ultra": "/v1/flux-pro-1.1-ultra",
|
||||||
|
"flux-dev": "/v1/flux-dev",
|
||||||
|
}
|
||||||
|
|
||||||
|
ASPECT_RATIO_TO_DIMENSIONS = {
|
||||||
|
"1:1": (1024, 1024),
|
||||||
|
"2:3": (1024, 1536),
|
||||||
|
"3:2": (1536, 1024),
|
||||||
|
"3:4": (1024, 1365),
|
||||||
|
"4:3": (1365, 1024),
|
||||||
|
"4:5": (1024, 1280),
|
||||||
|
"5:4": (1280, 1024),
|
||||||
|
"9:16": (1024, 1820),
|
||||||
|
"16:9": (1820, 1024),
|
||||||
|
"21:9": (2048, 878),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_request(url: str, headers: dict, payload: dict) -> dict:
|
||||||
|
"""Submit a BFL generation request and return the JSON response."""
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=180)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "BFL generation request")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE_URL) -> str:
|
||||||
|
"""Generate one image with the Black Forest Labs backend."""
|
||||||
|
del image_size # BFL quality is primarily controlled by model choice.
|
||||||
|
|
||||||
|
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for BFL backend. "
|
||||||
|
f"Supported: {VALID_ASPECT_RATIOS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_model = model.strip().lower()
|
||||||
|
endpoint = MODEL_ENDPOINTS.get(normalized_model)
|
||||||
|
if not endpoint:
|
||||||
|
supported = sorted(MODEL_ENDPOINTS)
|
||||||
|
raise ValueError(f"Unsupported BFL model '{model}'. Supported: {supported}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-key": api_key,
|
||||||
|
"accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"prompt_upsampling": False,
|
||||||
|
"output_format": "png",
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized_model.endswith("-ultra"):
|
||||||
|
payload["aspect_ratio"] = aspect_ratio
|
||||||
|
payload["raw"] = False
|
||||||
|
else:
|
||||||
|
width, height = ASPECT_RATIO_TO_DIMENSIONS[aspect_ratio]
|
||||||
|
payload["width"] = width
|
||||||
|
payload["height"] = height
|
||||||
|
|
||||||
|
url = base_url.rstrip("/") + endpoint
|
||||||
|
|
||||||
|
print("[Black Forest Labs]")
|
||||||
|
print(f" Model: {normalized_model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print()
|
||||||
|
print(" [..] Submitting request...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
request_payload = _submit_request(url, headers, payload)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Request accepted ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
polling_url = request_payload.get("polling_url")
|
||||||
|
if not polling_url:
|
||||||
|
raise RuntimeError(f"BFL response missing polling_url: {request_payload}")
|
||||||
|
|
||||||
|
print(" [..] Polling result...")
|
||||||
|
result_payload = poll_json(
|
||||||
|
polling_url,
|
||||||
|
{"x-key": api_key, "accept": "application/json"},
|
||||||
|
status_label="status",
|
||||||
|
ready_values=["Ready"],
|
||||||
|
failed_values=["Error", "Failed", "Request Moderated", "Content Moderated"],
|
||||||
|
)
|
||||||
|
|
||||||
|
image_url = ((result_payload.get("result") or {}).get("sample"))
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"BFL result missing sample URL: {result_payload}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the BFL backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"BFL_API_KEY",
|
||||||
|
message="No API key found. Set BFL_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("BFL_BASE_URL") or DEFAULT_BASE_URL
|
||||||
|
resolved_model = model or os.environ.get("BFL_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared helpers for image generation backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("This is an internal helper module used by image_gen.py backends.")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image as PILImage, ImageOps as PILImageOps
|
||||||
|
HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PIL = False
|
||||||
|
|
||||||
|
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BASE_DELAY = 10
|
||||||
|
RETRY_BACKOFF = 2
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output_path(prompt: str, output_dir: str = None,
|
||||||
|
filename: str = None, ext: str = ".png") -> str:
|
||||||
|
"""Compute the final output file path based on parameters."""
|
||||||
|
if filename:
|
||||||
|
file_name = os.path.splitext(filename)[0]
|
||||||
|
else:
|
||||||
|
safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip()
|
||||||
|
safe = safe.replace(" ", "_").lower()[:30]
|
||||||
|
file_name = safe or "generated_image"
|
||||||
|
|
||||||
|
full_name = f"{file_name}{ext}"
|
||||||
|
if output_dir:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
return os.path.join(output_dir, full_name)
|
||||||
|
return full_name
|
||||||
|
|
||||||
|
|
||||||
|
CONTENT_TYPE_TO_EXT = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
"image/tiff": ".tiff",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXT_TO_PIL_FORMAT = {
|
||||||
|
".png": "PNG",
|
||||||
|
".jpg": "JPEG",
|
||||||
|
".jpeg": "JPEG",
|
||||||
|
".webp": "WEBP",
|
||||||
|
".gif": "GIF",
|
||||||
|
".bmp": "BMP",
|
||||||
|
".tiff": "TIFF",
|
||||||
|
".tif": "TIFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str | None:
|
||||||
|
"""Best-effort detection of the real image format."""
|
||||||
|
if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return ".png"
|
||||||
|
if image_bytes.startswith(b"\xff\xd8\xff"):
|
||||||
|
return ".jpg"
|
||||||
|
if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
|
||||||
|
return ".gif"
|
||||||
|
if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
|
||||||
|
return ".webp"
|
||||||
|
if image_bytes.startswith(b"BM"):
|
||||||
|
return ".bmp"
|
||||||
|
if image_bytes.startswith((b"II*\x00", b"MM\x00*")):
|
||||||
|
return ".tiff"
|
||||||
|
if content_type:
|
||||||
|
clean_type = content_type.split(";", 1)[0].strip().lower()
|
||||||
|
if clean_type in CONTENT_TYPE_TO_EXT:
|
||||||
|
return CONTENT_TYPE_TO_EXT[clean_type]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DATA_URI_HEADER = re.compile(
|
||||||
|
r"data:(?P<mime>image/[A-Za-z0-9.+-]+)(?P<params>;[^,]*)?,",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_data_uri(value: str) -> tuple[bytes, str | None]:
|
||||||
|
"""
|
||||||
|
Decode a base64 image data URI into raw bytes plus its declared content type.
|
||||||
|
|
||||||
|
The declared type is returned so callers can hand it to `save_image_bytes` instead of
|
||||||
|
assuming the payload matches the output extension.
|
||||||
|
"""
|
||||||
|
header = DATA_URI_HEADER.match(value.strip())
|
||||||
|
if not header:
|
||||||
|
raise ValueError("Expected a base64 image data URI (data:image/...;base64,...).")
|
||||||
|
|
||||||
|
params = (header.group("params") or "").lower()
|
||||||
|
if "base64" not in params:
|
||||||
|
raise ValueError("Only base64-encoded image data URIs are supported.")
|
||||||
|
|
||||||
|
payload = "".join(value.strip()[header.end():].split())
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
return base64.urlsafe_b64decode(payload), header.group("mime").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def find_data_uri(content) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the first base64 image data URI inside a chat completion `content` value.
|
||||||
|
|
||||||
|
OpenAI-compatible gateways differ here: some return a dedicated image field, others
|
||||||
|
inline the image in the message text (often as ``)
|
||||||
|
or in a content-part list.
|
||||||
|
"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
header = DATA_URI_HEADER.search(content)
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
payload = re.match(r"[A-Za-z0-9+/=_-]*", content[header.end():]).group(0)
|
||||||
|
return content[header.start():header.end()] + payload
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict):
|
||||||
|
nested = part.get("image_url")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
nested = nested.get("url")
|
||||||
|
found = find_data_uri(nested if nested else part.get("text"))
|
||||||
|
else:
|
||||||
|
found = find_data_uri(part)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_extension(ext: str) -> str:
|
||||||
|
"""Normalize equivalent image extensions to a canonical form."""
|
||||||
|
ext = ext.lower()
|
||||||
|
if ext == ".jpeg":
|
||||||
|
return ".jpg"
|
||||||
|
if ext == ".tif":
|
||||||
|
return ".tiff"
|
||||||
|
return ext
|
||||||
|
|
||||||
|
|
||||||
|
def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Save image bytes to disk while keeping the file extension and the real bytes aligned.
|
||||||
|
|
||||||
|
If the target extension differs from the actual bytes, transcode through Pillow when
|
||||||
|
available. Otherwise fail loudly instead of writing a misleading file.
|
||||||
|
"""
|
||||||
|
target_ext = _normalize_extension(os.path.splitext(path)[1])
|
||||||
|
actual_ext = _normalize_extension(detect_image_extension(image_bytes, content_type) or "")
|
||||||
|
|
||||||
|
if not target_ext:
|
||||||
|
raise ValueError(f"Output path must include an image extension: {path}")
|
||||||
|
|
||||||
|
if actual_ext and target_ext == actual_ext:
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
print(f" File saved to: {path}")
|
||||||
|
report_resolution(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
if not HAS_PIL:
|
||||||
|
actual_label = actual_ext or "unknown"
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Image format mismatch for {path}: target extension is {target_ext}, "
|
||||||
|
f"but the actual image bytes are {actual_label}. "
|
||||||
|
"Install Pillow to enable automatic format conversion."
|
||||||
|
)
|
||||||
|
|
||||||
|
target_format = EXT_TO_PIL_FORMAT.get(target_ext)
|
||||||
|
if not target_format:
|
||||||
|
raise ValueError(f"Unsupported output image extension: {target_ext}")
|
||||||
|
|
||||||
|
with PILImage.open(io.BytesIO(image_bytes)) as source:
|
||||||
|
image = PILImageOps.exif_transpose(source)
|
||||||
|
try:
|
||||||
|
if target_format == "JPEG":
|
||||||
|
has_alpha = (
|
||||||
|
image.mode in ("RGBA", "LA")
|
||||||
|
or "transparency" in getattr(image, "info", {})
|
||||||
|
)
|
||||||
|
if has_alpha:
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
alpha = rgba.getchannel("A")
|
||||||
|
rgb = rgba.convert("RGB")
|
||||||
|
converted = PILImage.new("RGB", image.size, (255, 255, 255))
|
||||||
|
converted.paste(rgb, mask=alpha)
|
||||||
|
rgb.close()
|
||||||
|
alpha.close()
|
||||||
|
rgba.close()
|
||||||
|
if image is not source:
|
||||||
|
image.close()
|
||||||
|
image = converted
|
||||||
|
elif image.mode != "RGB":
|
||||||
|
converted = image.convert("RGB")
|
||||||
|
if image is not source:
|
||||||
|
image.close()
|
||||||
|
image = converted
|
||||||
|
image.save(path, format=target_format)
|
||||||
|
finally:
|
||||||
|
if image is not source:
|
||||||
|
image.close()
|
||||||
|
|
||||||
|
if actual_ext and actual_ext != target_ext:
|
||||||
|
print(f" Converted: {actual_ext} -> {target_ext}")
|
||||||
|
print(f" File saved to: {path}")
|
||||||
|
report_resolution(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def validate_image_file(path: str) -> str:
|
||||||
|
"""Require an existing regular file that Pillow can read as an image."""
|
||||||
|
image_path = Path(path)
|
||||||
|
if not image_path.exists():
|
||||||
|
raise RuntimeError(f"Image output path does not exist: {path}")
|
||||||
|
if not image_path.is_file():
|
||||||
|
raise RuntimeError(f"Image output path is not a file: {path}")
|
||||||
|
if not HAS_PIL:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Pillow is required to verify generated images. "
|
||||||
|
"Install it with: pip install Pillow"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with PILImage.open(image_path) as image:
|
||||||
|
image.verify()
|
||||||
|
except (OSError, ValueError, SyntaxError) as exc:
|
||||||
|
raise RuntimeError(f"Image output is not readable: {path}: {exc}") from exc
|
||||||
|
return str(image_path)
|
||||||
|
|
||||||
|
|
||||||
|
def report_resolution(path: str) -> None:
|
||||||
|
"""Try to report image resolution using PIL."""
|
||||||
|
if HAS_PIL:
|
||||||
|
try:
|
||||||
|
img = PILImage.open(path)
|
||||||
|
print(f" Resolution: {img.size[0]}x{img.size[1]}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_image_size(image_size: str) -> str:
|
||||||
|
"""Normalize image size input to standard format."""
|
||||||
|
s = image_size.strip()
|
||||||
|
upper = s.upper()
|
||||||
|
if upper in ("1K", "2K", "4K"):
|
||||||
|
return upper
|
||||||
|
if upper in ("512PX", "512"):
|
||||||
|
return "512px"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def is_rate_limit_error(exc: Exception) -> bool:
|
||||||
|
"""Check whether the exception appears to be rate limiting."""
|
||||||
|
err_str = str(exc).lower()
|
||||||
|
status_code = getattr(exc, "status_code", None)
|
||||||
|
error_code = getattr(exc, "code", None)
|
||||||
|
response = getattr(exc, "response", None)
|
||||||
|
error_name = type(exc).__name__.lower()
|
||||||
|
if (
|
||||||
|
status_code == 429
|
||||||
|
or error_code == 429
|
||||||
|
or getattr(response, "status_code", None) == 429
|
||||||
|
or error_name in {"ratelimiterror", "toomanyrequestserror"}
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
"429" in err_str
|
||||||
|
or "rate limit" in err_str
|
||||||
|
or "rate-limit" in err_str
|
||||||
|
or "rate_limit" in err_str
|
||||||
|
or "too many requests" in err_str
|
||||||
|
or "quota" in err_str
|
||||||
|
or "resource_exhausted" in err_str
|
||||||
|
or "resource exhausted" in err_str
|
||||||
|
or "throttl" in err_str
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def retry_delay(attempt: int, rate_limited: bool) -> int:
|
||||||
|
"""Return the retry delay for a given attempt."""
|
||||||
|
if rate_limited:
|
||||||
|
return RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def download_image(url: str, path: str, headers: dict = None, timeout: int = 180) -> str:
|
||||||
|
"""Download an image URL and save it to disk."""
|
||||||
|
response = requests.get(url, headers=headers or {}, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return save_image_bytes(
|
||||||
|
response.content,
|
||||||
|
path,
|
||||||
|
content_type=response.headers.get("Content-Type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(*candidates: str, message: str) -> str:
|
||||||
|
"""Return the first non-empty env var from candidates or raise."""
|
||||||
|
for name in candidates:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def http_error(response: requests.Response, label: str) -> RuntimeError:
|
||||||
|
"""Convert an HTTP response into a readable RuntimeError."""
|
||||||
|
body = response.text.strip()
|
||||||
|
if len(body) > 500:
|
||||||
|
body = body[:500] + "..."
|
||||||
|
return RuntimeError(f"{label} failed ({response.status_code}): {body}")
|
||||||
|
|
||||||
|
|
||||||
|
def poll_json(
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
*,
|
||||||
|
interval_seconds: float = 2.0,
|
||||||
|
timeout_seconds: int = 300,
|
||||||
|
status_label: str = "status",
|
||||||
|
ready_values: list[str] | None = None,
|
||||||
|
failed_values: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Poll a JSON endpoint until it reports a ready or failed status."""
|
||||||
|
ready = {value.lower() for value in (ready_values or ["ready", "success", "succeeded"])}
|
||||||
|
failed = {value.lower() for value in (failed_values or ["error", "failed", "fail"])}
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
while True:
|
||||||
|
response = requests.get(url, headers=headers, timeout=180)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
raw_status = str(payload.get(status_label, "")).strip()
|
||||||
|
status = raw_status.lower()
|
||||||
|
|
||||||
|
if raw_status:
|
||||||
|
print(f" Status: {raw_status}")
|
||||||
|
|
||||||
|
if status in ready:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
if status in failed:
|
||||||
|
raise RuntimeError(f"Remote generation failed: {payload}")
|
||||||
|
|
||||||
|
if time.time() - start > timeout_seconds:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Timed out after {timeout_seconds}s while polling {url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(interval_seconds)
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
fal.ai image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
FAL_KEY / FAL_API_KEY (required)
|
||||||
|
FAL_BASE_URL (optional)
|
||||||
|
FAL_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend fal")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3"]
|
||||||
|
DEFAULT_ENDPOINT = "https://fal.run"
|
||||||
|
DEFAULT_MODEL = "fal-ai/imagen3/fast"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str, model: str) -> str:
|
||||||
|
"""Resolve the full fal endpoint URL for a model."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith(model):
|
||||||
|
return base
|
||||||
|
return f"{base}/{model}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the fal.ai backend."""
|
||||||
|
del image_size
|
||||||
|
|
||||||
|
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for fal backend. "
|
||||||
|
f"Supported: {VALID_ASPECT_RATIOS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
url = _resolve_url(base_url, model)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Key {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"num_images": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[fal.ai]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "fal image generation")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
images = data.get("images") or []
|
||||||
|
image_url = images[0].get("url") if images else None
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"fal response missing image URL: {data}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the fal.ai backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"FAL_KEY",
|
||||||
|
"FAL_API_KEY",
|
||||||
|
message="No API key found. Set FAL_KEY or FAL_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("FAL_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("FAL_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Gemini Image Generation Backend
|
||||||
|
|
||||||
|
Generates images via the Google GenAI API (Gemini).
|
||||||
|
Used by image_gen.py as a backend module.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
GEMINI_API_KEY (required)
|
||||||
|
GEMINI_BASE_URL (optional) Custom API endpoint for proxy services
|
||||||
|
GEMINI_MODEL (optional) Override default model
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
pip install google-genai Pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend gemini")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
save_image_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════════════════════════════���═══════════════════════════════════╗
|
||||||
|
# ║ Constants ║
|
||||||
|
# ╚═══════════��═════════════════════════════��════════════════════════╝
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = [
|
||||||
|
"1:1", "1:4", "1:8",
|
||||||
|
"2:3", "3:2", "3:4", "4:1", "4:3",
|
||||||
|
"4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
|
||||||
|
]
|
||||||
|
|
||||||
|
VALID_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════���═══════════════════════════════════════════════��═══════════╗
|
||||||
|
# ║ Image Generation ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Image generation via Gemini API (streaming).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the saved image file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: When generation fails
|
||||||
|
"""
|
||||||
|
if base_url:
|
||||||
|
client = genai.Client(api_key=api_key, http_options={'base_url': base_url})
|
||||||
|
else:
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
|
config_kwargs = {
|
||||||
|
"response_modalities": ["IMAGE"],
|
||||||
|
"image_config": types.ImageConfig(
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if "flash" in model.lower():
|
||||||
|
config_kwargs["thinking_config"] = types.ThinkingConfig(
|
||||||
|
thinking_level="MINIMAL",
|
||||||
|
)
|
||||||
|
config = types.GenerateContentConfig(**config_kwargs)
|
||||||
|
|
||||||
|
mode_label = "Proxy Mode" if base_url else "Official Mode"
|
||||||
|
print(f"[Gemini - {mode_label}]")
|
||||||
|
if base_url:
|
||||||
|
print(f" Base URL: {base_url}")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Image Size: {image_size}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
print(f" [..] Generating...", end="", flush=True)
|
||||||
|
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
|
||||||
|
def _heartbeat():
|
||||||
|
while not heartbeat_stop.is_set():
|
||||||
|
heartbeat_stop.wait(5)
|
||||||
|
if not heartbeat_stop.is_set():
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" {elapsed:.0f}s...", end="", flush=True)
|
||||||
|
|
||||||
|
hb_thread = threading.Thread(target=_heartbeat, daemon=True)
|
||||||
|
hb_thread.start()
|
||||||
|
|
||||||
|
last_image_data = None
|
||||||
|
chunk_count = 0
|
||||||
|
total_bytes = 0
|
||||||
|
|
||||||
|
for chunk in client.models.generate_content_stream(
|
||||||
|
model=model,
|
||||||
|
contents=[prompt],
|
||||||
|
config=config,
|
||||||
|
):
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
if chunk.parts is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for part in chunk.parts:
|
||||||
|
if part.text is not None:
|
||||||
|
print(f"\n Model says: {part.text}", end="", flush=True)
|
||||||
|
elif part.inline_data is not None:
|
||||||
|
chunk_count += 1
|
||||||
|
data_size = len(part.inline_data.data) if part.inline_data.data else 0
|
||||||
|
total_bytes += data_size
|
||||||
|
size_str = f"{data_size / 1024:.0f}KB" if data_size < 1048576 else f"{data_size / 1048576:.1f}MB"
|
||||||
|
print(f"\n [OK] Chunk #{chunk_count} received ({size_str}, {elapsed:.1f}s)", end="", flush=True)
|
||||||
|
last_image_data = part
|
||||||
|
|
||||||
|
heartbeat_stop.set()
|
||||||
|
hb_thread.join(timeout=1)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f"\n [DONE] Stream complete ({elapsed:.1f}s, {chunk_count} chunk(s), {total_bytes / 1024:.0f}KB total)")
|
||||||
|
|
||||||
|
if last_image_data is not None and last_image_data.inline_data is not None:
|
||||||
|
if chunk_count > 1:
|
||||||
|
print(f" Keeping the final chunk (highest quality).")
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return save_image_bytes(
|
||||||
|
last_image_data.inline_data.data,
|
||||||
|
path,
|
||||||
|
content_type=getattr(last_image_data.inline_data, "mime_type", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError("No image was generated. The server may have refused the request.")
|
||||||
|
|
||||||
|
|
||||||
|
# ╔���═══════════════════════════════════���═════════════════════════════╗
|
||||||
|
# ║ Public Entry Point ║
|
||||||
|
# ╚═════════════���═══════════════════════════���════════════════════════╝
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""
|
||||||
|
Gemini image generation with automatic retry.
|
||||||
|
|
||||||
|
Reads credentials from the current process environment or a `.env` file:
|
||||||
|
GEMINI_API_KEY
|
||||||
|
GEMINI_BASE_URL
|
||||||
|
GEMINI_MODEL (optional override)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Prompt text
|
||||||
|
aspect_ratio: Aspect ratio (e.g. "16:9", "1:1")
|
||||||
|
image_size: Image size ("512px", "1K", "2K", "4K", case-insensitive)
|
||||||
|
output_dir: Output directory
|
||||||
|
filename: Output filename (without extension)
|
||||||
|
model: Model name (default: gemini-3.1-flash-image-preview)
|
||||||
|
max_retries: Maximum number of retries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the saved image file
|
||||||
|
"""
|
||||||
|
api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
base_url = os.environ.get("GEMINI_BASE_URL")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
"No API key found. Set GEMINI_API_KEY in the current environment or a .env file."
|
||||||
|
)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
model = os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
image_size = normalize_image_size(image_size)
|
||||||
|
|
||||||
|
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||||
|
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
|
||||||
|
|
||||||
|
if image_size not in VALID_IMAGE_SIZES:
|
||||||
|
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(api_key, prompt,
|
||||||
|
aspect_ratio, image_size, output_dir,
|
||||||
|
filename, model, base_url)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < max_retries and is_rate_limit_error(e):
|
||||||
|
delay = retry_delay(attempt, rate_limited=True)
|
||||||
|
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
|
||||||
|
f"Waiting {delay}s before retry...")
|
||||||
|
time.sleep(delay)
|
||||||
|
elif attempt < max_retries:
|
||||||
|
delay = retry_delay(attempt, rate_limited=False)
|
||||||
|
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||||
|
f"Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Ideogram image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
IDEOGRAM_API_KEY (required)
|
||||||
|
IDEOGRAM_BASE_URL (optional)
|
||||||
|
IDEOGRAM_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend ideogram")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ASPECT_RATIO_MAP = {
|
||||||
|
"1:1": "1x1",
|
||||||
|
"1:4": "1x4",
|
||||||
|
"2:3": "2x3",
|
||||||
|
"3:2": "3x2",
|
||||||
|
"3:4": "3x4",
|
||||||
|
"4:1": "4x1",
|
||||||
|
"4:3": "4x3",
|
||||||
|
"4:5": "4x5",
|
||||||
|
"5:4": "5x4",
|
||||||
|
"9:16": "9x16",
|
||||||
|
"16:9": "16x9",
|
||||||
|
"21:9": "21x9",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://api.ideogram.ai"
|
||||||
|
DEFAULT_MODEL = "ideogram-v3"
|
||||||
|
MODEL_ALIASES = {"ideogram-v3", "v3"}
|
||||||
|
|
||||||
|
IMAGE_SIZE_TO_SPEED = {
|
||||||
|
"512px": "TURBO",
|
||||||
|
"1K": "DEFAULT",
|
||||||
|
"2K": "QUALITY",
|
||||||
|
"4K": "QUALITY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the Ideogram generation endpoint."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/generate"):
|
||||||
|
return base
|
||||||
|
return base + "/v1/ideogram-v3/generate"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE_URL) -> str:
|
||||||
|
"""Generate one image with the Ideogram backend."""
|
||||||
|
normalized_model = model.strip().lower()
|
||||||
|
if normalized_model not in MODEL_ALIASES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported Ideogram model '{model}'. Supported: {sorted(MODEL_ALIASES)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
mapped_ratio = ASPECT_RATIO_MAP.get(aspect_ratio)
|
||||||
|
if not mapped_ratio:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for Ideogram backend. "
|
||||||
|
f"Supported: {sorted(ASPECT_RATIO_MAP)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rendering_speed = IMAGE_SIZE_TO_SPEED.get(image_size, "DEFAULT")
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
headers = {"Api-Key": api_key}
|
||||||
|
files = {
|
||||||
|
"prompt": (None, prompt),
|
||||||
|
"aspect_ratio": (None, mapped_ratio),
|
||||||
|
"rendering_speed": (None, rendering_speed),
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Ideogram]")
|
||||||
|
print(f" Model: {normalized_model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio} -> {mapped_ratio}")
|
||||||
|
print(f" Render Speed: {rendering_speed}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, files=files, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "Ideogram generation")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
data = payload.get("data") or []
|
||||||
|
image_url = data[0].get("url") if data else None
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"Ideogram response missing image URL: {payload}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the Ideogram backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"IDEOGRAM_API_KEY",
|
||||||
|
message="No API key found. Set IDEOGRAM_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("IDEOGRAM_BASE_URL") or DEFAULT_BASE_URL
|
||||||
|
resolved_model = model or os.environ.get("IDEOGRAM_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
MiniMax image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
MINIMAX_API_KEY (required)
|
||||||
|
MINIMAX_BASE_URL (optional)
|
||||||
|
MINIMAX_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend minimax")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
detect_image_extension,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
save_image_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = "https://api.minimaxi.com/v1/image_generation"
|
||||||
|
DEFAULT_MODEL = "image-01"
|
||||||
|
|
||||||
|
# International fallback: set MINIMAX_BASE_URL=https://api.minimax.io if needed
|
||||||
|
|
||||||
|
ASPECT_RATIO_SIZE_MAP = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": (512, 512),
|
||||||
|
"16:9": (640, 360),
|
||||||
|
"4:3": (576, 432),
|
||||||
|
"3:2": (624, 416),
|
||||||
|
"2:3": (416, 624),
|
||||||
|
"3:4": (432, 576),
|
||||||
|
"9:16": (360, 640),
|
||||||
|
"21:9": (672, 288),
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": (1024, 1024),
|
||||||
|
"16:9": (1280, 720),
|
||||||
|
"4:3": (1152, 864),
|
||||||
|
"3:2": (1248, 832),
|
||||||
|
"2:3": (832, 1248),
|
||||||
|
"3:4": (864, 1152),
|
||||||
|
"9:16": (720, 1280),
|
||||||
|
"21:9": (1344, 576),
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": (2048, 2048),
|
||||||
|
"16:9": (2048, 1152),
|
||||||
|
"4:3": (2048, 1536),
|
||||||
|
"3:2": (2048, 1368),
|
||||||
|
"2:3": (1368, 2048),
|
||||||
|
"3:4": (1536, 2048),
|
||||||
|
"9:16": (1152, 2048),
|
||||||
|
"21:9": (2048, 880),
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": (2048, 2048),
|
||||||
|
"16:9": (2048, 1152),
|
||||||
|
"4:3": (2048, 1536),
|
||||||
|
"3:2": (2048, 1368),
|
||||||
|
"2:3": (1368, 2048),
|
||||||
|
"3:4": (1536, 2048),
|
||||||
|
"9:16": (1152, 2048),
|
||||||
|
"21:9": (2048, 880),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the MiniMax image generation endpoint.
|
||||||
|
|
||||||
|
Accepts three forms of MINIMAX_BASE_URL:
|
||||||
|
- Full endpoint: https://api.minimax.io/v1/image_generation → used as-is
|
||||||
|
- Versioned base: https://api.minimax.io/v1 → appends /image_generation
|
||||||
|
- Root base: https://api.minimax.io → appends /v1/image_generation
|
||||||
|
"""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/image_generation"):
|
||||||
|
return base
|
||||||
|
if base.endswith("/v1"):
|
||||||
|
return base + "/image_generation"
|
||||||
|
return base + "/v1/image_generation"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dimensions(aspect_ratio: str, image_size: str) -> tuple[int, int]:
|
||||||
|
"""Resolve width and height from the unified aspect_ratio/image_size pair."""
|
||||||
|
normalized = normalize_image_size(image_size)
|
||||||
|
dimensions = (ASPECT_RATIO_SIZE_MAP.get(normalized) or {}).get(aspect_ratio)
|
||||||
|
if not dimensions:
|
||||||
|
supported = sorted(ASPECT_RATIO_SIZE_MAP["1K"])
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for MiniMax backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
return dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_image_bytes(payload: dict) -> bytes | None:
|
||||||
|
"""Extract image bytes from a MiniMax response payload."""
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
image_base64 = data.get("image_base64") or []
|
||||||
|
if image_base64:
|
||||||
|
return base64.b64decode(image_base64[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the MiniMax backend."""
|
||||||
|
width, height = _resolve_dimensions(aspect_ratio, image_size)
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"response_format": "base64",
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[MiniMax Image]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Resolution: {width}x{height} (from image_size={image_size})")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "MiniMax image generation")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
base_resp = data.get("base_resp") or {}
|
||||||
|
if base_resp.get("status_code") not in (None, 0, "0"):
|
||||||
|
raise RuntimeError(f"MiniMax image generation failed: {data}")
|
||||||
|
|
||||||
|
image_bytes = _extract_image_bytes(data)
|
||||||
|
if not image_bytes:
|
||||||
|
raise RuntimeError(f"MiniMax response missing image data: {data}")
|
||||||
|
|
||||||
|
ext = detect_image_extension(image_bytes) or ".jpeg"
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ext)
|
||||||
|
return save_image_bytes(image_bytes, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the MiniMax backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"MINIMAX_API_KEY",
|
||||||
|
message="No API key found. Set MINIMAX_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("MINIMAX_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("MINIMAX_MODEL") or DEFAULT_MODEL
|
||||||
|
normalized_size = normalize_image_size(image_size)
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=normalized_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ModelScope image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
MODELSCOPE_API_KEY (required)
|
||||||
|
MODELSCOPE_MODEL (optional)
|
||||||
|
MODELSCOPE_BASE_URL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
poll_json,
|
||||||
|
download_image
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = "https://api-inference.modelscope.cn"
|
||||||
|
DEFAULT_MODEL = "Tongyi-MAI/Z-Image-Turbo"
|
||||||
|
|
||||||
|
# Resolution must be 64-aligned.
|
||||||
|
ASPECT_RATIO_SIZE_MAP = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": "1024*1024",
|
||||||
|
"3:4": "768*1024",
|
||||||
|
"4:3": "1024*768",
|
||||||
|
"9:16": "576*1024",
|
||||||
|
"16:9": "1024*576"
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1280*1280",
|
||||||
|
"3:4": "960*1280",
|
||||||
|
"4:3": "1280*960",
|
||||||
|
"9:16": "576*1024",
|
||||||
|
"16:9": "1024*576"
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": "2048*2048",
|
||||||
|
"3:4": "1536*2048",
|
||||||
|
"4:3": "2048*1536",
|
||||||
|
"9:16": "1152*2048",
|
||||||
|
"16:9": "2048*1152"
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": "2048*2048",
|
||||||
|
"3:4": "1920*2560",
|
||||||
|
"4:3": "2560*1920",
|
||||||
|
"9:16": "1728*3072",
|
||||||
|
"16:9": "3072*1728"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the ModelScope generation endpoint."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/v1"):
|
||||||
|
base = base.removesuffix("/v1")
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||||
|
"""Resolve the target resolution for a ratio and logical size preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aspect_ratio (str): The aspect ratio string. Supported values: '1:1', '3:4', '4:3', '9:16', '16:9'.
|
||||||
|
image_size (str): The logical size preset. Supported values: '512px', '1K', '2K', '4K'.
|
||||||
|
"""
|
||||||
|
normalized = normalize_image_size(image_size)
|
||||||
|
size = (ASPECT_RATIO_SIZE_MAP.get(normalized) or {}).get(aspect_ratio)
|
||||||
|
if not size:
|
||||||
|
supported = sorted(ASPECT_RATIO_SIZE_MAP["1K"])
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for ModelScope backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the ModelScope backend."""
|
||||||
|
size = _resolve_size(aspect_ratio, image_size)
|
||||||
|
url = _resolve_url(base_url)+'/v1/images/generations'
|
||||||
|
common_headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size.replace("*", "x"),
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ModelScope Models]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Resolution: {size}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers={**common_headers,"X-ModelScope-Async-Mode": "true"}, json=payload, timeout=300)
|
||||||
|
|
||||||
|
if (response.status_code != 200):
|
||||||
|
raise http_error(response, "ModelScope image generation")
|
||||||
|
|
||||||
|
task_id = response.json()["task_id"]
|
||||||
|
data = poll_json(
|
||||||
|
url=f"{_resolve_url(base_url)}/v1/tasks/{task_id}",
|
||||||
|
headers={**common_headers, "X-ModelScope-Task-Type": "image_generation"},
|
||||||
|
status_label="task_status",
|
||||||
|
ready_values=["SUCCEED"],
|
||||||
|
failed_values=["FAILED"],
|
||||||
|
)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(data["output_images"][0], path)
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the ModelScope backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"MODELSCOPE_API_KEY",
|
||||||
|
message="No API key found. Set MODELSCOPE_API_KEY in the current environment or the project-root .env.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("MODELSCOPE_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("MODELSCOPE_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
|
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
OpenAI Compatible Image Generation Backend
|
||||||
|
|
||||||
|
Generates images via OpenAI-compatible APIs (OpenAI, local models like Qwen-Image, etc.).
|
||||||
|
Used by image_gen.py as a backend module.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
OPENAI_API_KEY (required) API key
|
||||||
|
OPENAI_BASE_URL (optional) Custom API endpoint (e.g. http://127.0.0.1:3000/v1)
|
||||||
|
OPENAI_MODEL (optional) Model name (default: gpt-image-2)
|
||||||
|
OPENAI_SIZE_PRESET (optional) auto, legacy, gpt-image, gpt-image-2, or dall-e-2
|
||||||
|
OPENAI_RESPONSE_FORMAT (optional) auto, b64_json, url, or omit
|
||||||
|
OPENAI_QUALITY (optional) auto, omit, low, medium, high, standard, or hd
|
||||||
|
OPENAI_OUTPUT_FORMAT (optional) png, jpeg, or webp for GPT image models
|
||||||
|
OPENAI_OUTPUT_COMPRESSION (optional) 0-100, only for jpeg/webp GPT image output
|
||||||
|
OPENAI_BACKGROUND (optional) auto or opaque for gpt-image-2
|
||||||
|
OPENAI_MODERATION (optional) auto or low for GPT image models
|
||||||
|
|
||||||
|
Image editing (image-to-image):
|
||||||
|
When image_gen.py passes reference_image=<path> (single-image CLI only,
|
||||||
|
via --reference-image), this backend calls /v1/images/edits with the source
|
||||||
|
image + the prompt as an edit instruction, instead of /v1/images/generations.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
pip install requests Pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend openai")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
save_image_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ Constants ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
# Aspect ratio -> DALL-E 3 / legacy compatible size mapping.
|
||||||
|
# Unknown OpenAI-compatible models use this table to preserve old behavior.
|
||||||
|
LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE = {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"16:9": "1792x1024",
|
||||||
|
"9:16": "1024x1792",
|
||||||
|
"3:2": "1536x1024",
|
||||||
|
"2:3": "1024x1536",
|
||||||
|
"4:3": "1536x1024", # closest available
|
||||||
|
"3:4": "1024x1536", # closest available
|
||||||
|
"4:5": "1024x1024", # fallback to square
|
||||||
|
"5:4": "1024x1024", # fallback to square
|
||||||
|
"21:9": "1792x1024", # closest wide format
|
||||||
|
}
|
||||||
|
|
||||||
|
# GPT Image 1/1.5/mini officially support only square, landscape, portrait, or auto.
|
||||||
|
GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE = {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"16:9": "1536x1024",
|
||||||
|
"9:16": "1024x1536",
|
||||||
|
"3:2": "1536x1024",
|
||||||
|
"2:3": "1024x1536",
|
||||||
|
"4:3": "1536x1024",
|
||||||
|
"3:4": "1024x1536",
|
||||||
|
"4:5": "1024x1536",
|
||||||
|
"5:4": "1536x1024",
|
||||||
|
"21:9": "1536x1024",
|
||||||
|
}
|
||||||
|
|
||||||
|
# GPT Image 2 supports flexible sizes when both edges are multiples of 16,
|
||||||
|
# the edge ratio is <= 3:1, and the total pixels are within model limits.
|
||||||
|
GPT_IMAGE_2_SIZES = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280",
|
||||||
|
"3:2": "1248x832", "2:3": "832x1248", "4:3": "1024x768",
|
||||||
|
"3:4": "768x1024", "4:5": "896x1120", "5:4": "1120x896",
|
||||||
|
"21:9": "1280x544",
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280",
|
||||||
|
"3:2": "1248x832", "2:3": "832x1248", "4:3": "1024x768",
|
||||||
|
"3:4": "768x1024", "4:5": "896x1120", "5:4": "1120x896",
|
||||||
|
"21:9": "1280x544",
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": "2048x2048", "16:9": "2048x1152", "9:16": "1152x2048",
|
||||||
|
"3:2": "2016x1344", "2:3": "1344x2016", "4:3": "1920x1440",
|
||||||
|
"3:4": "1440x1920", "4:5": "1600x2000", "5:4": "2000x1600",
|
||||||
|
"21:9": "2560x1088",
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": "2880x2880", "16:9": "3840x2160", "9:16": "2160x3840",
|
||||||
|
"3:2": "3520x2352", "2:3": "2352x3520", "4:3": "3264x2448",
|
||||||
|
"3:4": "2448x3264", "4:5": "2560x3200", "5:4": "3200x2560",
|
||||||
|
"21:9": "3840x1648",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DALL_E_2_SIZE_BY_IMAGE_SIZE = {
|
||||||
|
"512px": "512x512",
|
||||||
|
"1K": "1024x1024",
|
||||||
|
"2K": "1024x1024",
|
||||||
|
"4K": "1024x1024",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
|
||||||
|
|
||||||
|
# image_size -> quality mapping
|
||||||
|
IMAGE_SIZE_TO_QUALITY = {
|
||||||
|
"512px": "low",
|
||||||
|
"1K": "medium",
|
||||||
|
"2K": "high",
|
||||||
|
"4K": "high",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "gpt-image-2"
|
||||||
|
|
||||||
|
GPT_IMAGE_2_MIN_PIXELS = 655_360
|
||||||
|
GPT_IMAGE_2_MAX_PIXELS = 8_294_400
|
||||||
|
GPT_IMAGE_2_MAX_EDGE = 3840
|
||||||
|
GPT_IMAGE_2_MAX_RATIO = 3
|
||||||
|
|
||||||
|
GPT_IMAGE_OUTPUT_FORMATS = {"png", "jpeg", "webp"}
|
||||||
|
GPT_IMAGE_OUTPUT_EXTENSIONS = {
|
||||||
|
"png": ".png",
|
||||||
|
"jpeg": ".jpg",
|
||||||
|
"webp": ".webp",
|
||||||
|
}
|
||||||
|
OPENAI_SIZE_PRESETS = {
|
||||||
|
"auto",
|
||||||
|
"legacy",
|
||||||
|
"gpt-image",
|
||||||
|
"gpt-image-legacy",
|
||||||
|
"gpt-image-2",
|
||||||
|
"dall-e-2",
|
||||||
|
"dalle-2",
|
||||||
|
}
|
||||||
|
OPENAI_RESPONSE_FORMATS = {"auto", "b64_json", "url", "omit"}
|
||||||
|
OPENAI_QUALITY_VALUES = {
|
||||||
|
"auto",
|
||||||
|
"omit",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"standard",
|
||||||
|
"hd",
|
||||||
|
}
|
||||||
|
GPT_IMAGE_BACKGROUNDS = {"auto", "opaque", "transparent"}
|
||||||
|
GPT_IMAGE_MODERATION_VALUES = {"auto", "low"}
|
||||||
|
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
# Signals to image_gen.py that this backend can accept a reference_image
|
||||||
|
# (image-to-image edit via /v1/images/edits). Other backends omit this marker.
|
||||||
|
SUPPORTS_REFERENCE_IMAGE = True
|
||||||
|
|
||||||
|
|
||||||
|
def _field(value, name: str):
|
||||||
|
"""Read a response field from either an SDK object or a dict."""
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return value.get(name)
|
||||||
|
return getattr(value, name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_model(model: str) -> str:
|
||||||
|
return (model or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gpt_image_model(model: str) -> bool:
|
||||||
|
return _normalized_model(model).startswith("gpt-image-")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gpt_image_2(model: str) -> bool:
|
||||||
|
return _normalized_model(model).startswith("gpt-image-2")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dall_e_2(model: str) -> bool:
|
||||||
|
return _normalized_model(model) == "dall-e-2"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_size(size: str) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
width_s, height_s = size.lower().split("x", 1)
|
||||||
|
return int(width_s), int(height_s)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Invalid image size '{size}'. Expected WIDTHxHEIGHT.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gpt_image_2_size(size: str) -> None:
|
||||||
|
width, height = _parse_size(size)
|
||||||
|
total_pixels = width * height
|
||||||
|
long_edge = max(width, height)
|
||||||
|
short_edge = min(width, height)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if long_edge > GPT_IMAGE_2_MAX_EDGE:
|
||||||
|
errors.append(f"max edge {long_edge}px exceeds {GPT_IMAGE_2_MAX_EDGE}px")
|
||||||
|
if width % 16 != 0 or height % 16 != 0:
|
||||||
|
errors.append("both edges must be multiples of 16px")
|
||||||
|
if long_edge / short_edge > GPT_IMAGE_2_MAX_RATIO:
|
||||||
|
errors.append("long:short edge ratio must not exceed 3:1")
|
||||||
|
if not (GPT_IMAGE_2_MIN_PIXELS <= total_pixels <= GPT_IMAGE_2_MAX_PIXELS):
|
||||||
|
errors.append(
|
||||||
|
f"total pixels {total_pixels:,} must be between "
|
||||||
|
f"{GPT_IMAGE_2_MIN_PIXELS:,} and {GPT_IMAGE_2_MAX_PIXELS:,}"
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
raise ValueError(f"Invalid gpt-image-2 size '{size}': {', '.join(errors)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _select_size(
|
||||||
|
model: str,
|
||||||
|
aspect_ratio: str,
|
||||||
|
image_size: str,
|
||||||
|
size_preset: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Select a model-compatible size while preserving legacy fallbacks."""
|
||||||
|
preset = size_preset or "auto"
|
||||||
|
if preset in {"gpt-image-2"} or (preset == "auto" and _is_gpt_image_2(model)):
|
||||||
|
size = GPT_IMAGE_2_SIZES[image_size][aspect_ratio]
|
||||||
|
_validate_gpt_image_2_size(size)
|
||||||
|
return size
|
||||||
|
if preset in {"gpt-image", "gpt-image-legacy"} or (
|
||||||
|
preset == "auto" and _is_gpt_image_model(model)
|
||||||
|
):
|
||||||
|
return GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE[aspect_ratio]
|
||||||
|
if preset in {"dall-e-2", "dalle-2"} or (preset == "auto" and _is_dall_e_2(model)):
|
||||||
|
return DALL_E_2_SIZE_BY_IMAGE_SIZE[image_size]
|
||||||
|
return LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE[aspect_ratio]
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_response_format(model: str) -> bool:
|
||||||
|
"""GPT Image models always return base64; DALL-E/compatible models may need this."""
|
||||||
|
return not _is_gpt_image_model(model)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_choice(name: str, allowed: set[str]) -> str | None:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return None
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in allowed:
|
||||||
|
allowed_list = ", ".join(sorted(allowed))
|
||||||
|
raise ValueError(f"Invalid {name}='{value}'. Supported: {allowed_list}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_int(name: str, minimum: int, maximum: int) -> int | None:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid {name}='{value}'. Expected integer {minimum}-{maximum}.") from exc
|
||||||
|
if not (minimum <= parsed <= maximum):
|
||||||
|
raise ValueError(f"Invalid {name}={parsed}. Expected integer {minimum}-{maximum}.")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _gpt_image_options(model: str) -> tuple[dict, str]:
|
||||||
|
"""Read optional GPT Image request parameters from environment."""
|
||||||
|
output_format = _read_env_choice("OPENAI_OUTPUT_FORMAT", GPT_IMAGE_OUTPUT_FORMATS)
|
||||||
|
output_ext = GPT_IMAGE_OUTPUT_EXTENSIONS[output_format] if output_format else ".png"
|
||||||
|
options = {}
|
||||||
|
if output_format:
|
||||||
|
options["output_format"] = output_format
|
||||||
|
|
||||||
|
output_compression = _read_env_int("OPENAI_OUTPUT_COMPRESSION", 0, 100)
|
||||||
|
if output_compression is not None:
|
||||||
|
if output_format not in {"jpeg", "webp"}:
|
||||||
|
raise ValueError(
|
||||||
|
"OPENAI_OUTPUT_COMPRESSION is only supported when "
|
||||||
|
"OPENAI_OUTPUT_FORMAT is jpeg or webp."
|
||||||
|
)
|
||||||
|
options["output_compression"] = output_compression
|
||||||
|
|
||||||
|
background = _read_env_choice("OPENAI_BACKGROUND", GPT_IMAGE_BACKGROUNDS)
|
||||||
|
if background:
|
||||||
|
if _is_gpt_image_2(model) and background == "transparent":
|
||||||
|
raise ValueError("gpt-image-2 does not support OPENAI_BACKGROUND=transparent.")
|
||||||
|
options["background"] = background
|
||||||
|
|
||||||
|
moderation = _read_env_choice("OPENAI_MODERATION", GPT_IMAGE_MODERATION_VALUES)
|
||||||
|
if moderation:
|
||||||
|
options["moderation"] = moderation
|
||||||
|
|
||||||
|
return options, output_ext
|
||||||
|
|
||||||
|
|
||||||
|
def _image_generations_url(base_url: str | None) -> str:
|
||||||
|
base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
||||||
|
if base.endswith("/images/generations"):
|
||||||
|
return base
|
||||||
|
return f"{base}/images/generations"
|
||||||
|
|
||||||
|
|
||||||
|
def _image_edits_url(base_url: str | None) -> str:
|
||||||
|
base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
||||||
|
if base.endswith("/images/edits"):
|
||||||
|
return base
|
||||||
|
if base.endswith("/images/generations"):
|
||||||
|
# Swap the sibling endpoint rather than appending to a full URL.
|
||||||
|
return base[: -len("/generations")] + "/edits"
|
||||||
|
return f"{base}/images/edits"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_size_preset() -> str | None:
|
||||||
|
"""Read the optional size mapping preset for OpenAI-compatible providers."""
|
||||||
|
return _read_env_choice("OPENAI_SIZE_PRESET", OPENAI_SIZE_PRESETS)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_response_format() -> str | None:
|
||||||
|
"""Read the optional response_format override."""
|
||||||
|
return _read_env_choice("OPENAI_RESPONSE_FORMAT", OPENAI_RESPONSE_FORMATS)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_quality(image_size: str) -> str | None:
|
||||||
|
"""Resolve the quality field for OpenAI-compatible requests."""
|
||||||
|
quality = _read_env_choice("OPENAI_QUALITY", OPENAI_QUALITY_VALUES)
|
||||||
|
if quality == "omit":
|
||||||
|
return None
|
||||||
|
if quality and quality != "auto":
|
||||||
|
return quality
|
||||||
|
return IMAGE_SIZE_TO_QUALITY.get(image_size, "auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_response_format(request: dict, model: str) -> None:
|
||||||
|
"""Apply response_format while preserving the existing default behavior."""
|
||||||
|
response_format = _read_response_format()
|
||||||
|
if response_format == "omit":
|
||||||
|
return
|
||||||
|
if response_format in {"b64_json", "url"}:
|
||||||
|
request["response_format"] = response_format
|
||||||
|
return
|
||||||
|
if _supports_response_format(model):
|
||||||
|
request["response_format"] = "b64_json"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_image_generation(api_key: str, base_url: str | None, request: dict) -> dict:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
_image_generations_url(base_url),
|
||||||
|
headers=headers,
|
||||||
|
json=request,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
raise http_error(response, "OpenAI image generation")
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("OpenAI image generation returned invalid JSON.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _post_image_edit(api_key: str, base_url: str | None,
|
||||||
|
data: dict, image_path: str) -> dict:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
# GPT Image models take the image list field 'image[]'; dall-e-2 and other
|
||||||
|
# OpenAI-compatible edit models use the singular 'image'.
|
||||||
|
model = str(data.get("model", ""))
|
||||||
|
field = "image[]" if _is_gpt_image_model(model) else "image"
|
||||||
|
mime_type = mimetypes.guess_type(image_path)[0] or "application/octet-stream"
|
||||||
|
# Let requests build the multipart/form-data body (and its Content-Type
|
||||||
|
# boundary) from files=; do not set Content-Type by hand.
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
response = requests.post(
|
||||||
|
_image_edits_url(base_url),
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
files=[(field, (Path(image_path).name, image_file, mime_type))],
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
raise http_error(response, "OpenAI image edit")
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("OpenAI image edit returned invalid JSON.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ Image Generation ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Image generation via OpenAI-compatible API.
|
||||||
|
|
||||||
|
Maps aspect_ratio to OpenAI's size parameter, and image_size to quality.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the saved image file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: When generation fails
|
||||||
|
"""
|
||||||
|
# Map parameters
|
||||||
|
size_preset = _read_size_preset()
|
||||||
|
size = _select_size(model, aspect_ratio, image_size, size_preset)
|
||||||
|
quality = _read_quality(image_size)
|
||||||
|
output_ext = ".png"
|
||||||
|
request = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": model,
|
||||||
|
"size": size,
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
if quality is not None:
|
||||||
|
request["quality"] = quality
|
||||||
|
if _is_gpt_image_model(model):
|
||||||
|
gpt_options, output_ext = _gpt_image_options(model)
|
||||||
|
request.update(gpt_options)
|
||||||
|
_apply_response_format(request, model)
|
||||||
|
|
||||||
|
mode_label = f"Proxy: {base_url}" if base_url else "OpenAI API"
|
||||||
|
print(f"[OpenAI - {mode_label}]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
|
||||||
|
if size_preset and size_preset != "auto":
|
||||||
|
print(f" Size Preset: {size_preset}")
|
||||||
|
if quality is not None:
|
||||||
|
print(f" Quality: {quality} (from image_size={image_size})")
|
||||||
|
else:
|
||||||
|
print(" Quality: omitted")
|
||||||
|
if request.get("response_format"):
|
||||||
|
print(f" Response: {request['response_format']}")
|
||||||
|
elif _read_response_format() == "omit":
|
||||||
|
print(" Response: omitted")
|
||||||
|
if request.get("output_format"):
|
||||||
|
print(f" Format: {request['output_format']}")
|
||||||
|
if request.get("output_compression") is not None:
|
||||||
|
print(f" Compression: {request['output_compression']}")
|
||||||
|
if request.get("background"):
|
||||||
|
print(f" Background: {request['background']}")
|
||||||
|
if request.get("moderation"):
|
||||||
|
print(f" Moderation: {request['moderation']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
print(f" [..] Generating...", end="", flush=True)
|
||||||
|
|
||||||
|
# Heartbeat thread
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
|
||||||
|
def _heartbeat():
|
||||||
|
while not heartbeat_stop.is_set():
|
||||||
|
heartbeat_stop.wait(5)
|
||||||
|
if not heartbeat_stop.is_set():
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" {elapsed:.0f}s...", end="", flush=True)
|
||||||
|
|
||||||
|
hb_thread = threading.Thread(target=_heartbeat, daemon=True)
|
||||||
|
hb_thread.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = _post_image_generation(api_key, base_url, request)
|
||||||
|
finally:
|
||||||
|
heartbeat_stop.set()
|
||||||
|
hb_thread.join(timeout=1)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f"\n [DONE] Image generated ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
data = _field(resp, "data") if resp is not None else None
|
||||||
|
if data:
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, output_ext)
|
||||||
|
first_image = data[0]
|
||||||
|
b64_json = _field(first_image, "b64_json")
|
||||||
|
image_url = _field(first_image, "url")
|
||||||
|
if b64_json:
|
||||||
|
image_data = base64.b64decode(b64_json)
|
||||||
|
return save_image_bytes(image_data, path)
|
||||||
|
if image_url:
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
raise RuntimeError("No image was generated. The server may have refused the request.")
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_image(api_key: str, prompt: str, reference_image: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Image-to-image edit via the OpenAI-compatible /v1/images/edits endpoint.
|
||||||
|
|
||||||
|
Sends the reference image plus the prompt (used as the edit instruction).
|
||||||
|
Mirrors _generate_image's size/quality/format handling but posts
|
||||||
|
multipart/form-data instead of JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the saved image file
|
||||||
|
"""
|
||||||
|
size_preset = _read_size_preset()
|
||||||
|
size = _select_size(model, aspect_ratio, image_size, size_preset)
|
||||||
|
quality = None if _is_dall_e_2(model) else _read_quality(image_size)
|
||||||
|
output_ext = ".png"
|
||||||
|
request = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": model,
|
||||||
|
"size": size,
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
if quality is not None:
|
||||||
|
request["quality"] = quality
|
||||||
|
if _is_gpt_image_model(model):
|
||||||
|
gpt_options, output_ext = _gpt_image_options(model)
|
||||||
|
request.update(gpt_options)
|
||||||
|
_apply_response_format(request, model)
|
||||||
|
|
||||||
|
mode_label = f"Proxy: {base_url}" if base_url else "OpenAI API"
|
||||||
|
print(f"[OpenAI - {mode_label}]")
|
||||||
|
print(f" Mode: edit (image-to-image)")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Reference: {reference_image}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
|
||||||
|
if size_preset and size_preset != "auto":
|
||||||
|
print(f" Size Preset: {size_preset}")
|
||||||
|
if quality is not None:
|
||||||
|
print(f" Quality: {quality} (from image_size={image_size})")
|
||||||
|
else:
|
||||||
|
print(" Quality: omitted")
|
||||||
|
if request.get("response_format"):
|
||||||
|
print(f" Response: {request['response_format']}")
|
||||||
|
elif _read_response_format() == "omit":
|
||||||
|
print(" Response: omitted")
|
||||||
|
if request.get("output_format"):
|
||||||
|
print(f" Format: {request['output_format']}")
|
||||||
|
if request.get("output_compression") is not None:
|
||||||
|
print(f" Compression: {request['output_compression']}")
|
||||||
|
if request.get("background"):
|
||||||
|
print(f" Background: {request['background']}")
|
||||||
|
if request.get("moderation"):
|
||||||
|
print(f" Moderation: {request['moderation']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
print(f" [..] Editing...", end="", flush=True)
|
||||||
|
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
|
||||||
|
def _heartbeat():
|
||||||
|
while not heartbeat_stop.is_set():
|
||||||
|
heartbeat_stop.wait(5)
|
||||||
|
if not heartbeat_stop.is_set():
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" {elapsed:.0f}s...", end="", flush=True)
|
||||||
|
|
||||||
|
hb_thread = threading.Thread(target=_heartbeat, daemon=True)
|
||||||
|
hb_thread.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = _post_image_edit(api_key, base_url, request, reference_image)
|
||||||
|
finally:
|
||||||
|
heartbeat_stop.set()
|
||||||
|
hb_thread.join(timeout=1)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f"\n [DONE] Image edited ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
data = _field(resp, "data") if resp is not None else None
|
||||||
|
if data:
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, output_ext)
|
||||||
|
first_image = data[0]
|
||||||
|
b64_json = _field(first_image, "b64_json")
|
||||||
|
image_url = _field(first_image, "url")
|
||||||
|
if b64_json:
|
||||||
|
image_data = base64.b64decode(b64_json)
|
||||||
|
return save_image_bytes(image_data, path)
|
||||||
|
if image_url:
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
raise RuntimeError("No image was returned. The server may have refused the edit request.")
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ Public Entry Point ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES,
|
||||||
|
reference_image: str = None) -> str:
|
||||||
|
"""
|
||||||
|
OpenAI-compatible image generation (or image-to-image edit) with retry.
|
||||||
|
|
||||||
|
Reads credentials from the current process environment or a `.env` file:
|
||||||
|
OPENAI_API_KEY
|
||||||
|
OPENAI_BASE_URL
|
||||||
|
OPENAI_MODEL (optional override)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Prompt text (edit instruction when reference_image is set)
|
||||||
|
aspect_ratio: Aspect ratio, mapped to OpenAI size
|
||||||
|
image_size: Image size, mapped to OpenAI quality
|
||||||
|
output_dir: Output directory
|
||||||
|
filename: Output filename (without extension)
|
||||||
|
model: Model name (default: gpt-image-2)
|
||||||
|
max_retries: Maximum number of retries
|
||||||
|
reference_image: Optional source image path. When set, the request
|
||||||
|
goes to /v1/images/edits (image-to-image) instead of
|
||||||
|
/v1/images/generations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the saved image file
|
||||||
|
"""
|
||||||
|
api_key = os.environ.get("OPENAI_API_KEY")
|
||||||
|
base_url = os.environ.get("OPENAI_BASE_URL")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
"No API key found. Set OPENAI_API_KEY in the current environment or a .env file."
|
||||||
|
)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
model = os.environ.get("OPENAI_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
image_size = normalize_image_size(image_size)
|
||||||
|
|
||||||
|
if aspect_ratio not in LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE:
|
||||||
|
supported = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for OpenAI backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
if reference_image is not None:
|
||||||
|
return _edit_image(api_key, prompt, reference_image,
|
||||||
|
aspect_ratio, image_size, output_dir,
|
||||||
|
filename, model, base_url)
|
||||||
|
return _generate_image(api_key, prompt,
|
||||||
|
aspect_ratio, image_size, output_dir,
|
||||||
|
filename, model, base_url)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < max_retries and is_rate_limit_error(e):
|
||||||
|
delay = retry_delay(attempt, rate_limited=True)
|
||||||
|
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
|
||||||
|
f"Waiting {delay}s before retry...")
|
||||||
|
time.sleep(delay)
|
||||||
|
elif attempt < max_retries:
|
||||||
|
delay = retry_delay(attempt, rate_limited=False)
|
||||||
|
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||||
|
f"Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
OpenRouter image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
OPENROUTER_API_KEY (required)
|
||||||
|
OPENROUTER_BASE_URL (optional)
|
||||||
|
OPENROUTER_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend openrouter")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
decode_data_uri,
|
||||||
|
find_data_uri,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
save_image_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ Constants ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = [
|
||||||
|
"1:1", "1:4", "1:8",
|
||||||
|
"2:3", "3:2", "3:4", "4:1", "4:3",
|
||||||
|
"4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
|
||||||
|
]
|
||||||
|
|
||||||
|
VALID_IMAGE_SIZES = ["1K", "2K", "4K", "0.5K"]
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"
|
||||||
|
DEFAULT_ENDPOINT = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ Image Generation ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the OpenRouter generation endpoint."""
|
||||||
|
return base_url.rstrip("/") + "/chat/completions"
|
||||||
|
|
||||||
|
def _message_image_uri(message: dict) -> str | None:
|
||||||
|
"""
|
||||||
|
Locate the generated image in a chat completion message.
|
||||||
|
|
||||||
|
OpenRouter returns it in a dedicated `images` array; other OpenAI-compatible endpoints
|
||||||
|
reachable through OPENROUTER_BASE_URL inline it in the message content instead.
|
||||||
|
"""
|
||||||
|
images = message.get("images")
|
||||||
|
if images:
|
||||||
|
url = images[0].get("image_url")
|
||||||
|
if isinstance(url, dict):
|
||||||
|
url = url.get("url")
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
return find_data_uri(message.get("content"))
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""
|
||||||
|
Image generation via OpenRouter's API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modalities": ["image", "text"],
|
||||||
|
"image_config": {
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"image_size": image_size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[OpenRouter]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Image Size: {image_size}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
print(f" [..] Generating...", end="", flush=True)
|
||||||
|
|
||||||
|
# Heartbeat thread
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
|
||||||
|
def _heartbeat():
|
||||||
|
while not heartbeat_stop.is_set():
|
||||||
|
heartbeat_stop.wait(5)
|
||||||
|
if not heartbeat_stop.is_set():
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f" {elapsed:.0f}s...", end="", flush=True)
|
||||||
|
|
||||||
|
hb_thread = threading.Thread(target=_heartbeat, daemon=True)
|
||||||
|
hb_thread.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = requests.post(url, headers=headers, json=payload, timeout=300).json()
|
||||||
|
finally:
|
||||||
|
heartbeat_stop.set()
|
||||||
|
hb_thread.join(timeout=1)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f"\n [DONE] Image generated ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if result.get("choices"):
|
||||||
|
message = result["choices"][0]["message"]
|
||||||
|
image_uri = _message_image_uri(message)
|
||||||
|
if image_uri:
|
||||||
|
image_data, content_type = decode_data_uri(image_uri)
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return save_image_bytes(image_data, path, content_type)
|
||||||
|
|
||||||
|
raise RuntimeError("No image was generated. The server may have refused the request.")
|
||||||
|
|
||||||
|
|
||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ Public Entry Point ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""
|
||||||
|
OpenRouter image generation with automatic retry.
|
||||||
|
|
||||||
|
Reads credentials from the current process environment or a `.env` file:
|
||||||
|
OPENROUTER_API_KEY
|
||||||
|
OPENROUTER_BASE_URL
|
||||||
|
OPENROUTER_MODEL (optional override)
|
||||||
|
"""
|
||||||
|
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||||
|
base_url = os.environ.get("OPENROUTER_BASE_URL")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
"No API key found. Set OPENROUTER_API_KEY in the current environment or a .env file."
|
||||||
|
)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
model = os.environ.get("OPENROUTER_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
image_size = normalize_image_size(image_size)
|
||||||
|
|
||||||
|
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||||
|
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
|
||||||
|
|
||||||
|
if image_size not in VALID_IMAGE_SIZES:
|
||||||
|
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(api_key, prompt,
|
||||||
|
aspect_ratio, image_size, output_dir,
|
||||||
|
filename, model, base_url)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < max_retries and is_rate_limit_error(e):
|
||||||
|
delay = retry_delay(attempt, rate_limited=True)
|
||||||
|
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
|
||||||
|
f"Waiting {delay}s before retry...")
|
||||||
|
time.sleep(delay)
|
||||||
|
elif attempt < max_retries:
|
||||||
|
delay = retry_delay(attempt, rate_limited=False)
|
||||||
|
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||||
|
f"Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Alibaba Cloud Qwen image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
QWEN_API_KEY / DASHSCOPE_API_KEY (required)
|
||||||
|
QWEN_BASE_URL (optional)
|
||||||
|
QWEN_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend qwen")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||||
|
DEFAULT_MODEL = "qwen-image-2.0-pro"
|
||||||
|
|
||||||
|
ASPECT_RATIO_SIZE_MAP = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": "1024*1024",
|
||||||
|
"2:3": "768*1152",
|
||||||
|
"3:2": "1152*768",
|
||||||
|
"3:4": "864*1152",
|
||||||
|
"4:3": "1152*864",
|
||||||
|
"4:5": "896*1120",
|
||||||
|
"5:4": "1120*896",
|
||||||
|
"9:16": "720*1280",
|
||||||
|
"16:9": "1280*720",
|
||||||
|
"21:9": "1344*576",
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1536*1536",
|
||||||
|
"2:3": "1024*1536",
|
||||||
|
"3:2": "1536*1024",
|
||||||
|
"3:4": "1152*1536",
|
||||||
|
"4:3": "1536*1152",
|
||||||
|
"4:5": "1216*1536",
|
||||||
|
"5:4": "1536*1216",
|
||||||
|
"9:16": "896*1600",
|
||||||
|
"16:9": "1600*896",
|
||||||
|
"21:9": "1792*768",
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": "2048*2048",
|
||||||
|
"2:3": "1536*2048",
|
||||||
|
"3:2": "2048*1536",
|
||||||
|
"3:4": "1728*2368",
|
||||||
|
"4:3": "2368*1728",
|
||||||
|
"4:5": "1792*2240",
|
||||||
|
"5:4": "2240*1792",
|
||||||
|
"9:16": "1536*2688",
|
||||||
|
"16:9": "2688*1536",
|
||||||
|
"21:9": "2688*1152",
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": "2048*2048",
|
||||||
|
"2:3": "1536*2048",
|
||||||
|
"3:2": "2048*1536",
|
||||||
|
"3:4": "1728*2368",
|
||||||
|
"4:3": "2368*1728",
|
||||||
|
"4:5": "1792*2240",
|
||||||
|
"5:4": "2240*1792",
|
||||||
|
"9:16": "1536*2688",
|
||||||
|
"16:9": "2688*1536",
|
||||||
|
"21:9": "2688*1152",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the Qwen generation endpoint."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/generation"):
|
||||||
|
return base
|
||||||
|
return base + "/api/v1/services/aigc/multimodal-generation/generation"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||||
|
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||||
|
normalized = normalize_image_size(image_size)
|
||||||
|
size = (ASPECT_RATIO_SIZE_MAP.get(normalized) or {}).get(aspect_ratio)
|
||||||
|
if not size:
|
||||||
|
supported = sorted(ASPECT_RATIO_SIZE_MAP["1K"])
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for Qwen backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the Qwen backend."""
|
||||||
|
size = _resolve_size(aspect_ratio, image_size)
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"input": {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"text": prompt}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"size": size,
|
||||||
|
"prompt_extend": True,
|
||||||
|
"watermark": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Alibaba Qwen Image]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Resolution: {size}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "Qwen image generation")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
choices = ((data.get("output") or {}).get("choices") or [])
|
||||||
|
contents = (((choices[0] if choices else {}).get("message") or {}).get("content") or [])
|
||||||
|
image_url = contents[0].get("image") if contents else None
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"Qwen response missing image URL: {data}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the Qwen backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"QWEN_API_KEY",
|
||||||
|
"DASHSCOPE_API_KEY",
|
||||||
|
message="No API key found. Set QWEN_API_KEY or DASHSCOPE_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("QWEN_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("QWEN_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Replicate image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
REPLICATE_API_KEY / REPLICATE_API_TOKEN (required)
|
||||||
|
REPLICATE_BASE_URL (optional)
|
||||||
|
REPLICATE_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend replicate")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
poll_json,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "3:2", "2:3", "4:5", "5:4", "21:9"]
|
||||||
|
DEFAULT_BASE_URL = "https://api.replicate.com/v1"
|
||||||
|
DEFAULT_MODEL = "black-forest-labs/flux-1.1-pro"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_model(model: str) -> tuple[str, str]:
|
||||||
|
"""Split a Replicate model reference into owner and name."""
|
||||||
|
parts = [part for part in model.strip().split("/") if part]
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(
|
||||||
|
f"Replicate model must be in 'owner/name' format, got '{model}'."
|
||||||
|
)
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_output_url(payload: dict) -> str | None:
|
||||||
|
"""Extract an output URL from a Replicate prediction payload."""
|
||||||
|
output = payload.get("output")
|
||||||
|
if isinstance(output, str):
|
||||||
|
return output
|
||||||
|
if isinstance(output, list) and output:
|
||||||
|
first = output[0]
|
||||||
|
if isinstance(first, str):
|
||||||
|
return first
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return first.get("url")
|
||||||
|
if isinstance(output, dict):
|
||||||
|
return output.get("url")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE_URL) -> str:
|
||||||
|
"""Generate one image with the Replicate backend."""
|
||||||
|
del image_size
|
||||||
|
|
||||||
|
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for Replicate backend. "
|
||||||
|
f"Supported: {VALID_ASPECT_RATIOS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
owner, name = _split_model(model)
|
||||||
|
url = f"{base_url.rstrip('/')}/models/{owner}/{name}/predictions"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "wait=60",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input": {
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"output_format": "png",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Replicate]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=180)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Initial response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise http_error(response, "Replicate generation request")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
status = str(data.get("status", "")).lower()
|
||||||
|
if status != "succeeded":
|
||||||
|
poll_url = ((data.get("urls") or {}).get("get"))
|
||||||
|
if not poll_url:
|
||||||
|
prediction_id = data.get("id")
|
||||||
|
if prediction_id:
|
||||||
|
poll_url = f"{base_url.rstrip('/')}/predictions/{prediction_id}"
|
||||||
|
if not poll_url:
|
||||||
|
raise RuntimeError(f"Replicate response missing poll URL: {data}")
|
||||||
|
|
||||||
|
print(" [..] Polling result...")
|
||||||
|
data = poll_json(
|
||||||
|
poll_url,
|
||||||
|
{"Authorization": f"Bearer {api_key}"},
|
||||||
|
status_label="status",
|
||||||
|
ready_values=["succeeded"],
|
||||||
|
failed_values=["failed", "canceled"],
|
||||||
|
)
|
||||||
|
|
||||||
|
image_url = _extract_output_url(data)
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"Replicate response missing output URL: {data}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the Replicate backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"REPLICATE_API_KEY",
|
||||||
|
"REPLICATE_API_TOKEN",
|
||||||
|
message="No API key found. Set REPLICATE_API_KEY or REPLICATE_API_TOKEN in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("REPLICATE_BASE_URL") or DEFAULT_BASE_URL
|
||||||
|
resolved_model = model or os.environ.get("REPLICATE_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SiliconFlow image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
SILICONFLOW_API_KEY (required)
|
||||||
|
SILICONFLOW_BASE_URL (optional)
|
||||||
|
SILICONFLOW_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend siliconflow")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = "https://api.siliconflow.cn/v1/images/generations"
|
||||||
|
DEFAULT_MODEL = "Qwen/Qwen-Image"
|
||||||
|
|
||||||
|
ASPECT_RATIO_SIZE_MAP = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"2:3": "1056x1584",
|
||||||
|
"3:2": "1584x1056",
|
||||||
|
"3:4": "1140x1472",
|
||||||
|
"4:3": "1472x1140",
|
||||||
|
"4:5": "1140x1425",
|
||||||
|
"5:4": "1425x1140",
|
||||||
|
"9:16": "928x1664",
|
||||||
|
"16:9": "1664x928",
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1328x1328",
|
||||||
|
"2:3": "1056x1584",
|
||||||
|
"3:2": "1584x1056",
|
||||||
|
"3:4": "1140x1472",
|
||||||
|
"4:3": "1472x1140",
|
||||||
|
"4:5": "1140x1425",
|
||||||
|
"5:4": "1425x1140",
|
||||||
|
"9:16": "928x1664",
|
||||||
|
"16:9": "1664x928",
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": "2048x2048",
|
||||||
|
"2:3": "1536x2048",
|
||||||
|
"3:2": "2048x1536",
|
||||||
|
"3:4": "1536x2048",
|
||||||
|
"4:3": "2048x1536",
|
||||||
|
"4:5": "1638x2048",
|
||||||
|
"5:4": "2048x1638",
|
||||||
|
"9:16": "1152x2048",
|
||||||
|
"16:9": "2048x1152",
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": "2048x2048",
|
||||||
|
"2:3": "1536x2048",
|
||||||
|
"3:2": "2048x1536",
|
||||||
|
"3:4": "1536x2048",
|
||||||
|
"4:3": "2048x1536",
|
||||||
|
"4:5": "1638x2048",
|
||||||
|
"5:4": "2048x1638",
|
||||||
|
"9:16": "1152x2048",
|
||||||
|
"16:9": "2048x1152",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the SiliconFlow generation endpoint."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/images/generations"):
|
||||||
|
return base
|
||||||
|
return base + "/v1/images/generations"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||||
|
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||||
|
normalized = normalize_image_size(image_size)
|
||||||
|
size = (ASPECT_RATIO_SIZE_MAP.get(normalized) or {}).get(aspect_ratio)
|
||||||
|
if not size:
|
||||||
|
supported = sorted(ASPECT_RATIO_SIZE_MAP["1K"])
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for SiliconFlow backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the SiliconFlow backend."""
|
||||||
|
size = _resolve_size(aspect_ratio, image_size)
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"image_size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[SiliconFlow]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Resolution: {size}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "SiliconFlow image generation")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
images = data.get("images") or []
|
||||||
|
image_url = images[0].get("url") if images else None
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"SiliconFlow response missing image URL: {data}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the SiliconFlow backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"SILICONFLOW_API_KEY",
|
||||||
|
message="No API key found. Set SILICONFLOW_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("SILICONFLOW_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("SILICONFLOW_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Stability AI image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
STABILITY_API_KEY (required)
|
||||||
|
STABILITY_BASE_URL (optional)
|
||||||
|
STABILITY_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend stability")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
report_resolution,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_ASPECT_RATIOS = [
|
||||||
|
"1:1", "2:3", "3:2", "3:4", "4:3",
|
||||||
|
"4:5", "5:4", "9:16", "16:9", "21:9",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://api.stability.ai"
|
||||||
|
DEFAULT_MODEL = "stable-image-core"
|
||||||
|
|
||||||
|
MODEL_ENDPOINTS = {
|
||||||
|
"core": "/v2beta/stable-image/generate/core",
|
||||||
|
"stable-image-core": "/v2beta/stable-image/generate/core",
|
||||||
|
"ultra": "/v2beta/stable-image/generate/ultra",
|
||||||
|
"stable-image-ultra": "/v2beta/stable-image/generate/ultra",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_endpoint(model: str, image_size: str, base_url: str) -> tuple[str, str]:
|
||||||
|
"""Resolve the Stability model alias and endpoint URL."""
|
||||||
|
resolved_model = model or DEFAULT_MODEL
|
||||||
|
if not model and image_size.upper() in ("2K", "4K"):
|
||||||
|
resolved_model = "stable-image-ultra"
|
||||||
|
|
||||||
|
normalized_model = resolved_model.lower()
|
||||||
|
endpoint = MODEL_ENDPOINTS.get(normalized_model)
|
||||||
|
if not endpoint:
|
||||||
|
supported = sorted(MODEL_ENDPOINTS)
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported Stability model '{resolved_model}'. Supported aliases: {supported}"
|
||||||
|
)
|
||||||
|
return normalized_model, base_url.rstrip("/") + endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE_URL) -> str:
|
||||||
|
"""Generate one image with the Stability backend."""
|
||||||
|
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for Stability backend. "
|
||||||
|
f"Supported: {VALID_ASPECT_RATIOS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_model, url = _resolve_endpoint(model, image_size, base_url)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "image/*",
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"output_format": "png",
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Stability AI]")
|
||||||
|
print(f" Model: {resolved_model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Preset Size: {image_size}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, data=data, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "Stability generation")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
print(f" File saved to: {path}")
|
||||||
|
report_resolution(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the Stability backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"STABILITY_API_KEY",
|
||||||
|
message="No API key found. Set STABILITY_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("STABILITY_BASE_URL") or DEFAULT_BASE_URL
|
||||||
|
resolved_model = model or os.environ.get("STABILITY_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Volcengine Seedream image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
VOLCENGINE_API_KEY / ARK_API_KEY (required)
|
||||||
|
VOLCENGINE_BASE_URL (optional)
|
||||||
|
VOLCENGINE_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend volcengine")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = "https://operator.las.cn-beijing.volces.com/api/v1/images/generations"
|
||||||
|
DEFAULT_MODEL = "doubao-seedream-4-5-251128"
|
||||||
|
|
||||||
|
ASPECT_RATIO_SIZE_MAP = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"2:3": "1024x1536",
|
||||||
|
"3:2": "1536x1024",
|
||||||
|
"3:4": "1024x1365",
|
||||||
|
"4:3": "1365x1024",
|
||||||
|
"4:5": "1024x1280",
|
||||||
|
"5:4": "1280x1024",
|
||||||
|
"9:16": "1024x1820",
|
||||||
|
"16:9": "1820x1024",
|
||||||
|
"21:9": "2048x878",
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1536x1536",
|
||||||
|
"2:3": "1344x2016",
|
||||||
|
"3:2": "2016x1344",
|
||||||
|
"3:4": "1440x1920",
|
||||||
|
"4:3": "1920x1440",
|
||||||
|
"4:5": "1536x1920",
|
||||||
|
"5:4": "1920x1536",
|
||||||
|
"9:16": "1152x2048",
|
||||||
|
"16:9": "2048x1152",
|
||||||
|
"21:9": "2048x878",
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": "2048x2048",
|
||||||
|
"2:3": "1536x2048",
|
||||||
|
"3:2": "2048x1536",
|
||||||
|
"3:4": "1536x2048",
|
||||||
|
"4:3": "2048x1536",
|
||||||
|
"4:5": "1638x2048",
|
||||||
|
"5:4": "2048x1638",
|
||||||
|
"9:16": "1152x2048",
|
||||||
|
"16:9": "2048x1152",
|
||||||
|
"21:9": "2048x878",
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": "2048x2048",
|
||||||
|
"2:3": "1536x2048",
|
||||||
|
"3:2": "2048x1536",
|
||||||
|
"3:4": "1536x2048",
|
||||||
|
"4:3": "2048x1536",
|
||||||
|
"4:5": "1638x2048",
|
||||||
|
"5:4": "2048x1638",
|
||||||
|
"9:16": "1152x2048",
|
||||||
|
"16:9": "2048x1152",
|
||||||
|
"21:9": "2048x878",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the Volcengine generation endpoint."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/images/generations"):
|
||||||
|
return base
|
||||||
|
return base + "/api/v1/images/generations"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||||
|
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||||
|
normalized = normalize_image_size(image_size)
|
||||||
|
size = (ASPECT_RATIO_SIZE_MAP.get(normalized) or {}).get(aspect_ratio)
|
||||||
|
if not size:
|
||||||
|
supported = sorted(ASPECT_RATIO_SIZE_MAP["1K"])
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for Volcengine backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the Volcengine backend."""
|
||||||
|
size = _resolve_size(aspect_ratio, image_size)
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
"response_format": "url",
|
||||||
|
"watermark": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Volcengine Seedream]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Resolution: {size}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "Volcengine image generation")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
items = data.get("data") or []
|
||||||
|
image_url = items[0].get("url") if items else None
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"Volcengine response missing image URL: {data}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".jpeg")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the Volcengine backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"VOLCENGINE_API_KEY",
|
||||||
|
"ARK_API_KEY",
|
||||||
|
message="No API key found. Set VOLCENGINE_API_KEY or ARK_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("VOLCENGINE_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("VOLCENGINE_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Zhipu GLM-Image generation backend.
|
||||||
|
|
||||||
|
Configuration keys:
|
||||||
|
ZHIPU_API_KEY / BIGMODEL_API_KEY (required)
|
||||||
|
ZHIPU_BASE_URL (optional)
|
||||||
|
ZHIPU_MODEL (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend zhipu")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_backends.backend_common import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
download_image,
|
||||||
|
http_error,
|
||||||
|
is_rate_limit_error,
|
||||||
|
normalize_image_size,
|
||||||
|
require_api_key,
|
||||||
|
resolve_output_path,
|
||||||
|
retry_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = "https://open.bigmodel.cn/api/paas/v4/images/generations"
|
||||||
|
DEFAULT_MODEL = "glm-image"
|
||||||
|
|
||||||
|
ASPECT_RATIO_SIZE_MAP = {
|
||||||
|
"512px": {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"2:3": "768x1152",
|
||||||
|
"3:2": "1152x768",
|
||||||
|
"3:4": "864x1152",
|
||||||
|
"4:3": "1152x864",
|
||||||
|
"4:5": "1024x1280",
|
||||||
|
"5:4": "1280x1024",
|
||||||
|
"9:16": "720x1440",
|
||||||
|
"16:9": "1440x720",
|
||||||
|
"21:9": "1536x640",
|
||||||
|
},
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1280x1280",
|
||||||
|
"2:3": "960x1440",
|
||||||
|
"3:2": "1440x960",
|
||||||
|
"3:4": "1024x1365",
|
||||||
|
"4:3": "1365x1024",
|
||||||
|
"4:5": "1024x1280",
|
||||||
|
"5:4": "1280x1024",
|
||||||
|
"9:16": "768x1344",
|
||||||
|
"16:9": "1344x768",
|
||||||
|
"21:9": "1536x640",
|
||||||
|
},
|
||||||
|
"2K": {
|
||||||
|
"1:1": "1440x1440",
|
||||||
|
"2:3": "1152x1728",
|
||||||
|
"3:2": "1728x1152",
|
||||||
|
"3:4": "1152x1536",
|
||||||
|
"4:3": "1536x1152",
|
||||||
|
"4:5": "1280x1600",
|
||||||
|
"5:4": "1600x1280",
|
||||||
|
"9:16": "720x1440",
|
||||||
|
"16:9": "1440x720",
|
||||||
|
"21:9": "1792x768",
|
||||||
|
},
|
||||||
|
"4K": {
|
||||||
|
"1:1": "1440x1440",
|
||||||
|
"2:3": "1152x1728",
|
||||||
|
"3:2": "1728x1152",
|
||||||
|
"3:4": "1152x1536",
|
||||||
|
"4:3": "1536x1152",
|
||||||
|
"4:5": "1280x1600",
|
||||||
|
"5:4": "1600x1280",
|
||||||
|
"9:16": "720x1440",
|
||||||
|
"16:9": "1440x720",
|
||||||
|
"21:9": "1792x768",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str) -> str:
|
||||||
|
"""Resolve the Zhipu generation endpoint."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/images/generations"):
|
||||||
|
return base
|
||||||
|
return base + "/api/paas/v4/images/generations"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||||
|
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||||
|
normalized = normalize_image_size(image_size)
|
||||||
|
size = (ASPECT_RATIO_SIZE_MAP.get(normalized) or {}).get(aspect_ratio)
|
||||||
|
if not size:
|
||||||
|
supported = sorted(ASPECT_RATIO_SIZE_MAP["1K"])
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported aspect ratio '{aspect_ratio}' for Zhipu backend. "
|
||||||
|
f"Supported: {supported}"
|
||||||
|
)
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_image(api_key: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
|
||||||
|
"""Generate one image with the Zhipu backend."""
|
||||||
|
size = _resolve_size(aspect_ratio, image_size)
|
||||||
|
url = _resolve_url(base_url)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Zhipu GLM-Image]")
|
||||||
|
print(f" Model: {model}")
|
||||||
|
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||||
|
print(f" Aspect Ratio: {aspect_ratio}")
|
||||||
|
print(f" Resolution: {size}")
|
||||||
|
print()
|
||||||
|
print(" [..] Generating...", end="", flush=True)
|
||||||
|
start = time.time()
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise http_error(response, "Zhipu image generation")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
items = data.get("data") or []
|
||||||
|
image_url = items[0].get("url") if items else None
|
||||||
|
if not image_url:
|
||||||
|
raise RuntimeError(f"Zhipu response missing image URL: {data}")
|
||||||
|
|
||||||
|
path = resolve_output_path(prompt, output_dir, filename, ".png")
|
||||||
|
return download_image(image_url, path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", image_size: str = "1K",
|
||||||
|
output_dir: str = None, filename: str = None,
|
||||||
|
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||||
|
"""Generate an image with retries using the Zhipu backend."""
|
||||||
|
api_key = require_api_key(
|
||||||
|
"ZHIPU_API_KEY",
|
||||||
|
"BIGMODEL_API_KEY",
|
||||||
|
message="No API key found. Set ZHIPU_API_KEY or BIGMODEL_API_KEY in the current environment or a .env file.",
|
||||||
|
)
|
||||||
|
base_url = os.environ.get("ZHIPU_BASE_URL") or DEFAULT_ENDPOINT
|
||||||
|
resolved_model = model or os.environ.get("ZHIPU_MODEL") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return _generate_image(
|
||||||
|
api_key=api_key,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size=image_size,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
limited = is_rate_limit_error(exc)
|
||||||
|
delay = retry_delay(attempt, rate_limited=limited)
|
||||||
|
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||||
|
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Web image source providers for PPT Master.
|
||||||
|
|
||||||
|
Each ``provider_<name>.py`` module exposes a ``search(request, *,
|
||||||
|
license_tier_filter)`` function returning a list of ``AssetCandidate``
|
||||||
|
objects, plus a ``parse_results(payload)`` helper. ``image_search.py``
|
||||||
|
dispatches to them by name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"""Shared primitives for web image providers.
|
||||||
|
|
||||||
|
This module is the single home for everything that all four providers
|
||||||
|
(Openverse / Wikimedia / Pexels / Pixabay) need:
|
||||||
|
|
||||||
|
- License tier classification (the central abstraction of this module)
|
||||||
|
- Search request / asset candidate dataclasses
|
||||||
|
- Query simplification for keyword-based image APIs
|
||||||
|
- Candidate scoring
|
||||||
|
- Attribution text builder
|
||||||
|
- Small helpers (orientation, json path, etc.)
|
||||||
|
|
||||||
|
Provider-specific code (API URLs, payload shape, parse_results) lives in
|
||||||
|
the corresponding provider_<name>.py module and only imports from here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("This is an internal helper module used by image_search.py and the four web image providers.")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Project-wide constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
USER_AGENT = "PPTMaster/1.0 (https://github.com/hugohe3/ppt-master)"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# License tier classification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Every accepted candidate is classified into exactly one of two tiers:
|
||||||
|
#
|
||||||
|
# "no-attribution" -> No on-slide credit needed (CC0, PD, Pexels,
|
||||||
|
# Pixabay). Default search target.
|
||||||
|
# "attribution-required" -> CC BY / CC BY-SA. Executor must add an
|
||||||
|
# inline credit text element on the slide.
|
||||||
|
#
|
||||||
|
# Anything else (CC BY-NC, CC BY-ND, all-rights-reserved, unknown) returns
|
||||||
|
# None and the candidate is rejected outright.
|
||||||
|
|
||||||
|
LICENSE_TIER_NO_ATTRIBUTION = "no-attribution"
|
||||||
|
LICENSE_TIER_ATTRIBUTION_REQUIRED = "attribution-required"
|
||||||
|
|
||||||
|
# Tokens that mark a license as "no attribution required".
|
||||||
|
NO_ATTRIBUTION_TOKENS: tuple[str, ...] = (
|
||||||
|
"cc0",
|
||||||
|
"public domain",
|
||||||
|
"publicdomain",
|
||||||
|
"creativecommons.org/publicdomain/",
|
||||||
|
"pexels license",
|
||||||
|
"pixabay content license",
|
||||||
|
"pixabay license",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tokens that mark a license as "attribution required".
|
||||||
|
ATTRIBUTION_REQUIRED_TOKENS: tuple[str, ...] = (
|
||||||
|
"cc by",
|
||||||
|
"cc-by",
|
||||||
|
"by-sa",
|
||||||
|
"by sa",
|
||||||
|
"creativecommons.org/licenses/by/",
|
||||||
|
"creativecommons.org/licenses/by-sa/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tokens that disqualify a candidate entirely.
|
||||||
|
REJECTED_TOKENS: tuple[str, ...] = (
|
||||||
|
"by-nc",
|
||||||
|
"by nc",
|
||||||
|
"noncommercial",
|
||||||
|
"non-commercial",
|
||||||
|
"by-nd",
|
||||||
|
"by nd",
|
||||||
|
"no derivatives",
|
||||||
|
"noderivatives",
|
||||||
|
"all rights reserved",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical display forms for license names. Different providers report
|
||||||
|
# the same license with different capitalization (Openverse: "cc0",
|
||||||
|
# Wikimedia: "Public domain"); the Executor renders these as on-slide
|
||||||
|
# text, so a normalized form prevents inconsistent credits.
|
||||||
|
_LICENSE_NAME_CANON: dict[str, str] = {
|
||||||
|
"cc0": "CC0",
|
||||||
|
"cc 0": "CC0",
|
||||||
|
"public domain": "Public Domain",
|
||||||
|
"publicdomain": "Public Domain",
|
||||||
|
"pdm": "Public Domain",
|
||||||
|
"pexels license": "Pexels License",
|
||||||
|
"pixabay content license": "Pixabay Content License",
|
||||||
|
"pixabay license": "Pixabay Content License",
|
||||||
|
}
|
||||||
|
|
||||||
|
# CC license short-name pattern used to canonicalize "cc by 4.0" → "CC BY 4.0".
|
||||||
|
_CC_PATTERN = re.compile(
|
||||||
|
r"^\s*cc[\s-]+(by(?:[\s-]+(?:sa|nc|nd))*)\s*([0-9.]*)\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_license_name(name: str) -> str:
|
||||||
|
"""Return a canonical display form for a license name.
|
||||||
|
|
||||||
|
Maps common aliases to a consistent capitalization so the on-slide
|
||||||
|
credit text written by the Executor is uniform across providers.
|
||||||
|
Unknown inputs are returned trimmed but otherwise unchanged.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
key = name.strip().lower()
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if key in _LICENSE_NAME_CANON:
|
||||||
|
return _LICENSE_NAME_CANON[key]
|
||||||
|
|
||||||
|
cc_match = _CC_PATTERN.match(key)
|
||||||
|
if cc_match:
|
||||||
|
suffix_raw, version = cc_match.group(1), cc_match.group(2)
|
||||||
|
suffix = suffix_raw.replace(" ", "-").upper()
|
||||||
|
return f"CC {suffix} {version}".strip()
|
||||||
|
|
||||||
|
return name.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_license(
|
||||||
|
license_name: str,
|
||||||
|
license_url: str = "",
|
||||||
|
provider: str = "",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Classify a license string into one of the two tiers, or reject it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``"no-attribution"`` / ``"attribution-required"`` / ``None``.
|
||||||
|
|
||||||
|
The provider hint lets us treat Pexels and Pixabay's own licenses as
|
||||||
|
``no-attribution`` even when the upstream API only returns a short
|
||||||
|
label like ``"Pexels"``.
|
||||||
|
"""
|
||||||
|
text = " ".join(
|
||||||
|
part.strip().lower()
|
||||||
|
for part in (license_name or "", license_url or "")
|
||||||
|
if part
|
||||||
|
)
|
||||||
|
provider_key = (provider or "").strip().lower()
|
||||||
|
|
||||||
|
if not text and not provider_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if any(token in text for token in REJECTED_TOKENS):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if any(token in text for token in NO_ATTRIBUTION_TOKENS):
|
||||||
|
return LICENSE_TIER_NO_ATTRIBUTION
|
||||||
|
|
||||||
|
# Provider-default fallback: pexels / pixabay items often arrive with a
|
||||||
|
# bare "Pexels" / "Pixabay" license string. Their site-wide license is
|
||||||
|
# "free for commercial use, no attribution required".
|
||||||
|
#
|
||||||
|
# Guard: require the license text to actually mention the provider name,
|
||||||
|
# so an empty / missing license field never silently passes as no-attribution.
|
||||||
|
if (
|
||||||
|
provider_key in {"pexels", "pixabay"}
|
||||||
|
and provider_key in text
|
||||||
|
and not any(token in text for token in ATTRIBUTION_REQUIRED_TOKENS)
|
||||||
|
):
|
||||||
|
return LICENSE_TIER_NO_ATTRIBUTION
|
||||||
|
|
||||||
|
if any(token in text for token in ATTRIBUTION_REQUIRED_TOKENS):
|
||||||
|
return LICENSE_TIER_ATTRIBUTION_REQUIRED
|
||||||
|
|
||||||
|
return None # unknown license -> reject
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dataclasses
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageSearchRequest:
|
||||||
|
"""A single image search intent passed to a provider."""
|
||||||
|
|
||||||
|
query: str
|
||||||
|
purpose: str = ""
|
||||||
|
orientation: str = "" # "landscape" / "portrait" / "square" / ""
|
||||||
|
min_width: int = 0
|
||||||
|
min_height: int = 0
|
||||||
|
filename: str = ""
|
||||||
|
slide: str = ""
|
||||||
|
required_terms: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssetCandidate:
|
||||||
|
"""One ranked candidate returned by a provider's parse_results."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
title: str
|
||||||
|
asset_id: str = ""
|
||||||
|
source_page_url: str = ""
|
||||||
|
license_name: str = ""
|
||||||
|
license_url: str = ""
|
||||||
|
license_tier: str = "" # one of LICENSE_TIER_* constants
|
||||||
|
width: int = 0
|
||||||
|
height: int = 0
|
||||||
|
download_url: str = ""
|
||||||
|
author: str = ""
|
||||||
|
raw: Any = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Query simplification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Web image APIs do keyword matching against image metadata, not semantic
|
||||||
|
# search. Long, descriptive queries with brand names, HEX codes, and
|
||||||
|
# composition notes return zero results. We progressively trim the query
|
||||||
|
# down to the most concrete nouns.
|
||||||
|
|
||||||
|
_NOISE_WORDS = frozenset({
|
||||||
|
# Brand / product names
|
||||||
|
"claude", "openai", "gpt", "gemini", "copilot", "chatgpt", "midjourney",
|
||||||
|
"stable", "diffusion", "dall-e", "cursor", "anthropic", "microsoft",
|
||||||
|
"google", "apple", "meta", "nvidia", "tesla",
|
||||||
|
# Generic filler
|
||||||
|
"using", "with", "from", "that", "this", "have", "been", "will",
|
||||||
|
"into", "more", "also", "very", "some", "than", "them", "other",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Words that look generic but are actually useful when they ARE the
|
||||||
|
# subject of the deck (e.g. a deck about AI). We only drop them when
|
||||||
|
# there are still other concrete nouns left.
|
||||||
|
_SOFT_NOISE_WORDS = frozenset({
|
||||||
|
"ai", "code", "software", "system", "digital", "platform", "solution",
|
||||||
|
"application", "interface", "framework", "algorithm", "api", "sdk",
|
||||||
|
"assistant", "tool", "service", "technology", "tech", "program",
|
||||||
|
# Visual-quality / usage terms. These are helpful in the full provider
|
||||||
|
# query, but should not consume the 3-4 keyword fallback budget or
|
||||||
|
# dominate relevance scoring over the real subject.
|
||||||
|
"professional", "editorial", "commercial", "premium", "stock",
|
||||||
|
"photo", "photograph", "photography", "image", "picture", "visual",
|
||||||
|
"background", "hero", "cover", "banner", "wallpaper",
|
||||||
|
"high", "quality", "resolution", "sharp", "clean", "cinematic",
|
||||||
|
"dramatic", "lighting", "light", "modern", "natural", "visible",
|
||||||
|
})
|
||||||
|
|
||||||
|
_TOKEN_STRIP_CHARS = ".,;:!?\"'()[]{},。;:!?、"
|
||||||
|
_MATCH_SEPARATOR_RE = re.compile(r"""[\s\-_./:;,'"()[\]{}]+""")
|
||||||
|
|
||||||
|
|
||||||
|
def simplify_query(query: str, max_words: int = 4) -> str:
|
||||||
|
"""Trim a verbose query into a short keyword phrase.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Strip HEX color codes and parenthetical asides.
|
||||||
|
2. Drop hard-noise words (brand names, generic filler).
|
||||||
|
3. Drop soft-noise words ONLY if concrete nouns remain.
|
||||||
|
4. If the result would be empty, return the original query
|
||||||
|
(fail-open: better an over-broad search than zero results).
|
||||||
|
5. Cap at ``max_words`` words.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"#[0-9a-fA-F]{3,8}", "", query)
|
||||||
|
cleaned = re.sub(r"\([^)]*\)", "", cleaned)
|
||||||
|
words = [w.strip(_TOKEN_STRIP_CHARS) for w in cleaned.split()]
|
||||||
|
words = [w for w in words if len(w) > 2]
|
||||||
|
|
||||||
|
after_hard = [w for w in words if w.lower() not in _NOISE_WORDS]
|
||||||
|
after_soft = [w for w in after_hard if w.lower() not in _SOFT_NOISE_WORDS]
|
||||||
|
|
||||||
|
# Only drop soft-noise if there are still concrete nouns left.
|
||||||
|
filtered = after_soft if after_soft else after_hard
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
# Everything got filtered. Fail open: return the original query.
|
||||||
|
return query.strip()
|
||||||
|
|
||||||
|
return " ".join(filtered[:max_words])
|
||||||
|
|
||||||
|
|
||||||
|
def build_query_progression(query: str) -> list[str]:
|
||||||
|
"""Return a list of progressively simpler queries to try in order.
|
||||||
|
|
||||||
|
Stops as soon as one of them yields candidates upstream. Duplicates
|
||||||
|
are dropped while preserving order.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for candidate in (
|
||||||
|
query,
|
||||||
|
simplify_query(query, max_words=4),
|
||||||
|
simplify_query(query, max_words=3),
|
||||||
|
simplify_query(query, max_words=2),
|
||||||
|
simplify_query(query, max_words=1),
|
||||||
|
):
|
||||||
|
candidate = candidate.strip()
|
||||||
|
if candidate and candidate not in seen:
|
||||||
|
seen.add(candidate)
|
||||||
|
out.append(candidate)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scoring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_orientation(width: int, height: int) -> str:
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return "unknown"
|
||||||
|
if width > height:
|
||||||
|
return "landscape"
|
||||||
|
if height > width:
|
||||||
|
return "portrait"
|
||||||
|
return "square"
|
||||||
|
|
||||||
|
|
||||||
|
def _query_tokens(query: str) -> list[str]:
|
||||||
|
"""Extract ASCII keyword tokens from a query for relevance scoring.
|
||||||
|
|
||||||
|
Uses the same noise-word filtering as ``simplify_query`` so the
|
||||||
|
relevance signal lines up with the keywords we actually search by.
|
||||||
|
Non-ASCII tokens (CJK etc.) are dropped — image metadata is mostly
|
||||||
|
English even on multi-language providers, so substring matching CJK
|
||||||
|
against an English title is unreliable. When this leaves no tokens,
|
||||||
|
``compute_relevance`` falls back to neutral (1.0) and lets the other
|
||||||
|
score dimensions decide.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"#[0-9a-fA-F]{3,8}", "", query.lower())
|
||||||
|
cleaned = re.sub(r"\([^)]*\)", "", cleaned)
|
||||||
|
words = [w.strip(_TOKEN_STRIP_CHARS) for w in cleaned.split()]
|
||||||
|
words = [w for w in words if len(w) > 2 and w.isascii()]
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
after_hard = [w for w in words if w not in _NOISE_WORDS]
|
||||||
|
after_soft = [w for w in after_hard if w not in _SOFT_NOISE_WORDS]
|
||||||
|
return after_soft if after_soft else after_hard
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_text(candidate: AssetCandidate) -> str:
|
||||||
|
"""Concatenate the candidate's matchable metadata fields for scoring."""
|
||||||
|
return " ".join(
|
||||||
|
filter(
|
||||||
|
None,
|
||||||
|
(
|
||||||
|
candidate.title,
|
||||||
|
candidate.author,
|
||||||
|
candidate.source_page_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_match_text(text: str) -> str:
|
||||||
|
"""Normalize metadata / required terms for conservative substring matching."""
|
||||||
|
lowered = (text or "").lower()
|
||||||
|
return _MATCH_SEPARATOR_RE.sub(" ", lowered).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _term_group_alternatives(term_group: str) -> list[str]:
|
||||||
|
"""Split one required term group into alternatives.
|
||||||
|
|
||||||
|
``"Jiefangbei|Liberation Monument"`` means either alternative satisfies
|
||||||
|
that required group. Different list items are ANDed by
|
||||||
|
``missing_required_terms``.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
_normalize_match_text(part)
|
||||||
|
for part in str(term_group or "").split("|")
|
||||||
|
if _normalize_match_text(part)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def missing_required_terms(
|
||||||
|
candidate: AssetCandidate,
|
||||||
|
required_terms: tuple[str, ...] | list[str] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return required term groups not present in candidate metadata.
|
||||||
|
|
||||||
|
This is an entity-safety gate, not a fuzzy visual classifier. Use it for
|
||||||
|
exact subjects such as landmarks, people, companies, or products where a
|
||||||
|
visually nice but wrong image is worse than no image.
|
||||||
|
"""
|
||||||
|
if not required_terms:
|
||||||
|
return []
|
||||||
|
|
||||||
|
text = _normalize_match_text(_candidate_text(candidate))
|
||||||
|
compact_text = text.replace(" ", "")
|
||||||
|
missing: list[str] = []
|
||||||
|
for group in required_terms:
|
||||||
|
alternatives = _term_group_alternatives(group)
|
||||||
|
if not alternatives:
|
||||||
|
continue
|
||||||
|
matched = any(
|
||||||
|
alt in text or alt.replace(" ", "") in compact_text
|
||||||
|
for alt in alternatives
|
||||||
|
)
|
||||||
|
if not matched:
|
||||||
|
missing.append(str(group))
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def compute_relevance(candidate: AssetCandidate, query: str) -> float:
|
||||||
|
"""Fraction of query tokens that appear in the candidate's metadata.
|
||||||
|
|
||||||
|
Range ``[0.0, 1.0]``. Returns ``1.0`` (neutral) when the query has no
|
||||||
|
ASCII tokens to match — this lets non-English queries fall through
|
||||||
|
to license / size scoring without being unfairly rejected.
|
||||||
|
"""
|
||||||
|
tokens = _query_tokens(query)
|
||||||
|
if not tokens:
|
||||||
|
return 1.0
|
||||||
|
text = _candidate_text(candidate)
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
hits = sum(1 for t in tokens if t in text)
|
||||||
|
return hits / len(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> float:
|
||||||
|
"""Score a candidate against a request. Higher is better; -inf rejects.
|
||||||
|
|
||||||
|
Relevance dominates: a candidate whose metadata shares no query
|
||||||
|
tokens is rejected outright, so size / license / orientation cannot
|
||||||
|
rescue an irrelevant image from a permissive provider.
|
||||||
|
"""
|
||||||
|
if not candidate.license_tier:
|
||||||
|
return float("-inf")
|
||||||
|
if (
|
||||||
|
candidate.license_tier == LICENSE_TIER_ATTRIBUTION_REQUIRED
|
||||||
|
and not candidate.author.strip()
|
||||||
|
):
|
||||||
|
return float("-inf")
|
||||||
|
|
||||||
|
required_misses = missing_required_terms(candidate, request.required_terms)
|
||||||
|
if required_misses:
|
||||||
|
return float("-inf")
|
||||||
|
|
||||||
|
relevance = compute_relevance(candidate, request.query)
|
||||||
|
if relevance == 0.0 and not request.required_terms:
|
||||||
|
return float("-inf")
|
||||||
|
|
||||||
|
score = relevance * 10000.0
|
||||||
|
title_text = _normalize_match_text(candidate.title)
|
||||||
|
compact_title = title_text.replace(" ", "")
|
||||||
|
for group in request.required_terms or ():
|
||||||
|
alternatives = _term_group_alternatives(group)
|
||||||
|
if any(
|
||||||
|
alt in title_text or alt.replace(" ", "") in compact_title
|
||||||
|
for alt in alternatives
|
||||||
|
):
|
||||||
|
score += 1500.0
|
||||||
|
|
||||||
|
# Penalize infrastructure/transit metadata if the user didn't explicitly ask for it.
|
||||||
|
# This prevents high-res subway station photos from outranking actual tourist landmarks.
|
||||||
|
text = _candidate_text(candidate)
|
||||||
|
query_lower = request.query.lower()
|
||||||
|
infra_terms = [
|
||||||
|
"station", "subway", "metro", "rail", "transit", "airport", "bus",
|
||||||
|
"地铁", "站", "轨道",
|
||||||
|
]
|
||||||
|
|
||||||
|
if not any(t in query_lower for t in infra_terms):
|
||||||
|
if any(t in text for t in infra_terms):
|
||||||
|
score -= 5000.0
|
||||||
|
|
||||||
|
candidate_orientation = normalize_orientation(candidate.width, candidate.height)
|
||||||
|
requested = (request.orientation or "").strip().lower()
|
||||||
|
if requested:
|
||||||
|
if candidate_orientation == requested:
|
||||||
|
score += 1000.0
|
||||||
|
else:
|
||||||
|
score -= 250.0
|
||||||
|
|
||||||
|
if request.min_width and candidate.width < request.min_width:
|
||||||
|
score -= 500.0
|
||||||
|
if request.min_height and candidate.height < request.min_height:
|
||||||
|
score -= 500.0
|
||||||
|
|
||||||
|
if candidate.license_tier == LICENSE_TIER_NO_ATTRIBUTION:
|
||||||
|
score += 250.0
|
||||||
|
|
||||||
|
# Larger images score higher, but only as a tie-breaker; entity accuracy
|
||||||
|
# and metadata relevance must dominate pixel count.
|
||||||
|
pixel_score = max(candidate.width, 0) * max(candidate.height, 0) / 1000.0
|
||||||
|
score += min(pixel_score, 1500.0)
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Attribution text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
||||||
|
"openverse": "Openverse",
|
||||||
|
"wikimedia": "Wikimedia Commons",
|
||||||
|
"pexels": "Pexels",
|
||||||
|
"pixabay": "Pixabay",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_attribution_text(filename: str, candidate: AssetCandidate) -> str:
|
||||||
|
"""Render the canonical attribution string for the manifest.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
``filename — "title" by author, via Provider, license: name (url)``
|
||||||
|
|
||||||
|
Empty fields are gracefully omitted. The text is intended for use by
|
||||||
|
the Executor when generating in-SVG credit elements; it is not meant
|
||||||
|
to be machine-parsed downstream.
|
||||||
|
"""
|
||||||
|
provider_name = PROVIDER_DISPLAY_NAMES.get(
|
||||||
|
candidate.provider, candidate.provider or "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
parts: list[str] = [filename or candidate.download_url or "image"]
|
||||||
|
middle: list[str] = []
|
||||||
|
if candidate.title:
|
||||||
|
middle.append(f'"{candidate.title}"')
|
||||||
|
if candidate.author:
|
||||||
|
middle.append(f"by {candidate.author}")
|
||||||
|
middle.append(f"via {provider_name}")
|
||||||
|
parts.append(" ".join(middle))
|
||||||
|
|
||||||
|
license_part = candidate.license_name or candidate.license_url
|
||||||
|
if license_part:
|
||||||
|
if candidate.license_url and candidate.license_name:
|
||||||
|
license_part = f"{candidate.license_name} ({candidate.license_url})"
|
||||||
|
parts.append(f"license: {license_part}")
|
||||||
|
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Small helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_json_parent(path: str | Path) -> Path:
|
||||||
|
"""Make sure the parent directory of ``path`` exists; return as Path."""
|
||||||
|
p = Path(path)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Openverse provider.
|
||||||
|
|
||||||
|
Zero-config (no API key required). Indexes openly licensed images across
|
||||||
|
Wikimedia, Flickr, museums, and other sources.
|
||||||
|
|
||||||
|
API docs: https://api.openverse.org/v1/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(__doc__)
|
||||||
|
print("Use via: python3 skills/ppt-master/scripts/image_search.py ...")
|
||||||
|
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from image_sources.provider_common import (
|
||||||
|
AssetCandidate,
|
||||||
|
ImageSearchRequest,
|
||||||
|
USER_AGENT,
|
||||||
|
build_query_progression,
|
||||||
|
classify_license,
|
||||||
|
normalize_license_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.openverse.org/v1/images/"
|
||||||
|
DEFAULT_PAGE_SIZE = 20
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Map our orientation vocabulary to Openverse's ``aspect_ratio`` parameter.
|
||||||
|
_ASPECT_MAP = {"landscape": "wide", "portrait": "tall", "square": "square"}
|
||||||
|
|
||||||
|
# Openverse license param values. ``cc0,pdm`` covers our "no-attribution" tier;
|
||||||
|
# adding ``by,by-sa`` opens the "attribution-required" tier.
|
||||||
|
_LICENSE_PARAM = {
|
||||||
|
"no-attribution-only": "cc0,pdm",
|
||||||
|
"all": "by,by-sa,cc0,pdm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_results(payload: dict) -> list[AssetCandidate]:
|
||||||
|
"""Translate an Openverse search payload into a list of candidates."""
|
||||||
|
candidates: list[AssetCandidate] = []
|
||||||
|
for item in payload.get("results", []) or []:
|
||||||
|
license_name = (item.get("license") or "").strip()
|
||||||
|
license_url = (item.get("license_url") or "").strip()
|
||||||
|
tier = classify_license(license_name, license_url, provider="openverse")
|
||||||
|
if not tier:
|
||||||
|
continue
|
||||||
|
|
||||||
|
download_url = (item.get("url") or item.get("thumbnail") or "").strip()
|
||||||
|
if not download_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates.append(
|
||||||
|
AssetCandidate(
|
||||||
|
provider="openverse",
|
||||||
|
title=(item.get("title") or "").strip() or "Untitled",
|
||||||
|
asset_id=str(item.get("id") or ""),
|
||||||
|
source_page_url=(
|
||||||
|
item.get("foreign_landing_url") or item.get("detail_url") or ""
|
||||||
|
).strip(),
|
||||||
|
license_name=normalize_license_name(license_name),
|
||||||
|
license_url=license_url,
|
||||||
|
license_tier=tier,
|
||||||
|
width=int(item.get("width") or 0),
|
||||||
|
height=int(item.get("height") or 0),
|
||||||
|
download_url=download_url,
|
||||||
|
author=(item.get("creator") or "").strip(),
|
||||||
|
raw=item,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def search(
|
||||||
|
request: ImageSearchRequest,
|
||||||
|
*,
|
||||||
|
license_tier_filter: str = "no-attribution-only",
|
||||||
|
page_size: int = DEFAULT_PAGE_SIZE,
|
||||||
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
) -> list[AssetCandidate]:
|
||||||
|
"""Search Openverse for candidates matching ``request``.
|
||||||
|
|
||||||
|
``license_tier_filter`` is one of ``"no-attribution-only"`` or ``"all"``.
|
||||||
|
Returns the candidates from the first non-empty query in the
|
||||||
|
progression — caller is responsible for picking the best one via
|
||||||
|
``score_candidate``.
|
||||||
|
"""
|
||||||
|
if license_tier_filter not in _LICENSE_PARAM:
|
||||||
|
raise ValueError(f"unsupported license_tier_filter: {license_tier_filter!r}")
|
||||||
|
|
||||||
|
orientation = (request.orientation or "").strip().lower()
|
||||||
|
|
||||||
|
for query in build_query_progression(request.query):
|
||||||
|
params: dict[str, str | int] = {
|
||||||
|
"q": query,
|
||||||
|
"page_size": page_size,
|
||||||
|
"license": _LICENSE_PARAM[license_tier_filter],
|
||||||
|
"size": "large",
|
||||||
|
}
|
||||||
|
if orientation in _ASPECT_MAP:
|
||||||
|
params["aspect_ratio"] = _ASPECT_MAP[orientation]
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
API_URL,
|
||||||
|
params=params,
|
||||||
|
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
candidates = parse_results(response.json())
|
||||||
|
if candidates:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
return []
|
||||||