Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
+337
View File
@@ -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())
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

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())
+707
View File
@@ -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)."""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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.
+316
View File
@@ -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.
+284
View File
@@ -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.
+433
View File
@@ -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())
File diff suppressed because it is too large Load Diff
+507
View File
@@ -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()
+135
View File
@@ -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 `![image](data:image/png;base64,...)`)
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}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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 []
@@ -0,0 +1,134 @@
"""Pexels provider.
Requires ``PEXELS_API_KEY`` in the environment. Pexels's site-wide license
allows commercial use without attribution, so all returned candidates are
classified as ``no-attribution``.
API docs: https://www.pexels.com/api/documentation/
"""
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 os
import requests
from image_sources.provider_common import (
AssetCandidate,
ImageSearchRequest,
LICENSE_TIER_NO_ATTRIBUTION,
USER_AGENT,
build_query_progression,
normalize_license_name,
)
API_URL = "https://api.pexels.com/v1/search"
DEFAULT_PAGE_SIZE = 20
DEFAULT_TIMEOUT = 30
_ORIENTATION_MAP = {
"landscape": "landscape",
"portrait": "portrait",
"square": "square",
}
def _require_api_key() -> str:
key = (os.environ.get("PEXELS_API_KEY") or "").strip()
if not key:
raise RuntimeError(
"PEXELS_API_KEY is not set. Add it to your environment or .env file. "
"Get one at https://www.pexels.com/api/"
)
return key
def parse_results(payload: dict) -> list[AssetCandidate]:
"""Translate a Pexels response into candidates."""
candidates: list[AssetCandidate] = []
for item in payload.get("photos", []) or []:
src = item.get("src") or {}
download_url = (src.get("original") or src.get("large2x") or src.get("large") or "").strip()
if not download_url:
continue
candidates.append(
AssetCandidate(
provider="pexels",
title=(item.get("alt") or "").strip() or "Pexels photo",
asset_id=str(item.get("id") or ""),
source_page_url=(item.get("url") or "").strip(),
license_name=normalize_license_name("Pexels License"),
license_url="https://www.pexels.com/license/",
license_tier=LICENSE_TIER_NO_ATTRIBUTION,
width=int(item.get("width") or 0),
height=int(item.get("height") or 0),
download_url=download_url,
author=(item.get("photographer") 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 Pexels for candidates.
Pexels images are uniformly ``no-attribution``, so the ``"all"`` filter
behaves identically to ``"no-attribution-only"``. Both are accepted to
keep the dispatcher simple.
"""
if license_tier_filter not in {"no-attribution-only", "all"}:
raise ValueError(f"unsupported license_tier_filter: {license_tier_filter!r}")
api_key = _require_api_key()
orientation = (request.orientation or "").strip().lower()
for query in build_query_progression(request.query):
params: dict[str, str | int] = {
"query": query,
"per_page": page_size,
"size": "large",
}
if orientation in _ORIENTATION_MAP:
params["orientation"] = _ORIENTATION_MAP[orientation]
response = requests.get(
API_URL,
params=params,
headers={
"Authorization": api_key,
"User-Agent": USER_AGENT,
"Accept": "application/json",
},
timeout=timeout,
)
response.raise_for_status()
candidates = parse_results(response.json())
if candidates:
return candidates
return []
@@ -0,0 +1,139 @@
"""Pixabay provider.
Requires ``PIXABAY_API_KEY`` in the environment. Pixabay's Content License
allows commercial use without attribution, so all returned candidates are
classified as ``no-attribution``.
API docs: https://pixabay.com/api/docs/
"""
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 os
import requests
from image_sources.provider_common import (
AssetCandidate,
ImageSearchRequest,
LICENSE_TIER_NO_ATTRIBUTION,
USER_AGENT,
build_query_progression,
normalize_license_name,
)
API_URL = "https://pixabay.com/api/"
DEFAULT_PAGE_SIZE = 20
DEFAULT_TIMEOUT = 30
# Pixabay uses ``horizontal`` / ``vertical`` rather than landscape/portrait,
# and has no ``square`` value (it falls back to ``all``).
_ORIENTATION_MAP = {
"landscape": "horizontal",
"portrait": "vertical",
}
def _require_api_key() -> str:
key = (os.environ.get("PIXABAY_API_KEY") or "").strip()
if not key:
raise RuntimeError(
"PIXABAY_API_KEY is not set. Add it to your environment or .env file. "
"Get one at https://pixabay.com/api/docs/"
)
return key
def parse_results(payload: dict) -> list[AssetCandidate]:
"""Translate a Pixabay response into candidates."""
candidates: list[AssetCandidate] = []
for item in payload.get("hits", []) or []:
download_url = (
item.get("largeImageURL")
or item.get("webformatURL")
or item.get("previewURL")
or ""
).strip()
if not download_url:
continue
candidates.append(
AssetCandidate(
provider="pixabay",
title=(item.get("tags") or "").strip() or "Pixabay image",
asset_id=str(item.get("id") or ""),
source_page_url=(item.get("pageURL") or "").strip(),
license_name=normalize_license_name("Pixabay Content License"),
license_url="https://pixabay.com/service/license-summary/",
license_tier=LICENSE_TIER_NO_ATTRIBUTION,
width=int(item.get("imageWidth") or 0),
height=int(item.get("imageHeight") or 0),
download_url=download_url,
author=(item.get("user") 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 Pixabay for candidates.
Pixabay images are uniformly ``no-attribution``, so the ``"all"`` filter
behaves identically to ``"no-attribution-only"``.
"""
if license_tier_filter not in {"no-attribution-only", "all"}:
raise ValueError(f"unsupported license_tier_filter: {license_tier_filter!r}")
api_key = _require_api_key()
orientation = (request.orientation or "").strip().lower()
for query in build_query_progression(request.query):
params: dict[str, str | int] = {
"key": api_key,
"q": query,
"image_type": "photo",
"per_page": page_size,
"safesearch": "true",
}
if orientation in _ORIENTATION_MAP:
params["orientation"] = _ORIENTATION_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 []
@@ -0,0 +1,206 @@
"""Wikimedia Commons provider.
Zero-config (no API key required). Strong on educational, scientific,
geographic, and historical imagery; weaker on contemporary stock-style
photography and people.
Uses the MediaWiki API's ``generator=search`` mode to combine fulltext
search with imageinfo/extmetadata in a single round trip.
API docs: https://www.mediawiki.org/wiki/API:Search
"""
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 html
import re
import requests
from image_sources.provider_common import (
AssetCandidate,
ImageSearchRequest,
USER_AGENT,
build_query_progression,
classify_license,
normalize_license_name,
normalize_orientation,
)
API_URL = "https://commons.wikimedia.org/w/api.php"
DEFAULT_SEARCH_LIMIT = 20
DEFAULT_TIMEOUT = 30
# File extensions we are willing to embed in a deck. SVG/GIF/audio etc. are
# excluded — Wikimedia returns these freely from a generic search.
_ACCEPTED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp", ".tiff", ".tif"})
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")
def _strip_html(value: str) -> str:
"""Wikimedia's extmetadata fields contain inline HTML markup. Flatten it."""
if not value:
return ""
text = html.unescape(str(value))
text = _TAG_RE.sub(" ", text)
text = _WS_RE.sub(" ", text)
return text.strip()
def _ext_value(extmetadata: dict, key: str) -> str:
"""Pull ``extmetadata[key].value`` and strip HTML."""
entry = extmetadata.get(key) or {}
if isinstance(entry, dict):
return _strip_html(entry.get("value", ""))
return _strip_html(entry)
def _accept_extension(title: str) -> bool:
"""Drop non-image files (svg/gif/audio/video) by extension."""
lower = (title or "").lower()
return any(lower.endswith(ext) for ext in _ACCEPTED_EXTENSIONS)
def _page_label(title: str) -> str:
"""``File:Some_image.jpg`` → ``Some_image.jpg``."""
clean = _strip_html(title)
if clean.lower().startswith("file:"):
return clean.split(":", 1)[1].strip()
return clean
def parse_results(payload: dict) -> list[AssetCandidate]:
"""Translate a MediaWiki ``query`` payload into a list of candidates."""
candidates: list[AssetCandidate] = []
pages = ((payload.get("query") or {}).get("pages") or {})
for page in pages.values():
title = page.get("title") or ""
if not _accept_extension(title):
continue
info_list = page.get("imageinfo") or []
if not info_list:
continue
info = info_list[0]
extmetadata = info.get("extmetadata") or {}
license_name = (
_ext_value(extmetadata, "LicenseShortName")
or _ext_value(extmetadata, "License")
)
license_url = _ext_value(extmetadata, "LicenseUrl")
tier = classify_license(license_name, license_url, provider="wikimedia")
if not tier:
continue
download_url = (info.get("url") or "").strip()
if not download_url:
continue
candidates.append(
AssetCandidate(
provider="wikimedia",
title=_page_label(title) or "Untitled",
asset_id=str(page.get("pageid") or ""),
source_page_url=(info.get("descriptionurl") or "").strip(),
license_name=normalize_license_name(license_name),
license_url=license_url,
license_tier=tier,
width=int(info.get("width") or 0),
height=int(info.get("height") or 0),
download_url=download_url,
author=_ext_value(extmetadata, "Artist"),
raw=page,
)
)
return candidates
def _filter_by_orientation(
candidates: list[AssetCandidate], orientation: str
) -> list[AssetCandidate]:
"""Wikimedia API has no orientation parameter; filter client-side."""
if not orientation or orientation == "any":
return candidates
matching = [
c for c in candidates if normalize_orientation(c.width, c.height) == orientation
]
# Fall back to the unfiltered list if orientation pruning leaves nothing —
# better an off-orientation match than no image at all.
return matching or candidates
def search(
request: ImageSearchRequest,
*,
license_tier_filter: str = "no-attribution-only",
search_limit: int = DEFAULT_SEARCH_LIMIT,
timeout: int = DEFAULT_TIMEOUT,
) -> list[AssetCandidate]:
"""Search Wikimedia Commons for candidates.
Wikimedia returns license info via ``extmetadata`` rather than as a
request parameter, so the ``license_tier_filter`` does its work in
``parse_results`` (and is honored implicitly because tier classification
happens there). Returning candidates from the first non-empty query.
"""
if license_tier_filter not in {"no-attribution-only", "all"}:
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 = {
"action": "query",
"format": "json",
"generator": "search",
"gsrnamespace": "6", # File: namespace
"gsrsearch": f"{query} filetype:bitmap",
"gsrlimit": search_limit,
"prop": "imageinfo",
"iiprop": "url|size|extmetadata|mime",
"iiextmetadatafilter": (
"LicenseShortName|License|LicenseUrl|Artist"
),
}
response = requests.get(
API_URL,
params=params,
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
timeout=timeout,
)
response.raise_for_status()
all_candidates = parse_results(response.json())
if license_tier_filter == "no-attribution-only":
all_candidates = [
c for c in all_candidates if c.license_tier == "no-attribution"
]
all_candidates = _filter_by_orientation(all_candidates, orientation)
if all_candidates:
return all_candidates
return []
+526
View File
@@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""
PPT Master - Image Treatment
Create a non-destructive PNG derivative from one project-local bitmap while
preserving its display dimensions, alpha mask, and any matching web provenance.
Usage:
python3 scripts/image_treat.py <project_path> <source> --output <filename.png> [options]
Examples:
python3 scripts/image_treat.py projects/demo hero.jpg --output hero_soft.png --blur 12
python3 scripts/image_treat.py projects/demo hero.jpg --output hero_duotone.png \
--contrast 1.1 --duotone "#14213D" "#FCA311"
Dependencies:
Pillow; project image-search dependencies when copying web provenance
Treatments run in this fixed order: brightness, contrast, tone treatment
(desaturate / grayscale / duotone), then Gaussian blur.
"""
from __future__ import annotations
import argparse
import copy
import math
import os
import re
import sys
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Optional
from PIL import (
Image,
ImageCms,
ImageEnhance,
ImageFilter,
ImageOps,
UnidentifiedImageError,
)
_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
configure_utf8_stdio()
_HEX_COLOR_RE = re.compile(r"^#?([0-9A-Fa-f]{6})$")
def _read_sources_manifest(path: Path) -> dict:
from image_search import _read_existing_manifest
return _read_existing_manifest(path)
def _write_sources_manifest(path: Path, item: dict) -> Path:
from image_search import write_sources_manifest
return write_sources_manifest(path, item)
def _finite_float(value: str) -> float:
try:
number = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"expected a number, got {value!r}") from exc
if not math.isfinite(number):
raise argparse.ArgumentTypeError("value must be finite")
return number
def _nonnegative_float(value: str) -> float:
number = _finite_float(value)
if number < 0:
raise argparse.ArgumentTypeError("value must be greater than or equal to 0")
return number
def _positive_float(value: str) -> float:
number = _finite_float(value)
if number <= 0:
raise argparse.ArgumentTypeError("value must be greater than 0")
return number
def _unit_float(value: str) -> float:
number = _finite_float(value)
if not 0 <= number <= 1:
raise argparse.ArgumentTypeError("value must be between 0 and 1")
return number
def _hex_color(value: str) -> str:
match = _HEX_COLOR_RE.fullmatch(value)
if match is None:
raise argparse.ArgumentTypeError("color must be #RRGGBB or RRGGBB")
return f"#{match.group(1).upper()}"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Create a project-local PNG derivative. Processing order: brightness -> "
"contrast -> desaturate/grayscale/duotone -> blur."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", help="Project root containing an images/ directory.")
parser.add_argument(
"source",
help="Existing bare bitmap filename under <project_path>/images (no path components).",
)
parser.add_argument(
"--output",
required=True,
help="New bare .png filename under <project_path>/images; existing files are never replaced.",
)
parser.add_argument(
"--brightness",
type=_positive_float,
help="Brightness factor greater than 0; 1 is unchanged.",
)
parser.add_argument(
"--contrast",
type=_positive_float,
help="Contrast factor greater than 0; 1 is unchanged.",
)
tone_group = parser.add_mutually_exclusive_group()
tone_group.add_argument(
"--desaturate",
type=_unit_float,
help="Remove this fraction of color (0..1); 1 is grayscale.",
)
tone_group.add_argument(
"--grayscale",
action="store_true",
help="Convert RGB content to grayscale while preserving any alpha mask.",
)
tone_group.add_argument(
"--duotone",
nargs=2,
type=_hex_color,
metavar=("SHADOW", "HIGHLIGHT"),
help="Map sRGB luminance between two sRGB #RRGGBB colors.",
)
parser.add_argument(
"--blur",
type=_nonnegative_float,
help="Gaussian blur radius greater than or equal to 0; 0 is unchanged.",
)
return parser
def _validate_bare_filename(value: str, *, field_name: str) -> str:
if (
not value.strip()
or value in {".", ".."}
or "/" in value
or "\\" in value
or ":" in value
or Path(value).is_absolute()
):
raise ValueError(
f"{field_name} must be a bare filename without path components: {value!r}"
)
return value
def _assert_output_absent(output_path: Path) -> None:
for child in output_path.parent.iterdir():
if child.name.casefold() == output_path.name.casefold():
raise ValueError(f"output already exists or conflicts by casing: {child}")
def _resolve_paths(project_value: str, source_name: str, output_name: str) -> tuple[Path, Path, Path]:
source_name = _validate_bare_filename(source_name, field_name="source")
output_name = _validate_bare_filename(output_name, field_name="output")
if Path(output_name).suffix.casefold() != ".png":
raise ValueError("output must use the .png extension")
if source_name.casefold() == output_name.casefold():
raise ValueError("output must differ from source, including filename casing")
project_input = Path(project_value).expanduser()
try:
project_path = project_input.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError(f"project path does not resolve safely: {project_input} ({exc})") from exc
if not project_path.is_dir():
raise ValueError(f"project path is not a directory: {project_path}")
images_link = project_path / "images"
if images_link.is_symlink():
raise ValueError(f"project images directory must not be a symlink: {images_link}")
if not images_link.is_dir():
raise ValueError(f"project images directory does not exist: {images_link}")
images_path = images_link.resolve(strict=True)
if images_path.parent != project_path:
raise ValueError(f"project images directory escapes the project root: {images_link}")
source_link = images_path / source_name
if source_link.is_symlink():
raise ValueError(f"source must not be a symlink: {source_link}")
try:
source_path = source_link.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError(f"source does not resolve safely: {source_link} ({exc})") from exc
if source_path.parent != images_path or not source_path.is_file():
raise ValueError(f"source must be a file directly under project images/: {source_link}")
output_path = images_path / output_name
_assert_output_absent(output_path)
if output_path.exists() or output_path.is_symlink():
raise ValueError(f"output already exists: {output_path}")
if output_path.parent.resolve() != images_path:
raise ValueError(f"output escapes the project images directory: {output_path}")
return images_path, source_path, output_path
def _treatment_plan(args: argparse.Namespace) -> list[dict]:
plan: list[dict] = []
if args.brightness is not None and args.brightness != 1:
plan.append({"operation": "brightness", "factor": args.brightness})
if args.contrast is not None and args.contrast != 1:
plan.append({"operation": "contrast", "factor": args.contrast})
if args.desaturate is not None and args.desaturate > 0:
plan.append({"operation": "desaturate", "amount": args.desaturate})
elif args.grayscale:
plan.append({"operation": "grayscale"})
elif args.duotone is not None:
plan.append(
{
"operation": "duotone",
"shadow": args.duotone[0],
"highlight": args.duotone[1],
}
)
if args.blur is not None and args.blur > 0:
plan.append({"operation": "blur", "radius": args.blur})
if not plan:
raise ValueError(
"select at least one effective treatment; identity values such as "
"--brightness 1, --desaturate 0, or --blur 0 do not change the image"
)
return plan
def _validate_blur_radius(radius: Optional[float], width: int, height: int) -> None:
if radius is None or radius <= 0:
return
effective_maximum = max(width, height)
if radius > effective_maximum:
raise ValueError(
f"blur radius {radius:g} exceeds the effective maximum "
f"{effective_maximum} for a {width}x{height} image; choose --blur "
f"at or below {effective_maximum}"
)
def _convert_duotone_source_to_srgb(image: Image.Image) -> tuple[Image.Image, bytes]:
output_profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB"))
output_icc = output_profile.tobytes()
source_icc = image.info.get("icc_profile")
if not source_icc:
return image.convert("RGB"), output_icc
if not isinstance(source_icc, bytes):
raise ValueError(
"source ICC profile has an unsupported representation; convert the "
"source to sRGB or remove the invalid profile, then retry"
)
try:
input_profile = ImageCms.ImageCmsProfile(BytesIO(source_icc))
converted = ImageCms.profileToProfile(
image,
input_profile,
output_profile,
outputMode="RGB",
)
except (ImageCms.PyCMSError, OSError, TypeError, ValueError) as exc:
raise ValueError(
"source ICC profile is invalid or incompatible with the image mode; "
"convert the source to sRGB or remove the invalid profile, then retry"
) from exc
if converted is None:
raise RuntimeError("ICC conversion did not produce an image")
return converted, output_icc
def _has_alpha(image: Image.Image) -> bool:
return "A" in image.getbands() or "transparency" in image.info
def _apply_treatments(
source_path: Path,
temporary_path: Path,
args: argparse.Namespace,
) -> tuple[int, int]:
try:
with Image.open(source_path) as source:
if int(getattr(source, "n_frames", 1)) != 1:
raise RuntimeError(
f"animated or multi-frame images are unsupported: {source_path}"
)
oriented = ImageOps.exif_transpose(source)
try:
oriented.load()
width, height = oriented.size
_validate_blur_radius(args.blur, width, height)
has_alpha = _has_alpha(oriented)
rgba = oriented.convert("RGBA") if has_alpha else None
alpha = rgba.getchannel("A") if rgba is not None else None
if args.duotone is not None:
rgb, icc_profile = _convert_duotone_source_to_srgb(oriented)
else:
keep_icc = oriented.mode in {"RGB", "RGBA"}
icc_profile = oriented.info.get("icc_profile") if keep_icc else None
rgb = rgba.convert("RGB") if rgba is not None else oriented.convert("RGB")
if args.brightness is not None and args.brightness != 1:
rgb = ImageEnhance.Brightness(rgb).enhance(args.brightness)
if args.contrast is not None and args.contrast != 1:
rgb = ImageEnhance.Contrast(rgb).enhance(args.contrast)
if args.desaturate is not None and args.desaturate > 0:
rgb = ImageEnhance.Color(rgb).enhance(1 - args.desaturate)
elif args.grayscale:
rgb = ImageOps.grayscale(rgb).convert("RGB")
elif args.duotone is not None:
rgb = ImageOps.colorize(
ImageOps.grayscale(rgb),
black=args.duotone[0],
white=args.duotone[1],
)
if args.blur is not None and args.blur > 0:
rgb = rgb.filter(ImageFilter.GaussianBlur(radius=args.blur))
result = rgb.convert("RGBA") if alpha is not None else rgb
if alpha is not None:
result.putalpha(alpha)
save_options = {"format": "PNG"}
if isinstance(icc_profile, bytes) and icc_profile:
save_options["icc_profile"] = icc_profile
result.save(temporary_path, **save_options)
result.close()
if rgba is not None:
rgba.close()
if alpha is not None:
alpha.close()
rgb.close()
return width, height
finally:
if oriented is not source:
oriented.close()
except (OSError, UnidentifiedImageError) as exc:
raise RuntimeError(f"unable to read or treat source image {source_path}: {exc}") from exc
def _rewrite_attribution_text(item: dict, source_name: str, output_name: str) -> str:
value = item.get("attribution_text")
if not isinstance(value, str) or not value:
return value if isinstance(value, str) else ""
if not value.startswith(source_name):
return value
remainder = value[len(source_name) :]
if remainder and not (remainder[0].isspace() or remainder[0] in "—–-:"):
return value
return output_name + remainder
def _prepare_provenance(
manifest_path: Path,
source_name: str,
output_name: str,
treatments: list[dict],
) -> Optional[dict]:
if manifest_path.is_symlink():
raise ValueError(f"image source manifest must not be a symlink: {manifest_path}")
if not manifest_path.exists():
return None
payload = _read_sources_manifest(manifest_path)
items = payload.get("items") or []
source_item = None
for item in items:
filename = item.get("filename")
if not isinstance(filename, str):
continue
if filename.casefold() == output_name.casefold():
raise ValueError(
f"output already has an image_sources.json record: {filename!r}"
)
if filename.casefold() == source_name.casefold():
if filename != source_name:
raise ValueError(
"source filename casing does not match image_sources.json: "
f"{source_name!r} vs {filename!r}"
)
source_item = item
if source_item is None:
return None
derived_item = copy.deepcopy(source_item)
derived_item["filename"] = output_name
derived_item["attribution_text"] = _rewrite_attribution_text(
source_item,
source_name,
output_name,
)
derived_item["derived_from"] = source_name
derived_item["treatments"] = copy.deepcopy(treatments)
return derived_item
def _assert_manifest_output_absent(manifest_path: Path, output_name: str) -> None:
payload = _read_sources_manifest(manifest_path)
for item in payload.get("items") or []:
filename = item.get("filename")
if isinstance(filename, str) and filename.casefold() == output_name.casefold():
raise RuntimeError(
f"output already has an image_sources.json record: {filename!r}"
)
def _is_staged_output(temporary_path: Path, output_path: Path) -> bool:
try:
return not output_path.is_symlink() and os.path.samefile(temporary_path, output_path)
except OSError:
return False
def _write_derivative(
source_path: Path,
output_path: Path,
manifest_path: Path,
provenance_item: Optional[dict],
args: argparse.Namespace,
) -> None:
fd, temporary_name = tempfile.mkstemp(
prefix=f".{output_path.stem}.",
suffix=".tmp.png",
dir=str(output_path.parent),
)
os.close(fd)
temporary_path = Path(temporary_name)
try:
width, height = _apply_treatments(source_path, temporary_path, args)
if provenance_item is not None:
provenance_item["width"] = width
provenance_item["height"] = height
_assert_manifest_output_absent(manifest_path, output_path.name)
_assert_output_absent(output_path)
try:
os.chmod(temporary_path, 0o644)
except OSError:
pass
try:
os.link(temporary_path, output_path)
except FileExistsError as exc:
raise RuntimeError(
f"output appeared during processing and will not be replaced: {output_path}"
) from exc
if provenance_item is not None:
try:
if not _is_staged_output(temporary_path, output_path):
raise RuntimeError(
f"installed output changed before provenance commit: {output_path}"
)
_assert_manifest_output_absent(manifest_path, output_path.name)
_write_sources_manifest(manifest_path, provenance_item)
except Exception as exc:
try:
if _is_staged_output(temporary_path, output_path):
output_path.unlink()
except OSError as cleanup_exc:
raise RuntimeError(
f"manifest update failed and output rollback also failed: {cleanup_exc}"
) from exc
raise RuntimeError(f"manifest update failed; output was rolled back: {exc}") from exc
finally:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
images_path, source_path, output_path = _resolve_paths(
args.project_path,
args.source,
args.output,
)
treatments = _treatment_plan(args)
manifest_path = images_path / "image_sources.json"
provenance_item = _prepare_provenance(
manifest_path,
args.source,
args.output,
treatments,
)
_write_derivative(
source_path,
output_path,
manifest_path,
provenance_item,
args,
)
except (OSError, RuntimeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
PPT Master - Language Tag Helpers
Normalize and inspect the project primary-language tag shared by confirmation
and export tooling.
Usage:
Import ``normalize_language_tag`` from another script.
Examples:
from language_tags import normalize_language_tag
normalize_language_tag("Chinese")
Dependencies:
None (only uses standard library)
"""
import re
class LanguageTagError(ValueError):
"""Report an invalid or ambiguous project language tag."""
_LANGUAGE_RE = re.compile(r"^[A-Za-z]{2,8}$")
_EXTLANG_RE = re.compile(r"^[A-Za-z]{3}$")
_SCRIPT_RE = re.compile(r"^[A-Za-z]{4}$")
_REGION_RE = re.compile(r"^(?:[A-Za-z]{2}|[0-9]{3})$")
_VARIANT_RE = re.compile(r"^(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})$")
_SINGLETON_RE = re.compile(r"^[0-9A-WY-Za-wy-z]$")
_EXTENSION_RE = re.compile(r"^[A-Za-z0-9]{2,8}$")
_PRIVATE_RE = re.compile(r"^[A-Za-z0-9]{1,8}$")
def _alias_key(value: str) -> str:
"""Return a whitespace-stable key for legacy natural-language aliases."""
return " ".join(value.strip().casefold().split())
_LANGUAGE_ALIASES = {
_alias_key(alias): tag
for tag, aliases in {
"en-US": (
"English",
"英文",
"英语",
"英語",
),
"zh-Hans": (
"Chinese",
"Chinese Simplified",
"Chinese (Simplified)",
"Simplified Chinese",
"Mandarin",
"中文",
"汉语",
"漢語",
"普通话",
"普通話",
"简体",
"簡體",
"简体中文",
"簡體中文",
"简中",
),
"zh-Hant": (
"Chinese Traditional",
"Chinese (Traditional)",
"Traditional Chinese",
"繁体",
"繁體",
"繁体中文",
"繁體中文",
"繁中",
),
"ja-JP": (
"Japanese",
"日本語",
"日文",
"日语",
"日語",
),
"ko-KR": (
"Korean",
"한국어",
"조선말",
"韩语",
"韓語",
"朝鲜语",
"朝鮮語",
),
}.items()
for alias in aliases
}
_NATURAL_LANGUAGE_NAMES = {
"arabic",
"bengali",
"czech",
"danish",
"dutch",
"farsi",
"finnish",
"french",
"georgian",
"german",
"greek",
"hebrew",
"hindi",
"hungarian",
"indonesian",
"italian",
"malay",
"marathi",
"nepali",
"norwegian",
"persian",
"polish",
"portuguese",
"romanian",
"russian",
"slovak",
"spanish",
"swedish",
"tamil",
"telugu",
"thai",
"turkish",
"ukrainian",
"urdu",
"vietnamese",
}
_RTL_BASE_LANGUAGES = {
"ar",
"dv",
"fa",
"he",
"iw",
"ps",
"sd",
"ug",
"ur",
"yi",
}
_RTL_SCRIPTS = {
"Adlm",
"Arab",
"Hebr",
"Mand",
"Nkoo",
"Rohg",
"Samr",
"Syrc",
"Thaa",
}
def normalize_language_tag(value: str) -> str:
"""Return one canonical, unambiguous BCP-47 language tag.
The helper accepts the four historical natural-language aliases used by
Confirm UI. Other language names stay invalid: callers must provide a real
tag such as ``es-ES``, ``ru-RU``, or ``ar-SA``.
"""
if not isinstance(value, str) or not value.strip():
raise LanguageTagError("primary language must be a non-empty BCP-47 tag")
raw = value.strip()
alias = _LANGUAGE_ALIASES.get(_alias_key(raw))
if alias:
raw = alias
else:
raw = raw.replace("_", "-")
parts = raw.split("-")
if any(not part for part in parts):
raise LanguageTagError(f"invalid BCP-47 language tag: {value!r}")
language = parts[0]
if not _LANGUAGE_RE.fullmatch(language):
raise LanguageTagError(
"use a BCP-47 language tag such as es-ES, ru-RU, ar-SA, or zh-Hans"
)
language = language.lower()
if language in _NATURAL_LANGUAGE_NAMES:
raise LanguageTagError(
"use a BCP-47 language tag such as es-ES, ru-RU, ar-SA, or zh-Hans"
)
if language == "und":
raise LanguageTagError(
"und is not a usable primary language; choose the presentation language"
)
normalized = [language]
index = 1
extlang_count = 0
while (
len(language) <= 3
and index < len(parts)
and extlang_count < 3
and _EXTLANG_RE.fullmatch(parts[index])
):
normalized.append(parts[index].lower())
index += 1
extlang_count += 1
has_script = False
if index < len(parts) and _SCRIPT_RE.fullmatch(parts[index]):
normalized.append(parts[index].title())
index += 1
has_script = True
has_region = False
if index < len(parts) and _REGION_RE.fullmatch(parts[index]):
region = parts[index]
normalized.append(region.upper() if region.isalpha() else region)
index += 1
has_region = True
variants = set()
while index < len(parts) and _VARIANT_RE.fullmatch(parts[index]):
variant = parts[index].lower()
if variant in variants:
raise LanguageTagError(f"duplicate BCP-47 variant: {parts[index]!r}")
variants.add(variant)
normalized.append(variant)
index += 1
singletons = set()
while index < len(parts) and _SINGLETON_RE.fullmatch(parts[index]):
singleton = parts[index].lower()
if singleton in singletons:
raise LanguageTagError(f"duplicate BCP-47 extension: {parts[index]!r}")
singletons.add(singleton)
normalized.append(singleton)
index += 1
start = index
while index < len(parts) and _EXTENSION_RE.fullmatch(parts[index]):
normalized.append(parts[index].lower())
index += 1
if index == start:
raise LanguageTagError(
f"BCP-47 extension {singleton!r} requires a following subtag"
)
if index < len(parts) and parts[index].casefold() == "x":
normalized.append("x")
index += 1
start = index
while index < len(parts) and _PRIVATE_RE.fullmatch(parts[index]):
normalized.append(parts[index].lower())
index += 1
if index == start:
raise LanguageTagError("BCP-47 private use requires a following subtag")
if index != len(parts):
raise LanguageTagError(f"invalid BCP-47 subtag: {parts[index]!r}")
if language == "zh" and not (has_script or has_region):
raise LanguageTagError(
"Chinese must declare a script or region, for example zh-Hans, "
"zh-Hant, zh-CN, or zh-TW"
)
return "-".join(normalized)
def language_base(value: str) -> str:
"""Return the lower-case primary language subtag."""
return normalize_language_tag(value).split("-", 1)[0]
def language_uses_rtl(value: str) -> bool:
"""Return whether the canonical language or explicit script is RTL."""
tag = normalize_language_tag(value)
parts = tag.split("-")
index = 1
if len(parts[0]) <= 3:
while index < len(parts) and _EXTLANG_RE.fullmatch(parts[index]):
index += 1
if index < len(parts) and _SCRIPT_RE.fullmatch(parts[index]):
return parts[index] in _RTL_SCRIPTS
return parts[0] in _RTL_BASE_LANGUAGES
+619
View File
@@ -0,0 +1,619 @@
#!/usr/bin/env python3
"""
PPT Master - LaTeX Formula Renderer
Render project-declared LaTeX formulas to transparent PNG assets.
The script reads an explicit manifest; it never scans spec_lock.md or source
content for dollar-delimited math.
Usage:
python3 scripts/latex_render.py <project_path>
python3 scripts/latex_render.py <project_path> --manifest images/formula_manifest.json
python3 scripts/latex_render.py <project_path> --dry-run
Examples:
python3 scripts/latex_render.py projects/demo_ppt169_20260523
python3 scripts/latex_render.py projects/demo_ppt169_20260523 --providers codecogs,quicklatex,mathpad,wikimedia
Dependencies:
Pillow (for measuring generated PNG dimensions)
Network access to at least one configured rendering provider
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from console_encoding import configure_utf8_stdio
try:
from PIL import Image
except ImportError:
Image = None
configure_utf8_stdio()
DEFAULT_DPI = 300
DEFAULT_TRANSPARENT_TOLERANCE = 12
DEFAULT_MANIFEST = "images/formula_manifest.json"
DEFAULT_PROVIDERS = ["codecogs", "quicklatex", "mathpad", "wikimedia"]
CODECOGS_ENDPOINT = "https://latex.codecogs.com/png.image?"
WIKIMEDIA_CHECK_ENDPOINT = "https://wikimedia.org/api/rest_v1/media/math/check"
WIKIMEDIA_RENDER_ENDPOINT = "https://wikimedia.org/api/rest_v1/media/math/render/png"
QUICKLATEX_ENDPOINT = "https://quicklatex.com/latex3.f"
MATHPAD_ENDPOINT = "https://mathpad.ai/api/v1/latex2image"
VALID_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*\.png$")
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def _project_relative(path: Path, project_path: Path) -> str:
"""Return a POSIX-style path relative to the project."""
return path.relative_to(project_path).as_posix()
def _load_manifest(path: Path) -> dict[str, Any]:
"""Load a JSON formula manifest."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise RuntimeError(f"Cannot read manifest: {path} ({exc})") from exc
except json.JSONDecodeError as exc:
raise RuntimeError(f"Manifest is not valid JSON: {path} ({exc})") from exc
if not isinstance(data, dict):
raise RuntimeError("Manifest root must be a JSON object.")
items = data.get("items")
if not isinstance(items, list):
raise RuntimeError("Manifest must contain an `items` array.")
return data
def _safe_filename(item: dict[str, Any], index: int) -> str:
"""Resolve and validate the output PNG filename for one formula."""
filename = item.get("filename")
if filename is None:
formula_id = str(item.get("id") or f"formula_{index:03d}")
filename = f"{formula_id}.png"
filename = str(filename)
if "/" in filename or "\\" in filename or not VALID_FILENAME_RE.match(filename):
raise RuntimeError(
f"Invalid formula filename `{filename}`. Use a simple PNG filename "
"such as `formula_001.png`."
)
return filename
def _normalize_hex_color(color: str | None, field_name: str) -> str | None:
"""Normalize an optional 6-digit HEX color."""
if not color:
return None
value = color.strip()
if value.startswith("#"):
value = value[1:]
if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
raise RuntimeError(f"Formula {field_name} must be a 6-digit HEX value: {color}")
return value.upper()
def _hex_to_rgb(color: str) -> tuple[int, int, int]:
"""Convert a normalized HEX color to an RGB tuple."""
return (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16))
def _parse_bool(value: Any, default: bool) -> bool:
"""Parse a manifest boolean with a default."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise RuntimeError(f"Expected boolean value, got: {value!r}")
def _normalize_tolerance(value: Any) -> int:
"""Normalize the background removal tolerance."""
if value is None:
return DEFAULT_TRANSPARENT_TOLERANCE
tolerance = int(value)
if tolerance < 0 or tolerance > 255:
raise RuntimeError("transparent_tolerance must be between 0 and 255.")
return tolerance
def _parse_providers(value: str | list[str] | None) -> list[str]:
"""Parse and validate a provider chain."""
if value is None:
providers = DEFAULT_PROVIDERS
elif isinstance(value, list):
providers = value
else:
providers = [part.strip() for part in value.split(",") if part.strip()]
valid = {"codecogs", "quicklatex", "mathpad", "wikimedia"}
unknown = [provider for provider in providers if provider not in valid]
if unknown:
raise RuntimeError(
f"Unknown formula provider(s): {', '.join(unknown)}. "
f"Available: {', '.join(sorted(valid))}"
)
if not providers:
raise RuntimeError("Provider chain must include at least one provider.")
return providers
def _request_bytes(req: urllib.request.Request, timeout: int = 30) -> tuple[bytes, str]:
"""Fetch bytes and return the response content type."""
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read(), resp.headers.get("Content-Type", "")
except (urllib.error.URLError, TimeoutError) as exc:
raise RuntimeError(str(exc)) from exc
def _assert_png(data: bytes, provider: str, content_type: str) -> bytes:
"""Validate PNG response bytes."""
if not data.startswith(PNG_SIGNATURE):
raise RuntimeError(
f"{provider} did not return PNG data (Content-Type: {content_type})"
)
return data
def _build_codecogs_payload(latex: str, dpi: int, color: str | None) -> str:
"""Build a CodeCogs LaTeX payload."""
# CodeCogs documents PNG DPI in the 50-300 range.
safe_dpi = min(max(dpi, 50), 300)
parts = [rf"\dpi{{{safe_dpi}}}"]
if color:
parts.append(rf"\fg{{{color}}}")
parts.append(latex)
return " ".join(parts)
def _render_codecogs(
latex: str,
dpi: int,
color: str | None,
background: str | None,
display: str,
) -> bytes:
"""Render one formula through CodeCogs."""
payload = _build_codecogs_payload(latex, dpi, color)
url = CODECOGS_ENDPOINT + urllib.parse.quote(payload)
req = urllib.request.Request(url, headers={"User-Agent": "PPT-Master/1.0"})
data, content_type = _request_bytes(req)
return _assert_png(data, "codecogs", content_type)
def _render_wikimedia(
latex: str,
dpi: int,
color: str | None,
background: str | None,
display: str,
) -> bytes:
"""Render one formula through Wikimedia Mathoid."""
formula_type = "inline-tex" if display == "inline" else "tex"
payload = urllib.parse.urlencode({"q": latex}).encode("utf-8")
check_req = urllib.request.Request(
f"{WIKIMEDIA_CHECK_ENDPOINT}/{formula_type}",
data=payload,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "PPT-Master/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(check_req, timeout=30) as resp:
resp.read()
resource = resp.headers.get("x-resource-location")
except (urllib.error.URLError, TimeoutError) as exc:
raise RuntimeError(str(exc)) from exc
if not resource:
raise RuntimeError("missing x-resource-location header")
render_req = urllib.request.Request(
f"{WIKIMEDIA_RENDER_ENDPOINT}/{resource}",
headers={"User-Agent": "PPT-Master/1.0"},
)
data, content_type = _request_bytes(render_req)
return _assert_png(data, "wikimedia", content_type)
def _render_quicklatex(
latex: str,
dpi: int,
color: str | None,
background: str | None,
display: str,
) -> bytes:
"""Render one formula through QuickLaTeX."""
wrapped = f"${latex}$" if display == "inline" else f"$${latex}$$"
# QuickLaTeX uses CSS font size rather than DPI. Keep a conservative fixed
# size; downstream placement uses measured dimensions from the PNG.
params = {
"formula": wrapped,
"fsize": "24px",
"fcolor": color or "000000",
"mode": "0",
"out": "1",
"remhost": "quicklatex.com",
}
req = urllib.request.Request(
QUICKLATEX_ENDPOINT,
data=urllib.parse.urlencode(params).encode("utf-8"),
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "PPT-Master/1.0",
},
method="POST",
)
data, _ = _request_bytes(req)
text = data.decode("utf-8", errors="replace").strip()
lines = text.splitlines()
if not lines or lines[0].strip() != "0":
raise RuntimeError(text or "QuickLaTeX returned an empty response")
if len(lines) < 2:
raise RuntimeError(f"QuickLaTeX response missing image URL: {text}")
image_url = lines[1].split()[0]
image_req = urllib.request.Request(
image_url,
headers={"User-Agent": "PPT-Master/1.0"},
)
image_data, content_type = _request_bytes(image_req)
return _assert_png(image_data, "quicklatex", content_type)
def _render_mathpad(
latex: str,
dpi: int,
color: str | None,
background: str | None,
display: str,
) -> bytes:
"""Render one formula through MathPad's public LaTeX image endpoint."""
wrapped = f"${latex}$" if display == "inline" else f"$${latex}$$"
scale = "4" if dpi >= 300 else "2"
params = {
"latex": wrapped,
"format": "png",
"scale": scale,
"color": f"#{color or '000000'}",
"bg": f"#{background or 'FFFFFF'}",
}
url = MATHPAD_ENDPOINT + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": "PPT-Master/1.0"})
data, content_type = _request_bytes(req)
return _assert_png(data, "mathpad", content_type)
PROVIDER_RENDERERS = {
"codecogs": _render_codecogs,
"quicklatex": _render_quicklatex,
"mathpad": _render_mathpad,
"wikimedia": _render_wikimedia,
}
COLOR_AWARE_PROVIDERS = {"codecogs", "quicklatex", "mathpad"}
def _render_with_providers(
latex: str,
output_path: Path,
dpi: int,
color: str | None,
background: str | None,
display: str,
providers: list[str],
) -> tuple[str, list[str]]:
"""Try providers in order and write the first successful PNG."""
errors: list[str] = []
for provider in providers:
try:
data = PROVIDER_RENDERERS[provider](latex, dpi, color, background, display)
output_path.write_bytes(data)
return provider, errors
except RuntimeError as exc:
errors.append(f"{provider}: {exc}")
raise RuntimeError("; ".join(errors))
def _image_dimensions(path: Path) -> tuple[int, int]:
"""Read PNG dimensions."""
if Image is None:
raise RuntimeError(
"Pillow is required to measure formula PNGs. Run: pip install Pillow"
)
with Image.open(path) as img:
return img.size
def _make_png_background_transparent(
path: Path,
*,
color: str | None,
background: str | None,
tolerance: int,
) -> None:
"""Convert the rendered formula's matte background to alpha."""
if Image is None:
raise RuntimeError(
"Pillow is required to post-process transparent formula PNGs. "
"Run: pip install Pillow"
)
bg_rgb = _hex_to_rgb(background or "FFFFFF")
fg_rgb = _hex_to_rgb(color or "000000")
with Image.open(path) as img:
rgba = img.convert("RGBA")
alpha = rgba.getchannel("A")
if alpha.getextrema()[0] < 255:
if color:
pixels = rgba.load()
width, height = rgba.size
for y in range(height):
for x in range(width):
_, _, _, a = pixels[x, y]
if a:
pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], a)
rgba.save(path)
return
fg_bg_distance = max(abs(fg_rgb[i] - bg_rgb[i]) for i in range(3))
pixels = rgba.load()
width, height = rgba.size
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a == 0:
continue
bg_distance = max(
abs(r - bg_rgb[0]),
abs(g - bg_rgb[1]),
abs(b - bg_rgb[2]),
)
if bg_distance <= tolerance:
pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], 0)
continue
if fg_bg_distance > tolerance:
coverage = min(1.0, bg_distance / fg_bg_distance)
new_alpha = max(1, min(255, round(a * coverage)))
else:
new_alpha = a
pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], new_alpha)
rgba.save(path)
def _process_item(
item: dict[str, Any],
index: int,
project_path: Path,
output_dir: Path,
default_dpi: int,
providers: list[str],
dry_run: bool,
) -> dict[str, Any]:
"""Render one manifest item and return its updated record."""
latex = str(item.get("latex") or "").strip()
if not latex:
raise RuntimeError(f"Formula item #{index} is missing `latex`.")
filename = _safe_filename(item, index)
output_path = output_dir / filename
dpi = int(item.get("dpi") or default_dpi)
color = _normalize_hex_color(item.get("color"), "color")
background = _normalize_hex_color(item.get("background"), "background")
transparent = _parse_bool(item.get("transparent"), True)
transparent_tolerance = _normalize_tolerance(item.get("transparent_tolerance"))
display = str(item.get("display") or "block").strip().lower()
if display not in {"inline", "block"}:
raise RuntimeError(f"Formula item #{index} has invalid `display`: {display}")
item_providers = _parse_providers(
item.get("providers") or item.get("provider_chain") or providers
)
updated = dict(item)
updated["filename"] = filename
updated["file"] = _project_relative(output_path, project_path)
updated["dpi"] = dpi
updated["display"] = display
updated["providers"] = item_providers
updated["transparent"] = transparent
if color:
updated["color"] = f"#{color}"
if background:
updated["background"] = f"#{background}"
if transparent:
updated["transparent_tolerance"] = transparent_tolerance
if dry_run:
updated["status"] = item.get("status") or "Pending"
return updated
try:
if not output_path.exists() or item.get("status") != "Rendered":
provider_used, provider_errors = _render_with_providers(
latex,
output_path,
dpi,
color,
background,
display,
item_providers,
)
updated["provider"] = provider_used
if color and provider_used not in COLOR_AWARE_PROVIDERS:
updated["color_warning"] = (
f"Provider `{provider_used}` is an availability fallback and may "
"not preserve the requested formula color."
)
if provider_errors:
updated["provider_errors"] = provider_errors
if transparent:
_make_png_background_transparent(
output_path,
color=color,
background=background,
tolerance=transparent_tolerance,
)
width, height = _image_dimensions(output_path)
updated["pixel_width"] = width
updated["pixel_height"] = height
updated["ratio"] = round(width / height, 4) if height else None
updated["status"] = "Rendered"
updated.pop("error", None)
except RuntimeError as exc:
updated["status"] = "Failed"
updated["error"] = str(exc)
return updated
def render_manifest(
project_path: Path,
manifest_path: Path,
*,
default_dpi: int = DEFAULT_DPI,
providers: list[str] | None = None,
dry_run: bool = False,
) -> int:
"""Render all formulas declared in a manifest."""
manifest = _load_manifest(manifest_path)
output_dir = project_path / "images"
updated_items: list[dict[str, Any]] = []
provider_chain = _parse_providers(providers or manifest.get("providers"))
if not dry_run:
output_dir.mkdir(parents=True, exist_ok=True)
failures = 0
for index, raw_item in enumerate(manifest["items"], 1):
if not isinstance(raw_item, dict):
raise RuntimeError(f"Formula item #{index} must be an object.")
updated = _process_item(
raw_item,
index,
project_path,
output_dir,
default_dpi,
provider_chain,
dry_run,
)
updated_items.append(updated)
status = updated.get("status")
label = updated.get("id") or updated.get("filename")
print(f"{status}: {label} -> {updated.get('file')}", file=sys.stderr)
if status == "Failed":
failures += 1
print(f" {updated.get('error')}", file=sys.stderr)
if dry_run:
print(f"Dry run: {len(updated_items)} formula item(s) parsed.", file=sys.stderr)
return 0
manifest["items"] = updated_items
manifest["renderer"] = {
"providers": provider_chain,
"default_dpi": default_dpi,
"output_dir": _project_relative(output_dir, project_path),
}
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if failures:
print(f"Formula rendering completed with {failures} failure(s).", file=sys.stderr)
return 2
print(f"Formula rendering complete: {len(updated_items)} item(s).", file=sys.stderr)
return 0
def build_parser() -> argparse.ArgumentParser:
"""Build the CLI parser."""
parser = argparse.ArgumentParser(
description="Render project-declared LaTeX formulas to PNG assets.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", type=Path, help="Project directory.")
parser.add_argument(
"--manifest",
type=Path,
default=None,
help=f"Formula manifest path. Default: <project>/{DEFAULT_MANIFEST}",
)
parser.add_argument(
"--dpi",
type=int,
default=DEFAULT_DPI,
help=f"Default render DPI when an item omits `dpi` (default: {DEFAULT_DPI}).",
)
parser.add_argument(
"--providers",
default=None,
help=(
"Comma-separated provider fallback chain "
f"(default: {','.join(DEFAULT_PROVIDERS)})."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate and list formula items without rendering or writing files.",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the CLI entry point."""
parser = build_parser()
args = parser.parse_args(argv)
project_path = args.project_path.resolve()
if not project_path.is_dir():
print(f"Error: project directory not found: {project_path}", file=sys.stderr)
return 1
manifest_path = args.manifest
if manifest_path is None:
manifest_path = project_path / DEFAULT_MANIFEST
elif not manifest_path.is_absolute():
manifest_path = project_path / manifest_path
manifest_path = manifest_path.resolve()
try:
return render_manifest(
project_path,
manifest_path,
default_dpi=args.dpi,
providers=_parse_providers(args.providers) if args.providers else None,
dry_run=args.dry_run,
)
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
PPT Master - Native Enhance PPTX Entrypoint
Public CLI wrapper for native enhancement of existing PPTX decks. It delegates
to the shared native core for delivery checks, notes, narration, timings, and
global or per-slide transitions while keeping the stable workflow command.
Usage:
python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name]
python3 scripts/native_enhance_pptx.py plan <project_path>
python3 scripts/native_enhance_pptx.py validate <project_path> [--materials {all,notes}]
python3 scripts/native_enhance_pptx.py apply <project_path>
Examples:
python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station
python3 scripts/native_enhance_pptx.py plan projects/fire_station_native_enhance_20260626
python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626
Dependencies:
Same as native_enhance_pptx_core.py.
"""
from __future__ import annotations
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 attribution_guard import require_skill_integrity # noqa: E402
from console_encoding import configure_utf8_stdio # noqa: E402
from native_enhance_pptx_core import main # noqa: E402
configure_utf8_stdio()
if __name__ == "__main__":
require_skill_integrity()
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""
PPT Master - Native Narration PPTX Compatibility Entrypoint
Backward-compatible CLI for callers that still use the retired narration
script name. New calls use native_enhance_pptx.py.
Usage:
python3 scripts/native_narration_pptx.py init <source.pptx> [--name project_name]
python3 scripts/native_narration_pptx.py plan <project_path>
python3 scripts/native_narration_pptx.py validate <project_path>
python3 scripts/native_narration_pptx.py apply <project_path>
Examples:
python3 scripts/native_narration_pptx.py validate projects/native_enhance_project
Dependencies:
Same as native_enhance_pptx_core.py.
"""
from __future__ import annotations
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 native_enhance_pptx_core import main # noqa: E402
configure_utf8_stdio()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,655 @@
#!/usr/bin/env python3
"""
PPT Master - Native SVG Metadata Store
Deduplicate opaque PowerPoint-native payloads and repeated restoration
attributes into one deterministic gzip-compressed workspace store, then
hydrate legacy inline metadata on demand.
Usage:
Imported by mirror materialization, SVG validation, and SVG-to-PPTX export.
Examples:
from native_payloads import hydrate_native_payload_refs
Dependencies:
None (standard library only).
"""
from __future__ import annotations
import base64
import binascii
import gzip
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from xml.etree import ElementTree as ET
LEGACY_PAYLOAD_STORE_SCHEMA = "ppt-master.native-payload-store.v1"
PAYLOAD_STORE_SCHEMA = "ppt-master.native-payload-store.v2"
PAYLOAD_STORE_FILENAME = "native_payloads.json.gz"
PAYLOAD_STORE_RELATIVE_PATH = Path("templates") / PAYLOAD_STORE_FILENAME
PAYLOAD_STORE_REFERENCE_PREFIX = (
f"project:{PAYLOAD_STORE_RELATIVE_PATH.as_posix()}#sha256:"
)
NATIVE_RECORD_REF_ATTRIBUTE = "data-pptx-native-ref"
TXBODY_REF_ATTRIBUTE = "data-pptx-ref"
SHAPE_STYLE_ATTRIBUTE = "data-pptx-shape-style"
SHAPE_STYLE_REF_ATTRIBUTE = "data-pptx-shape-style-ref"
CUSTOM_GEOMETRY_ATTRIBUTE = "data-pptx-custgeom"
CUSTOM_GEOMETRY_REF_ATTRIBUTE = "data-pptx-custgeom-ref"
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
_NATIVE_RECORD_ID_RE = re.compile(r"r(?:0|[1-9][0-9]*)")
_NATIVE_RECORD_ATTRIBUTES = frozenset({
"data-pptx-custgeom-ref",
"data-pptx-frame",
"data-pptx-geometry-kind",
"data-pptx-geometry-sha256",
"data-pptx-object",
"data-pptx-part",
"data-pptx-preview-sha256",
"data-pptx-prst",
"data-pptx-ref",
"data-pptx-shape-id",
"data-pptx-shape-name",
"data-pptx-shape-scope",
"data-pptx-shape-style-ref",
"data-pptx-text-sha256",
})
_NATIVE_RECORD_PREFIXES = (
"data-pptx-av-",
"data-pptx-end-",
"data-pptx-start-",
)
class NativePayloadError(ValueError):
"""Reject malformed, missing, or contradictory native metadata transport."""
@dataclass
class NativePayloadStats:
"""Count externalized native metadata and its original inline bytes."""
txbody_count: int = 0
shape_style_count: int = 0
custom_geometry_count: int = 0
inline_bytes: int = 0
native_record_count: int = 0
native_attribute_bytes: int = 0
def merge(self, other: "NativePayloadStats") -> None:
self.txbody_count += other.txbody_count
self.shape_style_count += other.shape_style_count
self.custom_geometry_count += other.custom_geometry_count
self.inline_bytes += other.inline_bytes
self.native_record_count += other.native_record_count
self.native_attribute_bytes += other.native_attribute_bytes
def as_dict(self) -> dict[str, int]:
return {
"txbody_count": self.txbody_count,
"shape_style_count": self.shape_style_count,
"custom_geometry_count": self.custom_geometry_count,
"inline_bytes": self.inline_bytes,
"native_record_count": self.native_record_count,
"native_attribute_bytes": self.native_attribute_bytes,
}
@dataclass(frozen=True)
class NativePayloadStore:
"""Validated payload and native-attribute records loaded from one store."""
payloads: dict[str, bytes]
native_records: dict[str, dict[str, str]]
_STORE_CACHE: dict[Path, tuple[int, int, NativePayloadStore]] = {}
def _local_name(name: object) -> str:
return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
def _sha256(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _decode_base64(value: str, *, context: str) -> bytes:
try:
return base64.b64decode(value.strip(), validate=True)
except (ValueError, binascii.Error) as exc:
raise NativePayloadError(f"{context} is not valid base64: {exc}") from exc
def _register_payload(payloads: dict[str, bytes], raw: bytes) -> str:
digest = _sha256(raw)
existing = payloads.get(digest)
if existing is not None and existing != raw:
raise NativePayloadError(
f"SHA-256 collision while registering native payload {digest}"
)
payloads[digest] = raw
return PAYLOAD_STORE_REFERENCE_PREFIX + digest
def _is_native_record_attribute(name: str) -> bool:
return (
name in _NATIVE_RECORD_ATTRIBUTES
or name.startswith(_NATIVE_RECORD_PREFIXES)
)
def _native_record_attributes(element: ET.Element) -> dict[str, str]:
return {
name: value
for name, value in element.attrib.items()
if _is_native_record_attribute(name)
}
def _native_record_key(attributes: dict[str, str]) -> str:
return json.dumps(
attributes,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
)
def collect_native_attribute_record_keys(root: ET.Element) -> set[str]:
"""Return canonical record keys for compressible native attributes."""
keys: set[str] = set()
for element in root.iter():
if NATIVE_RECORD_REF_ATTRIBUTE in element.attrib:
raise NativePayloadError(
"native metadata is already externalized; source attributes "
"must be hydrated before building a new store"
)
attributes = _native_record_attributes(element)
if attributes:
keys.add(_native_record_key(attributes))
return keys
def build_native_attribute_records(
record_keys: set[str],
) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
"""Assign deterministic short ids to canonical native-attribute records."""
ids_by_key: dict[str, str] = {}
records: dict[str, dict[str, str]] = {}
for index, key in enumerate(sorted(record_keys)):
try:
attributes = json.loads(key)
except json.JSONDecodeError as exc:
raise NativePayloadError(
f"Cannot decode canonical native-attribute record: {exc}"
) from exc
if not isinstance(attributes, dict) or not attributes:
raise NativePayloadError(
"Canonical native-attribute record must be a non-empty object"
)
if any(
not isinstance(name, str)
or not isinstance(value, str)
or not _is_native_record_attribute(name)
for name, value in attributes.items()
):
raise NativePayloadError(
"Canonical native-attribute record contains an unsupported field"
)
record_id = f"r{index}"
ids_by_key[key] = record_id
records[record_id] = attributes
return ids_by_key, records
def externalize_native_attribute_records(
root: ET.Element,
ids_by_key: dict[str, str],
) -> NativePayloadStats:
"""Replace supported native-attribute groups with deterministic short ids."""
stats = NativePayloadStats()
for element in root.iter():
if NATIVE_RECORD_REF_ATTRIBUTE in element.attrib:
raise NativePayloadError(
"native metadata is already externalized; source attributes "
"must be hydrated before building a new store"
)
attributes = _native_record_attributes(element)
if not attributes:
continue
record_id = ids_by_key.get(_native_record_key(attributes))
if record_id is None:
raise NativePayloadError(
"Native-attribute record was not registered before externalization"
)
for name in attributes:
element.attrib.pop(name)
element.set(NATIVE_RECORD_REF_ATTRIBUTE, record_id)
stats.native_record_count += 1
stats.native_attribute_bytes += sum(
len(name) + len(value) + 4
for name, value in attributes.items()
)
return stats
def externalize_native_payloads(
root: ET.Element,
payloads: dict[str, bytes],
) -> NativePayloadStats:
"""Move supported large inline native payloads into ``payloads``.
Repeated restoration attributes are handled separately by
``externalize_native_attribute_records``. The supported opaque payload
classes are:
- ``metadata[data-pptx-part="txbody"]``
- ``data-pptx-shape-style``
- ``data-pptx-custgeom``
"""
stats = NativePayloadStats()
for element in root.iter():
if (
_local_name(element.tag) == "metadata"
and element.get("data-pptx-part") == "txbody"
):
has_reference = TXBODY_REF_ATTRIBUTE in element.attrib
reference = element.get(TXBODY_REF_ATTRIBUTE) or ""
encoded = (element.text or "").strip()
encoding = element.get("data-pptx-encoding")
if has_reference:
if not reference:
raise NativePayloadError(
"txbody metadata has an empty payload reference"
)
raise NativePayloadError(
"txbody metadata is already externalized; source payload "
"must be hydrated before building a new store"
)
elif encoded:
if encoding != "base64":
raise NativePayloadError(
"txbody metadata must use base64 before externalization"
)
raw = _decode_base64(encoded, context="txbody metadata")
element.text = None
element.attrib.pop("data-pptx-encoding", None)
element.set(TXBODY_REF_ATTRIBUTE, _register_payload(payloads, raw))
stats.txbody_count += 1
stats.inline_bytes += len(encoded)
else:
raise NativePayloadError(
"txbody metadata requires inline base64 data or a payload reference"
)
has_shape_reference = SHAPE_STYLE_REF_ATTRIBUTE in element.attrib
shape_reference = element.get(SHAPE_STYLE_REF_ATTRIBUTE) or ""
shape_encoded = element.get(SHAPE_STYLE_ATTRIBUTE)
if has_shape_reference and not shape_reference:
raise NativePayloadError("shape-style metadata has an empty payload reference")
if has_shape_reference:
raise NativePayloadError(
"shape-style metadata is already externalized; source payload "
"must be hydrated before building a new store"
)
if shape_encoded:
raw = _decode_base64(shape_encoded, context="shape-style metadata")
element.attrib.pop(SHAPE_STYLE_ATTRIBUTE, None)
element.set(
SHAPE_STYLE_REF_ATTRIBUTE,
_register_payload(payloads, raw),
)
stats.shape_style_count += 1
stats.inline_bytes += len(shape_encoded)
has_geometry_reference = CUSTOM_GEOMETRY_REF_ATTRIBUTE in element.attrib
geometry_reference = element.get(CUSTOM_GEOMETRY_REF_ATTRIBUTE) or ""
geometry_encoded = element.get(CUSTOM_GEOMETRY_ATTRIBUTE)
if has_geometry_reference and not geometry_reference:
raise NativePayloadError(
"custom-geometry metadata has an empty payload reference"
)
if has_geometry_reference:
raise NativePayloadError(
"custom-geometry metadata is already externalized; source payload "
"must be hydrated before building a new store"
)
if geometry_encoded:
raw = _decode_base64(geometry_encoded, context="custom-geometry metadata")
element.attrib.pop(CUSTOM_GEOMETRY_ATTRIBUTE, None)
element.set(
CUSTOM_GEOMETRY_REF_ATTRIBUTE,
_register_payload(payloads, raw),
)
stats.custom_geometry_count += 1
stats.inline_bytes += len(geometry_encoded)
return stats
def serialize_native_payload_store(
payloads: dict[str, bytes],
native_records: dict[str, dict[str, str]] | None = None,
) -> bytes:
"""Return one deterministic gzip-compressed native metadata store."""
encoded_payloads: dict[str, str] = {}
for digest, raw in sorted(payloads.items()):
if _SHA256_RE.fullmatch(digest) is None or _sha256(raw) != digest:
raise NativePayloadError(
f"Native payload store key does not match its content: {digest!r}"
)
encoded_payloads[digest] = base64.b64encode(raw).decode("ascii")
encoded_records: dict[str, dict[str, str]] = {}
for record_id, attributes in sorted((native_records or {}).items()):
if _NATIVE_RECORD_ID_RE.fullmatch(record_id) is None:
raise NativePayloadError(
f"Native metadata store contains an invalid record id: {record_id!r}"
)
if not isinstance(attributes, dict) or not attributes:
raise NativePayloadError(
f"Native metadata record {record_id} must be a non-empty object"
)
if any(
not isinstance(name, str)
or not isinstance(value, str)
or not _is_native_record_attribute(name)
for name, value in attributes.items()
):
raise NativePayloadError(
f"Native metadata record {record_id} contains an unsupported field"
)
encoded_records[record_id] = dict(sorted(attributes.items()))
document = {
"schema": PAYLOAD_STORE_SCHEMA,
"hash": "sha256",
"payloads": encoded_payloads,
"native_records": encoded_records,
}
raw_json = json.dumps(
document,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return gzip.compress(raw_json, compresslevel=9, mtime=0)
def _parse_reference(value: str) -> tuple[PurePosixPath, str]:
prefix = "project:"
marker = "#sha256:"
if not value.startswith(prefix) or marker not in value:
raise NativePayloadError(
f"Unsupported native payload reference: {value!r}"
)
path_text, digest = value[len(prefix):].split(marker, 1)
relative = PurePosixPath(path_text)
if (
not path_text
or relative.is_absolute()
or any(part in {"", ".", ".."} for part in relative.parts)
):
raise NativePayloadError(
f"Native payload reference must use a safe project-relative path: {value!r}"
)
if _SHA256_RE.fullmatch(digest) is None:
raise NativePayloadError(
f"Native payload reference has an invalid SHA-256 digest: {value!r}"
)
return relative, digest
def _resolve_store_path(svg_path: Path, relative: PurePosixPath) -> Path:
start = Path(svg_path).expanduser().resolve().parent
relative_path = Path(*relative.parts)
for base in (start, *start.parents):
candidate = base / relative_path
if candidate.is_file():
return candidate.resolve()
raise NativePayloadError(
f"Native payload store not found for {svg_path}: {relative.as_posix()}"
)
def _load_store(path: Path) -> NativePayloadStore:
resolved = path.resolve()
try:
stat = resolved.stat()
except OSError as exc:
raise NativePayloadError(
f"Cannot inspect native payload store {resolved}: {exc}"
) from exc
cached = _STORE_CACHE.get(resolved)
if cached and cached[0] == stat.st_mtime_ns and cached[1] == stat.st_size:
return cached[2]
try:
document = json.loads(gzip.decompress(resolved.read_bytes()).decode("utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise NativePayloadError(
f"Cannot decode native payload store {resolved}: {exc}"
) from exc
if not isinstance(document, dict) or document.get("schema") not in {
LEGACY_PAYLOAD_STORE_SCHEMA,
PAYLOAD_STORE_SCHEMA,
}:
raise NativePayloadError(
f"Unsupported native payload store schema in {resolved}"
)
if document.get("hash") != "sha256":
raise NativePayloadError(
f"Unsupported native payload store hash algorithm in {resolved}"
)
encoded_payloads = document.get("payloads")
if not isinstance(encoded_payloads, dict):
raise NativePayloadError(
f"Native payload store must contain a payload object: {resolved}"
)
payloads: dict[str, bytes] = {}
for digest, encoded in encoded_payloads.items():
if not isinstance(digest, str) or _SHA256_RE.fullmatch(digest) is None:
raise NativePayloadError(
f"Native payload store contains an invalid digest key: {digest!r}"
)
if not isinstance(encoded, str):
raise NativePayloadError(
f"Native payload {digest} must be a base64 string"
)
raw = _decode_base64(
encoded,
context=f"native payload {digest} in {resolved}",
)
if _sha256(raw) != digest:
raise NativePayloadError(
f"Native payload {digest} failed content-hash validation"
)
payloads[digest] = raw
schema = document.get("schema")
if schema == PAYLOAD_STORE_SCHEMA and "native_records" not in document:
raise NativePayloadError(
f"Native payload store is missing native_records: {resolved}"
)
native_records_value = document.get("native_records", {})
if schema == PAYLOAD_STORE_SCHEMA and not isinstance(native_records_value, dict):
raise NativePayloadError(
f"Native payload store must contain a native_records object: {resolved}"
)
if schema == LEGACY_PAYLOAD_STORE_SCHEMA:
native_records_value = {}
native_records: dict[str, dict[str, str]] = {}
for record_id, attributes in native_records_value.items():
if (
not isinstance(record_id, str)
or _NATIVE_RECORD_ID_RE.fullmatch(record_id) is None
):
raise NativePayloadError(
f"Native payload store contains an invalid record id: {record_id!r}"
)
if not isinstance(attributes, dict) or not attributes:
raise NativePayloadError(
f"Native metadata record {record_id} must be a non-empty object"
)
validated: dict[str, str] = {}
for name, value in attributes.items():
if (
not isinstance(name, str)
or not isinstance(value, str)
or not _is_native_record_attribute(name)
):
raise NativePayloadError(
f"Native metadata record {record_id} contains an unsupported field"
)
validated[name] = value
native_records[record_id] = validated
store = NativePayloadStore(
payloads=payloads,
native_records=native_records,
)
_STORE_CACHE[resolved] = (stat.st_mtime_ns, stat.st_size, store)
return store
def _payload_for_reference(value: str, svg_path: Path) -> bytes:
relative, digest = _parse_reference(value)
store_path = _resolve_store_path(svg_path, relative)
payload = _load_store(store_path).payloads.get(digest)
if payload is None:
raise NativePayloadError(
f"Native payload {digest} is missing from {store_path}"
)
return payload
def _native_record_for_reference(
value: str,
svg_path: Path,
) -> dict[str, str]:
if _NATIVE_RECORD_ID_RE.fullmatch(value) is None:
raise NativePayloadError(
f"Native metadata reference has an invalid record id: {value!r}"
)
store_path = _resolve_store_path(
svg_path,
PurePosixPath(PAYLOAD_STORE_RELATIVE_PATH.as_posix()),
)
record = _load_store(store_path).native_records.get(value)
if record is None:
raise NativePayloadError(
f"Native metadata record {value} is missing from {store_path}"
)
return record
def hydrate_native_payload_refs(root: ET.Element, svg_path: Path) -> int:
"""Restore compact native records and payloads as legacy inline metadata.
The operation preflights every reference before mutating the tree, so an
invalid store leaves the caller's parsed SVG unchanged.
"""
record_operations: list[tuple[ET.Element, dict[str, str]]] = []
payload_operations: list[tuple[str, ET.Element, bytes]] = []
for element in root.iter():
effective_attributes = dict(element.attrib)
has_native_record = NATIVE_RECORD_REF_ATTRIBUTE in element.attrib
native_record_ref = element.get(NATIVE_RECORD_REF_ATTRIBUTE) or ""
if has_native_record:
if not native_record_ref:
raise NativePayloadError(
"native metadata has an empty record reference"
)
record = _native_record_for_reference(native_record_ref, svg_path)
conflicts = sorted(set(record) & set(element.attrib))
if conflicts:
raise NativePayloadError(
"native metadata cannot carry both inline fields and a record: "
+ ", ".join(conflicts)
)
effective_attributes.update(record)
record_operations.append((element, record))
if (
_local_name(element.tag) == "metadata"
and effective_attributes.get("data-pptx-part") == "txbody"
):
has_reference = TXBODY_REF_ATTRIBUTE in effective_attributes
reference = effective_attributes.get(TXBODY_REF_ATTRIBUTE) or ""
if has_reference:
if not reference:
raise NativePayloadError(
"txbody metadata has an empty payload reference"
)
if (element.text or "").strip() or element.get("data-pptx-encoding"):
raise NativePayloadError(
"txbody metadata cannot carry both inline data and a reference"
)
payload_operations.append(
("txbody", element, _payload_for_reference(reference, svg_path))
)
has_shape_reference = SHAPE_STYLE_REF_ATTRIBUTE in effective_attributes
shape_reference = effective_attributes.get(SHAPE_STYLE_REF_ATTRIBUTE) or ""
if has_shape_reference:
if not shape_reference:
raise NativePayloadError(
"shape-style metadata has an empty payload reference"
)
if effective_attributes.get(SHAPE_STYLE_ATTRIBUTE):
raise NativePayloadError(
"shape-style metadata cannot carry both inline data and a reference"
)
payload_operations.append(
(
"shape-style",
element,
_payload_for_reference(shape_reference, svg_path),
)
)
has_geometry_reference = CUSTOM_GEOMETRY_REF_ATTRIBUTE in effective_attributes
geometry_reference = (
effective_attributes.get(CUSTOM_GEOMETRY_REF_ATTRIBUTE) or ""
)
if has_geometry_reference:
if not geometry_reference:
raise NativePayloadError(
"custom-geometry metadata has an empty payload reference"
)
if effective_attributes.get(CUSTOM_GEOMETRY_ATTRIBUTE):
raise NativePayloadError(
"custom-geometry metadata cannot carry both inline data and a reference"
)
payload_operations.append(
(
"custom-geometry",
element,
_payload_for_reference(geometry_reference, svg_path),
)
)
for element, record in record_operations:
element.attrib.pop(NATIVE_RECORD_REF_ATTRIBUTE)
element.attrib.update(record)
for kind, element, raw in payload_operations:
encoded = base64.b64encode(raw).decode("ascii")
if kind == "txbody":
element.text = encoded
element.set("data-pptx-encoding", "base64")
element.attrib.pop(TXBODY_REF_ATTRIBUTE, None)
elif kind == "shape-style":
element.set(SHAPE_STYLE_ATTRIBUTE, encoded)
element.attrib.pop(SHAPE_STYLE_REF_ATTRIBUTE, None)
else:
element.set(CUSTOM_GEOMETRY_ATTRIBUTE, encoded)
element.attrib.pop(CUSTOM_GEOMETRY_REF_ATTRIBUTE, None)
return len(record_operations) + len(payload_operations)
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""Generate per-slide narration audio from PPT Master notes.
This script uses provider backends for the same per-slide output contract on
macOS, Linux, and Windows. `edge-tts` remains the default no-key backend and
also writes one compact, word-timed SRT file per slide from the same TTS stream.
MiniMax and CosyVoice request word timings; ElevenLabs requests character
alignment. All four apply the same compact, text-faithful cue regrouping.
Qwen remains audio-only because its current TTS API exposes no timestamps.
Usage:
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider elevenlabs --voice-id <voice_id>
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider minimax --voice-id <voice_id>
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider qwen --voice-id <voice>
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider cosyvoice --voice-id <voice>
python3 skills/ppt-master/scripts/notes_to_audio.py --list-common-voices
python3 skills/ppt-master/scripts/notes_to_audio.py --list-voices --locale zh-CN
Dependencies:
python3 -m pip install edge-tts
ELEVENLABS_API_KEY=<key> for --provider elevenlabs
MINIMAX_API_KEY=<key> for --provider minimax
QWEN_API_KEY or DASHSCOPE_API_KEY=<key> for --provider qwen
COSYVOICE_API_KEY or DASHSCOPE_API_KEY=<key> for --provider cosyvoice
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
import os
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from console_encoding import configure_utf8_stdio
from config import load_prefixed_env_file
from slide_roster import discover_slide_svgs
from tts_backends import (
backend_cosyvoice,
backend_edge,
backend_elevenlabs,
backend_minimax,
backend_qwen,
)
from tts_backends.backend_common import temporary_path
configure_utf8_stdio()
DEFAULT_EDGE_CONCURRENCY = 3
SUPPORTED_AUDIO_EXTENSIONS = frozenset({".m4a", ".mp3", ".wav"})
@dataclass(frozen=True)
class AudioBackend:
provider: str
extension: str
api_key: str = ""
voice_id: str = ""
@dataclass(frozen=True)
class NoteRosterEntry:
note_path: Path
output_stem: str
@dataclass(frozen=True)
class AudioJob:
note_path: Path
text: str
output_path: Path
def _load_tts_env_file() -> None:
"""Load TTS-related keys from the first .env file, without overriding shell env."""
load_prefixed_env_file((
"ELEVENLABS_",
"MINIMAX_",
"QWEN_",
"DASHSCOPE_",
"COSYVOICE_",
))
def spoken_text(markdown: str) -> str:
"""Return narration text exactly from notes, except Markdown headings."""
lines: list[str] = []
for raw in markdown.splitlines():
if raw.lstrip().startswith("#"):
continue
line = raw.rstrip()
if not line.strip():
if lines and lines[-1] != "":
lines.append("")
continue
lines.append(line)
return "\n".join(lines).strip()
def _prepare_audio_jobs(
note_roster: list[NoteRosterEntry],
output_dir: Path,
extension: str,
) -> list[AudioJob]:
"""Read a complete per-slide notes roster into ordered audio jobs."""
jobs: list[AudioJob] = []
invalid: list[str] = []
for entry in note_roster:
note_path = entry.note_path
if not note_path.is_file():
invalid.append(f"{note_path.name} is missing")
continue
try:
text = spoken_text(note_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError) as exc:
invalid.append(f"{note_path.name} is unreadable: {exc}")
continue
if not text:
invalid.append(f"{note_path.name} has no spoken text")
continue
jobs.append(AudioJob(
note_path=note_path,
text=text,
output_path=output_dir / f"{entry.output_stem}{extension}",
))
if invalid:
raise ValueError(
"per-slide notes are incomplete: " + "; ".join(invalid)
)
return jobs
def _expected_note_roster(project: Path) -> list[NoteRosterEntry]:
"""Resolve the owning route's complete per-slide notes roster."""
notes_dir = project / "notes"
svg_files = discover_slide_svgs(project / "svg_output")
if svg_files:
aliases: dict[int, list[Path]] = {}
for path in sorted(notes_dir.glob("*.md")):
match = re.search(r"slide[_]?(\d+)", path.stem)
if match:
aliases.setdefault(int(match.group(1)), []).append(path)
note_roster: list[NoteRosterEntry] = []
for index, svg_path in enumerate(svg_files, 1):
exact = notes_dir / f"{svg_path.stem}.md"
if exact.exists():
note_roster.append(NoteRosterEntry(
note_path=exact,
output_stem=svg_path.stem,
))
continue
matches = aliases.get(index, [])
if len(matches) > 1:
raise ValueError(
f"multiple notes files match slide {index}: "
+ ", ".join(path.name for path in matches)
)
note_roster.append(
NoteRosterEntry(
note_path=matches[0] if matches else exact,
output_stem=svg_path.stem,
)
)
return note_roster
slide_index_path = project / "analysis" / "slide_index.json"
if slide_index_path.is_file():
try:
slide_index = json.loads(
slide_index_path.read_text(encoding="utf-8")
)
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid slide index: {exc}") from exc
if not isinstance(slide_index, dict):
raise ValueError("invalid slide index root")
slides = slide_index.get("slides")
slide_count = slide_index.get("slide_count")
if (
not isinstance(slides, list)
or isinstance(slide_count, bool)
or not isinstance(slide_count, int)
or slide_count != len(slides)
):
raise ValueError("invalid slide index notes roster")
note_roster: list[NoteRosterEntry] = []
for index, slide in enumerate(slides, 1):
note_file = slide.get("note_file") if isinstance(slide, dict) else None
if not isinstance(note_file, str) or Path(note_file).suffix != ".md":
raise ValueError(
f"invalid slide index note_file for slide {index}"
)
note_name = Path(note_file).name
note_roster.append(
NoteRosterEntry(
note_path=notes_dir / note_name,
output_stem=Path(note_name).stem,
)
)
return note_roster
return [
NoteRosterEntry(
note_path=path,
output_stem=path.stem,
)
for path in sorted(notes_dir.glob("*.md"))
if path.name != "total.md"
]
def _remove_stale_audio_variants(output_path: Path) -> None:
"""Remove other supported formats only after the target audio is published."""
for candidate in output_path.parent.iterdir():
if (
candidate.name != output_path.name
and candidate.is_file()
and candidate.stem == output_path.stem
and candidate.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS
):
candidate.unlink()
def _sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _provider_manifest_details(
args: argparse.Namespace,
backend: AudioBackend,
) -> tuple[str, dict[str, object]]:
if backend.provider == "edge":
return "edge-tts", {
"rate": args.rate,
}
if backend.provider == "elevenlabs":
return args.elevenlabs_model, {
"stability": args.elevenlabs_stability,
"similarity_boost": args.elevenlabs_similarity_boost,
"style": args.elevenlabs_style,
"speed": args.elevenlabs_speed,
"speaker_boost": args.elevenlabs_speaker_boost,
}
if backend.provider == "minimax":
return args.minimax_model, {
"speed": args.minimax_speed,
"volume": args.minimax_volume,
"pitch": args.minimax_pitch,
"language_boost": args.minimax_language_boost,
}
if backend.provider == "qwen":
return args.qwen_model, {
"language_type": args.qwen_language_type,
"optimize_instructions": args.qwen_optimize_instructions,
"custom_instructions": True if args.qwen_instructions else None,
}
return args.cosyvoice_model, {
"volume": args.cosyvoice_volume,
"rate": args.cosyvoice_rate,
"pitch": args.cosyvoice_pitch,
"language_hint": args.cosyvoice_language_hint,
"custom_instruction": True if args.cosyvoice_instruction else None,
}
def _narration_manifest(
args: argparse.Namespace,
backend: AudioBackend,
*,
writes_subtitles: bool,
) -> dict[str, object]:
model, raw_settings = _provider_manifest_details(args, backend)
settings = {
key: value
for key, value in raw_settings.items()
if value is not None
}
voice_ref = backend.voice_id
if backend.provider != "edge":
voice_ref = f"sha256:{_sha256_text(voice_ref)}"
manifest: dict[str, object] = {
"schema": "ppt-master.narration.v1",
"generated_at": datetime.now(timezone.utc).isoformat(
timespec="seconds"
).replace("+00:00", "Z"),
"provider": backend.provider,
"model": model,
"voice_ref": voice_ref,
"audio_format": backend.extension.lstrip("."),
}
if settings:
manifest["settings"] = settings
if writes_subtitles:
manifest["subtitles"] = {
"format": "srt",
"timing": (
"character" if backend.provider == "elevenlabs" else "word"
),
"max_visible_chars": args.subtitle_max_chars,
}
return manifest
def _publish_manifest(path: Path, manifest: dict[str, object]) -> None:
descriptor, staged_path = temporary_path(path, ".tmp")
try:
with os.fdopen(
descriptor,
"w",
encoding="utf-8",
newline="\n",
) as stream:
descriptor = -1
json.dump(manifest, stream, ensure_ascii=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(staged_path, path)
finally:
if descriptor >= 0:
os.close(descriptor)
staged_path.unlink(missing_ok=True)
async def _generate_edge_jobs(
jobs: list[AudioJob],
subtitle_dir: Path,
*,
voice: str,
rate: str,
subtitle_max_chars: int,
concurrency: int,
) -> list[BaseException | None]:
"""Generate ordered Edge jobs with bounded slide-level concurrency."""
semaphore = asyncio.Semaphore(concurrency)
async def generate_job(job: AudioJob) -> None:
async with semaphore:
await backend_edge.generate(
job.text,
job.output_path,
voice=voice,
rate=rate,
subtitle_path=subtitle_dir / f"{job.output_path.stem}.srt",
subtitle_max_chars=subtitle_max_chars,
)
raw_results = await asyncio.gather(
*(generate_job(job) for job in jobs),
return_exceptions=True,
)
return [
result if isinstance(result, BaseException) else None
for result in raw_results
]
def main() -> int:
_load_tts_env_file()
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", type=Path, nargs="?")
parser.add_argument("-o", "--output", type=Path, default=None)
parser.add_argument(
"--provider",
choices=["edge", "elevenlabs", "minimax", "qwen", "cosyvoice"],
default="edge",
help="audio generation backend (default: edge)",
)
parser.add_argument(
"--voice",
default=None,
help="edge-tts voice ShortName. For elevenlabs, --voice-id is preferred.",
)
parser.add_argument(
"--voice-id",
default=None,
help="provider voice ID/name. If omitted for cloud providers, --voice is used as a fallback.",
)
parser.add_argument(
"--rate",
default="+0%",
help='edge-tts speaking rate, e.g. "+0%%", "-10%%", "+15%%" (default: +0%%). Ignored by cloud providers.',
)
parser.add_argument(
"--concurrency",
type=int,
default=DEFAULT_EDGE_CONCURRENCY,
help="maximum concurrent Edge slide requests (default: 3; ignored by cloud providers)",
)
parser.add_argument(
"--subtitle-max-chars",
type=int,
default=backend_edge.DEFAULT_SUBTITLE_MAX_CHARS,
help="maximum visible characters per provider-timed subtitle cue (default: 20)",
)
parser.add_argument(
"--elevenlabs-api-key-env",
default="ELEVENLABS_API_KEY",
help="environment variable containing the ElevenLabs API key (default: ELEVENLABS_API_KEY)",
)
parser.add_argument(
"--elevenlabs-model",
default="eleven_multilingual_v2",
help="ElevenLabs TTS model ID (default: eleven_multilingual_v2)",
)
parser.add_argument(
"--elevenlabs-output-format",
default="mp3_44100_128",
help="ElevenLabs output format (default: mp3_44100_128)",
)
parser.add_argument(
"--elevenlabs-stability",
type=float,
default=None,
help="optional ElevenLabs voice stability override, 0.0-1.0",
)
parser.add_argument(
"--elevenlabs-similarity-boost",
type=float,
default=None,
help="optional ElevenLabs similarity boost override, 0.0-1.0",
)
parser.add_argument(
"--elevenlabs-style",
type=float,
default=None,
help="optional ElevenLabs style exaggeration override, 0.0-1.0",
)
parser.add_argument(
"--elevenlabs-speed",
type=float,
default=None,
help="optional ElevenLabs speaking speed override, 0.7-1.2",
)
parser.add_argument(
"--elevenlabs-speaker-boost",
action=argparse.BooleanOptionalAction,
default=None,
help="optionally override ElevenLabs speaker boost",
)
parser.add_argument("--minimax-api-key-env", default="MINIMAX_API_KEY",
help="environment variable containing the MiniMax API key")
parser.add_argument("--minimax-model", default="speech-2.8-hd",
help="MiniMax T2A model ID (default: speech-2.8-hd)")
parser.add_argument("--minimax-base-url", default=None,
help="MiniMax T2A endpoint or base URL")
parser.add_argument("--minimax-output-format", default="mp3", choices=["mp3", "wav"],
help="MiniMax audio format for PPT narration (default: mp3)")
parser.add_argument("--minimax-sample-rate", type=int, default=32000,
help="MiniMax sample rate (default: 32000)")
parser.add_argument("--minimax-bitrate", type=int, default=128000,
help="MiniMax bitrate in bps (default: 128000)")
parser.add_argument("--minimax-channel", type=int, default=1,
help="MiniMax channel count (default: 1)")
parser.add_argument("--minimax-speed", type=float, default=1.0,
help="MiniMax speaking speed (default: 1.0)")
parser.add_argument("--minimax-volume", type=float, default=1.0,
help="MiniMax volume multiplier (default: 1.0)")
parser.add_argument("--minimax-pitch", type=int, default=0,
help="MiniMax pitch adjustment (default: 0)")
parser.add_argument("--minimax-language-boost", default="auto",
help="MiniMax language boost (default: auto)")
parser.add_argument("--qwen-api-key-env", default=None,
help="environment variable containing the Qwen/DashScope API key")
parser.add_argument("--qwen-model", default="qwen3-tts-flash",
help="Qwen TTS model ID (default: qwen3-tts-flash)")
parser.add_argument("--qwen-base-url", default=None,
help="Qwen TTS endpoint or base URL")
parser.add_argument("--qwen-language-type", default="Chinese",
help="Qwen language_type, e.g. Chinese or English (default: Chinese)")
parser.add_argument("--qwen-instructions", default=None,
help="optional Qwen instruction text for supported models")
parser.add_argument("--qwen-optimize-instructions", action=argparse.BooleanOptionalAction,
default=None, help="optionally ask Qwen to optimize instructions")
parser.add_argument("--cosyvoice-api-key-env", default="COSYVOICE_API_KEY",
help="environment variable containing the CosyVoice/DashScope API key")
parser.add_argument("--cosyvoice-model", default="cosyvoice-v3-flash",
help="CosyVoice model ID (default: cosyvoice-v3-flash)")
parser.add_argument("--cosyvoice-base-url", default=None,
help="CosyVoice SpeechSynthesizer endpoint or base URL")
parser.add_argument("--cosyvoice-output-format", default="mp3", choices=["mp3", "wav"],
help="CosyVoice audio format for PPT narration (default: mp3)")
parser.add_argument("--cosyvoice-sample-rate", type=int, default=24000,
choices=[8000, 16000, 22050, 24000, 44100, 48000],
help="CosyVoice sample rate (default: 24000)")
parser.add_argument("--cosyvoice-volume", type=int, default=None,
help="optional CosyVoice volume, 0-100")
parser.add_argument("--cosyvoice-rate", type=float, default=None,
help="optional CosyVoice speaking rate, 0.5-2.0")
parser.add_argument("--cosyvoice-pitch", type=float, default=None,
help="optional CosyVoice pitch multiplier, 0.5-2.0")
parser.add_argument("--cosyvoice-instruction", default=None,
help="optional CosyVoice instruction text for supported voices/models")
parser.add_argument("--cosyvoice-language-hint", default=None,
help="optional CosyVoice language hint, e.g. zh, en, ja")
parser.add_argument(
"--cosyvoice-audio-only",
action="store_true",
help=(
"skip CosyVoice timestamps/SRT for a model or voice that does not "
"support them"
),
)
parser.add_argument("--list-common-voices", action="store_true", help="print a curated voice list and exit")
parser.add_argument("--list-voices", action="store_true", help="query provider voices and exit")
parser.add_argument("--locale", default=None, help='filter --list-voices by locale, e.g. "zh-CN"')
args = parser.parse_args()
if args.list_common_voices:
backend_edge.print_common_voices()
return 0
if args.list_voices:
try:
if args.provider == "elevenlabs":
backend_elevenlabs.print_voices(
backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
)
elif args.provider == "minimax":
backend_minimax.print_voices()
elif args.provider == "qwen":
backend_qwen.print_voices()
elif args.provider == "cosyvoice":
backend_cosyvoice.print_voices()
else:
asyncio.run(backend_edge.print_voices(args.locale))
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if args.project_path is None:
parser.error("project_path is required unless --list-voices or --list-common-voices is used")
voice_id = args.voice_id or args.voice
if args.provider == "edge" and not args.voice:
parser.error(
"--voice is required for --provider edge. Run --list-voices --locale <locale> to discover voices "
"(e.g. --locale zh-CN), or follow skills/ppt-master/workflows/stages/generate-audio.md "
"for an AI-curated recommendation."
)
raise AssertionError("unreachable")
if args.provider != "edge" and not voice_id:
parser.error(f"--voice-id is required for --provider {args.provider}")
raise AssertionError("unreachable")
if args.subtitle_max_chars < 1:
parser.error("--subtitle-max-chars must be at least 1")
raise AssertionError("unreachable")
if args.concurrency < 1:
parser.error("--concurrency must be at least 1")
raise AssertionError("unreachable")
for option, value, minimum, maximum in (
("--elevenlabs-stability", args.elevenlabs_stability, 0.0, 1.0),
(
"--elevenlabs-similarity-boost",
args.elevenlabs_similarity_boost,
0.0,
1.0,
),
("--elevenlabs-style", args.elevenlabs_style, 0.0, 1.0),
("--elevenlabs-speed", args.elevenlabs_speed, 0.7, 1.2),
("--cosyvoice-volume", args.cosyvoice_volume, 0, 100),
("--cosyvoice-rate", args.cosyvoice_rate, 0.5, 2.0),
("--cosyvoice-pitch", args.cosyvoice_pitch, 0.5, 2.0),
):
if value is not None and not minimum <= value <= maximum:
parser.error(f"{option} must be between {minimum} and {maximum}")
raise AssertionError("unreachable")
if args.cosyvoice_audio_only and args.provider != "cosyvoice":
parser.error("--cosyvoice-audio-only requires --provider cosyvoice")
raise AssertionError("unreachable")
if args.provider == "qwen" and args.qwen_instructions:
if "instruct" not in args.qwen_model:
parser.error(
"--qwen-instructions requires a Qwen3 TTS Instruct model"
)
raise AssertionError("unreachable")
if args.qwen_optimize_instructions and not args.qwen_instructions:
parser.error(
"--qwen-optimize-instructions requires --qwen-instructions"
)
raise AssertionError("unreachable")
if args.provider == "elevenlabs":
if not voice_id:
parser.error("--voice-id is required for --provider elevenlabs")
raise AssertionError("unreachable")
try:
api_key = backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
extension = backend_elevenlabs.output_extension(args.elevenlabs_output_format)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
elif args.provider == "minimax":
try:
api_key = backend_minimax.read_minimax_api_key(args.minimax_api_key_env)
extension = backend_minimax.output_extension(args.minimax_output_format)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
elif args.provider == "qwen":
try:
api_key = backend_qwen.read_qwen_api_key(args.qwen_api_key_env)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(
provider=args.provider,
extension=backend_qwen.output_extension(),
api_key=api_key,
voice_id=voice_id,
)
elif args.provider == "cosyvoice":
try:
api_key = backend_cosyvoice.read_cosyvoice_api_key(args.cosyvoice_api_key_env)
extension = backend_cosyvoice.output_extension(args.cosyvoice_output_format)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(
provider=args.provider,
extension=extension,
api_key=api_key,
voice_id=voice_id,
)
else:
backend = AudioBackend(
provider=args.provider,
extension=backend_edge.edge_output_extension(),
voice_id=args.voice,
)
project = args.project_path
notes_dir = project / "notes"
output_dir = args.output or (project / "audio")
subtitle_dir = output_dir
writes_subtitles = backend.provider in {
"edge",
"elevenlabs",
"minimax",
} or (
backend.provider == "cosyvoice"
and not args.cosyvoice_audio_only
)
try:
note_roster = _expected_note_roster(project)
if not note_roster:
raise ValueError(f"no per-slide notes found in {notes_dir}")
jobs = _prepare_audio_jobs(
note_roster,
output_dir,
backend.extension,
)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
output_dir.mkdir(parents=True, exist_ok=True)
manifest_path = output_dir / "manifest.json"
try:
manifest_path.unlink(missing_ok=True)
(output_dir / "total.srt").unlink(missing_ok=True)
except OSError as exc:
print(f"error: failed to clear stale narration metadata: {exc}", file=sys.stderr)
return 1
generated = 0
if backend.provider == "edge":
print(
f"[Edge] Generating {len(jobs)} audio/SRT pair(s) "
f"with concurrency={args.concurrency}"
)
try:
results = asyncio.run(_generate_edge_jobs(
jobs,
subtitle_dir,
voice=args.voice,
rate=args.rate,
subtitle_max_chars=args.subtitle_max_chars,
concurrency=args.concurrency,
))
except Exception as exc:
print(f"error: Edge audio generation failed: {exc}", file=sys.stderr)
return 1
failed = False
for job, result in zip(jobs, results):
subtitle_path = subtitle_dir / f"{job.output_path.stem}.srt"
if result is not None:
print(
f"error: failed to generate {job.output_path}: {result}",
file=sys.stderr,
)
failed = True
continue
try:
_remove_stale_audio_variants(job.output_path)
except OSError as exc:
print(
f"error: failed to remove stale audio for "
f"{job.output_path.stem}: {exc}",
file=sys.stderr,
)
failed = True
continue
generated += 1
print(f"[OK] {job.output_path}")
print(f" {subtitle_path}")
if failed:
return 1
else:
for job in jobs:
output_path = job.output_path
text = job.text
subtitle_path: Path | None = None
try:
if backend.provider == "elevenlabs":
if writes_subtitles:
subtitle_path = subtitle_dir / f"{output_path.stem}.srt"
backend_elevenlabs.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.elevenlabs_model,
output_format=args.elevenlabs_output_format,
stability=args.elevenlabs_stability,
similarity_boost=args.elevenlabs_similarity_boost,
style=args.elevenlabs_style,
speed=args.elevenlabs_speed,
speaker_boost=args.elevenlabs_speaker_boost,
subtitle_path=subtitle_path,
subtitle_max_chars=args.subtitle_max_chars,
)
elif backend.provider == "minimax":
if writes_subtitles:
subtitle_path = subtitle_dir / f"{output_path.stem}.srt"
backend_minimax.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.minimax_model,
audio_format=args.minimax_output_format,
sample_rate=args.minimax_sample_rate,
bitrate=args.minimax_bitrate,
channel=args.minimax_channel,
speed=args.minimax_speed,
volume=args.minimax_volume,
pitch=args.minimax_pitch,
language_boost=args.minimax_language_boost,
base_url=args.minimax_base_url,
subtitle_path=subtitle_path,
subtitle_max_chars=args.subtitle_max_chars,
)
elif backend.provider == "qwen":
backend_qwen.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.qwen_model,
language_type=args.qwen_language_type,
instructions=args.qwen_instructions,
optimize_instructions=args.qwen_optimize_instructions,
base_url=args.qwen_base_url,
)
elif backend.provider == "cosyvoice":
if writes_subtitles:
subtitle_path = subtitle_dir / f"{output_path.stem}.srt"
backend_cosyvoice.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.cosyvoice_model,
audio_format=args.cosyvoice_output_format,
sample_rate=args.cosyvoice_sample_rate,
volume=args.cosyvoice_volume,
rate=args.cosyvoice_rate,
pitch=args.cosyvoice_pitch,
instruction=args.cosyvoice_instruction,
language_hint=args.cosyvoice_language_hint,
base_url=args.cosyvoice_base_url,
subtitle_path=subtitle_path,
subtitle_max_chars=args.subtitle_max_chars,
)
_remove_stale_audio_variants(output_path)
if not writes_subtitles:
output_path.with_suffix(".srt").unlink(missing_ok=True)
except Exception as exc:
print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
return 1
generated += 1
print(f"[OK] {output_path}")
if subtitle_path is not None:
print(f" {subtitle_path}")
try:
_publish_manifest(
manifest_path,
_narration_manifest(
args,
backend,
writes_subtitles=writes_subtitles,
),
)
except (OSError, RuntimeError) as exc:
print(f"error: failed to publish narration manifest: {exc}", file=sys.stderr)
return 1
if writes_subtitles:
print(
f"[Done] Generated {generated}/{len(note_roster)} audio/SRT pair(s): "
f"{output_dir}"
)
else:
print(f"[Done] Generated {generated}/{len(note_roster)} audio file(s): {output_dir}")
print(f"[REPORT] Narration manifest: {manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
PPT Master - Page Context Compatibility Module
Re-export the project-management page-context API from its domain package.
New internal imports should use ``project_management.page_context``.
Usage:
Import the public projection helpers from this module.
Examples:
from page_context import build_page_context
Dependencies:
Same as project_management.page_context.
"""
from project_management.page_context import (
LOCK_PROJECTION_TOKEN_TARGET,
PAGE_CONTEXT_REPORT_SCHEMA,
PAGE_CONTEXT_SCHEMA,
PAGE_CONTEXT_TOKEN_TARGET,
PAGE_CONTEXT_USAGE_SCHEMA,
TOKEN_ENCODING,
PageContextError,
PageContextResult,
PageRead,
build_page_context,
normalize_page_key,
page_context_usage_report,
record_page_context_usage,
render_page_context,
)
__all__ = [
"LOCK_PROJECTION_TOKEN_TARGET",
"PAGE_CONTEXT_REPORT_SCHEMA",
"PAGE_CONTEXT_SCHEMA",
"PAGE_CONTEXT_TOKEN_TARGET",
"PAGE_CONTEXT_USAGE_SCHEMA",
"TOKEN_ENCODING",
"PageContextError",
"PageContextResult",
"PageRead",
"build_page_context",
"normalize_page_key",
"page_context_usage_report",
"record_page_context_usage",
"render_page_context",
]
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
PPT Master - PowerPoint Video Export
Export a narrated PPTX through the installed Windows PowerPoint application and
wait until its native video encoder finishes.
See workflows/stages/generate-audio.md for the narration handoff.
Usage:
python3 scripts/powerpoint_video.py <pptx> [-o <video>]
python3 scripts/powerpoint_video.py --check
Examples:
python3 scripts/powerpoint_video.py projects/demo/exports/demo_narrated.pptx
python3 scripts/powerpoint_video.py deck.pptx -o deck.mp4 --resolution 1080
Dependencies:
Windows PowerPoint with the CreateVideo automation API
"""
from __future__ import annotations
import argparse
import base64
import os
import shutil
import subprocess
import sys
from pathlib import Path
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
_CHECK_SCRIPT = r"""
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$powerPoint = $null
$ownsApplication = $false
try {
try {
$powerPoint = [Runtime.InteropServices.Marshal]::GetActiveObject(
"PowerPoint.Application"
)
}
catch {
$powerPoint = New-Object -ComObject PowerPoint.Application
$ownsApplication = $true
}
$version = [version]$powerPoint.Version
if ($version.Major -lt 16) {
throw "PowerPoint $version is older than the supported Office 2016 baseline."
}
[Console]::Out.WriteLine(
"PowerPoint video export available (version {0})." -f $version
)
}
catch {
[Console]::Error.WriteLine(
"PowerPoint video export is unavailable: {0}" -f $_.Exception.Message
)
exit 1
}
finally {
if ($null -ne $powerPoint) {
if ($ownsApplication) {
try { $powerPoint.Quit() } catch {}
}
try {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject(
$powerPoint
)
}
catch {}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
"""
_EXPORT_SCRIPT = r"""
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$inputPath = [Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_INPUT")
$outputPath = [Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_OUTPUT")
$resolution = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_RESOLUTION"
)
$framesPerSecond = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_FPS"
)
$quality = [int][Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_QUALITY")
$defaultSlideDuration = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_DEFAULT_SLIDE_DURATION"
)
$timeoutSeconds = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_TIMEOUT"
)
$powerPoint = $null
$presentation = $null
$ownsApplication = $false
try {
try {
$powerPoint = [Runtime.InteropServices.Marshal]::GetActiveObject(
"PowerPoint.Application"
)
}
catch {
$powerPoint = New-Object -ComObject PowerPoint.Application
$ownsApplication = $true
}
$version = [version]$powerPoint.Version
if ($version.Major -lt 16) {
throw "PowerPoint $version is older than the supported Office 2016 baseline."
}
# ReadOnly=-1, Untitled=0, WithWindow=0.
$presentation = $powerPoint.Presentations.Open($inputPath, -1, 0, 0)
$startMessage = "PowerPoint video export started: {0}p, {1} fps." -f @(
$resolution,
$framesPerSecond
)
[Console]::Error.WriteLine($startMessage)
$presentation.CreateVideo(
$outputPath,
$true,
$defaultSlideDuration,
$resolution,
$framesPerSecond,
$quality
)
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
$nextProgress = [DateTime]::UtcNow.AddSeconds(15)
while ($true) {
$status = [int]$presentation.CreateVideoStatus
if ($status -eq 3) {
break
}
if ($status -eq 4) {
throw "PowerPoint reported that video creation failed."
}
if ([DateTime]::UtcNow -ge $deadline) {
throw "PowerPoint video creation exceeded the ${timeoutSeconds}-second timeout."
}
if ([DateTime]::UtcNow -ge $nextProgress) {
[Console]::Error.WriteLine("PowerPoint video export is still running.")
$nextProgress = [DateTime]::UtcNow.AddSeconds(15)
}
Start-Sleep -Milliseconds 1000
}
if (-not (Test-Path -LiteralPath $outputPath)) {
throw "PowerPoint reported success but did not create the output file."
}
$outputFile = Get-Item -LiteralPath $outputPath
if ($outputFile.Length -le 0) {
throw "PowerPoint created an empty video file."
}
[Console]::Out.WriteLine($outputFile.FullName)
}
catch {
$errorMessage = (
"PowerPoint video export failed: {0} " +
"Close any PowerPoint dialog and retry."
) -f $_.Exception.Message
[Console]::Error.WriteLine($errorMessage)
exit 1
}
finally {
if ($null -ne $presentation) {
try { $presentation.Close() } catch {}
try {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject(
$presentation
)
}
catch {}
}
if ($null -ne $powerPoint) {
if ($ownsApplication) {
try { $powerPoint.Quit() } catch {}
}
try {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject(
$powerPoint
)
}
catch {}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Export a PPTX with Windows PowerPoint's native video encoder and "
"wait for completion."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("pptx", nargs="?", help="Input PPTX file.")
parser.add_argument(
"-o",
"--output",
help="Output .mp4 or .wmv path. Default: beside the PPTX as .mp4.",
)
parser.add_argument(
"--resolution",
type=int,
default=1080,
help="Vertical video resolution in pixels (default: 1080).",
)
parser.add_argument(
"--fps",
type=int,
default=30,
help="Frames per second, from 1 to 60 (default: 30).",
)
parser.add_argument(
"--quality",
type=int,
default=85,
help="PowerPoint encoder quality, from 1 to 100 (default: 85).",
)
parser.add_argument(
"--default-slide-duration",
type=int,
default=5,
help="Fallback seconds for slides without recorded timings (default: 5).",
)
parser.add_argument(
"--timeout",
type=int,
default=7200,
help="Maximum seconds to wait for PowerPoint (default: 7200).",
)
parser.add_argument(
"--force",
action="store_true",
help="Replace an existing output video.",
)
parser.add_argument(
"--check",
action="store_true",
help="Check whether compatible Windows PowerPoint automation is available.",
)
return parser
def _find_powershell() -> str | None:
"""Return the local Windows PowerShell executable."""
return shutil.which("powershell.exe") or shutil.which("powershell")
def _run_powershell(script: str, *, env: dict[str, str], timeout: int) -> int:
"""Run an encoded PowerShell automation script."""
executable = _find_powershell()
if executable is None:
print(
"PowerPoint video export requires Windows PowerShell. "
"Install or restore powershell.exe, then retry.",
file=sys.stderr,
)
return 1
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
try:
completed = subprocess.run(
[
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-STA",
"-OutputFormat",
"Text",
"-EncodedCommand",
encoded,
],
check=False,
env=env,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(
"PowerPoint automation exceeded the command timeout. "
"Close any PowerPoint dialog and retry.",
file=sys.stderr,
)
return 1
return completed.returncode
def _validate_positive(
parser: argparse.ArgumentParser,
*,
name: str,
value: int,
maximum: int | None = None,
) -> None:
if value <= 0 or (maximum is not None and value > maximum):
suffix = f" and no greater than {maximum}" if maximum is not None else ""
parser.error(f"{name} must be greater than 0{suffix}")
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if sys.platform != "win32":
print(
"PowerPoint video export currently requires Windows PowerPoint. "
"Keep the narrated PPTX and export it manually on another platform.",
file=sys.stderr,
)
return 1
if args.check:
return _run_powershell(
_CHECK_SCRIPT,
env=os.environ.copy(),
timeout=60,
)
if not args.pptx:
parser.error("pptx is required unless --check is used")
_validate_positive(parser, name="resolution", value=args.resolution)
_validate_positive(parser, name="fps", value=args.fps, maximum=60)
_validate_positive(parser, name="quality", value=args.quality, maximum=100)
_validate_positive(
parser,
name="default-slide-duration",
value=args.default_slide_duration,
)
_validate_positive(parser, name="timeout", value=args.timeout)
input_path = Path(args.pptx).expanduser().resolve()
if not input_path.is_file():
print(f"Input PPTX does not exist: {input_path}", file=sys.stderr)
return 1
if input_path.suffix.lower() != ".pptx":
print(f"Input must be a .pptx file: {input_path}", file=sys.stderr)
return 1
output_path = (
Path(args.output).expanduser().resolve()
if args.output
else input_path.with_suffix(".mp4")
)
if output_path.suffix.lower() not in {".mp4", ".wmv"}:
print(
f"Output must use the .mp4 or .wmv extension: {output_path}",
file=sys.stderr,
)
return 1
if output_path == input_path:
print("Input PPTX and output video paths must differ.", file=sys.stderr)
return 1
if output_path.exists() and not args.force:
print(
f"Output already exists: {output_path}. "
"Use --force to replace it.",
file=sys.stderr,
)
return 1
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_path.exists():
output_path.unlink()
env = os.environ.copy()
env.update(
{
"PPT_MASTER_VIDEO_INPUT": str(input_path),
"PPT_MASTER_VIDEO_OUTPUT": str(output_path),
"PPT_MASTER_VIDEO_RESOLUTION": str(args.resolution),
"PPT_MASTER_VIDEO_FPS": str(args.fps),
"PPT_MASTER_VIDEO_QUALITY": str(args.quality),
"PPT_MASTER_VIDEO_DEFAULT_SLIDE_DURATION": str(
args.default_slide_duration
),
"PPT_MASTER_VIDEO_TIMEOUT": str(args.timeout),
}
)
return _run_powershell(
_EXPORT_SCRIPT,
env=env,
timeout=args.timeout + 60,
)
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More