Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-08-16 00:02:14 +08:00
parent d8dd22da40
commit 5729c062ed
51 changed files with 3342 additions and 464 deletions
+6 -6
View File
@@ -42,8 +42,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"commit": "c72984e4392c7a154e55c11dbf445f01ce5c35d4", "commit": "12aa8cc0e980b6d3310a5be4f477c434da51f4b0",
"syncedAt": "2026-08-13T15:59:59Z" "syncedAt": "2026-08-15T16:00:01Z"
}, },
{ {
"id": "taste-skill", "id": "taste-skill",
@@ -96,8 +96,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "b87f5f524cabff9376355163c0f2c622ead43493", "commit": "090a133040d9bf41dca887dab78386c553af5dc6",
"syncedAt": "2026-08-14T16:00:00Z" "syncedAt": "2026-08-15T16:00:01Z"
}, },
{ {
"id": "grill-me", "id": "grill-me",
@@ -114,8 +114,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "a02b83fd53a66548520a5dff4ac61a0e18b83733", "commit": "6d0379d4b8558d1157dee78906f0850432453a21",
"syncedAt": "2026-08-14T16:00:00Z" "syncedAt": "2026-08-15T16:00:01Z"
} }
] ]
} }
@@ -2,8 +2,8 @@
"sourceId": "caveman", "sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"commit": "c72984e4392c7a154e55c11dbf445f01ce5c35d4", "commit": "12aa8cc0e980b6d3310a5be4f477c434da51f4b0",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"sourcePath": "plugins/caveman", "sourcePath": "plugins/caveman",
"syncedAt": "2026-08-13T15:59:59Z" "syncedAt": "2026-08-15T16:00:01Z"
} }
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-08-14T16:03:04Z" "syncedAt": "2026-08-15T16:02:13Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "a02b83fd53a66548520a5dff4ac61a0e18b83733", "commit": "6d0379d4b8558d1157dee78906f0850432453a21",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-08-14T16:00:00Z" "syncedAt": "2026-08-15T16:00:01Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "b87f5f524cabff9376355163c0f2c622ead43493", "commit": "090a133040d9bf41dca887dab78386c553af5dc6",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-08-14T16:00:00Z" "syncedAt": "2026-08-15T16:00:01Z"
} }
@@ -595,6 +595,22 @@ object-animation timing before and after their allowed edits, then run
structural package validation; they do not author or normalize animation structural package validation; they do not author or normalize animation
effects. effects.
`pptx_to_svg.py` uses the same generated-transition read-back validator to
project supported source `p:transition` into canonical `animations.json` rows.
It retains the registry effect, effective options, exact duration, automatic
advance, and supported WAV sound; the sidecar defaults to `none` so absent
source transitions remain absent on re-export. Unknown or inexact native
carriers stay diagnosed/direct-preserve. This is a closed PPT Master-owned
contract, not an arbitrary OOXML transition normalizer.
For source `p:timing`, the importer accepts only current generated behavior
trees whose registry effect/options, pane order, Start trigger, exact duration,
relative delay, and target/optional trigger shape map to unique top-level slide
SVG groups. It emits one group row or `effects[]` in the same sidecar. Rows
without a native duration, advanced timing modifiers, sounds, builds/media
commands, unknown behavior trees, and unmapped targets remain diagnosed/direct-
preserve; no timing value is inferred.
**Validation boundary**: these checks prove PPTX timing, relationships, and **Validation boundary**: these checks prove PPTX timing, relationships, and
embedded sound parts. They are not final-video audio acceptance. The embedded sound parts. They are not final-video audio acceptance. The
native-export branch requires a triggered `video_sound_mix.py` receipt; the native-export branch requires a triggered `video_sound_mix.py` receipt; the
@@ -115,6 +115,7 @@ Apply the content-vs-expression contract above within the selected reading mode.
- **Core message ≥ `body`**: map the page's primary claim to declared `lead` / `subtitle`, never below the current body treatment. Footnotes, page numbers, and credits use declared `footnote` / `annotation`; do not invent a smaller role. - **Core message ≥ `body`**: map the page's primary claim to declared `lead` / `subtitle`, never below the current body treatment. Footnotes, page numbers, and credits use declared `footnote` / `annotation`; do not invent a smaller role.
- **Write unitless px, with at most two decimals.** Structural and mapped-role text uses only its anchor or a value within its `±2`px band; the sparse display-size exception is defined separately below. Do not substitute familiar pt-style numbers or emit long precision tails. - **Write unitless px, with at most two decimals.** Structural and mapped-role text uses only its anchor or a value within its `±2`px band; the sparse display-size exception is defined separately below. Do not substitute familiar pt-style numbers or emit long precision tails.
- **Sparse display-size exception**: a short non-structural Hero/Display element may use one undeclared size outside all anchor bands at most twice across the deck without a lock row. The third occurrence makes that size recurring: stop and return to Strategist to name the role in the Design Spec and `spec_lock.md`, then read back and validate the affected fragments before reuse. This exception never applies to titles, body copy, subtitles, annotations, footnotes, captions, data labels, or card copy, and nearby sizes must not be introduced to imitate one recurring treatment. - **Sparse display-size exception**: a short non-structural Hero/Display element may use one undeclared size outside all anchor bands at most twice across the deck without a lock row. The third occurrence makes that size recurring: stop and return to Strategist to name the role in the Design Spec and `spec_lock.md`, then read back and validate the affected fragments before reuse. This exception never applies to titles, body copy, subtitles, annotations, footnotes, captions, data labels, or card copy, and nearby sizes must not be introduced to imitate one recurring treatment.
- **Prepared decorative lettering**: When the approved plan selects stable artistic lettering as part of the visual, place its prepared AI/slice file as an image asset and keep the ordinary editable title/subtitle in separate native text frames. Do not recreate the asset with layered glyph copies or native WordArt; when the plan selects the text fallback instead, use ordinary `<text>` without inventing a missing image.
- **Outside-band recovery**: for structural text, reflow geometry and use the declared role band locally. For a sparse display occurrence, keep the unitless value and verify that its deck-wide count remains at most two. Never flatten a justified distinction or add a role merely to silence the checker. Mirror pages preserve exact source typography as inherited input. - **Outside-band recovery**: for structural text, reflow geometry and use the declared role band locally. For a sparse display occurrence, keep the unitless value and verify that its deck-wide count remains at most two. Never flatten a justified distinction or add a role merely to silence the checker. Mirror pages preserve exact source typography as inherited input.
- Images MUST reference files listed under `images`; no invented filenames - Images MUST reference files listed under `images`; no invented filenames
- For math, load [`native-formula.md`](./native-formula.md): simple notation stays text; one-line structural prose uses inline; matrices, multiline derivations, or standalone high structure use block. Keep exact LaTeX plus preview; never use an image. - For math, load [`native-formula.md`](./native-formula.md): simple notation stays text; one-line structural prose uses inline; matrices, multiline derivations, or standalone high structure use block. Keep exact LaTeX plus preview; never use an image.
@@ -64,7 +64,7 @@ Classify `Reference: Derived from <canonical bare filename>; treatment=<operatio
| Deterministic prepared derivative | This common reference | After parent is usable, run `image_treat.py` to a distinct `.png`; preserve source | Inherit parent: `user → Existing`, `web → Sourced`, `ai/slice → Generated` | | Deterministic prepared derivative | This common reference | After parent is usable, run `image_treat.py` to a distinct `.png`; preserve source | Inherit parent: `user → Existing`, `web → Sourced`, `ai/slice → Generated` |
| Registered-layer derivative | [`image-generator.md`](./image-generator.md) §4.4 | After parent is usable, run §4.4 | Supplied final: `user → Existing`; generated/reconstructed: `ai → Generated` | | Registered-layer derivative | [`image-generator.md`](./image-generator.md) §4.4 | After parent is usable, run §4.4 | Supplied final: `user → Existing`; generated/reconstructed: `ai → Generated` |
| `ai` | [`image-generator.md`](./image-generator.md) | `image_gen.py` | `Generated` | | `ai` | [`image-generator.md`](./image-generator.md) | `image_gen.py` | `Generated` |
| `web` | [`image-searcher.md`](./image-searcher.md) | `image_search.py` | `Sourced` | | `web` | [`image-searcher.md`](./image-searcher.md) | `image_search.py`; with vision, bounded thumbnail pages then one selected original; without vision, strict metadata-ranked best-only | `Sourced` (`Needs-Selection` is intermediate only) |
| `slice` | [`image-generator.md`](./image-generator.md) §4.3 | `slice_images.py` after parent AI sheet is `Generated` | `Generated` | | `slice` | [`image-generator.md`](./image-generator.md) §4.3 | `slice_images.py` after parent AI sheet is `Generated` | `Generated` |
| `user` | — | — | (already `Existing`) | | `user` | — | — | (already `Existing`) |
| `placeholder` | — | — | (already `Placeholder`) | | `placeholder` | — | — | (already `Placeholder`) |
@@ -89,15 +89,16 @@ After all rows reach terminal status:
- Every non-skipped row has a file at `project/images/<filename>`, or is marked `Needs-Manual` - Every non-skipped row has a file at `project/images/<filename>`, or is marked `Needs-Manual`
- Each derivative has its distinct file and usable parent; web provenance is copied in `image_sources.json` - Each derivative has its distinct file and usable parent; web provenance is copied in `image_sources.json`
- Every `slice` row has a generated element file, or is marked `Needs-Manual` because its parent sheet is not available - Every `slice` row has a generated element file, or is marked `Needs-Manual` because its parent sheet is not available
- No `Pending` or `Failed` rows remain - No `Pending`, `Failed`, or `Needs-Selection` rows remain
- `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Needs-Manual}` (no `Pending` or `Failed` remaining) - `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Needs-Manual}` (no `Pending` or `Failed` remaining)
- `image_sources.json` exists when ≥1 web row processed; every entry has `license_tier ∈ {no-attribution, attribution-required, manual}` (`manual` = a user-supplied `--from-url` replacement) - `image_sources.json` exists when ≥1 web row processed; every entry has `license_tier ∈ {no-attribution, attribution-required, manual}` (`manual` = a user-supplied `--from-url` replacement)
> `Needs-Manual` is terminal for acquisition, not export readiness. A later > `Needs-Manual` is terminal for acquisition, not export readiness. A later
> supplied/replaced file must be validated and its row reconciled to > supplied/replaced file must be validated and its row reconciled to
> `Existing`, `Generated`, or `Sourced` with matching evidence. > `Existing`, `Generated`, or `Sourced` with matching evidence.
> Quick blocks every required row that still says `Needs-Manual`, regardless of > Quick blocks every required row that still says `Needs-Selection` or
> whether an unverified candidate file happens to exist. See > `Needs-Manual`, regardless of whether a preview or unverified candidate file
> happens to exist. See
> [`image-generator.md`](./image-generator.md) §7. > [`image-generator.md`](./image-generator.md) §7.
--- ---
@@ -108,13 +109,13 @@ After all rows reach terminal status:
1. Run the selected path's initial strategy 1. Run the selected path's initial strategy
2. On recoverable failure (network, no candidates, license rejection, rate limit), continue through materially different strategies that remain inside that path's confirmed permissions; never loop an already exhausted strategy 2. On recoverable failure (network, no candidates, license rejection, rate limit), continue through materially different strategies that remain inside that path's confirmed permissions; never loop an already exhausted strategy
3. When the path-specific query/provider/license-stage or backend/retry strategy is exhausted, set `Status: Needs-Manual`, log the reason in conversation, and continue 3. When the path-specific query variants/ranked pages/provider/license-stage or backend/retry strategy is exhausted, set `Status: Needs-Manual`, log the reason in conversation, and continue
4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/<filename>`). After supply/replacement, validate the file and reconcile the owning row plus manifest to its usable status. For `slice` rows, list the parent sheet filename and target element names; the user places the sheet, then the agent reruns `slice_images.py`. 4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/<filename>`). After supply/replacement, validate the file and reconcile the owning row plus manifest to its usable status. For `slice` rows, list the parent sheet filename and target element names; the user places the sheet, then the agent reruns `slice_images.py`.
**Quick Generate export gate**: exhaust allowed automation without asking; stop **Quick Generate export gate**: exhaust allowed automation without asking; stop
before `--quick-generate` when a required row is not both backed by its before `--quick-generate` when a required row is not both backed by its
validated file/provenance and in a usable status. File presence alone never validated file/provenance and in a usable status. Preview/file presence alone
bypasses `Needs-Manual`. never bypasses `Needs-Selection` or `Needs-Manual`.
`Needs-Manual` is also the entry status for **Offline Manual Mode** (no `IMAGE_BACKEND` configured, no host-native image tool in use). Affected ai rows are marked `Needs-Manual` from the start without a failed attempt — see [`image-generator.md`](./image-generator.md) §7 Offline Manual Mode. `Needs-Manual` is also the entry status for **Offline Manual Mode** (no `IMAGE_BACKEND` configured, no host-native image tool in use). Affected ai rows are marked `Needs-Manual` from the start without a failed attempt — see [`image-generator.md`](./image-generator.md) §7 Offline Manual Mode.
@@ -2,7 +2,7 @@
# Image_Generator Reference Manual # Image_Generator Reference Manual
Role definition for the **AI image generation path**: convert each active `Acquire Via: ai` row into an optimized prompt, generate the image, and save it to `project/images/`; also defines the `slice` derivation path for AI-generated illustration sheets. Role definition for the **AI image generation path**: convert each active `Acquire Via: ai` row into an optimized prompt, generate the image, and save it to `project/images/`; also defines the `slice` derivation path for AI-generated illustration and decorative-lettering sheets.
**Trigger**: the Default Generate resource list contains `Acquire Via: ai` or `slice`, or Quick Generate has resolved a required AI/sliced image in active context. Load only when at least one such resource exists. **Trigger**: the Default Generate resource list contains `Acquire Via: ai` or `slice`, or Quick Generate has resolved a required AI/sliced image in active context. Load only when at least one such resource exists.
@@ -24,7 +24,7 @@ AI images exist to serve the deck's communication goal. Pick whatever combinatio
| `text_policy` | Use | | `text_policy` | Use |
|---|---| |---|---|
| `none` | No text inside the image | | `none` | No text inside the image |
| `embedded` | Image contains stable text as part of the artwork — decorative lettering, artistic wordmarks, hand-lettered keywords, or figure-internal labels | | `embedded` | Image contains stable text as part of the artwork — decorative lettering, artistic wordmarks, hand-lettered words or phrases, or figure-internal labels |
**Hard rule — only what's actually hard**: **Hard rule — only what's actually hard**:
@@ -185,7 +185,7 @@ Use for: founder profile, speaker bio, testimonial page, or executive intro, inc
**Primitive C — typographic hero (the text *is* the image)** **Primitive C — typographic hero (the text *is* the image)**
> The image's central content is one large text element — a short headline, big number, or single word — rendered as art and carrying dominant visual weight. Keep any supporting visual (small icon, geometric anchor, accent line) clearly subordinate. Give the letterforms enough breathing room for readability, adjusting scale and spacing to the actual text and container. > The image's central content is one large text element — a single word, a phrase, a headline, a big number, or a short multi-line lockup — rendered as art and carrying dominant visual weight. Keep any supporting visual (small icon, geometric anchor, accent line) clearly subordinate. Give the letterforms enough breathing room for readability, adjusting scale and spacing to the actual text and container.
Use with `text_policy: embedded`. Must obey the §5.3 rule — text that is part of the artwork and stable can be embedded; copy that must stay exact or editable goes to SVG overlay (switch to Primitive D). Use with `text_policy: embedded`. Must obey the §5.3 rule — text that is part of the artwork and stable can be embedded; copy that must stay exact or editable goes to SVG overlay (switch to Primitive D).
@@ -248,21 +248,29 @@ Example opening for a triptych hero:
**When uncertain about field conventions**: read `sources/` before drafting the prompt. **When uncertain about field conventions**: read `sources/` before drafting the prompt.
### 4.3 Illustration sheets — one generation, many spot elements ### 4.3 Illustration sheets — one generation, many spot or lettering elements
An illustration sheet can produce several small **spot illustrations** in one generation and preserve closely matched rendering, deck-color treatment, and line quality before slicing. An Illustration Sheet can produce several small **spot illustrations** or
**decorative lettering elements** in one generation and preserve closely
matched rendering, deck-color treatment, and finish before slicing. A lettering
sheet is the batch form of stable Layer 1 artwork; it does not turn page copy
into an image.
**Default — one sheet for a compatible spot family (may override when separate generation serves the assets better)**: Prefer a sheet when several elements share similar proportions, detail, quality, and semantic precision. Generate elements separately when those needs differ materially; quantity alone neither requires nor forbids a sheet. A single hero/local image stays with the normal one-row-per-image flow (§4.1). **Default — one sheet for a compatible asset family (may override when separate generation serves the assets better)**: Prefer a sheet when several spots or lettering elements share similar proportions, detail, quality, and semantic precision. Generate elements separately when those needs differ materially; quantity alone neither requires nor forbids a sheet. A single hero/local image or standalone artistic word stays with the normal one-row-per-image flow (§4.1).
**Hard rule**: a spot sheet is a generation source, not a slide asset. In Default Generate, keep the sheet row out of `spec_lock.md images`; in Quick Generate, retain its generation-only status in active context and the operational manifest. The sheet is never referenced from SVG. Only sliced element rows are placed. **Hard rule**: a sheet is a generation source, not a slide asset. In Default Generate, keep the sheet row out of `spec_lock.md images`; in Quick Generate, retain its generation-only status in active context and the operational manifest. The sheet is never referenced from SVG. Only sliced element rows are placed.
**Sheet prompt convention** (one manifest item, `page_role: local`, `text_policy: none`, `image_size` chosen from final placement size): **Sheet prompt convention** (one manifest item, `page_role: local`,
`image_size` chosen from final placement size; spot sheets use
`text_policy: none`, lettering sheets use `text_policy: embedded`):
- Choose the sheet `aspect_ratio` and `--grid` from the target element shape. Do not default every sheet to `1:1` + a symmetric grid. - Choose the sheet `aspect_ratio` and `--grid` from the target element shape. Do not default every sheet to `1:1` + a symmetric grid.
- Lay the elements out in an explicit **R×C grid, evenly spaced with clear gutters**, each element **centered in its own cell** and isolated (no element bleeds into a neighbor). - Lay the elements out in an explicit **R×C grid, evenly spaced with clear gutters**, each element **centered in its own cell** and isolated (no element bleeds into a neighbor).
- State the intended cell shape in the prompt: compact square object, tall portrait element, or wide landscape vignette. Do not let the model shrink every subject into a centered square sticker. - State the intended cell shape in the prompt: compact square object, tall portrait element, wide landscape vignette, or wide lettering mark. Do not let the model shrink every subject into a centered square sticker.
- One **flat single-color background** across the whole sheet, set to the deck's background/secondary HEX — this is what lets the slicer key it out cleanly and lets the cut element sit on the slide without a visible box. - One **flat single-color background** across the whole sheet, set to the deck's background/secondary HEX — this is what lets the slicer key it out cleanly and lets the cut element sit on the slide without a visible box. Gutters are part of that same flat ground; keep paper grain, halftone, vignette, and every other texture inside the elements, never over the background.
- Shared `deck_rendering` + `color_scheme` as always. NO text, labels, or numbers anywhere (§5.1, §5.3). - Shared `deck_rendering` + `color_scheme` as always.
- **Spot sheet**: apply the §5.3 `none` cue; include no text, labels, or numbers.
- **Lettering sheet**: put exactly one named stable string in each cell as the only text, quote every complete character sequence literally in the prompt, and describe one shared artistic treatment. Keep each mark isolated on the flat key background with no scene, title copy, labels, watermark, mockup surface, or unrequested symbols.
**Cell geometry is designed, not assumed.** `slice_images.py --grid RxC` cuts rows first and columns second. The cell ratio is: **Cell geometry is designed, not assumed.** `slice_images.py --grid RxC` cuts rows first and columns second. The cell ratio is:
@@ -277,12 +285,13 @@ Use that deliberately. On a wide sheet (`16:9`, `21:9`, `4:1`, `8:1`), `1xN` mak
| Compact objects / badges | `1:1` sheet | `2x2`, `2x3`, or `3x3` | | Compact objects / badges | `1:1` sheet | `2x2`, `2x3`, or `3x3` |
| Tall side accents / upright objects | wide or square sheet | `1xN`, or any `MxN` whose cells are portrait | | Tall side accents / upright objects | wide or square sheet | `1xN`, or any `MxN` whose cells are portrait |
| Wide banners / horizontal vignettes | wide sheet | `Nx1`, or any `MxN` whose cells are landscape | | Wide banners / horizontal vignettes | wide sheet | `Nx1`, or any `MxN` whose cells are landscape |
| Decorative words, phrases, or multi-line lettering lockups | wide sheet | `Nx1`, or any `MxN` whose cells fit the planned string shapes |
If one deck needs mixed shapes, create separate sheets per shape family unless one carefully designed grid gives every element enough room. Keep the visual family consistent through the same `deck_rendering` and `color_scheme`, not by forcing all cells into one square sheet. If one deck needs mixed shapes, create separate sheets per shape family unless one carefully designed grid gives every element enough room. Keep the visual family consistent through the same `deck_rendering` and `color_scheme`, not by forcing all cells into one square sheet.
**Resource contract — the sheet and its elements are different row kinds.** A sliced element can only be placed if it exists in the active placeable-resource authority: `spec_lock.md images` in Default Generate or the current agent's prepared resource decision in Quick Generate. Default Generate keeps both row kinds in §VIII under [`strategist-image.md`](./strategist-image.md); Quick Generate resolves the same distinction in active context and its operational manifest without creating planning artifacts: **Resource contract — the sheet and its elements are different row kinds.** A sliced element can only be placed if it exists in the active placeable-resource authority: `spec_lock.md images` in Default Generate or the current agent's prepared resource decision in Quick Generate. Default Generate keeps both row kinds in §VIII under [`strategist-image.md`](./strategist-image.md); Quick Generate resolves the same distinction in active context and its operational manifest without creating planning artifacts:
- **Sheet row** — `Acquire Via: ai`, `Type: Illustration Sheet`, the intent prompt, named as the slice source with its intended cell shape and placement purpose (`Reference: landscape footer-vignette spot set`). It is generated in Step 5 but **never placed on a slide** — keep it **out of** `spec_lock.md images`. Image_Generator resolves the exact `aspect_ratio`, grid, and slice command from this intent. - **Sheet row** — `Acquire Via: ai`, `Type: Illustration Sheet`, the intent prompt, named as the slice source with its intended cell shape and placement purpose (`Reference: landscape footer-vignette spot set`, or `decorative lettering set: exact strings = ...`). It is generated in Step 5 but **never placed on a slide** — keep it **out of** `spec_lock.md images`. Image_Generator resolves the exact `aspect_ratio`, grid, and slice command from this intent.
- **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in the active placeable-resource authority, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (the preparation pass re-runs `analyze_images.py`). Each row carries an owner-resolved layout recommendation; SVG authoring may realize it as a direct cutout or inside an appropriate container while preserving the resource and crop/content constraints. - **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in the active placeable-resource authority, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (the preparation pass re-runs `analyze_images.py`). Each row carries an owner-resolved layout recommendation; SVG authoring may realize it as a direct cutout or inside an appropriate container while preserving the resource and crop/content constraints.
For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` validates, preserves, and displays these metadata fields; it does not run the separate slicing command. For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` validates, preserves, and displays these metadata fields; it does not run the separate slicing command.
@@ -296,13 +305,13 @@ python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
**Three constraints that decide whether it looks good**: **Three constraints that decide whether it looks good**:
1. **Flat background, matched to the slide.** `image_gen.py` has no transparent-background mode, so the cut element carries whatever was behind it. A flat sheet background (= deck background HEX) is what `--alpha` keys out and what makes non-keyed pieces blend. 1. **Flat background, matched to the slide.** `image_gen.py` has no transparent-background mode, so the cut element carries whatever was behind it. A flat sheet background (= deck background HEX) is what `--alpha` keys out and what makes non-keyed pieces blend. Texture over the ground defeats the key, so request grain or halftone only inside the elements. `slice_images.py` warns when an element keeps opaque corners after `--alpha` or when `--trim` removed nothing; both mean the key did not take. Regenerate the sheet with a clean ground, or rerun with an explicit `--bg` and a larger `--tolerance`, before placing the element on a non-background surface.
2. **Clean grid, or it cuts ugly.** State the exact row/column structure and cell shape so the model does not invent a square matrix; `--trim` absorbs smaller placement variance. Do not generate several sheets or read them back merely to choose a favorite; re-roll only when user/live-preview feedback exposes an unusable slice. 2. **Clean grid, or it cuts ugly.** State the exact row/column structure and cell shape so the model does not invent a square matrix; `--trim` absorbs smaller placement variance. For lettering, a wrong/missing character, extra copy, fused cells, or scene background makes the parent sheet unusable. Do not generate several sheets or read them back merely to choose a favorite; re-roll only when user/live-preview feedback exposes an unusable slice, then slice the replacement sheet again.
3. **Generate only as large as needed.** Each cell is a fraction of the sheet. Pick the smallest sheet size that keeps each sliced cell at least **1.5-2x** the intended display size. `1K` is usually enough for small 80-160px decorative spots; use `2K` for medium 180-320px placements; reserve `4K` for large, cropped, or potentially enlarged elements. 3. **Generate only as large as needed.** Each cell is a fraction of the sheet. Pick the smallest sheet size that keeps each sliced cell at least **1.5-2x** the intended display size. `1K` is usually enough for small 80-160px decorative spots; use `2K` for medium 180-320px placements; reserve `4K` for large, cropped, or potentially enlarged elements.
**Reference — sliced-asset placement is not a constraint**: A transparent slice may remain an unboxed cutout or enter a card, evidence frame, label, panel, or other suitable container. The owner-resolved layout text is an expression recommendation; SVG authoring owns the actual geometry and treatment while preserving the resource role and crop/content constraints. **Reference — sliced-asset placement is not a constraint**: A transparent slice may remain an unboxed cutout or enter a card, evidence frame, label, panel, or other suitable container. Decorative-lettering slices may combine with shapes or other slices, while ordinary titles/subtitles remain separate SVG text. The owner-resolved layout text is an expression recommendation; SVG authoring owns the actual geometry and treatment while preserving the resource role and crop/content constraints.
**Through-line — one family, many roles.** A spot sheet pays off more when the same motif family also drives the cover and section dividers. A large cover / divider anchor is not a giant sheet cell—generate it as its own `hero_page` image sharing the sheet's `deck_rendering`, `color_scheme`, and subject world. Plan this only when the deck leans into illustration, never as a quota. **Through-line — one family, many roles.** A sheet pays off more when its visual family recurs coherently: an illustration sheet may share a motif world with cover/divider anchors, while a lettering sheet can supply the deck's small set of matching artistic hooks. A large cover/divider scene is not a giant sheet cell—generate it as its own `hero_page` image sharing the sheet's `deck_rendering` and `color_scheme`. Plan either relationship only when it serves the deck, never as a quota.
--- ---
@@ -483,12 +492,12 @@ Layer 1 text is rasterized into the artwork — once generated it cannot be edit
| Text | Layer | | Text | Layer |
|---|---| |---|---|
| Part of the artwork and stable — decorative lettering, artistic wordmark, hand-lettered keyword, figure-internal identifiers (axis labels, panel letters, units) | Layer 1 (image) OK | | Part of the artwork and stable — decorative lettering, artistic wordmark, hand-lettered word or phrase, figure-internal identifiers (axis labels, panel letters, units) | Layer 1 (image) OK |
| Authoritative titles, page chrome, body copy, captions, data values — anything that must stay exact, searchable, editable, or may be reworded | Layer 2 (SVG) | | Authoritative titles, page chrome, body copy, captions, data values — anything that must stay exact, searchable, editable, or may be reworded | Layer 2 (SVG) |
Generation is non-deterministic on every backend, but **do not pre-judge by script or length** — never push text to SVG, shorten a headline, or downgrade `embedded` to `none` on the assumption that a particular script or a long string "won't render". Decide where text lives by the editability rule above, not by guessed rendering ability. Name the exact characters to bake literally in the prompt; do not re-read the generated image to verify them. Generation is non-deterministic on every backend, but **do not pre-judge by script or length** — never push text to SVG, shorten a headline, or downgrade `embedded` to `none` on the assumption that a particular script or a long string "won't render". Decide where text lives by the editability rule above, not by guessed rendering ability. Name the exact characters to bake literally in the prompt; do not re-read the generated image to verify them.
**Prefer in-image**: text that is genuinely part of the artwork and will not be edited — a designed word, a stat lettering, a figure-internal label. **Prefer in-image**: text that is genuinely part of the artwork and will not be edited — a designed word or phrase, a stat lettering, a figure-internal label. String length never decides this; a multi-word phrase or two-line lockup qualifies exactly as a single word does.
**Push to SVG overlay instead**: page chrome, captions, data values, or any copy that must stay exact or editable. When the headline must remain editable, switch to **Primitive D (atmospheric backdrop)** and overlay it as SVG text. **Push to SVG overlay instead**: page chrome, captions, data values, or any copy that must stay exact or editable. When the headline must remain editable, switch to **Primitive D (atmospheric backdrop)** and overlay it as SVG text.
@@ -586,6 +595,13 @@ C (AI-generated) supports three implementation modes sharing one `image_prompts.
| `IMAGE_BACKEND` not configured (or Path A fails) AND host has a native image tool | **Path B**: Host-native tool | Agent invokes the host's image capability; outputs land at `project/images/<filename>` | | `IMAGE_BACKEND` not configured (or Path A fails) AND host has a native image tool | **Path B**: Host-native tool | Agent invokes the host's image capability; outputs land at `project/images/<filename>` |
| **Both Path A and Path B fail/unavailable** | **Offline Manual Mode** | Manifest stays on disk; user generates externally from `items[].prompt` and places files at `project/images/<filename>` | | **Both Path A and Path B fail/unavailable** | **Offline Manual Mode** | Manifest stays on disk; user generates externally from `items[].prompt` and places files at `project/images/<filename>` |
**Callable-generator test for proactive planning**: Path A counts when
`IMAGE_BACKEND` is configured; Path B counts when the current host exposes a
native image-generation tool. Offline Manual alone, web search, and a
vision-only tool do not count. This predicate allows Strategist/Quick to add
decorative-lettering resources proactively; it never overrides an explicit or
confirmed image-source/path choice.
**Quick Generate selection**: an explicit user instruction for `api`, `host-native`, or `manual` retained in active context wins. When the user did not specify a path, select `auto` and run the A → B → C chain without asking or creating a planning artifact. **Quick Generate selection**: an explicit user instruction for `api`, `host-native`, or `manual` retained in active context wins. When the user did not specify a path, select `auto` and run the A → B → C chain without asking or creating a planning artifact.
**Default Generate selection — declared-procedure fallback when no path is confirmed**: the confirmed user choice wins. When neither channel confirmed a specific path, Generate Step 4 records the effective choice as `auto`; that explicit durable value uses the automatic A → B → C chain. A missing/blank/unknown project value is not an implicit API authorization: **Default Generate selection — declared-procedure fallback when no path is confirmed**: the confirmed user choice wins. When neither channel confirmed a specific path, Generate Step 4 records the effective choice as `auto`; that explicit durable value uses the automatic A → B → C chain. A missing/blank/unknown project value is not an implicit API authorization:
@@ -10,7 +10,7 @@ Role definition for the **web image acquisition path**: translate the active res
## 1. License Tier Discipline ## 1. License Tier Discipline
Every **provider-sourced** image is classified into one of two tiers; anything else is rejected outright. A third tier, `manual`, exists **only** for a user-supplied [`--from-url`](#5-running-image_searchpy) replacement — it is never the result of a provider search accepting an unknown license. Every **provider-sourced** image is classified into one of two tiers; anything else is rejected outright. A third tier, `manual`, exists **only** for a directly selected [`--from-url`](#5-running-image_searchpy) replacement — it is never the result of a provider search accepting an unknown license.
| Tier | Licenses | On-slide attribution | | Tier | Licenses | On-slide attribution |
|---|---|---| |---|---|---|
@@ -34,10 +34,13 @@ Every **provider-sourced** image is classified into one of two tiers; anything e
Default: quality-first across all allowed license tiers. Do not prefer CC0 / Public Domain over a better CC BY / CC BY-SA image; rely on the manifest's `license_tier` so Executor can add attribution only when needed. Default: quality-first across all allowed license tiers. Do not prefer CC0 / Public Domain over a better CC BY / CC BY-SA image; rely on the manifest's `license_tier` so Executor can add attribution only when needed.
``` ```
Default: provider chain, license filter = cc0,pdm,pexels,pixabay,cc by,cc by-sa Multimodal Generate: explicit query variants × provider chain + allowed licenses
rank candidates across providers; first downloadable ranked hit wins. aggregate/deduplicate/rank → first 8 thumbnails → visually select
→ download one original; if none passes, inspect the next 8 first.
Non-visual / standalone best-only: explicit query variants × provider chain
→ strict metadata gate → first downloadable ranked original wins.
Strict: provider chain, license filter = cc0,pdm,pexels,pixabay Strict: provider chain, license filter = cc0,pdm,pexels,pixabay
fail if no no-attribution image can be downloaded. apply the same selected execution mode without CC BY / CC BY-SA.
``` ```
`--strict-no-attribution` is opt-in. Use it only when the deck cannot tolerate any on-slide credit (corporate template, full-bleed hero). `--strict-no-attribution` is opt-in. Use it only when the deck cannot tolerate any on-slide credit (corporate template, full-bleed hero).
@@ -73,11 +76,11 @@ Keep two layers distinct:
| Quick Generate active `Reference` | Current main agent's active-context intent after honoring explicit user assets, URLs, subjects, and constraints; unspecified choices are resolved automatically without confirmation. | | Quick Generate active `Reference` | Current main agent's active-context intent after honoring explicit user assets, URLs, subjects, and constraints; unspecified choices are resolved automatically without confirmation. |
| `image_queries.json.items[].query` / positional query | Image_Searcher's concrete entity/identity keyword string. Start with the shortest phrase that preserves identity; keep exact multi-word names and necessary disambiguators even when they exceed four words. Omit mood, quality, composition, HEX, and negative wording. | | `image_queries.json.items[].query` / positional query | Image_Searcher's concrete entity/identity keyword string. Start with the shortest phrase that preserves identity; keep exact multi-word names and necessary disambiguators even when they exceed four words. Omit mood, quality, composition, HEX, and negative wording. |
Web APIs match metadata, not semantic intent. Providers try the original query first, then progressively simplified four/three/two/one-word variants. A pipeline manifest should therefore use a concise query without pre-truncating exact names. For Chinese landmarks, use the precise Chinese name with Wikimedia; for stock providers, use compact English identity terms when they retain the subject. Web APIs match metadata, not semantic intent. Providers try each explicit query first, then progressively simplified four/three/two/one-word variants. A pipeline manifest should therefore use a concise primary `query` without pre-truncating exact names, plus `query_variants` for materially different official translations, spellings, aliases, or Chinese names. The tool aggregates and deduplicates their results; do not use variants for cosmetic word-order changes. For Chinese landmarks, pair the precise Chinese name used by Wikimedia with compact English identity terms used by stock providers.
Image_Searcher consumes the active Reference and never rewrites its owner. In Default Generate, that means no rewrite of `design_spec.md` or `spec_lock.md`; in Quick Generate, the active-context Reference remains fixed for the run. A candidate either satisfies that existing subject/focal/crop intent, or the role tries materially different query/provider/permitted-license strategies until no untried strategy remains, then marks `Needs-Manual`. Never loosen `required_terms`, the license policy, or the active intent to manufacture a match. Image_Searcher consumes the active Reference and never rewrites its owner. In Default Generate, that means no rewrite of `design_spec.md` or `spec_lock.md`; in Quick Generate, the active-context Reference remains fixed for the run. A candidate either satisfies that existing subject/focal/crop intent, or the role tries materially different query/provider/permitted-license strategies until no untried strategy remains, then marks `Needs-Manual`. Never loosen `required_terms`, the license policy, or the active intent to manufacture a match.
When the subject is an exact entity (landmark / person / company / product / venue), write `required_terms` at the same time you write the row's `query`. Use one required group per identity anchor and `|` for aliases / translations, e.g. `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This keeps the query short for provider search while preventing metadata-ranked wrong entities from being accepted. When the subject is an exact entity (landmark / person / company / product / venue), write `required_terms` at the same time you write the row's `query` and `query_variants`. Use one required group per identity anchor and `|` for aliases / translations, e.g. `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This keeps provider queries short while preventing metadata-ranked wrong entities from being accepted automatically.
Do **not** loosen `required_terms` to generic category words just to improve coverage. Terms like `canyon`, `grand canyon`, `stone pillar`, `ground fissure`, `ancient town`, `bridge`, `temple`, or `village` belong in the search query, not as the only identity gate. For small / Chinese-local attractions, the correct failure mode is `Needs-Manual` or a user-provided `--from-url`, not a visually plausible image of the wrong place. Do **not** loosen `required_terms` to generic category words just to improve coverage. Terms like `canyon`, `grand canyon`, `stone pillar`, `ground fissure`, `ancient town`, `bridge`, `temple`, or `village` belong in the search query, not as the only identity gate. For small / Chinese-local attractions, the correct failure mode is `Needs-Manual` or a user-provided `--from-url`, not a visually plausible image of the wrong place.
@@ -107,6 +110,7 @@ python3 scripts/image_search.py "<query>" \
| Parameter | Required | Default | Description | | Parameter | Required | Default | Description |
|---|---|---|---| |---|---|---|---|
| `query` | yes | — | Positional. Pre-simplification not necessary; CLI runs `simplify_query` internally. | | `query` | yes | — | Positional. Pre-simplification not necessary; CLI runs `simplify_query` internally. |
| `--query-variant` | no | — | Repeatable official translation, spelling, alias, or materially different entity phrase; results are aggregated and deduplicated. Batch rows use `query_variants`. |
| `--filename` | yes | — | Output filename matching the resource list | | `--filename` | yes | — | Output filename matching the resource list |
| `-o / --output` | no | `.` | Output directory; manifest defaults to `<output>/image_sources.json` | | `-o / --output` | no | `.` | Output directory; manifest defaults to `<output>/image_sources.json` |
| `--slide` | no | `""` | Slide ID from resource list (recorded in manifest) | | `--slide` | no | `""` | Slide ID from resource list (recorded in manifest) |
@@ -117,10 +121,11 @@ python3 scripts/image_search.py "<query>" \
| `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA | | `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA |
| `--require-terms` | no | — | Entity-safety gate for exact subjects. Repeatable; comma separates required groups; `A|B` means aliases within one group. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"` | | `--require-terms` | no | — | Entity-safety gate for exact subjects. Repeatable; comma separates required groups; `A|B` means aliases within one group. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"` |
| `--manifest` | no | (default) | Override manifest path | | `--manifest` | no | (default) | Override manifest path |
| `--save-candidates` | no | off | Escalation only: also keep a review pool in `candidates/<stem>/`. Default downloads just the best match (+ a review copy) | | `--save-candidates` | no | off | Thumbnail-selection mode: save one ranked page of review-eligible previews and `review_sheet.jpg`, but no original or provenance record. Multimodal Generate enables this; standalone CLI remains best-only by default |
| `--max-candidates` | no | `4` | Pool size when `--save-candidates` is set | | `--max-candidates` | no | `8` | Thumbnail page size. `0` explicitly requests the complete pool and is reserved for debugging / exceptional review, not normal Generate |
| `--promote` | no | — | Human-selected candidate override; low resolution warns but does not block promotion | | `--candidate-page` | no | `1` | Ranked thumbnail page to fetch. Page 2 starts at rank 9 with the default page size. Batch rows may override with `candidate_page` |
| `--from-url` | no | — | Manual replace: download a user-supplied image URL into `--filename` (recorded `license_tier: manual`); works without a multimodal model | | `--promote` | no | — | Download exactly one selected candidate original, enforce the request's size/readability gates, and write provenance |
| `--from-url` | no | — | Manual replace: download a directly selected image URL into `--filename` (recorded `license_tier: manual`); works without a multimodal model |
### Batch mode (≥ 2 web rows) — preferred ### Batch mode (≥ 2 web rows) — preferred
@@ -128,9 +133,18 @@ When more than one row is `Acquire Via: web`, do **not** call the CLI once per r
```bash ```bash
python3 scripts/image_search.py --batch <project_path>/images/image_queries.json \ python3 scripts/image_search.py --batch <project_path>/images/image_queries.json \
-o <project_path>/images -o <project_path>/images \
--save-candidates
``` ```
The candidate flag above is the normal Generate invocation when the current
agent can inspect images. It downloads previews only and moves each successful
row to `Needs-Selection`; no target image or `image_sources.json` entry exists
yet. A non-multimodal agent omits it and follows the handoff rules under
Suitability review below: only strict metadata-verified candidates may download
automatically. Standalone CLI use remains best-only unless the caller explicitly
requests thumbnail selection.
`image_queries.json` schema (one item per web row): `image_queries.json` schema (one item per web row):
```json ```json
@@ -139,6 +153,7 @@ python3 scripts/image_search.py --batch <project_path>/images/image_queries.json
{ {
"filename": "jiefangbei.jpg", "filename": "jiefangbei.jpg",
"query": "Jiefangbei Chongqing downtown monument", "query": "Jiefangbei Chongqing downtown monument",
"query_variants": ["Chongqing Liberation Monument", "重庆 解放碑"],
"slide": "03_landmark", "slide": "03_landmark",
"purpose": "exact landmark photo", "purpose": "exact landmark photo",
"orientation": "landscape", "orientation": "landscape",
@@ -149,13 +164,13 @@ python3 scripts/image_search.py --batch <project_path>/images/image_queries.json
} }
``` ```
Required per item: `filename`, `query`, `status` (`Pending`). Optional per-item overrides: `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`. Required per item: `filename`, `query`, `status` (`Pending`). Optional per-item overrides: `query_variants`, `candidate_page`, `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`.
Use `required_terms` for **exact-entity images**: landmarks, people, companies, products, venues, named artworks, and named institutions. Each list item is required; alternatives inside one item use `|`. Example for a Chongqing landmark: `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This is a metadata gate: candidates whose title / author / source URL do not satisfy every group are rejected before ranking, so a visually polished but wrong Rome / Hoi An image cannot win a Chongqing landmark row. Do **not** use `required_terms` for generic mood / background rows such as "modern city skyline" or "team collaboration". Use `required_terms` for **exact-entity images**: landmarks, people, companies, products, venues, named artworks, and named institutions. Each list item is required; alternatives inside one item use `|`. Example for a Chongqing landmark: `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. In best-only mode, candidates whose title / author / source URL do not satisfy every group are rejected before ranking, so a visually polished but wrong Rome / Hoi An image cannot win. Thumbnail mode may show a separately labeled near match only for visual identity verification; it never promotes automatically. Do **not** use `required_terms` for generic mood / background rows such as "modern city skyline" or "team collaboration".
For less-covered local attractions, keep the strict identity gate rather than progressively deleting location anchors or replacing proper names with category words. If strict metadata cannot prove the entity, mark the row `Needs-Manual` and use the manual URL path when the user supplies a confirmed source. For less-covered local attractions, keep the strict identity gate rather than progressively deleting location anchors or replacing proper names with category words. If strict metadata cannot prove the entity, mark the row `Needs-Manual` and use the manual URL path when the user supplies a confirmed source.
The runner first revalidates every `Sourced` row against its readable file, requested dimensions, and `image_sources.json` entry; drift returns that row to `Failed`. It then searches all `Pending` / `Failed` rows concurrently, appends each success to the provenance manifest, and writes status back into `image_queries.json`: `Sourced` on success, retryable `Failed` on provider/download errors, and terminal `Needs-Manual` only after a clean provider/stage exhaustion. Status is saved after each completion. A single `web` row may still use single-query mode above. The runner first revalidates every `Sourced` row against its readable file, requested dimensions, and `image_sources.json` entry; drift returns that row to `Failed`. It then searches all `Pending` / `Failed` rows concurrently. Thumbnail mode writes `Needs-Selection`, `candidate_page`, `candidate_count`, `candidate_total`, `has_more_candidates`, `next_candidate_page`, and the relative `review_sheet` path without creating a target image or provenance. To inspect the next page for one row, set its `candidate_page` to `next_candidate_page`, reset only that row to `Pending`, and rerun the batch. Promoting one candidate with the same `--batch` manifest changes that row to `Sourced`. Provider failures remain retryable `Failed`, while clean provider/stage exhaustion becomes terminal `Needs-Manual`. Status is saved after each completion. A single `web` row may still use single-query mode above.
**Pacing**: free providers (Wikimedia/Openverse) are rate-sensitive, so batch concurrency defaults to a modest **3** (`--concurrency N`, or `IMAGE_SEARCH_CONCURRENCY` env). Use `--concurrency 1` to restore strict one-at-a-time pacing. Single-query mode is one request at a time by nature. **Pacing**: free providers (Wikimedia/Openverse) are rate-sensitive, so batch concurrency defaults to a modest **3** (`--concurrency N`, or `IMAGE_SEARCH_CONCURRENCY` env). Use `--concurrency 1` to restore strict one-at-a-time pacing. Single-query mode is one request at a time by nature.
@@ -163,29 +178,43 @@ The runner first revalidates every `Sourced` row against its readable file, requ
`image_search.py` ranks provider metadata, not pixels. The order is deliberately conservative: `image_search.py` ranks provider metadata, not pixels. The order is deliberately conservative:
1. hard reject: invalid license, zero query relevance, or any missing `required_terms`; 1. common hard reject: invalid license or zero query relevance;
2. identity priority: every required term group must match; candidates whose title also contains the required entity terms get an additional boost over candidates that only match via URL; 2. strict automatic gate: best-only mode rejects every candidate missing any `required_terms`; this is the only pool available without visual review;
3. query relevance: concrete query tokens dominate generic visual words like "photo", "high quality", "background"; 3. visual-review widening: thumbnail mode keeps strict matches first, then may admit a near match only when exactly one required group is absent and the explicit query that found it still has strong metadata relevance. The sidecar marks it `identity_evidence: visual-verification-required`; visual inspection must establish the missing identity before promotion;
4. layout fit: requested orientation helps; mismatched orientation is a small penalty, not a hard reject; 4. identity priority: metadata-verified candidates whose title contains the required entity terms outrank candidates that match only via URL;
5. license / size tie-breakers: no-attribution is a small bonus; pixel count is capped so a huge but weakly relevant image cannot outrank a smaller accurate image. 5. query relevance: concrete query tokens match whole ASCII metadata tokens and dominate generic visual words like "photo", "high quality", "background"; substrings such as `office` inside `officer` do not count;
6. layout fit: requested orientation helps; mismatched orientation is a small penalty, not a hard reject;
7. license / size tie-breakers: no-attribution is a small bonus; pixel count is capped so a huge but weakly relevant image cannot outrank a smaller accurate image.
Do not tune this into a visual taste engine. The scorer prevents obvious metadata failures and produces a reviewable best match; the `.review` copy still decides whether the image is visually fit for the slide. Do not tune this into a visual taste engine. The scorer removes obvious metadata failures and orders the thumbnail sheet; visual review still decides whether any candidate fits the slide.
### Suitability review — with or without a multimodal model ### Suitability review — with or without a multimodal model
A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable*`score_candidate` never sees pixels. Review it against the active Reference and Crop Policy before it is trusted: A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable*`score_candidate` never sees pixels. Review it against the active Reference and Crop Policy before it is trusted:
- **Multimodal model**: each download writes a downscaled review copy to `images/.review/<stem>.jpg` (the placed asset stays full-resolution). Judge subject identity, intended mood/view, focal or quiet region, and whether the active crop policy remains safe. - **Multimodal review available**: run `--save-candidates`. The tool aggregates explicit query variants, deduplicates them, and saves at most the first **8** ranked previews under `candidates/<stem>/review/`; `review_sheet.jpg` contains only that page and no original is downloaded. Run [`web-image-review.md`](../workflows/stages/web-image-review.md): use one isolated vision reviewer for the current batch when available, otherwise review locally. Only the active image owner may use the returned candidate filename with `--promote`. If `has_more_candidates` is true and none passes, fetch `--candidate-page 2` before changing the query.
- **Non-multimodal model (no vision)**: do **not** pretend to confirm. Default Generate hands off via each `source_page_url`. Quick Generate does not open an interaction; mark a required image `Needs-Manual` when visual suitability cannot be established, preserve provenance, and let the quick export gate block. - **Non-multimodal model (no vision)**: omit `--save-candidates`; the tool excludes every `visual-verification-required` near match, downloads only the first candidate that passes all strict metadata / license / dimension gates, and records `selection_method: metadata-ranked`. Do **not** describe this as visual confirmation. If no strict candidate exists, or the active Reference requires a viewpoint, crop, expression, or fine identity detail that metadata cannot establish, mark the row `Needs-Manual`; Quick does not open an acquisition-time interaction.
The review stage owns pixel-inspection gates, bounded detail reads, and the compact decision receipt. It receives only the locked row intent plus candidate sidecars/sheets; it never receives the full planning or acquisition context.
If no thumbnail on the current page passes, download no original. When
`has_more_candidates` is true, advance to `next_candidate_page` first. Only
after the ranked pool is exhausted should you change the query materially —
identity wording, translation, alias, viewpoint, or necessary disambiguator —
reset that row to `Pending`, and generate a fresh pool. Do not promote the
least-bad candidate.
For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject and satisfy the active focal/crop intent. Passing metadata never authorizes changing that intent downstream. For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject and satisfy the active focal/crop intent. Passing metadata never authorizes changing that intent downstream.
Never treat a generic `required_terms` pass as acceptance. For example, matching `Ground Fissure` can return an unrelated transit station named Yunlong, and matching `stone pillar` can return a different scenic area. If the proper name / geography cannot be retained, stop at `Needs-Manual`. Never treat a generic `required_terms` pass as acceptance. For example, matching `Ground Fissure` can return an unrelated transit station named Yunlong, and matching `stone pillar` can return a different scenic area. If the proper name / geography cannot be retained, stop at `Needs-Manual`.
**Replacement ladder when a best match is not right** (any reviewer): **Replacement ladder when the first round is not right**:
1. refine the query and re-run that row while each revision tests a materially different identity phrase or disambiguator; do not repeat a semantically exhausted query; 1. With vision, promote the one passing thumbnail selected under the review-stage contract; this is the first original-image request.
2. **manual URL replace (universal, model-agnostic)** — use a user-supplied URL and swap it in: 2. If none passes and `has_more_candidates` is true, fetch the next ranked page (8 by default). Candidate numbers continue globally, so page 2 starts at `candidate_09`; do not repeat page 1 or download an original.
3. After the current pool is exhausted, add materially different query variants for identity wording, official translation, alias, viewpoint, or disambiguation and generate a fresh pool; do not repeat a semantically exhausted query.
4. With vision only, if normal search is exhausted, open one relevant retained research page and test one plausible inline-image URL with the same `--from-url` command below. Inspect that single download before trying another; never bulk-download the page or use it as the initial pool.
5. **manual URL replace (universal, model-agnostic)** — use a directly selected URL and swap it in:
```bash ```bash
python3 scripts/image_search.py --from-url <image-url> --filename <name>.jpg -o <project_path>/images python3 scripts/image_search.py --from-url <image-url> --filename <name>.jpg -o <project_path>/images
``` ```
@@ -197,28 +226,50 @@ Never treat a generic `required_terms` pass as acceptance. For example, matching
that query row and the active roster to `Sourced` before export; a stale that query row and the active roster to `Sourced` before export; a stale
`Needs-Manual` status remains blocking `Needs-Manual` status remains blocking
([`executor-web-image.md`](./executor-web-image.md) §1); ([`executor-web-image.md`](./executor-web-image.md) §1);
3. (opt-in) `--save-candidates` to pull auto-alternatives with their own `source_page_url`s, then `--promote` the best (below); 6. When the query variants, ranked pages, configured provider chain, permitted license stages, and eligible retained-page fallback are exhausted, mark the row `Needs-Manual`.
4. when the query variants, configured provider chain, and permitted license stages are exhausted and no user-confirmed manual URL is available, mark the row `Needs-Manual`.
Web search is far cheaper than AI generation, so this review pass is well worth it.
**This review never opens an acquisition-time interaction** ([`image-base.md`](./image-base.md) §6). Default Generate may build a placeholder and continue to Step 6. Quick Generate finishes all permitted automated strategies, records `Needs-Manual`, and blocks direct export when the unresolved image is required. **This review never opens an acquisition-time interaction** ([`image-base.md`](./image-base.md) §6). Default Generate may build a placeholder and continue to Step 6. Quick Generate finishes all permitted automated strategies, records `Needs-Manual`, and blocks direct export when the unresolved image is required.
### Manual review candidates (escalation, opt-in) ### Visual selection candidates (multimodal Generate; standalone opt-in)
Candidate-pool saving is **off by default** — reach for it only when a best match fails confirmation, on a subjective topic, or for a prominent image (cover / chapter divider / `hero_page` / photo-led page). Candidate-thumbnail saving stays **off by default for standalone CLI use**.
Generate enables it for every web row when the current agent can inspect images,
so the first pass sees a bounded ranked page rather than trusting metadata rank
1 or flooding the reviewer with the complete pool.
```bash ```bash
python3 scripts/image_search.py "<query>" --filename <name>.jpg -o <project_path>/images \ python3 scripts/image_search.py "<query>" --filename <name>.jpg -o <project_path>/images \
--save-candidates --max-candidates 4 --save-candidates
``` ```
Saves the top candidates to `images/candidates/<stem>/` with a `candidates.json` manifest and one downscaled review copy per candidate under `candidates/<stem>/review/`. **Read the candidate review copies**, pick the best fit, then promote it — the full-resolution original is copied to the target filename: Saves provider previews to `images/candidates/<stem>/review/` with a
thumbnail-only `candidates.json` manifest and an automatically generated
`candidates/<stem>/review_sheet.jpg` containing only the current round. The
default first round is ranks 18. The sidecar records `candidate_page`,
`page_size`, `candidate_total`, `has_more_candidates`, each candidate's matched
query, and whether identity is metadata-verified or requires visual
verification. The target filename and `image_sources.json` remain untouched.
Inspect the sheet first, open only plausible individual previews when needed,
then promote the best fit — only that full-resolution original is downloaded
to the target:
```bash ```bash
python3 scripts/image_search.py --promote candidate_03.jpg --filename <name>.jpg -o <project_path>/images python3 scripts/image_search.py --promote candidate_03.jpg --filename <name>.jpg -o <project_path>/images
# No pass on page 1, but candidates.json says has_more_candidates: true
python3 scripts/image_search.py "<same query>" --filename <name>.jpg \
-o <project_path>/images --save-candidates --candidate-page 2
# Batch flow: also reconcile image_queries.json from Needs-Selection to Sourced
python3 scripts/image_search.py --promote candidate_03.jpg --filename <name>.jpg \
--batch <project_path>/images/image_queries.json -o <project_path>/images
``` ```
For batch continuation, set only the no-pass row's `candidate_page` to its
`next_candidate_page`, reset that row to `Pending`, and rerun. Use
`--max-candidates 0` only when a complete-pool dump is explicitly useful for
debugging; it is not the Generate default.
--- ---
## 6. Manifest Format (`image_sources.json`) ## 6. Manifest Format (`image_sources.json`)
@@ -235,6 +286,8 @@ Every successful download appends or replaces one entry keyed on `filename`:
"slide": "03_team", "slide": "03_team",
"purpose": "Leadership photo", "purpose": "Leadership photo",
"search_query": "executive boardroom meeting", "search_query": "executive boardroom meeting",
"matched_query": "leadership team boardroom",
"selection_method": "metadata-ranked",
"orientation": "landscape", "orientation": "landscape",
"provider": "openverse", "provider": "openverse",
"stage": "all", "stage": "all",
@@ -262,9 +315,11 @@ Every successful download appends or replaces one entry keyed on `filename`:
| Field | Notes | | Field | Notes |
|---|---| |---|---|
| `matched_query` | Explicit primary query or query variant that discovered the selected asset. |
| `selection_method` | `visual-thumbnail` after promotion from a reviewed preview, or `metadata-ranked` for the strict no-vision / best-only path. It never claims a visual check that did not occur. |
| `width` / `height` | Measured from the file actually saved to disk. Use these for layout. | | `width` / `height` | Measured from the file actually saved to disk. Use these for layout. |
| `metadata_dimensions` | Present only when upstream-claimed size differs from the saved file (preview vs original). Informational only. | | `metadata_dimensions` | Present only when upstream-claimed size differs from the saved file (preview vs original). Informational only. |
| `license_tier` | Drives Executor's attribution decision: `no-attribution` / `attribution-required` for provider-sourced images, or `manual` for a user-supplied `--from-url` replacement (embed only; rights/credit are the user's responsibility). | | `license_tier` | Drives Executor's attribution decision: `no-attribution` / `attribution-required` for provider-sourced images, or `manual` for a directly selected `--from-url` replacement (embed only; rights/credit are the user's responsibility). |
| `attribution_required` | Boolean alias of `license_tier == "attribution-required"`. | | `attribution_required` | Boolean alias of `license_tier == "attribution-required"`. |
| `attribution_text` | Canonical credit source. Preserve its author/provider/license facts; compress only through §7's visual grammar rather than inventing or dropping identity. | | `attribution_text` | Canonical credit source. Preserve its author/provider/license facts; compress only through §7's visual grammar rather than inventing or dropping identity. |
| `stage` | `all` by default, or `no-attribution-only` when strict mode is used. | | `stage` | `all` by default, or `no-attribution-only` when strict mode is used. |
@@ -305,12 +360,17 @@ Extends [`image-base.md`](./image-base.md) §6.
| Situation | Behavior | | Situation | Behavior |
|---|---| |---|---|
| No candidates from any provider in either stage | Mark row `Needs-Manual`. Suggest a more precise query or another configured provider; rerun without `--strict-no-attribution` only when the confirmed page may carry visible credit. | | No candidates from any provider in either stage | Mark row `Needs-Manual`. Suggest a more precise query or another configured provider; rerun without `--strict-no-attribution` only when the confirmed page may carry visible credit. |
| Single candidate fails to download (HTTP 403/404) | Dispatcher auto-falls through to the next ranked candidate. No user action. | | Current thumbnail page has no acceptable image and `has_more_candidates` is true | Fetch `next_candidate_page`; do not change the query or download an original yet. |
| All candidates from one provider fail | Dispatcher moves to the next provider in the chain. | | Requested thumbnail page is past `candidate_total` | Treat the current pool as exhausted; add a materially different query variant or move to the manual boundary. |
| One or more previews fail while another qualified preview succeeds | Keep the successful thumbnail set; no original has been requested. |
| Every qualified preview fails | Mark row `Failed`; a later batch run retries it. |
| Selected original fails its download/readability/dimension gate | Leave `Needs-Selection`; select another passing thumbnail or materially change the query. Do not commit provenance. |
| Best-only candidate fails to download (HTTP 403/404) | Dispatcher auto-falls through to the next ranked candidate. |
| Provider/network failure remains after dispatch | Mark row `Failed`; a later batch run retries it. | | Provider/network failure remains after dispatch | Mark row `Failed`; a later batch run retries it. |
| Keyed provider has no API key | Silently skipped. Not an error. | | Keyed provider has no API key | Silently skipped. Not an error. |
CLI exit: `0` when all attempted rows resolve; `1` while any row remains `Failed` or `Needs-Manual`. CLI exit: a successfully prepared `Needs-Selection` thumbnail set returns `0`
as an intermediate success; `Failed` or `Needs-Manual` returns `1`.
--- ---
@@ -330,7 +390,7 @@ Executor reads `image_sources.json` per slide that uses a Sourced image. For eac
|---|---| |---|---|
| `no-attribution` | Embed `<image>` only | | `no-attribution` | Embed `<image>` only |
| `attribution-required` | Embed `<image>` **and** an inline credit element per §7 | | `attribution-required` | Embed `<image>` **and** an inline credit element per §7 |
| `manual` | Embed `<image>` only — user-supplied URL (`--from-url`); verifying usage rights / any required credit is the user's responsibility | | `manual` | Embed `<image>` only — directly selected URL (`--from-url`); verifying usage rights / any required credit is the user's responsibility |
Executor does not interpret raw license strings — `license_tier` is sufficient. Executor does not interpret raw license strings — `license_tier` is sufficient.
@@ -342,8 +402,8 @@ Executor does not interpret raw license strings — `license_tier` is sufficient
In addition to the shared checkpoint in [`image-base.md`](./image-base.md) §10: In addition to the shared checkpoint in [`image-base.md`](./image-base.md) §10:
- [ ] Every web row has a downloaded file at `project/images/<filename>` OR is marked `Needs-Manual` - [ ] Every required web row is `Sourced` with a downloaded original at `project/images/<filename>` OR is marked `Needs-Manual`; `Needs-Selection` remains incomplete
- [ ] Each `Sourced` web image was reviewed against the active Reference/Crop Policy — a multimodal model via `images/.review/<stem>.jpg`; without vision, Default Generate hands off via `source_page_url` while Quick Generate records `Needs-Manual` without interaction. A mismatch was re-queried, replaced, escalated, or marked `Needs-Manual`, never repaired by rewriting the active intent - [ ] Each multimodal `Sourced` web image was selected from a bounded ranked thumbnail page and only its winner original was downloaded; a no-pass page advanced through remaining pages before query replacement. Without vision, only strict metadata candidates may become `Sourced`, with `selection_method: metadata-ranked`; unresolved or visually unprovable intent becomes `Needs-Manual` without pretending a visual check occurred
- [ ] Each `Sourced` row has a manifest entry with valid `license_tier` and non-empty `attribution_text` (except `manual` `--from-url` rows, which carry no `attribution_text`) - [ ] Each `Sourced` row has a manifest entry with valid `license_tier` and non-empty `attribution_text` (except `manual` `--from-url` rows, which carry no `attribution_text`)
- [ ] Any `attribution-required` image has visible author + license credit in every SVG that references it - [ ] Any `attribution-required` image has visible author + license credit in every SVG that references it
- [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size - [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size
@@ -115,7 +115,22 @@ explicit big-delimiter grades become auto-sizing delimiters; `\mathscr`
normalizes to `\mathcal`; `smallmatrix` normalizes to `matrix`; PowerPoint array normalizes to `\mathcal`; `smallmatrix` normalizes to `matrix`; PowerPoint array
columns become centered; style/size commands and equation tags are accepted but columns become centered; style/size commands and equation tags are accepted but
not stored. Color is stored in generated formula runs and structural control not stored. Color is stored in generated formula runs and structural control
properties. No reverse OMML-to-LaTeX build-down is implemented by PPT Master. properties.
**Narrow reverse import**: `pptx_to_svg.py` rebuilds a block formula marker or
same-paragraph inline marker only when one `a14:m` root passes this compiler's
closed OMML validator and its normalized structure can be serialized back to
LaTeX accepted by the same compiler. The reconstructed LaTeX is canonicalized;
it is not the original spelling. A formula-only `m:oMathPara` text shape becomes
one bounded block marker when its carrier also fits the unstyled rectangular
native-formula contract; carrier grouping, paint, effects, rotation, hyperlink,
or placeholder ownership force fallback instead of being silently discarded.
Supported `m:oMath` zones remain inline among their surrounding text runs. Both
forms receive a dependency-free linear SVG preview. This contract covers PPT
Master-emitted vocabulary, not arbitrary third-party OMML. Tolerant import
reports `formula-not-reconstructed`, renders readable formula text, and retains
a relationship-free unchanged source `txBody` as opaque metadata; strict import
stops instead.
**Fail-closed boundary**: Input containing unknown commands or environments, **Fail-closed boundary**: Input containing unknown commands or environments,
Microsoft's explicitly unsupported commands, unsupported mhchem arrows, Microsoft's explicitly unsupported commands, unsupported mhchem arrows,
@@ -596,9 +596,13 @@ Semantic markers are minimal compiler hints. Flat pages declare one root `data-p
`width`/`height` do not override `viewBox`. `width`/`height` do not override `viewBox`.
Root `<svg>` transform is forbidden; nested crop and `<symbol viewBox>` keep Root `<svg>` transform is forbidden; nested crop and `<symbol viewBox>` keep
their own contracts. their own contracts.
- **Font portability**: font families used by the deck must resolve to installed - **Font portability**: resolve an explicit user/template delivery target first;
export faces. `@font-face` remains forbidden; the typography contract lives in otherwise default to Windows Microsoft PowerPoint with locale following the
[`strategist.md §g`](strategist.md). deck's primary language. Exported Latin/EA faces must be installed or approved
on that target. The authoring host's fonts affect SVG preview and measurement
only and MUST NOT select PPTX faces; a local counterpart may appear only as a
preview tail that preserves the same export resolution. `@font-face` remains
forbidden; the typography contract lives in [`strategist.md §g`](strategist.md).
- **Icon placeholders**: `<use data-icon="library/name">` is a pipeline-specific - **Icon placeholders**: `<use data-icon="library/name">` is a pipeline-specific
form, distinct from local SVG reuse. Follow the contract in form, distinct from local SVG reuse. Follow the contract in
[`../templates/icons/README.md`](../templates/icons/README.md). [`../templates/icons/README.md`](../templates/icons/README.md).
@@ -16,7 +16,29 @@ For illustration, apply this precedence: confirmed `none` → explicit user inte
**Context-first understanding for provided assets**: Do not visually scan `images/`. First infer identity, role, and crop / focus needs from source position and surrounding prose, captions / alt / titles, filename, user notes / confirmed `image_notes`, existing resource records, and CSV geometry. Inspect only one specific image when a remaining ambiguity would change selection, factual identity, page role, crop safety, or focal placement. Never inspect for inspiration, bulk-open the folder, or infer external facts / provenance from pixels. Record the result in §VIII. Leave an optional unresolved asset unused; route an unresolved must-use asset through failure recovery. **Context-first understanding for provided assets**: Do not visually scan `images/`. First infer identity, role, and crop / focus needs from source position and surrounding prose, captions / alt / titles, filename, user notes / confirmed `image_notes`, existing resource records, and CSV geometry. Inspect only one specific image when a remaining ambiguity would change selection, factual identity, page role, crop safety, or focal placement. Never inspect for inspiration, bulk-open the folder, or infer external facts / provenance from pixels. Record the result in §VIII. Leave an optional unresolved asset unused; route an unresolved must-use asset through failure recovery.
**Default — one coherent sheet for compatible same-family spots (may override when aspect, detail, quality, or semantic needs differ)**: prefer one Illustration Sheet when several AI-generated spots can share a useful cell shape and production treatment; generate them independently when forcing one sheet would weaken a planned element. When a sheet is chosen, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`. State the intended placement shape family in the sheet reference and use separate sheets for incompatible shapes. [`image-generator.md`](./image-generator.md) §4.3 owns grid, ratio, slicing, and execution details. Final Stage 2 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here. **Default — one coherent sheet for compatible same-family spots or lettering elements (may override when aspect, detail, quality, or semantic needs differ)**: prefer one Illustration Sheet when several AI-generated spots or stable decorative-lettering elements can share a useful cell shape and production treatment; generate them independently when forcing one sheet would weaken a planned element. When a sheet is chosen, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`. State the intended placement shape family in the sheet reference. For lettering, also record every exact string, set the sheet to `text_policy: embedded`, and keep authoritative title/chrome wording outside the sheet. Use separate sheets for incompatible shapes or treatments. [`image-generator.md`](./image-generator.md) §4.3 owns grid, ratio, slicing, and execution details. Final Stage 2 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here.
**Mandatory — materialize proactive lettering**: When confirmed image usage
retains `ai`, the effective acquisition path has a callable Path A/B, and the
complete page roster contains a suitable display string anywhere in the deck,
collect the compatible set once before writing §VIII. Eligibility turns on two
questions only — is the wording stable, and would an artistic treatment
communicate better than native type. Page role, length, line count, and kind of
noun never filter candidates; treat cover hooks, chapter words, place or product
names, dish or exhibit names, years, hero numbers, pull quotes, and motif words
as examples rather than the allowed set. Use one ordinary `ai` row
for a single mark, or the sheet/element rows under §4.3 for several compatible
marks, and record every exact character sequence; do not leave the choice as an
`image_notes` or §IX suggestion only. A two-character mark, a multi-word phrase,
and a two-line lockup are equally eligible; never trim a phrase toward one or two
characters to look more like a wordmark. Eligibility is wide but use stays
selective: build one small coherent set rather than lettering every heading. A
planned wordmark and an editable page
title coexist: the asset carries the display layer while subtitle, chrome, and
body remain native text. A confirmed `none`, explicit no-AI
instruction, editable-only hook, or Offline Manual path does not activate this
proactive rule; an explicit user-required lettering asset still follows the
ordinary resource contract.
**Mandatory — image-treatment path scan, not a quota**: Per selected image choose `none` (unchanged), `native` (SVG crop/clip, transform, opacity, frame/depth, overlap), or `prepared derivative` (separate pixel blur/tone or cutout/registered layers); `none` is valid. **Mandatory — image-treatment path scan, not a quota**: Per selected image choose `none` (unchanged), `native` (SVG crop/clip, transform, opacity, frame/depth, overlap), or `prepared derivative` (separate pixel blur/tone or cutout/registered layers); `none` is valid.
@@ -48,4 +70,4 @@ References describe visual intent: AI uses subject + intent + composition withou
Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, and product markings are common triggers rather than an exhaustive list. Otherwise write `Crop Policy: adaptive`: Executor may use complete display or a focal-safe crop, and the value never commands cropping. Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, and product markings are common triggers rather than an exhaustive list. Otherwise write `Crop Policy: adaptive`: Executor may use complete display or a focal-safe crop, and the value never commands cropping.
Judge `text_policy` per AI row using [`image-generator.md`](./image-generator.md) §5.3; paper figures, academic schematics, panel comparisons, and data-axis graphics are positive triggers for reconsidering an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and pending `web` rows to Image_Searcher. Judge `text_policy` per AI row using [`image-generator.md`](./image-generator.md) §5.3; paper figures, academic schematics, panel comparisons, data-axis graphics, and stable decorative lettering are positive triggers for reconsidering an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and pending `web` rows to Image_Searcher.
@@ -58,7 +58,7 @@ Do not force communication intent into one catalog label; Stage 1 records compos
| Reference | Preserve the selected direction or role; adapt its realization to context. | | Reference | Preserve the selected direction or role; adapt its realization to context. |
| Permission / default | An allowed candidate/source boundary or preference; Strategist may leave it unused, with no quota. | | Permission / default | An allowed candidate/source boundary or preference; Strategist may leave it unused, with no quota. |
**Authority chain — materials → Strategist preparation → realization.** User inputs set materials/acquisition bounds. Strategist owns sufficiency, gap-filling, and selection: roster/content, resources, page-local visualization/Layout references, fonts, palette anchors, the icon library/stroke plus curated project pool, and crop bans. Fact research may precede confirmation; AI/web/slice follows final confirmation plus completed §VIII/lock; icons are synced/validated during authoring without page assignment. Before Executor, each resource has a path and terminal/`Needs-Manual` state. Executor owns geometry, composition, hierarchy, spacing, treatment, and per-page choice among prepared icons; it never searches, generates, syncs, invents, or substitutes resources. Missing material/reselection returns upstream. Specificity defines freedom; References flex realization, never selection. **Authority chain — materials → Strategist preparation → realization.** User inputs set materials/acquisition bounds. Strategist owns sufficiency, gap-filling, and selection: roster/content, resources, page-local visualization/Layout references, fonts, palette anchors, the icon library/stroke plus curated project pool, and crop bans. Topic research and text-only import of its retained webpages may precede confirmation; their image links are only a post-search fallback. Independent AI/web/slice acquisition follows final confirmation plus completed §VIII/lock; icons are synced/validated during authoring without page assignment. Before Executor, each resource has a path and terminal/`Needs-Manual` state. Executor owns geometry, composition, hierarchy, spacing, treatment, and per-page choice among prepared icons; it never searches, generates, syncs, invents, or substitutes resources. Missing material/reselection returns upstream. Specificity defines freedom; References flex realization, never selection.
Explicit *must*, *only*, *exactly*, *verbatim*, *do not*, or `no-crop` wording may strengthen only the named property into the appropriate Literal or Semantic requirement. Accepting an AI recommendation keeps the field's default type; it does not promote a Reference or Permission into a Literal requirement. Explicit *must*, *only*, *exactly*, *verbatim*, *do not*, or `no-crop` wording may strengthen only the named property into the appropriate Literal or Semantic requirement. Accepting an AI recommendation keeps the field's default type; it does not promote a Reference or Permission into a Literal requirement.
@@ -222,7 +222,7 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
- User/template typography is authoritative. Repeat fixed stacks with `typography.fixed: true` in every direction; never vary them for diversity. Keep the three directions distinguishable as full bundles; reasonable font repetition is non-blocking, with no extra font round. - User/template typography is authoritative. Repeat fixed stacks with `typography.fixed: true` in every direction; never vary them for diversity. Keep the three directions distinguishable as full bundles; reasonable font repetition is non-blocking, with no extra font round.
- Every Stage-2 direction carries `heading` / `body` `primary`, `css`, and positive `body_size`; add `english` only when the deck's main language is not English. - Every Stage-2 direction carries `heading` / `body` `primary`, `css`, and positive `body_size`; add `english` only when the deck's main language is not English.
- Use concrete, target-installed PowerPoint faces. The Confirm UI font catalog supplies additional manual dropdown choices, not a recommendation whitelist. - Resolve the delivery target under [`shared-standards-core.md`](./shared-standards-core.md) §4.1, then use concrete, target-installed/approved PowerPoint faces. The Confirm UI font catalog supplies additional manual dropdown choices, not a recommendation whitelist.
- Keep stacks to four families or fewer. A brand/web face may lead only after user-confirmed target installation/approved install; PPT Master does not embed fonts. Otherwise export a safe face and keep the unavailable face as Design Spec reference. - Keep stacks to four families or fewer. A brand/web face may lead only after user-confirmed target installation/approved install; PPT Master does not embed fonts. Otherwise export a safe face and keep the unavailable face as Design Spec reference.
- Avoid near-equivalent role splits such as YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, or Times New Roman↔Times. Counterparts may aid SVG/browser preview; CSS tails are not deterministic PowerPoint fallbacks. - Avoid near-equivalent role splits such as YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, or Times New Roman↔Times. Counterparts may aid SVG/browser preview; CSS tails are not deterministic PowerPoint fallbacks.
- Choose by locked style and vary the axis: serif×sans, Kai/FangSong×hei, hei×song, double-serif, display×neutral, same-family weight, or sans+mono. These are recall seeds, not presets. - Choose by locked style and vary the axis: serif×sans, Kai/FangSong×hei, hei×song, double-serif, display×neutral, same-family weight, or sans+mono. These are recall seeds, not presets.
@@ -279,13 +279,25 @@ owns SVG authoring under [`native-hyperlinks.md`](./native-hyperlinks.md).
|---|---|---| |---|---|---|
| `none` | No images | Data reports or process documentation whose visual burden is fully served by charts / native SVG | | `none` | No images | Data reports or process documentation whose visual burden is fully served by charts / native SVG |
| `provided` | User-provided assets | Existing images carry factual, brand, product, or narrative authority | | `provided` | User-provided assets | Existing images carry factual, brand, product, or narrative authority |
| `ai` | AI-generated | Custom illustrations, backgrounds, metaphors, or a coherent spot family are needed | | `ai` | AI-generated | Invented or deliberately stylized illustrations, backgrounds, metaphors, or a coherent spot family are needed |
| `web` | Web-sourced | Real-world editorial or stock-style reference imagery is needed | | `web` | Web-sourced | A named or evidence-bearing real-world subject must appear as itself |
| `placeholder` | Deferred | The image is required but will be supplied later | | `placeholder` | Deferred | The image is required but will be supplied later |
**Current inventory**: If `images/` is non-empty, run `python3 scripts/analyze_images.py <project_path>/images` and read `analysis/image_analysis.csv` before recommending a source. Re-run after that folder changes. **Current inventory**: If `images/` is non-empty, run `python3 scripts/analyze_images.py <project_path>/images` and read `analysis/image_analysis.csv` before recommending a source. Re-run after that folder changes.
**Recommendation output**: Write `recommend.image_usage` as one source id or an array for mixed sources. Put page roles, authoritative assets, preferred/avoided imagery, and placeholder tolerance in `image_notes.value`. `none` is exclusive. Human-scale topics such as family life, education, wellness, or children lean `ai` when no supplied asset carries the story; regulated investor decks, B2B finance reports, and data-only dashboards remain eligible for `none` by judgment. **Default — evidence before synthesis (may override when explicit source constraints or the communication intent require another permitted source)**: Prefer `provided` when supplied assets already carry authority. Propose `web` when the actual appearance of an externally verifiable subject is material; propose `ai` when custom expression matters more than documentary identity. Mixed sources may serve different page roles. This is a source-fit decision, not an image quota. The three Stage-2 style directions never settle it: a rendering candidate resolves how imagery looks, never whether an externally verifiable subject must appear as itself.
**Mandatory — proactive decorative-lettering scan**: Before each Stage-2
`recommend.image_usage`, treat a configured `IMAGE_BACKEND` or host-native image
generator as callable; Offline Manual, web, and vision-only access do not
qualify. If callable and the planned roster contains an exact stable string
suited to illustrative lettering anywhere in the deck — page role, length, and
kind of noun never filter candidates — include `ai` plus its role in
`image_notes.value` without waiting for a request. Never invent or rewrite copy
to trigger it. Explicit no-AI or editable-only requirements win. Execution
follows [`image-generator.md`](./image-generator.md) §7.
**Recommendation output**: Write `recommend.image_usage` as one source id or an array for mixed sources. Put page roles, authoritative assets, preferred/avoided imagery, and placeholder tolerance in `image_notes.value`. `none` is exclusive. Generic human-scale topics such as family life, education, wellness, or children lean `ai` when no supplied asset carries the story; regulated investor decks, B2B finance reports, and data-only dashboards remain eligible for `none` by judgment.
**Confirmed value wins**: Accept the confirmed legacy string or multi-select array. Map `ai→ai`, `web→web`, `provided→user`, and `placeholder→placeholder` into §VIII `Acquire Via`. Every direction already carries a rendering candidate whether or not AI is proposed; generated images inherit the deck colors and never introduce a second image-palette choice. **Confirmed value wins**: Accept the confirmed legacy string or multi-select array. Map `ai→ai`, `web→web`, `provided→user`, and `placeholder→placeholder` into §VIII `Acquire Via`. Every direction already carries a rendering candidate whether or not AI is proposed; generated images inherit the deck colors and never introduce a second image-palette choice.
@@ -309,6 +321,7 @@ user/template requirements bind.
| Image composition | Image-as-canvas, editorial crop, collage, cutout, or meaningful focus / comparison / evidence units carry the page better than an adjacent rectangle | Propose a permitted source; when selected, apply the already-loaded [`strategist-image.md`](./strategist-image.md) resource contract plus the conditional image-layout references, record a concise §VIII `Layout pattern` suggestion, and describe page-level image/overlay relationships in §IX `Layout` / `Images` | | Image composition | Image-as-canvas, editorial crop, collage, cutout, or meaningful focus / comparison / evidence units carry the page better than an adjacent rectangle | Propose a permitted source; when selected, apply the already-loaded [`strategist-image.md`](./strategist-image.md) resource contract plus the conditional image-layout references, record a concise §VIII `Layout pattern` suggestion, and describe page-level image/overlay relationships in §IX `Layout` / `Images` |
| Native paint / overlay | Gradient, translucency, scrim, vignette, or wash supports focus, hierarchy, depth, legibility, or image integration | Record purpose/layering in §IX `Layout`, plus `Images` when imagery participates; no new field or type/stops/opacity/coordinates—Executor chooses realization | | Native paint / overlay | Gradient, translucency, scrim, vignette, or wash supports focus, hierarchy, depth, legibility, or image integration | Record purpose/layering in §IX `Layout`, plus `Images` when imagery participates; no new field or type/stops/opacity/coordinates—Executor chooses realization |
| Native shape / Merge Shapes | A literal Office symbol, a stock bent/curved relationship contour, or a compound silhouette, negative-space cutout, overlap-only region, or meaningful fragmentation strengthens the visual idea | Add an optional §IX `Native shape suggestion` with the semantic result plus a candidate preset/Connector family or Boolean operation/operands | | Native shape / Merge Shapes | A literal Office symbol, a stock bent/curved relationship contour, or a compound silhouette, negative-space cutout, overlap-only region, or meaningful fragmentation strengthens the visual idea | Add an optional §IX `Native shape suggestion` with the semantic result plus a candidate preset/Connector family or Boolean operation/operands |
| AI decorative lettering asset | Any stable display string in the deck — cover hook, chapter word, place or product name, dish or exhibit name, year, hero number, pull quote, motif word — reads better with a material, dimensional, hand-rendered, or otherwise illustrative treatment than as ordinary text | Apply [`strategist-image.md`](./strategist-image.md): when compatible, plan one unplaced AI Illustration Sheet plus one transparent `slice` row per used lettering element; record every exact string, and keep subtitle/chrome/body as native text. A display wordmark and an editable page title may coexist |
| Page transition | A section/state change, spatial continuity, recorded/self-running flow, or the same semantic object changing position, scale, crop, or state across adjacent pages benefits from motion | Add an optional §IX `Motion suggestion` describing the communication job and any continuing object's initial state → action → end state; leave effect, ids, pairing names, and timing to Executor | | Page transition | A section/state change, spatial continuity, recorded/self-running flow, or the same semantic object changing position, scale, crop, or state across adjacent pages benefits from motion | Add an optional §IX `Motion suggestion` describing the communication job and any continuing object's initial state → action → end state; leave effect, ids, pairing names, and timing to Executor |
| Object animation | Progressive reveal, emphasis, movement, removal, or deliberate stillness clarifies sequence, causality, comparison, hierarchy, narration order, full-view → detail, atmosphere → evidence, or hotspot/annotation order | Add an optional §IX `Motion suggestion` naming each relevant semantic unit's lifecycle duty and initial state → communication action → end state, plus any meaningful order/relationship; leave group ids, effects, options, and timing to Executor | | Object animation | Progressive reveal, emphasis, movement, removal, or deliberate stillness clarifies sequence, causality, comparison, hierarchy, narration order, full-view → detail, atmosphere → evidence, or hotspot/annotation order | Add an optional §IX `Motion suggestion` naming each relevant semantic unit's lifecycle duty and initial state → communication action → end state, plus any meaningful order/relationship; leave group ids, effects, options, and timing to Executor |
@@ -584,6 +584,12 @@ respective sections; they do not weaken those contracts.
| Shadow/glow | §6.4 filter on `<text>` only | Shape shadow / run glow; `Approximate` | | Shadow/glow | §6.4 filter on `<text>` only | Shape shadow / run glow; `Approximate` |
| Native bullet | Leading `· • ● ▪ ■ ◆ ◇ ◦ ‣` + non-empty content | `·`/`•``•`; others unchanged; color/alpha from marker run; font/size follow text | | Native bullet | Leading `· • ● ▪ ■ ◆ ◇ ◦ ‣` + non-empty content | `·`/`•``•`; others unchanged; color/alpha from marker run; font/size follow text |
**Hard rule — generated decorative lettering ownership**: Approved AI
decorative lettering is a prepared `<image>` asset under the image contracts,
not an advanced native-text treatment. Keep ordinary editable titles and
subtitles as normal `<text>`; this contract does not add WordArt, text warp, or
text-on-path authoring.
```xml ```xml
<text x="100" y="200" font-size="20" xml:space="preserve">Current <tspan <text x="100" y="200" font-size="20" xml:space="preserve">Current <tspan
fill="#999999" text-decoration="line-through">old</tspan> value</text> fill="#999999" text-decoration="line-through">old</tspan> value</text>
@@ -875,7 +881,7 @@ back-to-front and omit every layer without a distinct job.
| Page / deck job | Back-to-front stack | Stop | | Page / deck job | Back-to-front stack | Stop |
|---|---|---| |---|---|---|
| Cover | Hero field → optional scrim/wash → purposeful opening/contour → native title | Stop when copy is safe and title/field read together | | Cover | Hero field → optional scrim/wash → purposeful opening/contour → native title, optionally paired with a prepared decorative-lettering image | Stop when copy is safe and title/field read together |
| Divider | Image band or quiet field → restrained wash → recurring geometry → number/title | Reuse deck language; add no effect family | | Divider | Image band or quiet field → restrained wash → recurring geometry → number/title | Reuse deck language; add no effect family |
| Text-led explanation | Quiet field → recurring material/contour → native hierarchy → optional local emphasis | Emphasis clarifies the argument, never decorates body copy | | Text-led explanation | Quiet field → recurring material/contour → native hierarchy → optional local emphasis | Emphasis clarifies the argument, never decorates body copy |
| Process / system | Context field → native relation lines → nodes/labels → optional state/direction focus | Every connector stays semantic; atmosphere must not obscure flow | | Process / system | Context field → native relation lines → nodes/labels → optional state/direction focus | Every connector stays semantic; atmosphere must not obscure flow |
@@ -30,6 +30,7 @@ and filter/clip contracts.
|--------|---------|-------------------| |--------|---------|-------------------|
| **Pending** | Acquisition or declared derivation is needed; not yet attempted | Step 5 consumes this; must not remain afterward | | **Pending** | Acquisition or declared derivation is needed; not yet attempted | Step 5 consumes this; must not remain afterward |
| **Failed** | The latest automatic acquisition attempt failed; this is retryable and non-terminal | Step 5 reruns the owning manifest or explicitly resolves the row to `Needs-Manual`; Executor must never treat `Failed` as usable content | | **Failed** | The latest automatic acquisition attempt failed; this is retryable and non-terminal | Step 5 reruns the owning manifest or explicitly resolves the row to `Needs-Manual`; Executor must never treat `Failed` as usable content |
| **Needs-Selection** | Web search produced one bounded thumbnail-only candidate page; no original or provenance exists yet | Step 5 reviews/promotes one candidate, advances to `next_candidate_page`, or after pool exhaustion materially changes the query and returns the row to `Pending`; Executor must never consume this intermediate state |
| **Generated** | AI/slice output exists | Reference from `../images/`; manifest records govern attribution. An `Illustration Sheet` stays in §VIII only as an unplaced slice source | | **Generated** | AI/slice output exists | Reference from `../images/`; manifest records govern attribution. An `Illustration Sheet` stays in §VIII only as an unplaced slice source |
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the attribution contract) | | **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the attribution contract) |
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Existing`, `Generated`, or `Sourced` first. For `slice`, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files. | | **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Existing`, `Generated`, or `Sourced` first. For `slice`, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files. |
@@ -48,7 +49,8 @@ and filter/clip contracts.
- user → materialize the explicit source under project/images/ → Existing - user → materialize the explicit source under project/images/ → Existing
- Pending prepared derivative → follow [`image-base.md`](./image-base.md) §3 before ordinary `Acquire Via` dispatch - Pending prepared derivative → follow [`image-base.md`](./image-base.md) §3 before ordinary `Acquire Via` dispatch
- Pending / Failed + ai → Image_Generator runs image_gen.py → Generated - Pending / Failed + ai → Image_Generator runs image_gen.py → Generated
- Pending / Failed + web → Image_Searcher runs image_search.py → Sourced - Pending / Failed + web + vision → Image_Searcher saves at most 8 ranked previews → Needs-Selection → promote one original or fetch the next page → Sourced / Needs-Manual
- Pending / Failed + web without vision → Image_Searcher accepts only a strict metadata-ranked best-only candidate and records that method → Sourced or Needs-Manual
- Pending + slice → after parent AI sheet is Generated, slice_images.py cuts element files → Generated - Pending + slice → after parent AI sheet is Generated, slice_images.py cuts element files → Generated
3. SVG authoring consumes only prepared resources (Executor in Default Generate; current main agent in Quick Generate) 3. SVG authoring consumes only prepared resources (Executor in Default Generate; current main agent in Quick Generate)
├── Existing / Generated → <image href="../images/xxx.png" .../> ├── Existing / Generated → <image href="../images/xxx.png" .../>
@@ -67,7 +67,7 @@ The aggregated brand review is the responsibility of the orchestrator at the end
## §2 Soft rules (act only when clearly bad) ## §2 Soft rules (act only when clearly bad)
Subagents must apply the **明显** ("clearly bad") threshold — when in doubt, leave it. Better to under-fix than to oscillate. Subagents must apply the **clearly bad** threshold — when in doubt, leave it. Better to under-fix than to oscillate.
| # | Category | Trigger | Fix direction | | # | Category | Trigger | Fix direction |
|---|----------|---------|---------------| |---|----------|---------|---------------|
@@ -290,6 +290,18 @@ Template `page_layouts` records authoring-input provenance, `pptx_masters` / `pp
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. 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` also writes a canonical `animations.json` whose default
transition is `none`. Page transitions produced by the current native
transition registry are read back with their effective options, exact duration,
automatic advance, and optional embedded WAV sound. Source transition XML
outside that closed writer/read-back contract remains diagnosed rather than
being normalized by guesswork.
Finite object-animation rows from the current writer are also projected when
their registry effect, effective options, pane order, trigger, exact duration,
relative delay, and top-level SVG group target all read back exactly. Advanced
timing, build/media trees, duration-less native rows, and unmapped targets stay
diagnosed/direct-preserve.
`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. `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. 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.
@@ -315,8 +327,12 @@ python3 scripts/analyze_images.py <project_path>/images
Generated-deck formulas do not use an image command. Author a native formula Generated-deck formulas do not use an image command. Author a native formula
marker in the page SVG; `svg_to_pptx.py` compiles its LaTeX metadata to editable marker in the page SVG; `svg_to_pptx.py` compiles its LaTeX metadata to editable
PowerPoint OMML. Forward compilation covers the explicitly documented Microsoft PowerPoint OMML. Forward compilation covers the explicitly documented Microsoft
365 LaTeX and mhchem input profiles and fails closed outside them; it does not 365 LaTeX and mhchem input profiles and fails closed outside them.
implement OMML-to-LaTeX build-down. The retained `latex_render.py` utility is `pptx_to_svg.py` also reconstructs PPT Master-owned, validator-clean OMML into
canonical block/inline formula markers with visible linear SVG previews. This
is a closed-vocabulary reverse import, not arbitrary third-party
OMML-to-LaTeX conversion; unknown OMML is reported and kept opaque in tolerant
mode. The retained `latex_render.py` utility is
standalone legacy rasterization only and is not connected to either Generate standalone legacy rasterization only and is not connected to either Generate
profile. profile.
@@ -55,9 +55,9 @@ Useful options:
- `--json` prints a compact machine-readable result after success when the - `--json` prints a compact machine-readable result after success when the
output path is known. With multiple inputs, each successful conversion prints output path is known. With multiple inputs, each successful conversion prints
its own JSON line after that source finishes. its own JSON line after that source finishes.
- `--images all|filtered|none`, `--no-images`, and `--filter-images` map to the - At the unified `source_to_md.py` entry, `--images all|filtered|none`,
existing PDF image mode. They are intentionally PDF-only until other backends `--no-images`, and `--filter-images` map to the PDF image mode. The web
expose the same behavior natively. backend exposes its own direct `--no-images` option described below.
- Unknown backend-specific flags are passed through to each selected converter. - Unknown backend-specific flags are passed through to each selected converter.
- `-o/--output` selects one Markdown file for one input, or an output directory - `-o/--output` selects one Markdown file for one input, or an output directory
for multiple inputs / directory inputs. for multiple inputs / directory inputs.
@@ -251,6 +251,10 @@ python3 scripts/pptx_to_svg.py deck.pptx --strict
| `layered` | Only the layered `svg/` view and inheritance metadata | | `layered` | Only the layered `svg/` view and inheritance metadata |
| `flat` | One self-contained slide SVG per page under `svg/` | | `flat` | One self-contained slide SVG per page under `svg/` |
Every mode also writes a canonical `animations.json`. Its default transition
is `none`, so slides without a source transition stay transition-free when the
workspace is exported again.
For Office pictures that carry both a raster compatibility preview on For Office pictures that carry both a raster compatibility preview on
`a:blip` and an editable SVG relationship in `asvg:svgBlip`, import resolves `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 the SVG relationship first. The raster relationship is used only when the SVG
@@ -277,8 +281,10 @@ exists. Pass `--strict` for parser development or contract verification when
the first unsupported/malformed source construct should stop conversion. the first unsupported/malformed source construct should stop conversion.
Every successful run writes `<output>/conversion-report.json`. Its stable Every successful run writes `<output>/conversion-report.json`. Its stable
top-level fields are `schemaVersion`, `source`, `mode`, `summary`, and top-level fields are `schemaVersion`, `source`, `mode`, `summary`, `artifacts`,
`diagnostics`. Each diagnostic records a reason `code`, source `message`, chosen and `diagnostics`; `artifacts.animationConfig` and
`artifacts.animationMedia` identify the converter-owned sidecar and transition
sounds. Each diagnostic records a reason `code`, source `message`, chosen
`fallback`, package `part_path`, and—when available—`slide_index`, `shape_id`, `fallback`, package `part_path`, and—when available—`slide_index`, `shape_id`,
`shape_name`, and `shape_kind`. The command also prints a bounded warning `shape_name`, and `shape_kind`. The command also prints a bounded warning
summary instead of a raw Python traceback. summary instead of a raw Python traceback.
@@ -288,6 +294,73 @@ the native replacement claim or strict mode. Default tolerant deck import
retains the usable fallback/object and records the degradation; it does not retains the usable fallback/object and records the degradation; it does not
discard unrelated shapes, pages, or the entire deck. discard unrelated shapes, pages, or the entire deck.
Source `p:transition` and `p:timing` nodes are never silently implied by the
static SVG view. Supported page transitions and finite object-animation
sequences are reconstructed in `animations.json`; source timing outside either
closed contract emits `transition-not-reconstructed` or
`animation-not-reconstructed` with the exact source slide. Direct PPTX
Fill/Enhance workflows remain the source-preserving route for all other timing;
`--strict` stops on the first unreconstructed node.
### Page-transition reverse import
The importer accepts exactly the current generated-transition registry and
validates the source carrier with the same read-back contract used after
SVG-to-PPTX export. It reconstructs the canonical effect and all effective
options, exact `p14:dur`, optional `advTm`, and an internal WAV transition
sound. Sound bytes are extracted under the selected media directory with a
content-addressed filename and referenced from the sidecar.
This is a PPT Master-owned semantic loop, not a general transition normalizer.
Unknown effects, legacy `p:transition@spd`, visual effects without exact
`p14:dur`, `advClick="0"`, malformed carriers, and unsupported or broken sound
relationships produce `transition-not-reconstructed` in tolerant mode;
`--strict` stops. The converter never substitutes `fade` for those cases.
### Finite object-animation reverse import
The importer accepts only rows that pass the current generated-animation
behavior-tree validator and map both their target and optional click trigger to
one unique top-level slide SVG group. It reconstructs the canonical registry
effect, non-default effective options, Animation Pane order, Start trigger,
exact native duration, and relative delay. Repeated targets use `effects[]`;
shape-triggered rows restore `trigger_shape`.
This exact-duration subset covers 199 of the 203 registered effects. The four
native rows without a readable behavior duration—`emphasis_change_font`,
`emphasis_change_font_style`, `emphasis_transparency`, and
`emphasis_bold_reveal`—remain diagnosed because their authored scheduling span
cannot be separated honestly from the following delay. Repeat/reverse/rewind,
acceleration/bounce/restart, after-effects, animation sounds, paragraph or
Chart/SmartArt builds, media commands, unknown behavior trees, and targets that
do not map to a top-level SVG group likewise produce
`animation-not-reconstructed`; `--strict` stops. The importer never invents a
replacement timing tree.
### Native formula reverse import
The importer reconstructs formulas only from the closed OMML vocabulary owned
by the native formula compiler. One formula-only `a14:m > m:oMathPara` text
shape becomes a bounded `<g data-pptx-replace-with="formula">` with canonical
LaTeX JSON and a visible linear SVG preview when its carrier is an ungrouped,
unstyled, unrotated rectangular formula shape. Carrier styling, effects,
hyperlinks, or placeholder ownership force diagnosed fallback rather than
silent loss.
Supported `a14:m > m:oMath` zones inside an ordinary paragraph become leaf
`<tspan data-pptx-inline-formula="...">preview</tspan>` markers while retaining
their surrounding text runs. The generated markers pass the same native-object
and inline-formula validators used by SVG-to-PPTX export.
This is normalized semantic read-back, not recovery of the author's original
LaTeX spelling and not a general Office Math converter. Every OMML root must
pass the compiler's namespace, element, attribute, structure, size, and depth
gates, and the reconstructed LaTeX must compile again under the same profile.
If any formula in one text body falls outside that boundary, tolerant import
keeps all formulas in that body as readable linear text and retains the
relationship-free source `txBody` as opaque metadata instead of partially
claiming native reconstruction. It records `formula-not-reconstructed`;
`--strict` stops on the same condition.
### Native table and chart import claims ### Native table and chart import claims
Supported text-grid tables and conservative classic-chart caches carry a Supported text-grid tables and conservative classic-chart caches carry a
@@ -535,7 +608,8 @@ Error: PPTX-to-SVG conversion failed: Invalid DrawingML sRGB color structure
## `source_to_md/web_to_md.py` ## `source_to_md/web_to_md.py`
Convert web pages to Markdown and download images locally. Convert web pages to Markdown and download images locally by default. Use
`--no-images` to retain remote image links without downloading their files.
```bash ```bash
python3 scripts/source_to_md/web_to_md.py https://example.com/article python3 scripts/source_to_md/web_to_md.py https://example.com/article
@@ -543,6 +617,7 @@ 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 -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 -o output.md
python3 scripts/source_to_md/web_to_md.py https://example.com --emit-result /tmp/result.json python3 scripts/source_to_md/web_to_md.py https://example.com --emit-result /tmp/result.json
python3 scripts/source_to_md/web_to_md.py https://example.com -o evidence.md --no-images
``` ```
When `curl_cffi` is installed (included in `requirements.txt`), this script When `curl_cffi` is installed (included in `requirements.txt`), this script
@@ -233,6 +233,11 @@ 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. 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.
For exact entities with multiple common names, add repeatable `--query-variant`
values (batch: `query_variants`) for materially different official
translations, spellings, aliases, or Chinese names. Results are aggregated and
deduplicated before ranking.
| Case | Pattern | | Case | Pattern |
|---|---| |---|---|
| Generic stock concept | `boardroom meeting` | | Generic stock concept | `boardroom meeting` |
@@ -262,15 +267,19 @@ Suitability & manual replacement (a web top hit is metadata-relevant, not guaran
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution). - 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. - 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`. - When the current Generate agent can inspect images, use `--save-candidates`. The tool saves only the first ranked page of review-eligible provider previews (**8 by default**), writes `candidates/<stem>/review_sheet.jpg`, and leaves the target image and `image_sources.json` untouched. Standalone CLI use remains best-only unless this flag is explicit.
- Compare the thumbnail set against the active Reference/Crop Policy. Only after one passes, run `--promote candidate_03.jpg --filename <name>.jpg`; this downloads and validates exactly that original. In batch mode, pass the same `--batch images/image_queries.json` so `Needs-Selection` becomes `Sourced`.
- If no thumbnail passes and `has_more_candidates` is true, fetch `--candidate-page 2` (or set the batch row's `candidate_page` to `next_candidate_page` and reset it to `Pending`). Candidate numbering continues at 9; no original is downloaded. Only after the pool is exhausted should you materially change the identity wording, viewpoint, translation, alias, or disambiguator and generate a fresh pool.
- Without multimodal inspection, omit `--save-candidates`. Best-only mode rejects visual-verification-required near matches, accepts only a strict metadata candidate, downloads one original, and records `selection_method: metadata-ranked`; if metadata cannot prove the entity or the active visual requirement, use `Needs-Manual` rather than claiming visual confirmation.
- `--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. - `--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. Full review / escalation flow: [`image-searcher.md`](../../references/image-searcher.md) §5.
Output: Output:
- Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands) - `--save-candidates`: thumbnail-only `candidates/<stem>/candidates.json`, at most 8 provider previews by default, and `review_sheet.jpg`; no target image or provenance entry. `--candidate-page N` advances through the ranked pool; `--max-candidates 0` explicitly dumps all candidates for exceptional debugging
- `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text) - Best-only / `--promote`: one original saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
- Best-only / `--promote`: `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 - 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. 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.
@@ -236,6 +236,15 @@ duplicate or malformed `p:cTn` ids, missing `p:spTgt` shapes, invalid build
targets, and unsupported generated effect tuples. A mismatch fails export targets, and unsupported generated effect tuples. A mismatch fails export
before the requested output file replaces an existing deck. before the requested output file replaces an existing deck.
`pptx_to_svg.py` reuses that semantic reader and behavior-tree validator for a
finite reverse projection. A row enters `animations.json` only when its current
registry effect/options, pane order, Start trigger, exact behavior duration,
relative delay, and target/optional trigger shape can be represented by unique
top-level slide SVG groups. Repeated targets become `effects[]`. Duration-less
native rows, advanced timing modifiers, sounds, build/media commands, unknown
trees, and unmapped targets remain explicit import diagnostics. This is not a
general PowerPoint timing-tree normalizer.
Narration injection parses and merges the slide DOM. It adds audio timing under Narration injection parses and merges the slide DOM. It adds audio timing under
the existing `tmRoot`, allocates fresh ids, and preserves object animation. the existing `tmRoot`, allocates fresh ids, and preserves object animation.
For bounce timing it updates both p14 Choice and Fallback; unsupported nested For bounce timing it updates both p14 Choice and Fallback; unsupported nested
@@ -231,7 +231,7 @@ class ErrorHelper:
'message': 'Forbidden web font (@font-face) detected', 'message': 'Forbidden web font (@font-face) detected',
'solutions': [ 'solutions': [
'Remove @font-face declarations', 'Remove @font-face declarations',
'Use font-family stacks that export PPT-safe pre-installed typefaces', 'Use font-family stacks that export target-installed/approved PPT-safe typefaces',
'Example: font-family: "Microsoft YaHei", Arial, sans-serif' 'Example: font-family: "Microsoft YaHei", Arial, sans-serif'
], ],
'severity': 'error' 'severity': 'error'
@@ -257,8 +257,9 @@ class ErrorHelper:
'invalid_font': { 'invalid_font': {
'message': 'Font stack exports non-PPT-safe typefaces to PPTX', 'message': 'Font stack exports non-PPT-safe typefaces to PPTX',
'solutions': [ 'solutions': [
'Use stacks whose exported Latin / EA typefaces are pre-installed', 'Use stacks whose exported Latin / EA faces are installed or approved on the delivery target',
'CJK: "Microsoft YaHei", sans-serif | SimSun, serif', 'Do not choose PPTX faces from the authoring host font inventory',
'CJK: "Microsoft YaHei", "Noto Sans CJK SC", sans-serif | SimSun, "Noto Serif CJK SC", serif',
'Latin: Arial, sans-serif | "Times New Roman", serif', 'Latin: Arial, sans-serif | "Times New Roman", serif',
'Mono: Consolas, "Courier New", monospace', 'Mono: Consolas, "Courier New", monospace',
'See strategist.md §g for the full PPT-safe discipline' 'See strategist.md §g for the full PPT-safe discipline'
@@ -230,7 +230,7 @@ TIER_ORDER = {"core": 0, "extended": 1, "experimental": 2}
SUPPORTED_BACKENDS = tuple(sorted(BACKEND_REGISTRY)) SUPPORTED_BACKENDS = tuple(sorted(BACKEND_REGISTRY))
def _load_image_env_file() -> None: def _load_image_env_file() -> Path | None:
""" """
Load image generation config from the resolved `.env` as a fallback layer. Load image generation config from the resolved `.env` as a fallback layer.
@@ -248,7 +248,10 @@ def _load_image_env_file() -> None:
) )
for key, replacement in replacements.items() for key, replacement in replacements.items()
} }
load_prefixed_env_file(IMAGE_ENV_PREFIXES, deprecated_keys=deprecated_messages) return load_prefixed_env_file(
IMAGE_ENV_PREFIXES,
deprecated_keys=deprecated_messages,
)
def _validate_runtime_config() -> None: def _validate_runtime_config() -> None:
@@ -304,6 +307,44 @@ def _load_backend(canonical_name: str) -> tuple[object, str]:
return module, canonical_name return module, canonical_name
def _print_backend_resolution() -> None:
"""Print the effective Path A backend without exposing credentials."""
backend_from_process = "IMAGE_BACKEND" in os.environ
try:
env_path = _load_image_env_file()
except ValueError as exc:
print("Resolved backend: invalid configuration")
print(f"Configuration source: {ENV_PATH}")
print(f"Configuration error: {exc}")
return
try:
_validate_runtime_config()
except ValueError as exc:
print("Resolved backend: invalid configuration")
print("Configuration source: process environment")
print(f"Configuration error: {exc}")
return
backend_name = os.environ.get("IMAGE_BACKEND", "").strip().lower()
if not backend_name:
if backend_from_process:
source = "process environment (empty)"
elif env_path is not None:
source = f"none (checked {env_path})"
else:
source = "none (no .env found)"
print("Resolved backend: not configured (Path A unavailable)")
print(f"Configuration source: {source}")
return
canonical = BACKEND_ALIASES.get(backend_name)
resolved = canonical or f"invalid ({backend_name})"
source = "process environment" if backend_from_process else str(env_path or ENV_PATH)
print(f"Resolved backend: {resolved}")
print(f"Configuration source: {source}")
def _print_backend_list() -> None: def _print_backend_list() -> None:
"""Print supported backends grouped by support tier.""" """Print supported backends grouped by support tier."""
print("Supported image backends:\n") print("Supported image backends:\n")
@@ -327,7 +368,7 @@ def _print_backend_list() -> None:
) )
print() print()
print("Recommendation: prefer CORE backends for everyday PPT generation.") print("Recommendation: prefer CORE backends for everyday PPT generation.")
print(f"Config fallback file: {ENV_PATH}") _print_backend_resolution()
def _resolve_backend() -> tuple[object, str]: def _resolve_backend() -> tuple[object, str]:
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,7 @@ if __name__ == "__main__":
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1) raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from typing import Any, Optional from typing import Any, Optional
@@ -209,6 +209,7 @@ class ImageSearchRequest:
filename: str = "" filename: str = ""
slide: str = "" slide: str = ""
required_terms: tuple[str, ...] = () required_terms: tuple[str, ...] = ()
query_variants: tuple[str, ...] = ()
@dataclass @dataclass
@@ -225,6 +226,8 @@ class AssetCandidate:
width: int = 0 width: int = 0
height: int = 0 height: int = 0
download_url: str = "" download_url: str = ""
preview_url: str = ""
discovery_query: str = ""
author: str = "" author: str = ""
raw: Any = field(default=None) raw: Any = field(default=None)
@@ -267,6 +270,7 @@ _SOFT_NOISE_WORDS = frozenset({
_TOKEN_STRIP_CHARS = ".,;:!?\"'()[]{},。;:!?、" _TOKEN_STRIP_CHARS = ".,;:!?\"'()[]{},。;:!?、"
_MATCH_SEPARATOR_RE = re.compile(r"""[\s\-_./:;,'"()[\]{}]+""") _MATCH_SEPARATOR_RE = re.compile(r"""[\s\-_./:;,'"()[\]{}]+""")
_ASCII_MATCH_TOKEN_RE = re.compile(r"[a-z0-9]+")
def simplify_query(query: str, max_words: int = 4) -> str: def simplify_query(query: str, max_words: int = 4) -> str:
@@ -371,6 +375,11 @@ def _candidate_text(candidate: AssetCandidate) -> str:
).lower() ).lower()
def _candidate_match_tokens(candidate: AssetCandidate) -> set[str]:
"""Return whole ASCII tokens from candidate metadata for relevance scoring."""
return set(_ASCII_MATCH_TOKEN_RE.findall(_candidate_text(candidate)))
def _normalize_match_text(text: str) -> str: def _normalize_match_text(text: str) -> str:
"""Normalize metadata / required terms for conservative substring matching.""" """Normalize metadata / required terms for conservative substring matching."""
lowered = (text or "").lower() lowered = (text or "").lower()
@@ -421,19 +430,20 @@ def missing_required_terms(
def compute_relevance(candidate: AssetCandidate, query: str) -> float: def compute_relevance(candidate: AssetCandidate, query: str) -> float:
"""Fraction of query tokens that appear in the candidate's metadata. """Fraction of query tokens that match whole candidate metadata tokens.
Range ``[0.0, 1.0]``. Returns ``1.0`` (neutral) when the query has no 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 ASCII tokens to match this lets non-English queries fall through
to license / size scoring without being unfairly rejected. to license / size scoring without being unfairly rejected. Whole-token
matching prevents false positives such as ``office`` matching ``officer``.
""" """
tokens = _query_tokens(query) tokens = _query_tokens(query)
if not tokens: if not tokens:
return 1.0 return 1.0
text = _candidate_text(candidate) candidate_tokens = _candidate_match_tokens(candidate)
if not text: if not candidate_tokens:
return 0.0 return 0.0
hits = sum(1 for t in tokens if t in text) hits = sum(1 for token in tokens if token in candidate_tokens)
return hits / len(tokens) return hits / len(tokens)
@@ -507,6 +517,42 @@ def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> f
return score return score
def score_review_candidate(
candidate: AssetCandidate,
request: ImageSearchRequest,
) -> float:
"""Score a wider visual-review pool without weakening automatic selection.
Metadata-verified candidates retain priority. A near match may enter the
thumbnail sheet only when at most one required identity group is missing
and the candidate still has meaningful relevance to the query that found
it. This never authorizes best-only download; ``score_candidate`` remains
the automatic-selection gate.
"""
strict_score = score_candidate(candidate, request)
if strict_score != float("-inf"):
return strict_score + 20000.0
if not request.required_terms:
return strict_score
missing = missing_required_terms(candidate, request.required_terms)
if len(missing) != 1:
return float("-inf")
discovery_request = replace(
request,
query=candidate.discovery_query or request.query,
required_terms=(),
)
relevance = compute_relevance(candidate, discovery_request.query)
if relevance < 0.5:
return float("-inf")
relaxed_score = score_candidate(candidate, discovery_request)
if relaxed_score == float("-inf"):
return relaxed_score
return relaxed_score - 5000.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Attribution text # Attribution text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote, urlparse
_SCRIPTS_DIR = Path(__file__).resolve().parents[1] _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path: if str(_SCRIPTS_DIR) not in sys.path:
@@ -51,6 +52,20 @@ _LICENSE_PARAM = {
} }
def _preview_url(item: dict, download_url: str) -> str:
"""Prefer Wikimedia's bounded preview over Openverse's fragile proxy."""
source = str(item.get("source") or item.get("provider") or "").lower()
parsed = urlparse(download_url)
if source == "wikimedia" and parsed.netloc == "upload.wikimedia.org":
filename = unquote(parsed.path.rsplit("/", 1)[-1])
if filename:
return (
"https://commons.wikimedia.org/wiki/Special:Redirect/file/"
f"{quote(filename, safe='')}?width=1024"
)
return (item.get("thumbnail") or "").strip()
def parse_results(payload: dict) -> list[AssetCandidate]: def parse_results(payload: dict) -> list[AssetCandidate]:
"""Translate an Openverse search payload into a list of candidates.""" """Translate an Openverse search payload into a list of candidates."""
candidates: list[AssetCandidate] = [] candidates: list[AssetCandidate] = []
@@ -79,6 +94,7 @@ def parse_results(payload: dict) -> list[AssetCandidate]:
width=int(item.get("width") or 0), width=int(item.get("width") or 0),
height=int(item.get("height") or 0), height=int(item.get("height") or 0),
download_url=download_url, download_url=download_url,
preview_url=_preview_url(item, download_url),
author=(item.get("creator") or "").strip(), author=(item.get("creator") or "").strip(),
raw=item, raw=item,
) )
@@ -65,7 +65,12 @@ def parse_results(payload: dict) -> list[AssetCandidate]:
candidates: list[AssetCandidate] = [] candidates: list[AssetCandidate] = []
for item in payload.get("photos", []) or []: for item in payload.get("photos", []) or []:
src = item.get("src") or {} src = item.get("src") or {}
download_url = (src.get("original") or src.get("large2x") or src.get("large") or "").strip() download_url = (
src.get("original")
or src.get("large2x")
or src.get("large")
or ""
).strip()
if not download_url: if not download_url:
continue continue
@@ -81,6 +86,7 @@ def parse_results(payload: dict) -> list[AssetCandidate]:
width=int(item.get("width") or 0), width=int(item.get("width") or 0),
height=int(item.get("height") or 0), height=int(item.get("height") or 0),
download_url=download_url, download_url=download_url,
preview_url=(src.get("large") or src.get("medium") or "").strip(),
author=(item.get("photographer") or "").strip(), author=(item.get("photographer") or "").strip(),
raw=item, raw=item,
) )
@@ -86,6 +86,11 @@ def parse_results(payload: dict) -> list[AssetCandidate]:
width=int(item.get("imageWidth") or 0), width=int(item.get("imageWidth") or 0),
height=int(item.get("imageHeight") or 0), height=int(item.get("imageHeight") or 0),
download_url=download_url, download_url=download_url,
preview_url=(
item.get("webformatURL")
or item.get("previewURL")
or ""
).strip(),
author=(item.get("user") or "").strip(), author=(item.get("user") or "").strip(),
raw=item, raw=item,
) )
@@ -129,6 +129,7 @@ def parse_results(payload: dict) -> list[AssetCandidate]:
width=int(info.get("width") or 0), width=int(info.get("width") or 0),
height=int(info.get("height") or 0), height=int(info.get("height") or 0),
download_url=download_url, download_url=download_url,
preview_url=(info.get("thumburl") or "").strip(),
author=_ext_value(extmetadata, "Artist"), author=_ext_value(extmetadata, "Artist"),
raw=page, raw=page,
) )
@@ -180,6 +181,7 @@ def search(
"gsrlimit": search_limit, "gsrlimit": search_limit,
"prop": "imageinfo", "prop": "imageinfo",
"iiprop": "url|size|extmetadata|mime", "iiprop": "url|size|extmetadata|mime",
"iiurlwidth": 1024,
"iiextmetadatafilter": ( "iiextmetadatafilter": (
"LicenseShortName|License|LicenseUrl|Artist" "LicenseShortName|License|LicenseUrl|Artist"
), ),
@@ -3857,6 +3857,39 @@ def describe_animation_effect(effect: object) -> dict[str, Any]:
} }
def effective_animation_effect_options(
effect: object,
effect_options: Mapping[str, object] | None = None,
) -> dict[str, object]:
"""Return the effective option values encoded by one registry request."""
canonical, normalized_options = normalize_animation_effect_request(
effect,
effect_options,
allow_none=False,
allow_modes=False,
)
assert canonical is not None
row = _animation_row_for_options(canonical, normalized_options)
errors: list[str] = []
filter_name = _row_filter(
row,
str(NATIVE_ANIMATIONS[canonical]['presetClass']),
errors,
)
effective = _read_effect_options(
row,
canonical,
filter_name,
errors,
)
if errors:
raise RuntimeError(
f'animation effect {canonical!r} option model failed: '
+ '; '.join(errors)
)
return effective
def main() -> None: def main() -> None:
"""Run the CLI entry point.""" """Run the CLI entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -11,6 +11,7 @@ Output structure (default --inheritance-mode both):
<output_dir>/ <output_dir>/
svg/ layered machine input: masters/layouts/slides svg/ layered machine input: masters/layouts/slides
svg-flat/ self-contained visual preview slides svg-flat/ self-contained visual preview slides
animations.json normalized transition/object-motion sidecar
<media_subdir>/ (default: assets/) <media_subdir>/ (default: assets/)
image1.png image1.png
image2.png image2.png
@@ -18,8 +19,9 @@ Output structure (default --inheritance-mode both):
If -o is omitted, writes alongside the source file as <pptx_stem>_pptx_to_svg/. If -o is omitted, writes alongside the source file as <pptx_stem>_pptx_to_svg/.
This is the reverse of svg_to_pptx.py: it reads OOXML directly and emits This is the semantic import counterpart to svg_to_pptx.py: it reads OOXML
shape-level SVG without going through PowerPoint or PDF rendering. directly and emits declared SVG/native-marker subsets without claiming an
arbitrary lossless PPTX round trip.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,6 +42,14 @@ from pptx_to_svg.converter import ConvertOptions
configure_utf8_stdio() configure_utf8_stdio()
def _diagnostic_preview(message: str, limit: int = 240) -> str:
"""Return one compact CLI preview while the report retains full detail."""
compact = " ".join(message.split())
if len(compact) <= limit:
return compact
return compact[: limit - 3].rstrip() + "..."
def _reconstruction_only_graphics(result: object) -> list[tuple[int, str]]: def _reconstruction_only_graphics(result: object) -> list[tuple[int, str]]:
"""Return slide/object labels for generated placeholders.""" """Return slide/object labels for generated placeholders."""
artifacts = getattr(result, "flat_slides", None) or getattr(result, "slides", []) artifacts = getattr(result, "flat_slides", None) or getattr(result, "slides", [])
@@ -163,7 +173,8 @@ def main() -> int:
if shape: if shape:
location = f"{location}, {shape}" if location else shape location = f"{location}, {shape}" if location else shape
print( print(
f" {location or 'package'}: {item.code}: {item.message}", f" {location or 'package'}: {item.code}: "
f"{_diagnostic_preview(item.message)}",
file=sys.stderr, file=sys.stderr,
) )
if len(result.diagnostics) > 20: if len(result.diagnostics) > 20:
@@ -188,6 +199,7 @@ def main() -> int:
file=sys.stderr, file=sys.stderr,
) )
print(f"Output: {output_dir}") print(f"Output: {output_dir}")
print(f"Animation config: {output_dir / 'animations.json'}")
print(f"Conversion report: {output_dir / 'conversion-report.json'}") print(f"Conversion report: {output_dir / 'conversion-report.json'}")
return 0 return 0
@@ -1,7 +1,8 @@
"""PPTX -> SVG semantic converter (reverse of svg_to_pptx). """PPTX -> SVG semantic importer for declared reversible subsets.
Reads OOXML (DrawingML) directly from a .pptx zip archive and emits SVG with Reads OOXML (DrawingML) directly from a .pptx zip archive and emits SVG with
shape-level fidelity: <p:sp prst="rect"> -> <rect>, <p:txBody> -> <text>, etc. shape-level fidelity plus explicit native-marker reconstruction where the
source fits a closed project-owned contract.
Public entry: convert_pptx_to_svg(). Public entry: convert_pptx_to_svg().
""" """
@@ -0,0 +1,287 @@
"""Import the finite PPT Master-owned object-animation contract.
The importer deliberately projects only canonical effect identity, pane order,
Start trigger, duration, relative delay, and one top-level SVG group target.
Everything else remains an explicit source-preservation boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from pptx_animations import (
AnimationRowSummary,
effective_animation_effect_options,
read_slide_animation_sequence,
validate_generated_animation_xml,
)
from .ooxml_loader import OoxmlPackage, SlideRef
_SVG_NS = "http://www.w3.org/2000/svg"
_UNSUPPORTED_TIMING_TAGS = frozenset(
{
"audio",
"bldDgm",
"bldGraphic",
"bldLst",
"bldOleChart",
"bldP",
"cmd",
"video",
}
)
class AnimationImportError(ValueError):
"""Raised when source timing exceeds the finite reversible contract."""
@dataclass(frozen=True)
class AnimationImport:
"""Canonical per-group animation rows for one slide."""
groups: dict[str, dict[str, object]]
def import_slide_animation(
pkg: OoxmlPackage,
slide: SlideRef,
*,
slide_svg: str,
) -> AnimationImport | None:
"""Read one exact generated object-animation sequence into sidecar rows."""
slide_xml = pkg.read_part_bytes(slide.part.path)
if slide_xml is None:
raise AnimationImportError(
f"source slide part is missing: {slide.part.path}"
)
return read_animation_config(slide_xml, slide_svg)
def read_animation_config(
slide_xml: str | bytes,
slide_svg: str,
) -> AnimationImport | None:
"""Read one exact generated object-animation sequence from XML and SVG."""
data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml
if not _contains_timing(data):
return None
try:
summary = read_slide_animation_sequence(
data,
require_supported_effects=True,
)
except ValueError as exc:
raise AnimationImportError(str(exc)) from exc
if summary.timing_count != 1:
raise AnimationImportError(
"object animation requires exactly one effective root p:timing"
)
if not summary.rows:
raise AnimationImportError(
"p:timing contains no supported object-animation rows"
)
if summary.audio_target_ids:
raise AnimationImportError(
"media playback and animation sounds are outside the finite "
"object-animation read-back contract"
)
unsupported_tags = _unsupported_timing_tags(data)
if unsupported_tags:
raise AnimationImportError(
"unsupported timing feature(s): " + ", ".join(unsupported_tags)
)
group_id_by_shape_id = _top_level_shape_group_index(slide_svg)
delays = _relative_delays(summary.rows)
expected_targets: list[dict[str, object]] = []
rows_by_group: dict[str, list[dict[str, object]]] = {}
for order, (row, delay_ms) in enumerate(
zip(summary.rows, delays),
1,
):
if row.effect is None or row.duration_ms is None:
raise AnimationImportError(
f"animation row {order} has no exact registry effect or duration"
)
group_id = _resolve_group_id(
group_id_by_shape_id,
row.shape_id,
label=f"animation row {order} target",
)
trigger_group_id = None
if row.trigger_shape_id is not None:
trigger_group_id = _resolve_group_id(
group_id_by_shape_id,
row.trigger_shape_id,
label=f"animation row {order} trigger",
)
sidecar_row: dict[str, object] = {
"effect": row.effect,
"duration": row.duration_ms / 1000.0,
"delay": delay_ms / 1000.0,
"order": order,
"trigger": row.trigger,
}
expected_target: dict[str, object] = {
"shape_id": row.shape_id,
"delay_ms": delay_ms,
"effect": row.effect,
"duration": row.duration_ms / 1000.0,
"trigger": row.trigger,
}
effect_options = dict(row.effect_options)
try:
default_options = effective_animation_effect_options(row.effect)
except ValueError:
default_options = None
if effect_options != default_options:
sidecar_row["effect_options"] = effect_options
expected_target["effect_options"] = effect_options
if row.trigger_shape_id is not None:
expected_target["trigger_shape_id"] = row.trigger_shape_id
sidecar_row["trigger_shape"] = trigger_group_id
expected_targets.append(expected_target)
rows_by_group.setdefault(group_id, []).append(sidecar_row)
try:
validate_generated_animation_xml(data, expected_targets)
except ValueError as exc:
raise AnimationImportError(str(exc)) from exc
groups: dict[str, dict[str, object]] = {}
for group_id, rows in rows_by_group.items():
groups[group_id] = (
rows[0]
if len(rows) == 1
else {"effects": rows}
)
return AnimationImport(groups=groups)
def _contains_timing(slide_xml: bytes) -> bool:
"""Return whether any selected or fallback branch contains p:timing."""
try:
root = ET.fromstring(slide_xml)
except ET.ParseError as exc:
raise AnimationImportError(f"invalid slide XML: {exc}") from exc
return any(_local_name(node.tag) == "timing" for node in root.iter())
def _unsupported_timing_tags(slide_xml: bytes) -> tuple[str, ...]:
"""Return timing features that the finite sidecar projection cannot own."""
root = ET.fromstring(slide_xml)
found = {
_local_name(node.tag)
for node in root.iter()
if _local_name(node.tag) in _UNSUPPORTED_TIMING_TAGS
}
return tuple(sorted(found))
def _top_level_shape_group_index(slide_svg: str) -> dict[int, tuple[str, ...]]:
"""Index direct slide-local SVG group anchors by source shape id."""
try:
root = ET.fromstring(slide_svg)
except ET.ParseError as exc:
raise AnimationImportError(f"invalid reconstructed slide SVG: {exc}") from exc
groups: dict[int, list[str]] = {}
group_id_counts: dict[str, int] = {}
for child in list(root):
if child.tag != f"{{{_SVG_NS}}}g":
continue
if child.get("data-pptx-shape-scope") != "slide":
continue
raw_shape_id = child.get("data-pptx-shape-id") or ""
if not raw_shape_id.isdigit() or int(raw_shape_id) <= 0:
continue
group_id = child.get("id") or ""
if not group_id.strip():
continue
shape_id = int(raw_shape_id)
groups.setdefault(shape_id, []).append(group_id)
group_id_counts[group_id] = group_id_counts.get(group_id, 0) + 1
return {
shape_id: (
tuple(group_ids)
if (
len(group_ids) == 1
and group_id_counts.get(group_ids[0]) == 1
)
else ()
)
for shape_id, group_ids in groups.items()
}
def _resolve_group_id(
group_id_by_shape_id: dict[int, tuple[str, ...]],
shape_id: int,
*,
label: str,
) -> str:
group_ids = group_id_by_shape_id.get(shape_id, ())
if len(group_ids) != 1:
raise AnimationImportError(
f"{label} shape {shape_id} must map to exactly one unique "
f"top-level slide SVG group; found {group_ids or 'none'}"
)
return group_ids[0]
def _relative_delays(rows: tuple[AnimationRowSummary, ...]) -> tuple[int, ...]:
"""Invert the native writer's absolute main-sequence offsets."""
delays: list[int] = []
previous_start_ms = 0
previous_duration_ms = 0
has_previous = False
for index, row in enumerate(rows, 1):
if row.playback_duration_ms is None:
raise AnimationImportError(
f"animation row {index} has no exact playback duration"
)
if row.trigger_shape_id is not None or row.trigger == "on-click":
base_ms = 0
elif row.trigger == "with-previous":
base_ms = previous_start_ms if has_previous else 0
elif row.trigger == "after-previous":
base_ms = (
previous_start_ms + previous_duration_ms
if has_previous
else 0
)
else:
raise AnimationImportError(
f"animation row {index} has unsupported trigger {row.trigger!r}"
)
delay_ms = row.offset_ms - base_ms
if delay_ms < 0:
raise AnimationImportError(
f"animation row {index} has a negative reconstructed delay"
)
delays.append(delay_ms)
if row.trigger_shape_id is None:
previous_start_ms = row.offset_ms
previous_duration_ms = row.playback_duration_ms
has_previous = True
return tuple(delays)
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
__all__ = [
"AnimationImport",
"AnimationImportError",
"import_slide_animation",
"read_animation_config",
]
@@ -24,6 +24,15 @@ from html import unescape
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit
from svg_to_pptx.animation_config import (
validate_animation_config_errors,
validate_transition_config,
)
from .animation_import import (
AnimationImportError,
import_slide_animation,
)
from .color_resolver import ColorPalette from .color_resolver import ColorPalette
from .emu_units import NS from .emu_units import NS
from .import_diagnostics import ImportDiagnostic, append_diagnostic from .import_diagnostics import ImportDiagnostic, append_diagnostic
@@ -34,6 +43,10 @@ from .ooxml_loader import (
part_show_master_sp, part_show_master_sp,
) )
from .slide_to_svg import assemble_part_solo, assemble_slide from .slide_to_svg import assemble_part_solo, assemble_slide
from .transition_import import (
TransitionImportError,
import_slide_transition,
)
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"}) _CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
@@ -41,6 +54,9 @@ _MANAGED_PRIMARY_SVG_RE = re.compile(
r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg" r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg"
) )
_MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg") _MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg")
_MANAGED_TRANSITION_SOUND_RE = re.compile(
r"transition_sound_[0-9a-f]{16}\.wav"
)
_SVG_HREF_RE = re.compile( _SVG_HREF_RE = re.compile(
r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']" r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']"
) )
@@ -203,6 +219,19 @@ class ConvertResult:
flat_slides: list[SlideArtifact] = field(default_factory=list) flat_slides: list[SlideArtifact] = field(default_factory=list)
master_themes: dict[str, dict[str, object]] = field(default_factory=dict) master_themes: dict[str, dict[str, object]] = field(default_factory=dict)
diagnostics: list[ImportDiagnostic] = field(default_factory=list) diagnostics: list[ImportDiagnostic] = field(default_factory=list)
animation_config: dict[str, object] = field(
default_factory=lambda: {
"version": 1,
"defaults": {
"transition": {
"effect": "none",
"duration": 0.0,
},
},
"slides": {},
}
)
animation_media_files: dict[str, bytes] = field(default_factory=dict)
source_file: str = "" source_file: str = ""
strict: bool = False strict: bool = False
@@ -325,6 +354,12 @@ def convert_pptx_to_svg(
# rendered alongside when needed. # rendered alongside when needed.
primary_mode = "layered" if emit_layered else "flat" primary_mode = "layered" if emit_layered else "flat"
for slide in pkg.iter_slides(): for slide in pkg.iter_slides():
_read_back_slide_transition(
pkg,
slide,
result,
options,
)
slide_theme = pkg.resolve_theme(slide.master) or default_theme slide_theme = pkg.resolve_theme(slide.master) or default_theme
slide_palette = _make_palette( slide_palette = _make_palette(
slide.master, slide.master,
@@ -345,6 +380,13 @@ def convert_pptx_to_svg(
inheritance_mode=primary_mode, inheritance_mode=primary_mode,
) )
result.slides.append(artifact) result.slides.append(artifact)
_read_back_slide_animation(
pkg,
slide,
artifact,
result,
options,
)
if emit_layered and emit_flat: if emit_layered and emit_flat:
for slide in pkg.iter_slides(): for slide in pkg.iter_slides():
slide_theme = pkg.resolve_theme(slide.master) or default_theme slide_theme = pkg.resolve_theme(slide.master) or default_theme
@@ -378,6 +420,99 @@ def convert_pptx_to_svg(
return result return result
def _read_back_slide_transition(
pkg: OoxmlPackage,
slide: SlideRef,
result: ConvertResult,
options: ConvertOptions,
) -> None:
"""Recover one supported slide transition into the sidecar."""
try:
transition = import_slide_transition(
pkg,
slide,
media_subdir=options.media_subdir,
)
except TransitionImportError as exc:
message = f"Slide transition was not reconstructed: {exc}"
if options.strict:
raise ValueError(message) from exc
append_diagnostic(
result.diagnostics,
ImportDiagnostic(
code="transition-not-reconstructed",
message=message,
fallback=(
"keep this transition in the source PPTX through direct "
"native preservation"
),
part_path=slide.part.path,
slide_index=slide.index,
),
)
else:
if transition is not None:
slides = result.animation_config["slides"]
if not isinstance(slides, dict):
raise RuntimeError("internal animations.json slides must be an object")
slides[f"slide_{slide.index:02d}"] = {
"transition": transition.config,
}
for filename, payload in transition.media_files.items():
existing = result.animation_media_files.get(filename)
if existing is not None and existing != payload:
raise RuntimeError(
"Transition sound filename collision with different bytes: "
f"{filename}"
)
result.animation_media_files[filename] = payload
def _read_back_slide_animation(
pkg: OoxmlPackage,
slide: SlideRef,
artifact: SlideArtifact,
result: ConvertResult,
options: ConvertOptions,
) -> None:
"""Recover one finite object-animation sequence into the sidecar."""
try:
animation = import_slide_animation(
pkg,
slide,
slide_svg=artifact.svg,
)
except AnimationImportError as exc:
message = f"Object animation timing was not reconstructed: {exc}"
if options.strict:
raise ValueError(message) from exc
append_diagnostic(
result.diagnostics,
ImportDiagnostic(
code="animation-not-reconstructed",
message=message,
fallback=(
"keep this timing in the source PPTX through direct "
"native preservation"
),
part_path=slide.part.path,
slide_index=slide.index,
),
)
return
if animation is None:
return
slides = result.animation_config["slides"]
if not isinstance(slides, dict):
raise RuntimeError("internal animations.json slides must be an object")
slide_config = slides.setdefault(f"slide_{slide.index:02d}", {})
if not isinstance(slide_config, dict):
raise RuntimeError("internal animations.json slide row must be an object")
slide_config["groups"] = animation.groups
def _convert_slide( def _convert_slide(
pkg: OoxmlPackage, pkg: OoxmlPackage,
slide: SlideRef, slide: SlideRef,
@@ -553,6 +688,42 @@ def _managed_svg_paths(output_dir: Path) -> list[Path]:
return managed return managed
def _managed_report_artifact_paths(output_dir: Path) -> set[Path]:
"""Return optional artifacts owned by the previous conversion report."""
report_path = output_dir / "conversion-report.json"
if report_path.is_symlink() or not report_path.is_file():
return set()
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return set()
artifacts = report.get("artifacts")
if not isinstance(artifacts, dict):
return set()
if artifacts.get("animationConfig") != "animations.json":
return set()
managed = {Path("animations.json")}
animation_media = artifacts.get("animationMedia")
if not isinstance(animation_media, list):
return managed
for value in animation_media:
if not isinstance(value, str):
continue
path = Path(value)
if (
path.drive
or path.anchor
or path.is_absolute()
or not path.parts
or ".." in path.parts
or not _MANAGED_TRANSITION_SOUND_RE.fullmatch(path.name)
):
continue
managed.add(path)
return managed
def _referenced_local_paths( def _referenced_local_paths(
output_dir: Path, output_dir: Path,
svg_paths: list[Path], svg_paths: list[Path],
@@ -744,6 +915,7 @@ def publish_staged_workspace(
} }
relative_paths.update(_referenced_local_paths(output_dir, managed_svg)) relative_paths.update(_referenced_local_paths(output_dir, managed_svg))
relative_paths.add(Path("conversion-report.json")) relative_paths.add(Path("conversion-report.json"))
relative_paths.update(_managed_report_artifact_paths(output_dir))
relative_paths.update(_validated_relative_paths(managed_root_files or set())) relative_paths.update(_validated_relative_paths(managed_root_files or set()))
relative_paths.update(_validated_relative_paths(managed_relative_paths or set())) relative_paths.update(_validated_relative_paths(managed_relative_paths or set()))
_remove_managed_paths(candidate_dir, relative_paths) _remove_managed_paths(candidate_dir, relative_paths)
@@ -822,6 +994,7 @@ def _write_artifact_tree(
target = svg_dir / f"slide_{art.index:02d}.svg" target = svg_dir / f"slide_{art.index:02d}.svg"
target.write_text(art.svg, encoding="utf-8") target.write_text(art.svg, encoding="utf-8")
_collect_media(art.media_files) _collect_media(art.media_files)
_collect_media(result.animation_media_files)
# Inheritance graph alongside the layered SVGs (only meaningful when we # Inheritance graph alongside the layered SVGs (only meaningful when we
# actually emitted a layered view). # actually emitted a layered view).
@@ -837,7 +1010,8 @@ def _write_artifact_tree(
target.write_text(art.svg, encoding="utf-8") target.write_text(art.svg, encoding="utf-8")
_collect_media(art.media_files) _collect_media(art.media_files)
_write_conversion_report(output_dir, result) _write_animation_config(output_dir, result)
_write_conversion_report(output_dir, result, options)
if media_written: if media_written:
media_dir.mkdir(parents=True, exist_ok=True) media_dir.mkdir(parents=True, exist_ok=True)
for filename, blob in media_written.items(): for filename, blob in media_written.items():
@@ -875,8 +1049,16 @@ def _write_artifacts(
shutil.rmtree(staging_root, ignore_errors=True) shutil.rmtree(staging_root, ignore_errors=True)
def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None: def _write_conversion_report(
output_dir: Path,
result: ConvertResult,
options: ConvertOptions,
) -> None:
"""Write the user-visible tolerant-import report.""" """Write the user-visible tolerant-import report."""
animation_media = [
(PurePosixPath(options.media_subdir) / filename).as_posix()
for filename in sorted(result.animation_media_files)
]
report = { report = {
"schemaVersion": 1, "schemaVersion": 1,
"source": result.source_file, "source": result.source_file,
@@ -885,6 +1067,10 @@ def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None:
"slides": len(result.slides), "slides": len(result.slides),
"warnings": len(result.diagnostics), "warnings": len(result.diagnostics),
}, },
"artifacts": {
"animationConfig": "animations.json",
"animationMedia": animation_media,
},
"diagnostics": [item.to_dict() for item in result.diagnostics], "diagnostics": [item.to_dict() for item in result.diagnostics],
} }
(output_dir / "conversion-report.json").write_text( (output_dir / "conversion-report.json").write_text(
@@ -893,6 +1079,28 @@ def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None:
) )
def _write_animation_config(output_dir: Path, result: ConvertResult) -> None:
"""Write the canonical transition/object-motion sidecar."""
errors = list(
dict.fromkeys(
validate_transition_config(result.animation_config)
+ validate_animation_config_errors(result.animation_config)
)
)
if errors:
raise RuntimeError(
"Generated animations.json is invalid: " + "; ".join(errors)
)
(output_dir / "animations.json").write_text(
json.dumps(
result.animation_config,
ensure_ascii=False,
indent=2,
) + "\n",
encoding="utf-8",
)
def _write_inheritance_json(svg_dir: Path, result: ConvertResult) -> None: def _write_inheritance_json(svg_dir: Path, result: ConvertResult) -> None:
"""Record layered parentage plus source-owned shape-visibility booleans.""" """Record layered parentage plus source-owned shape-visibility booleans."""
layout_by_path = {art.part_path: art.filename for art in result.layouts} layout_by_path = {art.part_path: art.filename for art in result.layouts}
@@ -0,0 +1,611 @@
"""Import PPT Master-owned Office Math into canonical SVG formula markers.
The reverse contract is deliberately narrow: accept only the closed OMML
vocabulary already validated by the native formula compiler, serialize that
structure to compiler-accepted LaTeX, and build a plain-text SVG preview.
Unknown third-party OMML remains outside this module's reconstruction claim.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from svg_to_pptx.native_objects.formula_compiler import (
FormulaCompileError,
compile_latex_to_inline_omml,
compile_latex_to_omml,
validate_omml_fragment,
)
from svg_to_pptx.native_objects.formula_profile import (
ACCENT_COMMANDS,
NARY_COMMANDS,
)
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
_M = f"{{{MATH_NS}}}"
_A = f"{{{DRAWING_NS}}}"
_A14_M = f"{{{A14_NS}}}m"
_PROPERTY_SUFFIX = "Pr"
_NARY_COMMAND_BY_SYMBOL = {
symbol: command
for command, (symbol, _category) in NARY_COMMANDS.items()
}
_ACCENT_COMMAND_BY_CHARACTER = {
character: command
for command, character in ACCENT_COMMANDS.items()
}
_GROUP_COMMANDS = {
("", "top", "bot"): "overbrace",
("", "bot", "top"): "underbrace",
("", "bot", "top"): "underrightarrow",
("", "bot", "top"): "underleftarrow",
("", "bot", "top"): "underleftrightarrow",
("", "top", "bot"): "overrightarrow",
("", "top", "bot"): "overleftarrow",
("", "top", "bot"): "overleftrightarrow",
}
_DELIMITER_COMMANDS = {
"{": r"\{",
"}": r"\}",
"": r"\langle",
"": r"\rangle",
"": r"\lfloor",
"": r"\rfloor",
"": r"\lceil",
"": r"\rceil",
"": r"\Vert",
"": "",
"": "",
}
_SPACING_LATEX = {
"\u200b": r"\!",
"\u2009": r"\,",
"\u205f": r"\:",
"\u2004": r"\;",
"\u2002": r"\enspace{}",
"\u2003": r"\quad{}",
"\u00a0": "~",
}
class FormulaImportError(ValueError):
"""Raised when source OMML is outside the reversible project contract."""
@dataclass(frozen=True)
class FormulaImport:
"""Canonical formula source plus a visible SVG-preview representation."""
latex: str
preview: str
font_size_px: float
color: str
align: str
language: str
def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
"""Validate and reconstruct one ``a14:m`` formula zone."""
if math_zone.tag != _A14_M:
raise FormulaImportError("formula carrier must be one a14:m element")
children = [child for child in math_zone if isinstance(child.tag, str)]
if len(children) != 1:
raise FormulaImportError("a14:m must contain exactly one Office Math root")
root = children[0]
expected = f"{_M}{'oMathPara' if display else 'oMath'}"
if root.tag != expected:
kind = "block" if display else "inline"
raise FormulaImportError(
f"{kind} formula requires m:{expected.rsplit('}', 1)[-1]}"
)
try:
canonical = validate_omml_fragment(
ET.tostring(root, encoding="unicode", short_empty_elements=True)
)
validated_root = ET.fromstring(canonical)
latex = _serialize_root(validated_root)
if display:
compile_latex_to_omml(latex)
else:
compile_latex_to_inline_omml(latex)
except (FormulaCompileError, ET.ParseError, RecursionError) as exc:
raise FormulaImportError(str(exc)) from exc
preview = _preview_root(validated_root).strip()
if not latex.strip() or not preview:
raise FormulaImportError("formula reconstruction produced empty content")
font_size_px, color, language = _dominant_run_style(validated_root)
return FormulaImport(
latex=latex,
preview=preview,
font_size_px=font_size_px,
color=color,
align=_formula_alignment(validated_root),
language=language,
)
def opaque_formula_preview(math_zone: ET.Element) -> str:
"""Return readable source text without claiming native reconstruction."""
text = "".join(
item.text or ""
for item in math_zone.iter(f"{_M}t")
).strip()
return text or "[unsupported formula]"
def _serialize_root(root: ET.Element) -> str:
if root.tag == f"{_M}oMathPara":
math = root.find(f"{_M}oMath")
if math is None:
raise FormulaImportError("m:oMathPara is missing m:oMath")
return _serialize_children(math)
if root.tag == f"{_M}oMath":
return _serialize_children(root)
raise FormulaImportError("unsupported Office Math root")
def _serialize_children(parent: ET.Element, *, alignment: bool = False) -> str:
return "".join(
_serialize_node(child, alignment=alignment)
for child in parent
if _local_name(child) not in {_PROPERTY_SUFFIX, "ctrlPr"}
and not _local_name(child).endswith(_PROPERTY_SUFFIX)
)
def _serialize_argument(owner: ET.Element, name: str, *, alignment: bool = False) -> str:
element = owner.find(f"{_M}{name}")
if element is None:
raise FormulaImportError(
f"m:{_local_name(owner)} is missing required m:{name}"
)
return _serialize_children(element, alignment=alignment)
def _serialize_node(element: ET.Element, *, alignment: bool = False) -> str:
name = _local_name(element)
if name == "r":
return _serialize_run(element, alignment=alignment)
if name == "f":
kind = _property_value(element, "fPr", "type", "bar")
command = {"bar": "frac", "skw": "ifrac"}.get(kind)
numerator = _serialize_argument(element, "num")
denominator = _serialize_argument(element, "den")
if command is not None:
return f"\\{command}{{{numerator}}}{{{denominator}}}"
if kind == "noBar":
return (
r"\genfrac{}{}{0pt}{}"
f"{{{numerator}}}{{{denominator}}}"
)
raise FormulaImportError(f"unsupported reversible fraction type: {kind!r}")
if name == "rad":
body = _serialize_argument(element, "e")
degree = _serialize_argument(element, "deg")
return f"\\sqrt[{degree}]{{{body}}}" if degree else f"\\sqrt{{{body}}}"
if name in {"sSub", "sSup", "sSubSup"}:
base = _serialize_argument(element, "e")
subscript = _serialize_optional_argument(element, "sub")
superscript = _serialize_optional_argument(element, "sup")
return _scripted(f"{{{base}}}", subscript, superscript)
if name == "sPre":
base = _serialize_argument(element, "e")
subscript = _serialize_optional_argument(element, "sub")
superscript = _serialize_optional_argument(element, "sup")
return _scripted("", subscript, superscript) + f"{{{base}}}"
if name == "nary":
symbol = _property_value(element, "naryPr", "chr", "")
command = _NARY_COMMAND_BY_SYMBOL.get(symbol)
if command is None:
raise FormulaImportError(f"unsupported reversible n-ary symbol: {symbol!r}")
source = f"\\{command}"
# Serialize the source location explicitly when it uses under/over
# limits. ``subSup`` is the neutral default in both formula contexts.
default_location = "subSup"
location = _property_value(element, "naryPr", "limLoc", default_location)
if location != default_location:
source += r"\limits" if location == "undOvr" else r"\nolimits"
source = _scripted(
source,
_serialize_optional_argument(element, "sub"),
_serialize_optional_argument(element, "sup"),
)
body = _serialize_optional_argument(element, "e")
return source + (f"{{{body}}}" if body else "")
if name == "d":
left = _property_value(element, "dPr", "begChr", "(")
right = _property_value(element, "dPr", "endChr", ")")
separator = _property_value(element, "dPr", "sepChr", "")
segments = [
_serialize_children(child)
for child in element.findall(f"{_M}e")
]
middle = f"\\middle{_delimiter(separator, side='middle')}" if separator else ""
return (
f"\\left{_delimiter(left, side='left')}"
+ middle.join(segments)
+ f"\\right{_delimiter(right, side='right')}"
)
if name == "m":
rows = []
for row in element.findall(f"{_M}mr"):
rows.append("&".join(
_serialize_children(cell)
for cell in row.findall(f"{_M}e")
))
return r"\begin{matrix}" + r"\\".join(rows) + r"\end{matrix}"
if name == "eqArr":
rows = [
_serialize_children(row, alignment=True)
for row in element.findall(f"{_M}e")
]
return r"\begin{aligned}" + r"\\".join(rows) + r"\end{aligned}"
if name == "acc":
character = _property_value(element, "accPr", "chr", "")
command = _ACCENT_COMMAND_BY_CHARACTER.get(character)
if character == "̸":
command = "not"
if command is None:
raise FormulaImportError(f"unsupported reversible accent: {character!r}")
return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
if name == "bar":
position = _property_value(element, "barPr", "pos", "top")
command = "overline" if position == "top" else "underline"
return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
if name == "groupChr":
key = (
_property_value(element, "groupChrPr", "chr", ""),
_property_value(element, "groupChrPr", "pos", "top"),
_property_value(element, "groupChrPr", "vertJc", "bot"),
)
command = _GROUP_COMMANDS.get(key)
if command is None:
raise FormulaImportError(f"unsupported reversible group character: {key!r}")
return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
if name == "limLow":
base = _serialize_argument(element, "e")
lower = _serialize_argument(element, "lim")
return f"\\underset{{{lower}}}{{{base}}}"
if name == "limUpp":
base = _serialize_argument(element, "e")
upper = _serialize_argument(element, "lim")
return f"\\overset{{{upper}}}{{{base}}}"
if name == "func":
function_name = _serialize_argument(element, "fName")
body = _serialize_optional_argument(element, "e")
source = f"\\mathop{{{function_name}}}"
return source + (f"{{{body}}}" if body else "")
if name == "box":
enabled = _property_value(element, "boxPr", "opEmu", "off")
if enabled not in {"on", "true", "1"}:
raise FormulaImportError("only operator-emulator m:box is reversible")
return f"\\mathrel{{{_serialize_argument(element, 'e')}}}"
if name == "phant":
properties = element.find(f"{_M}phantPr")
zero_width = _on_property(properties, "zeroWid")
zero_ascent = _on_property(properties, "zeroAsc")
zero_descent = _on_property(properties, "zeroDesc")
if zero_width:
command = "vphantom"
elif zero_ascent and zero_descent:
command = "hphantom"
else:
command = "phantom"
return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
if name == "borderBox":
properties = element.find(f"{_M}borderBoxPr")
rising = _on_property(properties, "strikeBLTR")
falling = _on_property(properties, "strikeTLBR")
hidden = all(
_on_property(properties, side)
for side in ("hideTop", "hideBot", "hideLeft", "hideRight")
)
if hidden and rising and falling:
command = "xcancel"
elif hidden and rising:
command = "cancel"
elif hidden and falling:
command = "bcancel"
elif not hidden and not rising and not falling:
command = "boxed"
else:
raise FormulaImportError("unsupported reversible border-box properties")
return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
if name in {"e", "deg", "den", "fName", "lim", "num", "sub", "sup"}:
return _serialize_children(element, alignment=alignment)
raise FormulaImportError(f"unsupported reversible Office Math element: m:{name}")
def _serialize_optional_argument(owner: ET.Element, name: str) -> str:
element = owner.find(f"{_M}{name}")
return _serialize_children(element) if element is not None else ""
def _scripted(base: str, subscript: str, superscript: str) -> str:
if subscript:
base += f"_{{{subscript}}}"
if superscript:
base += f"^{{{superscript}}}"
return base
def _serialize_run(run: ET.Element, *, alignment: bool) -> str:
text_element = run.find(f"{_M}t")
value = text_element.text or "" if text_element is not None else ""
properties = run.find(f"{_M}rPr")
literal = _on_property(properties, "lit")
normal = _on_property(properties, "nor")
source = _escape_text(value, alignment=alignment and not literal, text_mode=normal)
if not source:
return ""
drawing = run.find(f"{_A}rPr")
if normal:
typeface = ""
if drawing is not None:
latin = drawing.find(f"{_A}latin")
typeface = latin.get("typeface", "") if latin is not None else ""
text_command = {
"Arial": "textsf",
"Courier New": "texttt",
}.get(typeface, "text")
source = f"\\{text_command}{{{source}}}"
if drawing is not None and _drawing_on(drawing, "b"):
source = f"\\textbf{{{source}}}"
if drawing is not None and _drawing_on(drawing, "i"):
source = f"\\textit{{{source}}}"
else:
script = _property_child_value(properties, "scr")
style = _property_child_value(properties, "sty")
command = {
"sans-serif": "mathsf",
"monospace": "mathtt",
"double-struck": "mathbb",
"script": "mathcal",
"fraktur": "mathfrak",
}.get(script or "")
if command is None:
command = {
"p": "mathrm",
"b": "mathbf",
"i": "mathit",
"bi": "boldsymbol",
}.get(style or "")
if command is not None:
source = f"\\{command}{{{source}}}"
color = _drawing_color(drawing)
if color is not None:
source = f"\\textcolor{{#{color}}}{{{source}}}"
return source
def _escape_text(value: str, *, alignment: bool, text_mode: bool) -> str:
parts: list[str] = []
for character in value:
if character in _SPACING_LATEX and not text_mode:
parts.append(_SPACING_LATEX[character])
elif character == " " and not text_mode:
parts.append(r"\ ")
elif character == "&":
parts.append("&" if alignment else r"\&")
elif character in "{}_%#$":
parts.append("\\" + character)
elif character == "\\":
parts.append(r"\backslash{}")
elif character == "~" and not text_mode:
parts.append(r"\text{~}")
elif character == "^" and not text_mode:
parts.append(r"\text{^}")
else:
parts.append(character)
return "".join(parts)
def _delimiter(value: str, *, side: str) -> str:
if not value:
return "."
if value == "|":
return "|"
command = _DELIMITER_COMMANDS.get(value)
if command is not None:
if value == "" and side == "left":
command = r"\lVert"
if value == "" and side == "right":
command = r"\rVert"
if command.startswith("\\") and command[1:].isalpha():
return command + " "
return command
if len(value) == 1:
return value
raise FormulaImportError(f"unsupported reversible delimiter: {value!r}")
def _preview_root(root: ET.Element) -> str:
math = root.find(f"{_M}oMath") if root.tag == f"{_M}oMathPara" else root
if math is None:
return ""
return _preview_children(math)
def _preview_children(parent: ET.Element) -> str:
return "".join(
_preview_node(child)
for child in parent
if not _local_name(child).endswith(_PROPERTY_SUFFIX)
and _local_name(child) != "ctrlPr"
)
def _preview_argument(owner: ET.Element, name: str) -> str:
child = owner.find(f"{_M}{name}")
return _preview_children(child) if child is not None else ""
def _preview_node(element: ET.Element) -> str:
name = _local_name(element)
if name == "r":
text = element.find(f"{_M}t")
return text.text or "" if text is not None else ""
if name == "f":
return f"({_preview_argument(element, 'num')})/({_preview_argument(element, 'den')})"
if name == "rad":
degree = _preview_argument(element, "deg")
prefix = f"√[{degree}]" if degree else ""
return f"{prefix}({_preview_argument(element, 'e')})"
if name in {"sSub", "sSup", "sSubSup"}:
base = _preview_argument(element, "e")
subscript = _preview_argument(element, "sub")
superscript = _preview_argument(element, "sup")
return _preview_script(base, subscript, superscript)
if name == "sPre":
prefix = _preview_script(
"",
_preview_argument(element, "sub"),
_preview_argument(element, "sup"),
)
return prefix + _preview_argument(element, "e")
if name == "nary":
symbol = _property_value(element, "naryPr", "chr", "")
return _preview_script(
symbol,
_preview_argument(element, "sub"),
_preview_argument(element, "sup"),
) + _preview_argument(element, "e")
if name == "d":
left = _property_value(element, "dPr", "begChr", "(")
right = _property_value(element, "dPr", "endChr", ")")
separator = _property_value(element, "dPr", "sepChr", "")
segments = [_preview_children(child) for child in element.findall(f"{_M}e")]
return left + separator.join(segments) + right
if name == "m":
rows = [
", ".join(_preview_children(cell) for cell in row.findall(f"{_M}e"))
for row in element.findall(f"{_M}mr")
]
return "[" + "; ".join(rows) + "]"
if name == "eqArr":
return " ".join(
_preview_children(row)
for row in element.findall(f"{_M}e")
)
if name == "acc":
return _preview_argument(element, "e") + _property_value(element, "accPr", "chr", "")
if name == "bar":
marker = "¯" if _property_value(element, "barPr", "pos", "top") == "top" else "_"
return marker + f"({_preview_argument(element, 'e')})"
if name == "groupChr":
marker = _property_value(element, "groupChrPr", "chr", "")
return marker + f"({_preview_argument(element, 'e')})"
if name == "limLow":
return _preview_script(
_preview_argument(element, "e"),
_preview_argument(element, "lim"),
"",
)
if name == "limUpp":
return _preview_script(
_preview_argument(element, "e"),
"",
_preview_argument(element, "lim"),
)
if name == "func":
return _preview_argument(element, "fName") + _preview_argument(element, "e")
if name in {"box", "phant"}:
return _preview_argument(element, "e")
if name == "borderBox":
return "□(" + _preview_argument(element, "e") + ")"
if name in {"e", "deg", "den", "fName", "lim", "num", "sub", "sup"}:
return _preview_children(element)
return ""
def _preview_script(base: str, subscript: str, superscript: str) -> str:
if subscript:
base += f"_({subscript})"
if superscript:
base += f"^({superscript})"
return base
def _dominant_run_style(root: ET.Element) -> tuple[float, str, str]:
sizes: list[str] = []
colors: list[str] = []
languages: list[str] = []
for properties in root.iter(f"{_A}rPr"):
if properties.get("sz"):
sizes.append(properties.get("sz") or "")
if properties.get("lang"):
languages.append(properties.get("lang") or "")
color = properties.find(f"{_A}solidFill/{_A}srgbClr")
if color is not None and color.get("val"):
colors.append(color.get("val") or "")
raw_size = _dominant(sizes, "2100")
try:
font_size = max(1.0, min(400.0, int(raw_size) / 75.0))
except ValueError:
font_size = 28.0
return (
font_size,
f"#{_dominant(colors, '000000').upper()}",
_dominant(languages, "en-US"),
)
def _formula_alignment(root: ET.Element) -> str:
if root.tag != f"{_M}oMathPara":
return "left"
value = _property_value(root, "oMathParaPr", "jc", "center")
return {"left": "left", "right": "right"}.get(value, "center")
def _dominant(values: list[str], default: str) -> str:
return Counter(values).most_common(1)[0][0] if values else default
def _property_value(owner: ET.Element, properties: str, name: str, default: str) -> str:
container = owner.find(f"{_M}{properties}")
value = _property_child_value(container, name)
return value if value is not None else default
def _property_child_value(owner: ET.Element | None, name: str) -> str | None:
if owner is None:
return None
child = owner.find(f"{_M}{name}")
return child.get(f"{_M}val") if child is not None else None
def _on_property(owner: ET.Element | None, name: str) -> bool:
return _property_child_value(owner, name) in {"on", "true", "1"}
def _drawing_on(owner: ET.Element, name: str) -> bool:
return owner.get(name) in {"on", "true", "1"}
def _drawing_color(owner: ET.Element | None) -> str | None:
if owner is None:
return None
color = owner.find(f"{_A}solidFill/{_A}srgbClr")
return color.get("val") if color is not None else None
def _local_name(element: ET.Element) -> str:
return element.tag.rsplit("}", 1)[-1]
__all__ = [
"A14_NS",
"FormulaImport",
"FormulaImportError",
"import_formula",
"opaque_formula_preview",
]
@@ -264,13 +264,17 @@ class OoxmlPackage:
"""Load an arbitrary XML part by package path.""" """Load an arbitrary XML part by package path."""
return self._load_part(part_path) return self._load_part(part_path)
def read_media(self, part_path: str) -> bytes | None: def read_part_bytes(self, part_path: str) -> bytes | None:
"""Return raw bytes of an embedded media part (e.g. ppt/media/image1.png).""" """Return the unchanged bytes of one internal package part."""
assert self.zip is not None assert self.zip is not None
if part_path not in self.zip.namelist(): if part_path not in self.zip.namelist():
return None return None
return self.zip.read(part_path) return self.zip.read(part_path)
def read_media(self, part_path: str) -> bytes | None:
"""Return raw bytes of an embedded media part (e.g. ppt/media/image1.png)."""
return self.read_part_bytes(part_path)
def media_filename(self, part_path: str) -> str: def media_filename(self, part_path: str) -> str:
"""Last segment of the media path, e.g. 'image1.png'.""" """Last segment of the media path, e.g. 'image1.png'."""
return PurePosixPath(part_path).name return PurePosixPath(part_path).name
@@ -54,6 +54,13 @@ from .effect_to_svg import (
) )
from .emu_units import NS, Xfrm, fmt_num, format_canvas_px_from_emu from .emu_units import NS, Xfrm, fmt_num, format_canvas_px_from_emu
from .fill_to_svg import FillResult, resolve_fill from .fill_to_svg import FillResult, resolve_fill
from .formula_import import (
A14_NS,
FormulaImport,
FormulaImportError,
import_formula,
opaque_formula_preview,
)
from .import_diagnostics import ( from .import_diagnostics import (
ImportDiagnostic, ImportDiagnostic,
append_diagnostic, append_diagnostic,
@@ -524,6 +531,33 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
node.inherited_body_properties, node.inherited_body_properties,
) )
is_vertical = is_vertical_txbody(tx_body, node.xfrm) is_vertical = is_vertical_txbody(tx_body, node.xfrm)
block_formula = _block_formula_zone(tx_body)
block_formula_failed = False
if block_formula is not None and not is_vertical:
try:
carrier_error = _block_formula_carrier_error(
node,
top_level=top_level,
)
if carrier_error is not None:
raise FormulaImportError(carrier_error)
imported_formula = import_formula(block_formula, display=True)
except FormulaImportError as exc:
_diagnose_formula_fallback(ctx, exc)
block_formula_failed = True
else:
return _render_block_formula(
node,
ctx,
imported_formula,
top_level=top_level,
)
inline_formula_resolver = _prepare_inline_formula_resolver(
tx_body,
ctx,
allow_native=not is_vertical,
force_opaque=block_formula_failed,
)
local_has_run_effects = txbody_has_run_effects(source_tx_body) local_has_run_effects = txbody_has_run_effects(source_tx_body)
inherited_has_run_effects = txbody_has_run_effects( inherited_has_run_effects = txbody_has_run_effects(
*node.inherited_lst_styles *node.inherited_lst_styles
@@ -563,6 +597,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
rid, rid,
action, action,
), ),
inline_formula_resolver=inline_formula_resolver,
) )
else: else:
text_result = convert_txbody( text_result = convert_txbody(
@@ -579,6 +614,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
rid, rid,
action, action,
), ),
inline_formula_resolver=inline_formula_resolver,
) if tx_body is not None else TextResult() ) if tx_body is not None else TextResult()
except ValueError as exc: except ValueError as exc:
if ctx.strict: if ctx.strict:
@@ -617,7 +653,11 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
inner_parts.append(blip_image) inner_parts.append(blip_image)
if geom_xml: if geom_xml:
inner_parts.append(geom_xml) inner_parts.append(geom_xml)
if source_tx_body is not None and geom is not None: if (
source_tx_body is not None
and geom is not None
and not text_result.contains_inline_formula
):
inner_parts.append( inner_parts.append(
_txbody_metadata( _txbody_metadata(
source_tx_body, source_tx_body,
@@ -681,6 +721,214 @@ def _effective_placeholder_tx_body(
return effective return effective
def _block_formula_zone(tx_body: ET.Element | None) -> ET.Element | None:
"""Return the sole block-math zone from a canonical formula text body."""
if tx_body is None:
return None
paragraphs = tx_body.findall("a:p", NS)
if len(paragraphs) != 1:
return None
paragraph = paragraphs[0]
formula_zones = [
child
for child in paragraph
if child.tag == f"{{{A14_NS}}}m"
]
allowed = {
f"{{{NS['a']}}}pPr",
f"{{{NS['a']}}}endParaRPr",
f"{{{A14_NS}}}m",
}
if len(formula_zones) != 1 or any(
child.tag not in allowed
for child in paragraph
if isinstance(child.tag, str)
):
return None
root_children = [
child for child in formula_zones[0] if isinstance(child.tag, str)
]
if (
len(root_children) != 1
or root_children[0].tag
!= "{http://schemas.openxmlformats.org/officeDocument/2006/math}oMathPara"
):
return None
return formula_zones[0]
def _block_formula_carrier_error(
node: ShapeNode,
*,
top_level: bool,
) -> str | None:
"""Reject block carriers whose non-formula state would be discarded."""
if not top_level:
return "grouped block formula carrier is not reversible"
if (
node.xfrm.rot
or node.xfrm.flip_h
or node.xfrm.flip_v
or node.effective_rotation
):
return "block formula carrier rotation or flip is not reversible"
if node.placeholder is not None:
return "block formula carrier cannot retain placeholder ownership"
if node.hyperlink_rid or node.hyperlink_action:
return "block formula carrier hyperlink is not reversible"
if node.xml.find("p:style", NS) is not None:
return "block formula carrier style reference is not reversible"
sp_pr = node.xml.find("p:spPr", NS)
if sp_pr is None:
return "block formula carrier is missing p:spPr"
preset = sp_pr.find("a:prstGeom", NS)
if preset is None or preset.get("prst") != "rect":
return "block formula carrier must use rectangular geometry"
if any(
sp_pr.find(path, NS) is not None
for path in (
"a:solidFill",
"a:gradFill",
"a:pattFill",
"a:blipFill",
"a:grpFill",
"a:effectLst",
"a:effectDag",
"a:scene3d",
"a:sp3d",
)
):
return "block formula carrier paint or effect is not reversible"
line = sp_pr.find("a:ln", NS)
if line is not None and line.find("a:noFill", NS) is None:
return "block formula carrier line is not reversible"
return None
def _prepare_inline_formula_resolver(
tx_body: ET.Element | None,
ctx: AssemblyContext,
*,
allow_native: bool,
force_opaque: bool = False,
):
"""Build one all-or-opaque resolver for formula runs in a text body."""
if tx_body is None:
return None
zones = [
child
for paragraph in tx_body.findall("a:p", NS)
for child in paragraph
if child.tag == f"{{{A14_NS}}}m"
]
if not zones:
return None
imported: dict[int, FormulaImport] = {}
failed = force_opaque
if not allow_native:
failed = True
_diagnose_formula_fallback(
ctx,
FormulaImportError(
"formula reconstruction is not supported inside vertical text"
),
)
elif not force_opaque:
for zone in zones:
try:
imported[id(zone)] = import_formula(zone, display=False)
except FormulaImportError as exc:
failed = True
_diagnose_formula_fallback(ctx, exc)
def _resolve(zone: ET.Element) -> tuple[str | None, str]:
if failed:
return None, opaque_formula_preview(zone)
item = imported.get(id(zone))
if item is None:
return None, opaque_formula_preview(zone)
return item.latex, item.preview
return _resolve
def _diagnose_formula_fallback(
ctx: AssemblyContext,
error: FormulaImportError,
) -> None:
message = f"Office Math was not reconstructed: {error}"
if ctx.strict:
raise ValueError(message) from error
ctx.diagnose(
"formula-not-reconstructed",
message,
"render a linear text preview and preserve the relationship-free source txBody",
)
def _render_block_formula(
node: ShapeNode,
ctx: AssemblyContext,
formula: FormulaImport,
*,
top_level: bool,
) -> str:
"""Emit one canonical block marker and a dependency-free SVG preview."""
x = fmt_num(node.xfrm.x)
y = fmt_num(node.xfrm.y)
width = fmt_num(node.xfrm.w)
height = fmt_num(node.xfrm.h)
align = formula.align
if align == "right":
preview_x = node.xfrm.x + node.xfrm.w
anchor = "end"
elif align == "left":
preview_x = node.xfrm.x
anchor = "start"
else:
preview_x = node.xfrm.x + node.xfrm.w / 2.0
anchor = "middle"
preview_y = node.xfrm.y + node.xfrm.h / 2.0 + formula.font_size_px * 0.35
payload = {
"latex": formula.latex,
"display": "block",
"font_size": formula.font_size_px,
"color": formula.color,
"align": align,
"language": formula.language,
"name": node.name or f"Formula {node.spid}",
}
metadata = (
'<metadata type="application/json">'
+ _xml_escape(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
+ "</metadata>"
)
preview = (
f'<text x="{fmt_num(preview_x)}" y="{fmt_num(preview_y)}" '
f'text-anchor="{anchor}" font-family="Cambria Math" '
f'font-size="{fmt_num(formula.font_size_px)}" '
f'fill="{_xml_escape(formula.color)}">'
f'{_xml_escape(formula.preview)}</text>'
)
return _wrap_shape_group(
metadata + "\n" + preview,
node,
ctx,
top_level=top_level,
extra_attrs=[
'data-pptx-replace-with="formula"',
'data-pptx-import-source="pptx"',
f'data-pptx-x="{x}"',
f'data-pptx-y="{y}"',
f'data-pptx-width="{width}"',
f'data-pptx-height="{height}"',
f'data-pptx-bounds="{x} {y} {width} {height}"',
],
)
def _txbody_metadata( def _txbody_metadata(
tx_body: ET.Element, tx_body: ET.Element,
visible_text_svg: str, visible_text_svg: str,
@@ -0,0 +1,201 @@
"""Import PPT Master-owned slide transitions into animations.json rows.
The reverse contract accepts only source XML that passes the existing
generated-transition read-back validator. Unknown third-party carriers remain
outside the reconstruction claim and are handled by the caller's diagnostics.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import PurePosixPath
from pptx_transitions import (
TransitionSummary,
read_slide_transition_xml,
validate_generated_transition_xml,
)
from .ooxml_loader import OoxmlPackage, SlideRef
_AUDIO_RELATIONSHIP_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
)
class TransitionImportError(ValueError):
"""Raised when a source transition is outside the reversible contract."""
@dataclass(frozen=True)
class TransitionImport:
"""One canonical sidecar row plus any extracted transition sound."""
config: dict[str, object]
media_files: dict[str, bytes]
@dataclass(frozen=True)
class TransitionReadback:
"""One validated transition before its sound relationship is resolved."""
config: dict[str, object]
summary: TransitionSummary
def read_transition_config(
slide_xml: str | bytes,
) -> TransitionReadback | None:
"""Read one exact generated-transition contract from slide XML."""
summary = read_slide_transition_xml(slide_xml)
if summary.logical_count == 0:
return None
if summary.speed is not None:
raise TransitionImportError(
"legacy p:transition@spd cannot be represented exactly by "
"animations.json duration"
)
if summary.effect is not None and summary.canonical_effect is None:
raise TransitionImportError(
"transition effect is outside the canonical native registry"
)
if summary.canonical_effect is not None and summary.duration_ms is None:
raise TransitionImportError(
"visual transition is missing the exact p14:dur duration"
)
sound = _sound_expectation(summary)
duration = (
summary.duration_ms / 1000.0
if summary.duration_ms is not None
else 0.0
)
advance_after = (
summary.advance_after_ms / 1000.0
if summary.advance_after_ms is not None
else None
)
try:
validate_generated_transition_xml(
slide_xml,
effect=summary.canonical_effect,
effect_options=summary.effect_options,
duration=duration,
advance_on_click=summary.advance_on_click,
advance_after=advance_after,
sound=sound,
)
except ValueError as exc:
raise TransitionImportError(str(exc)) from exc
if summary.advance_on_click is not True:
raise TransitionImportError(
"advance_on_click=false is outside the animations.json "
"transition contract"
)
config: dict[str, object] = {
"effect": summary.canonical_effect or "none",
}
if summary.effect_options:
config["effect_options"] = dict(summary.effect_options)
if summary.canonical_effect is not None:
config["duration"] = duration
if advance_after is not None:
config["auto_advance"] = advance_after
return TransitionReadback(config=config, summary=summary)
def import_slide_transition(
pkg: OoxmlPackage,
slide: SlideRef,
*,
media_subdir: str,
) -> TransitionImport | None:
"""Read one slide transition and resolve its optional WAV relationship."""
slide_xml = pkg.read_part_bytes(slide.part.path)
if slide_xml is None:
raise TransitionImportError(
f"source slide part is missing: {slide.part.path}"
)
readback = read_transition_config(slide_xml)
if readback is None:
return None
config = dict(readback.config)
media_files: dict[str, bytes] = {}
relationship_id = readback.summary.sound_relationship_id
if relationship_id is not None:
sound_path, sound_bytes = _resolve_transition_sound(
pkg,
slide,
relationship_id,
)
media_files[sound_path] = sound_bytes
config["sound"] = (
PurePosixPath(media_subdir) / sound_path
).as_posix()
return TransitionImport(config=config, media_files=media_files)
def _sound_expectation(
summary: TransitionSummary,
) -> dict[str, str] | None:
relationship_id = summary.sound_relationship_id
name = summary.sound_name
if relationship_id is None and name is None:
return None
if not relationship_id or not name:
raise TransitionImportError(
"transition sound requires both relationship id and display name"
)
return {
"relationship_id": relationship_id,
"name": name,
}
def _resolve_transition_sound(
pkg: OoxmlPackage,
slide: SlideRef,
relationship_id: str,
) -> tuple[str, bytes]:
relationship = slide.part.rels.get(relationship_id)
if relationship is None:
raise TransitionImportError(
f"transition sound relationship is missing: {relationship_id}"
)
if relationship.get("external"):
raise TransitionImportError("transition sound relationship must be internal")
if relationship.get("type") != _AUDIO_RELATIONSHIP_TYPE:
raise TransitionImportError(
"transition sound relationship must use the OOXML audio type"
)
target = relationship.get("target") or ""
if PurePosixPath(target).suffix.lower() != ".wav":
raise TransitionImportError("transition sound part must use .wav")
payload = pkg.read_part_bytes(target)
if payload is None:
raise TransitionImportError(
f"transition sound part is missing: {target}"
)
if not (
len(payload) >= 12
and payload[:4] in {b"RIFF", b"RF64"}
and payload[8:12] == b"WAVE"
):
raise TransitionImportError(
f"transition sound part is not a valid WAV file: {target}"
)
digest = hashlib.sha256(payload).hexdigest()[:16]
return f"transition_sound_{digest}.wav", payload
__all__ = [
"TransitionImport",
"TransitionImportError",
"TransitionReadback",
"import_slide_transition",
"read_transition_config",
]
@@ -66,9 +66,11 @@ class TextRun:
letter_spacing_px: float = 0.0 letter_spacing_px: float = 0.0
is_break: bool = False # marks an a:br within a paragraph is_break: bool = False # marks an a:br within a paragraph
hyperlink_href: str | None = None hyperlink_href: str | None = None
formula_latex: str | None = None
HyperlinkResolver = Callable[[str, str], str | None] HyperlinkResolver = Callable[[str, str], str | None]
InlineFormulaResolver = Callable[[ET.Element], tuple[str | None, str]]
@dataclass @dataclass
@@ -97,6 +99,7 @@ class TextResult:
svg: str = "" svg: str = ""
defs: list[str] = field(default_factory=list) defs: list[str] = field(default_factory=list)
contains_inline_formula: bool = False
VERTICAL_TEXT_MODES = {"eaVert", "vert", "wordArtVert", "wordArtVertRtl"} VERTICAL_TEXT_MODES = {"eaVert", "vert", "wordArtVert", "wordArtVertRtl"}
@@ -120,6 +123,7 @@ def convert_txbody(
id_prefix: str = "txt", id_prefix: str = "txt",
id_seq: list[int] | None = None, id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None, hyperlink_resolver: HyperlinkResolver | None = None,
inline_formula_resolver: InlineFormulaResolver | None = None,
) -> TextResult: ) -> TextResult:
"""Convert <p:txBody> under the given shape geometry to SVG <text>(s).""" """Convert <p:txBody> under the given shape geometry to SVG <text>(s)."""
if tx_body is None: if tx_body is None:
@@ -133,6 +137,7 @@ def convert_txbody(
fallback_run_props=fallback_run_props, fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver, hyperlink_resolver=hyperlink_resolver,
inline_formula_resolver=inline_formula_resolver,
) )
if not paragraphs or not _has_visible_text(paragraphs): if not paragraphs or not _has_visible_text(paragraphs):
return TextResult() return TextResult()
@@ -204,7 +209,12 @@ def convert_txbody(
if cursor_y >= bottom_y: if cursor_y >= bottom_y:
break break
return TextResult(svg="\n".join(text_blocks), defs=_collect_text_defs(paragraphs)) svg = "\n".join(text_blocks)
return TextResult(
svg=svg,
defs=_collect_text_defs(paragraphs),
contains_inline_formula="data-pptx-inline-formula=" in svg,
)
def is_vertical_txbody(tx_body: ET.Element | None, xfrm: Xfrm | None = None) -> bool: def is_vertical_txbody(tx_body: ET.Element | None, xfrm: Xfrm | None = None) -> bool:
@@ -232,6 +242,7 @@ def convert_vertical_txbody(
id_prefix: str = "txt", id_prefix: str = "txt",
id_seq: list[int] | None = None, id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None, hyperlink_resolver: HyperlinkResolver | None = None,
inline_formula_resolver: InlineFormulaResolver | None = None,
) -> TextResult: ) -> TextResult:
"""Render East Asian vertical text as upright stacked glyphs. """Render East Asian vertical text as upright stacked glyphs.
@@ -250,6 +261,7 @@ def convert_vertical_txbody(
fallback_run_props=fallback_run_props, fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver, hyperlink_resolver=hyperlink_resolver,
inline_formula_resolver=inline_formula_resolver,
) )
runs = [ runs = [
run run
@@ -309,6 +321,7 @@ def convert_vertical_txbody(
return TextResult( return TextResult(
svg=f"<text{attrs}>{''.join(spans)}</text>", svg=f"<text{attrs}>{''.join(spans)}</text>",
defs=_collect_text_defs(paragraphs), defs=_collect_text_defs(paragraphs),
contains_inline_formula=False,
) )
@@ -387,6 +400,7 @@ def _parse_paragraphs(
id_prefix: str = "txt", id_prefix: str = "txt",
id_seq: list[int] | None = None, id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None, hyperlink_resolver: HyperlinkResolver | None = None,
inline_formula_resolver: InlineFormulaResolver | None = None,
) -> list[TextParagraph]: ) -> list[TextParagraph]:
"""Walk <a:p> children producing TextParagraph objects.""" """Walk <a:p> children producing TextParagraph objects."""
paragraphs: list[TextParagraph] = [] paragraphs: list[TextParagraph] = []
@@ -407,6 +421,7 @@ def _parse_paragraphs(
slide_number=slide_number, slide_number=slide_number,
id_prefix=id_prefix, id_seq=id_seq, id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver, hyperlink_resolver=hyperlink_resolver,
inline_formula_resolver=inline_formula_resolver,
) )
paragraphs.append(para) paragraphs.append(para)
@@ -427,6 +442,7 @@ def _parse_paragraph(
id_prefix: str = "txt", id_prefix: str = "txt",
id_seq: list[int] | None = None, id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None, hyperlink_resolver: HyperlinkResolver | None = None,
inline_formula_resolver: InlineFormulaResolver | None = None,
) -> TextParagraph: ) -> TextParagraph:
para = TextParagraph() para = TextParagraph()
@@ -504,6 +520,20 @@ def _parse_paragraph(
text = str(slide_number) text = str(slide_number)
if text: if text:
para.runs.append(resolved_run(text, rpr)) para.runs.append(resolved_run(text, rpr))
elif (
child.tag
== "{http://schemas.microsoft.com/office/drawing/2010/main}m"
and inline_formula_resolver is not None
):
latex, preview = inline_formula_resolver(child)
if preview:
formula_rpr = next(
child.iter(f"{{{NS['a']}}}rPr"),
None,
)
run = resolved_run(preview, formula_rpr)
run.formula_latex = latex
para.runs.append(run)
return para return para
@@ -1082,6 +1112,14 @@ def _wrap_paragraph_into_lines(
continue continue
if not run.text: if not run.text:
continue continue
if run.formula_latex is not None:
width = _estimate_run_width(run.text, run)
if lines[-1] and cur_w + width > max_width:
lines.append([])
cur_w = 0.0
lines[-1].append(_copy_run(run, text=run.text))
cur_w += width
continue
text = run.text text = run.text
i = 0 i = 0
while i < len(text): while i < len(text):
@@ -1152,6 +1190,7 @@ def _copy_run(run: TextRun, *, text: str) -> TextRun:
strikethrough=run.strikethrough, strikethrough=run.strikethrough,
letter_spacing_px=run.letter_spacing_px, letter_spacing_px=run.letter_spacing_px,
hyperlink_href=run.hyperlink_href, hyperlink_href=run.hyperlink_href,
formula_latex=run.formula_latex,
) )
@@ -1269,8 +1308,7 @@ def _emit_paragraph(
if line_has_hyperlink: if line_has_hyperlink:
run_spans = ''.join( run_spans = ''.join(
_wrap_run_hyperlink( _wrap_run_hyperlink(
f'<tspan{_run_tspan_attrs(run)}>' _run_tspan_markup(run),
f'{_xml_escape(run.text)}</tspan>',
run, run,
) )
for run in line for run in line
@@ -1285,15 +1323,20 @@ def _emit_paragraph(
for run_idx, run in enumerate(line): for run_idx, run in enumerate(line):
attrs = _run_tspan_attrs(run) attrs = _run_tspan_attrs(run)
if run_idx == 0 and line_advance is not None: if run_idx == 0 and line_advance is not None:
spans.append( if run.formula_latex is not None:
f'<tspan x="{fmt_num(anchor_x)}" ' spans.append(
f'dy="{fmt_num(line_advance)}"' f'<tspan x="{fmt_num(anchor_x)}" '
f'{attrs}>{_xml_escape(run.text)}</tspan>' f'dy="{fmt_num(line_advance)}">'
) f'{_run_tspan_markup(run)}</tspan>'
)
else:
spans.append(
f'<tspan x="{fmt_num(anchor_x)}" '
f'dy="{fmt_num(line_advance)}"'
f'{attrs}>{_xml_escape(run.text)}</tspan>'
)
else: else:
spans.append( spans.append(_run_tspan_markup(run))
f"<tspan{attrs}>{_xml_escape(run.text)}</tspan>"
)
base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor) base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor)
return f"<text{base_attrs}>{''.join(spans)}</text>" return f"<text{base_attrs}>{''.join(spans)}</text>"
@@ -1362,6 +1405,20 @@ def _run_tspan_attrs(run: TextRun) -> str:
return " " + " ".join(parts) return " " + " ".join(parts)
def _run_tspan_markup(run: TextRun) -> str:
formula_attr = ""
if run.formula_latex is not None:
formula_attr = (
' data-pptx-inline-formula="'
+ _xml_escape(run.formula_latex)
+ '"'
)
return (
f"<tspan{_run_tspan_attrs(run)}{formula_attr}>"
f"{_xml_escape(run.text)}</tspan>"
)
def _wrap_run_hyperlink(markup: str, run: TextRun) -> str: def _wrap_run_hyperlink(markup: str, run: TextRun) -> str:
"""Wrap one visible SVG run in the canonical hyperlink carrier.""" """Wrap one visible SVG run in the canonical hyperlink carrier."""
if not run.hyperlink_href: if not run.hyperlink_href:
@@ -2431,8 +2431,11 @@ def validate_generated_transition_xml(
for child in (list(primary) if primary is not None else []) for child in (list(primary) if primary is not None else [])
if child.tag != _qn(PML_NS, "sndAc") if child.tag != _qn(PML_NS, "sndAc")
] ]
if not effect_children: if len(effect_children) != 1:
errors.append("generated transition has no visual effect child") errors.append(
"generated transition must contain exactly one visual "
f"effect child; found {len(effect_children)}"
)
else: else:
for name, value in expected_attrs.items(): for name, value in expected_attrs.items():
if effect_children[0].get(name) != str(value): if effect_children[0].get(name) != str(value):
@@ -218,6 +218,33 @@ def _has_usable_import(summary: dict[str, list[str]]) -> bool:
) )
def _research_source_urls(path: Path) -> list[str]:
"""Return unique source URLs from a v1 topic-research provenance file."""
if not path.is_file() or not path.name.endswith(".facts.json"):
return []
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Fact provenance is unreadable: {path} ({exc})") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"Fact provenance must be a JSON object: {path}")
if payload.get("schema") != "ppt-master.fact-provenance.v1":
return []
facts = payload.get("facts")
if not isinstance(facts, list):
raise RuntimeError(f"Fact provenance facts must be an array: {path}")
urls: list[str] = []
for index, fact in enumerate(facts):
if not isinstance(fact, dict) or not is_url(str(fact.get("source_url", ""))):
raise RuntimeError(
f"Fact provenance facts[{index}] has no valid source_url: {path}"
)
url = fact["source_url"]
if url not in urls:
urls.append(url)
return urls
class ProjectManager: class ProjectManager:
"""Create, inspect, validate, and populate project folders.""" """Create, inspect, validate, and populate project folders."""
@@ -448,11 +475,18 @@ class ProjectManager:
) )
self._run_tool(route.command) self._run_tool(route.command)
def _import_url(self, url: str, markdown_path: Path) -> None: def _import_url(
self,
url: str,
markdown_path: Path,
*,
download_images: bool = True,
) -> None:
route = build_conversion_command( route = build_conversion_command(
url, url,
markdown_path, markdown_path,
forced_type="web", forced_type="web",
extra_args=[] if download_images else ["--no-images"],
) )
self._run_tool(route.command) self._run_tool(route.command)
@@ -760,6 +794,9 @@ class ProjectManager:
"assets": [], "assets": [],
"images": [], "images": [],
"analysis": [], "analysis": [],
"research_sources_expected": [],
"research_sources_imported": [],
"research_sources_failed": [],
"notes": [], "notes": [],
"skipped": [], "skipped": [],
} }
@@ -786,6 +823,20 @@ class ProjectManager:
continue continue
expanded_items.append(item) expanded_items.append(item)
research_urls: list[str] = []
for item in expanded_items:
if is_url(item):
continue
for url in _research_source_urls(Path(item).expanduser()):
if url not in research_urls:
research_urls.append(url)
summary["research_sources_expected"] = research_urls.copy()
explicit_urls = {item for item in expanded_items if is_url(item)}
expanded_items.extend(url for url in research_urls if url not in explicit_urls)
research_url_set = set(research_urls)
text_only_research_urls = research_url_set - explicit_urls
imported_research_urls: set[str] = set()
explicit_markdown_stems = { explicit_markdown_stems = {
Path(item).stem Path(item).stem
for item in expanded_items for item in expanded_items
@@ -801,7 +852,11 @@ class ProjectManager:
sources_dir / f"{derive_url_basename(item)}.md" sources_dir / f"{derive_url_basename(item)}.md"
) )
try: try:
self._import_url(item, markdown_path) self._import_url(
item,
markdown_path,
download_images=item not in text_only_research_urls,
)
except Exception as exc: # pragma: no cover - summary path except Exception as exc: # pragma: no cover - summary path
archived = self._archive_url_record(sources_dir, item) archived = self._archive_url_record(sources_dir, item)
summary["url_records"].append(str(archived)) summary["url_records"].append(str(archived))
@@ -816,7 +871,10 @@ class ProjectManager:
continue continue
summary["markdown"].append(str(markdown_path)) summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir) if item in research_url_set:
imported_research_urls.add(item)
if item not in text_only_research_urls:
self._propagate_companion_image_assets(markdown_path, project_dir)
continue continue
source_path = Path(item) source_path = Path(item)
@@ -996,6 +1054,13 @@ class ProjectManager:
else: else:
summary["notes"].append(f"{item}: archived only, no automatic conversion") summary["notes"].append(f"{item}: archived only, no automatic conversion")
summary["research_sources_imported"] = [
url for url in research_urls if url in imported_research_urls
]
summary["research_sources_failed"] = [
url for url in research_urls if url not in imported_research_urls
]
# Cleanup: only a projects-local source directory may be removed after # Cleanup: only a projects-local source directory may be removed after
# its files move into the target project. Every other location is copied # its files move into the target project. Every other location is copied
# and remains untouched, even when the caller passes --move. # and remains untouched, even when the caller passes --move.
@@ -1195,8 +1260,15 @@ def main(argv: list[str] | None = None) -> int:
copy=args.copy, copy=args.copy,
) )
has_usable_import = _has_usable_import(summary) has_usable_import = _has_usable_import(summary)
if has_usable_import: import_complete = has_usable_import and not summary["research_sources_failed"]
if import_complete:
print(f"[OK] Imported sources into: {args.project_path}") print(f"[OK] Imported sources into: {args.project_path}")
elif has_usable_import:
print(
f"[ERROR] Topic-research source import is incomplete: "
f"{args.project_path}",
file=sys.stderr,
)
else: else:
print( print(
f"[ERROR] No usable sources imported into: {args.project_path}", f"[ERROR] No usable sources imported into: {args.project_path}",
@@ -1226,6 +1298,11 @@ def main(argv: list[str] | None = None) -> int:
print("\nAnalysis artifacts:") print("\nAnalysis artifacts:")
for item in summary["analysis"]: for item in summary["analysis"]:
print(f" - {item}") print(f" - {item}")
if summary["research_sources_expected"]:
print("\nTopic-research source reconciliation:")
print(f" expected: {len(summary['research_sources_expected'])}")
print(f" imported: {len(summary['research_sources_imported'])}")
print(f" failed: {len(summary['research_sources_failed'])}")
if summary["notes"]: if summary["notes"]:
print("\nNotes:") print("\nNotes:")
for item in summary["notes"]: for item in summary["notes"]:
@@ -1234,7 +1311,7 @@ def main(argv: list[str] | None = None) -> int:
print("\nSkipped:") print("\nSkipped:")
for item in summary["skipped"]: for item in summary["skipped"]:
print(f" - {item}") print(f" - {item}")
return 0 if has_usable_import else 1 return 0 if import_complete else 1
if args.command == "scaffold-spec": if args.command == "scaffold-spec":
artifact_path = manager.scaffold_artifact(args.project_path, "design_spec") artifact_path = manager.scaffold_artifact(args.project_path, "design_spec")
@@ -32,7 +32,7 @@
"skills/ppt-master/references/svg-effects.md": 15000, "skills/ppt-master/references/svg-effects.md": 15000,
"skills/ppt-master/references/native-data-interface.md": 6750, "skills/ppt-master/references/native-data-interface.md": 6750,
"skills/ppt-master/references/pptx-structure-interface.md": 4750, "skills/ppt-master/references/pptx-structure-interface.md": 4750,
"skills/ppt-master/references/strategist.md": 16000, "skills/ppt-master/references/strategist.md": 18000,
"skills/ppt-master/references/strategist-image.md": 2750, "skills/ppt-master/references/strategist-image.md": 2750,
"skills/ppt-master/references/strategist-template.md": 3000, "skills/ppt-master/references/strategist-template.md": 3000,
"skills/ppt-master/templates/design_spec_reference.md": 3750, "skills/ppt-master/templates/design_spec_reference.md": 3750,
@@ -151,7 +151,7 @@
"files": [ "files": [
"skills/ppt-master/workflows/template-fill-pptx.md" "skills/ppt-master/workflows/template-fill-pptx.md"
], ],
"max_tokens": 13000 "max_tokens": 14000
}, },
"route.generate.planning": { "route.generate.planning": {
"description": "Generate-PPTX planning through Strategist, including reference-first whole-document authoring, the three compact decision indexes, and a conservative audit envelope for only the exact custom bases selected after those indexes; schemas and optional scaffolds are tool-consumed.", "description": "Generate-PPTX planning through Strategist, including reference-first whole-document authoring, the three compact decision indexes, and a conservative audit envelope for only the exact custom bases selected after those indexes; schemas and optional scaffolds are tool-consumed.",
@@ -362,6 +362,7 @@
"include": [ "include": [
"route.generate.quick-generate", "route.generate.quick-generate",
"stage.generate.image.web", "stage.generate.image.web",
"stage.generate.image.web-review",
"stage.generate.executor.web-image" "stage.generate.executor.web-image"
], ],
"files": [], "files": [],
@@ -602,7 +603,8 @@
"stage.generate.executor.flat", "stage.generate.executor.flat",
"stage.generate.executor.web-image", "stage.generate.executor.web-image",
"stage.generate.executor.notes", "stage.generate.executor.notes",
"stage.generate.image.web" "stage.generate.image.web",
"stage.generate.image.web-review"
], ],
"files": [], "files": [],
"max_tokens": 190000 "max_tokens": 190000
@@ -793,13 +795,21 @@
"max_tokens": 26000 "max_tokens": 26000
}, },
"stage.generate.image.web": { "stage.generate.image.web": {
"description": "Incremental web-image acquisition role.", "description": "Incremental web-image acquisition role. Ceiling raised after BUDGET_LOAD_SET reported 9341 tokens.",
"scope": "incremental", "scope": "incremental",
"files": [ "files": [
"skills/ppt-master/references/image-base.md", "skills/ppt-master/references/image-base.md",
"skills/ppt-master/references/image-searcher.md" "skills/ppt-master/references/image-searcher.md"
], ],
"max_tokens": 8000 "max_tokens": 11000
},
"stage.generate.image.web-review": {
"description": "Compact isolated-or-local multimodal review contract for bounded web-image thumbnail pages.",
"scope": "incremental",
"files": [
"skills/ppt-master/workflows/stages/web-image-review.md"
],
"max_tokens": 1000
}, },
"stage.generate.resume-execute": { "stage.generate.resume-execute": {
"description": "Fresh-session resume plus the flat Executor core and post-SVG notes rules.", "description": "Fresh-session resume plus the flat Executor core and post-SVG notes rules.",
@@ -832,7 +842,7 @@
"files": [ "files": [
"skills/ppt-master/workflows/stages/topic-research.md" "skills/ppt-master/workflows/stages/topic-research.md"
], ],
"max_tokens": 2000 "max_tokens": 2750
}, },
"profile.generate.beautify-pptx": { "profile.generate.beautify-pptx": {
"description": "Strict 1:1 wording, order, and page-count profile with Default and explicit Quick branches.", "description": "Strict 1:1 wording, order, and page-count profile with Default and explicit Quick branches.",
@@ -951,7 +961,7 @@
"files": [ "files": [
"skills/ppt-master/scripts/docs/conversion.md" "skills/ppt-master/scripts/docs/conversion.md"
], ],
"max_tokens": 6750 "max_tokens": 7500
}, },
"stage.shared.troubleshooting": { "stage.shared.troubleshooting": {
"description": "Conditional troubleshooting reference for generation failures.", "description": "Conditional troubleshooting reference for generation failures.",
@@ -1052,7 +1062,7 @@
"files": [ "files": [
"skills/ppt-master/references/native-formula.md" "skills/ppt-master/references/native-formula.md"
], ],
"max_tokens": 2000 "max_tokens": 2250
}, },
"stage.generate.executor.hyperlink": { "stage.generate.executor.hyperlink": {
"description": "Conditional direct authoring contract for PowerPoint-native external and same-deck click hyperlinks.", "description": "Conditional direct authoring contract for PowerPoint-native external and same-deck click hyperlinks.",
@@ -15,7 +15,10 @@ Two optional cleanups address the realities of cropping a raster sheet:
--alpha knock the (flat) sheet background out to transparency, so an element --alpha knock the (flat) sheet background out to transparency, so an element
can sit on a differently-colored slide without a visible box. can sit on a differently-colored slide without a visible box.
Both need a background color; it is auto-sampled from each cell's border unless Both need a background color; it is auto-sampled from each cell's border unless
you pass --bg. you pass --bg. Keying only works on a genuinely flat ground, so each element is
checked afterwards and a warning is printed when the background clearly did not
key out. See references/image-generator.md section 4.3 for the sheet contract
that keeps the ground flat.
Usage: Usage:
python3 scripts/slice_images.py <sheet_image> --grid RxC [options] python3 scripts/slice_images.py <sheet_image> --grid RxC [options]
@@ -47,6 +50,7 @@ from PIL import Image, ImageChops, ImageFilter
_GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$") _GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$")
_BG_SAMPLE_BORDER = 2 _BG_SAMPLE_BORDER = 2
_DEFAULT_FEATHER = 4 _DEFAULT_FEATHER = 4
_CORNER_OPAQUE_ALPHA = 32
def _log(msg: str) -> None: def _log(msg: str) -> None:
@@ -148,6 +152,51 @@ def _content_masks(
return trim_mask, alpha_mask return trim_mask, alpha_mask
def _keying_findings(
label: str,
cell_size: tuple[int, int],
bbox: tuple[int, int, int, int],
alpha_mask: Optional[Image.Image],
cell_bg: tuple[int, int, int],
*,
trim: bool,
alpha: bool,
) -> list[str]:
"""Report objective signs that the flat-background key did not take.
Two deterministic symptoms: a cut element whose corners are still opaque,
and a ``--trim`` that removed nothing because the mask found content along
every edge. Both mean the sampled background did not match the real ground
(a textured or multi-color ground is the usual cause).
"""
findings: list[str] = []
hex_bg = "#{:02X}{:02X}{:02X}".format(*cell_bg)
if alpha and alpha_mask is not None:
px = alpha_mask.load()
width, height = alpha_mask.size
corners = (
px[0, 0], px[width - 1, 0],
px[0, height - 1], px[width - 1, height - 1],
)
opaque = sum(1 for value in corners if value > _CORNER_OPAQUE_ALPHA)
if opaque:
findings.append(
f"{label}: {opaque}/4 corners stayed opaque after --alpha "
f"(sampled background {hex_bg})"
)
if trim:
cell_width, cell_height = cell_size
if bbox[2] - bbox[0] >= cell_width and bbox[3] - bbox[1] >= cell_height:
findings.append(
f"{label}: --trim removed nothing, so content reaches every cell edge "
f"(sampled background {hex_bg})"
)
return findings
def slice_sheet( def slice_sheet(
sheet_path: Path, sheet_path: Path,
rows: int, rows: int,
@@ -199,6 +248,7 @@ def slice_sheet(
stem = sheet_path.stem stem = sheet_path.stem
name_prefix = _safe_basename(prefix) if prefix else f"{stem}_" name_prefix = _safe_basename(prefix) if prefix else f"{stem}_"
written: list[Path] = [] written: list[Path] = []
findings: list[str] = []
idx = 0 idx = 0
for r in range(rows): for r in range(rows):
@@ -221,6 +271,10 @@ def slice_sheet(
bbox = trim_mask.getbbox() bbox = trim_mask.getbbox()
if bbox is None: if bbox is None:
raise ValueError(f"cell ({r},{c}) is all background; no element was sliced") raise ValueError(f"cell ({r},{c}) is all background; no element was sliced")
findings.extend(_keying_findings(
f"cell ({r},{c})", cell.size, bbox, alpha_mask, cell_bg,
trim=trim, alpha=alpha,
))
if trim and trim_mask is not None and alpha_mask is not None and bbox is not None: if trim and trim_mask is not None and alpha_mask is not None and bbox is not None:
cell = cell.crop(bbox) cell = cell.crop(bbox)
@@ -243,6 +297,18 @@ def slice_sheet(
if len(written) != total_cells: if len(written) != total_cells:
raise ValueError(f"sliced {len(written)} elements but expected {total_cells}") raise ValueError(f"sliced {len(written)} elements but expected {total_cells}")
if findings:
_log("\n[WARN] Background keying looks incomplete — the cut element(s) may")
_log(" still carry a visible box on a differently-colored slide:")
for finding in findings:
_log(f" - {finding}")
_log(" Fix: regenerate the sheet with one genuinely flat ground "
"(no grain, halftone, or")
_log(" vignette over the background, gutters included), or rerun with "
"an explicit")
_log(" --bg <hex> and a larger --tolerance.")
return written return written
@@ -303,6 +303,39 @@ def build_image_filename(abs_url: str, seq: int, content_type: str | None = None
return f"{stem}{ext}" return f"{stem}{ext}"
def resolve_content_image_url(img: Tag, page_url: str) -> str | None:
"""Resolve one content image, preferring real lazy-load URLs."""
candidates = [
img.get("data-src"),
img.get("data-original"),
img.get("data-lazy-src"),
img.get("data-actualsrc"),
img.get("src"),
]
for value in candidates:
if not isinstance(value, str):
continue
src = value.strip()
if not src or src.startswith(("data:", "javascript:", "blob:", "#")):
continue
resolved = urljoin(page_url, src)
parsed = urlparse(resolved)
if parsed.scheme in {"http", "https"} and parsed.netloc:
img["src"] = resolved
return resolved
return None
def rewrite_images_to_remote_urls(content_element: Tag | None, page_url: str) -> int:
"""Retain remote image links without downloading image bytes."""
if content_element is None:
return 0
return sum(
resolve_content_image_url(img, page_url) is not None
for img in content_element.find_all("img")
)
def download_and_rewrite_images( def download_and_rewrite_images(
content_element: Tag | None, content_element: Tag | None,
page_url: str, page_url: str,
@@ -322,27 +355,9 @@ def download_and_rewrite_images(
saved = 0 saved = 0
for idx, img in enumerate(images): for idx, img in enumerate(images):
# Prefer lazy-load attributes — WeChat, Zhihu, and many CMSes keep the abs_url = resolve_content_image_url(img, page_url)
# real image URL in data-src / data-original / data-lazy-src, with if abs_url is None:
# `src` pointing at a 1x1 placeholder or a template literal.
candidates = [
img.get("data-src"),
img.get("data-original"),
img.get("data-lazy-src"),
img.get("data-actualsrc"),
img.get("src"),
]
src = next((s for s in candidates
if s and not s.startswith("data:")
and s.startswith(("http://", "https://", "//", "/"))), None)
if not src:
continue continue
# Promote the chosen URL into the element's src so downstream rewrite
# (which matches on src) can retarget it to the local file.
img["src"] = src
abs_url = urljoin(page_url, src)
content_type = "" content_type = ""
converted_from = "" converted_from = ""
if abs_url in downloaded: if abs_url in downloaded:
@@ -808,7 +823,12 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
return md or "" return md or ""
def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, str | None, str | None]: def process_url(
url: str,
output_file: str | None = None,
*,
download_images: bool = True,
) -> tuple[bool, str, str | None, str | None]:
"""Fetch, convert, and save one web page as Markdown. """Fetch, convert, and save one web page as Markdown.
Returns (success, url, error, output_path). output_path is the actual saved Returns (success, url, error, output_path). output_path is the actual saved
@@ -844,8 +864,12 @@ def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, st
content_div = find_main_content(soup) content_div = find_main_content(soup)
# Download images and rewrite src before markdown conversion # Download images and rewrite src before markdown conversion
image_count = download_and_rewrite_images( image_count = 0
content_div, url, image_dir, rel_image_prefix) if download_images:
image_count = download_and_rewrite_images(
content_div, url, image_dir, rel_image_prefix)
else:
rewrite_images_to_remote_urls(content_div, url)
if image_count: if image_count:
print(f" [OK] Images: {image_count} saved to {image_dir}") print(f" [OK] Images: {image_count} saved to {image_dir}")
@@ -883,7 +907,7 @@ def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, st
markdown_path=output_path, markdown_path=output_path,
converter="web_to_md.py", converter="web_to_md.py",
conversion_type="web", conversion_type="web",
asset_dir=image_dir, asset_dir=image_dir if image_count else None,
) )
print(f" [OK] Saved: {output_path}") print(f" [OK] Saved: {output_path}")
@@ -925,6 +949,11 @@ def main(argv: list[str] | None = None) -> int:
"--emit-result", "--emit-result",
help="On success, write the saved output path as JSON to this file " help="On success, write the saved output path as JSON to this file "
"(single-URL dispatcher use, so a title-named file can be located)") "(single-URL dispatcher use, so a title-named file can be located)")
parser.add_argument(
"--no-images",
action="store_true",
help="Keep remote image links without downloading image files",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -957,7 +986,11 @@ def main(argv: list[str] | None = None) -> int:
for i, url in enumerate(targets): for i, url in enumerate(targets):
# Allow specific output file only if 1 URL # Allow specific output file only if 1 URL
out = args.output if (len(targets) == 1 and args.output) else None out = args.output if (len(targets) == 1 and args.output) else None
success, url, err, out_path = process_url(url, out) success, url, err, out_path = process_url(
url,
out,
download_images=not args.no_images,
)
results.append((success, url, err)) results.append((success, url, err))
if args.emit_result and success and out_path: if args.emit_result and success and out_path:
_write_emit_result(args.emit_result, url, out_path) _write_emit_result(args.emit_result, url, out_path)
@@ -57,7 +57,7 @@ EA_FONTS = {
'Hiragino Kaku Gothic ProN', 'Hiragino Kaku Gothic Pro', 'Hiragino Kaku Gothic ProN', 'Hiragino Kaku Gothic Pro',
'Hiragino Mincho Pro', 'Hiragino Mincho Pro',
'Noto Sans SC', 'Noto Sans TC', 'Noto Serif SC', 'Noto Serif TC', 'Noto Sans SC', 'Noto Sans TC', 'Noto Serif SC', 'Noto Serif TC',
'Noto Sans CJK SC', 'Noto Sans CJK SC', 'Noto Serif CJK SC',
'Noto Sans JP', 'Noto Serif JP', 'Noto Sans CJK JP', 'Noto Sans JP', 'Noto Serif JP', 'Noto Sans CJK JP',
'Source Han Sans SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Source Han Sans TC',
'Source Han Serif SC', 'Source Han Serif TC', 'Source Han Serif SC', 'Source Han Serif TC',
@@ -106,6 +106,7 @@ FONT_FALLBACK_WIN = {
'Noto Sans CJK SC': 'Microsoft YaHei', 'Noto Sans CJK SC': 'Microsoft YaHei',
'Noto Sans TC': 'Microsoft JhengHei', 'Noto Sans TC': 'Microsoft JhengHei',
'Noto Serif SC': 'SimSun', 'Noto Serif SC': 'SimSun',
'Noto Serif CJK SC': 'SimSun',
'Noto Serif TC': 'PMingLiU', 'Noto Serif TC': 'PMingLiU',
# Japanese: keep as-is if user specified (PowerPoint will fallback if uninstalled) # Japanese: keep as-is if user specified (PowerPoint will fallback if uninstalled)
# 'Noto Sans JP': → keep as 'Noto Sans JP' (do not map) # 'Noto Sans JP': → keep as 'Noto Sans JP' (do not map)
@@ -50,7 +50,9 @@ request does not explicitly select Quick.
🚧 **GATE**: The user has provided a topic / desired outcome and any available initial material. 🚧 **GATE**: The user has provided a topic / desired outcome and any available initial material.
> **Topic-only**: run [`topic-research`](stages/topic-research.md) immediately, then use its factual supplement as source content. > **Topic-only**: run [`topic-research`](stages/topic-research.md) immediately,
> then use its factual supplement as source content; Step 2 reads retained
> webpage URLs from the facts JSON and imports them as text evidence.
When the user provides non-Markdown content, convert immediately through the When the user provides non-Markdown content, convert immediately through the
unified dispatcher. It preserves the backend converters' existing behavior, unified dispatcher. It preserves the backend converters' existing behavior,
@@ -83,7 +85,12 @@ After reading direct and converted content, assess factual sufficiency:
| Required externally verifiable claims remain unsupported | Run [`topic-research`](stages/topic-research.md) for those gaps only | | Required externally verifiable claims remain unsupported | Run [`topic-research`](stages/topic-research.md) for those gaps only |
| Closed corpus / source-only / no external enrichment | Stay within supplied material | | Closed corpus / source-only / no external enrichment | Stay within supplied material |
**Sufficiency test**: research only to avoid inventing, omitting, or leaving unsupported a factual claim the requested outcome requires; file presence or length is irrelevant. It gathers facts only. Step 5 acquires Strategist-selected images after final confirmation. **Sufficiency test**: research only to avoid inventing, omitting, or leaving
unsupported a factual claim the requested outcome requires; file presence or
length is irrelevant. It records the needed facts and adopted webpages. Step 2
imports those webpages as text-only evidence; Step 5
acquires only Strategist-selected independent AI / web / slice assets after
final confirmation.
> **Office vector assets (EMF/WMF) from DOCX/PPTX sources**: > **Office vector assets (EMF/WMF) from DOCX/PPTX sources**:
> Source conversion extracts embedded Office vector images (.emf/.wmf) > Source conversion extracts embedded Office vector images (.emf/.wmf)
@@ -99,7 +106,7 @@ After reading direct and converted content, assess factual sufficiency:
> Browser-based live preview cannot render EMF (will show blank) — this is expected; > Browser-based live preview cannot render EMF (will show blank) — this is expected;
> the PPTX output is the source of truth. > the PPTX output is the source of truth.
**✅ Checkpoint — Confirm source content and any factual supplement are ready, proceed to Step 2.** **✅ Checkpoint — Confirm source content, retained webpage inputs, and any factual supplement are ready, proceed to Step 2.**
--- ---
@@ -150,6 +157,11 @@ Import source content (choose based on the situation):
| Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...>` | | Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...>` |
| User provided text directly in conversation | No import needed — content is already in conversation context; subsequent steps can reference it directly | | User provided text directly in conversation | No import needed — content is already in conversation context; subsequent steps can reference it directly |
When Topic Research ran, include only its research pair. `project_manager.py`
reads the facts JSON's unique `source_url` values, archives each page in
text-only mode, and fails incomplete source reconciliation. It does not add page
images to `<project>/images/`.
For PPTX sources, `import-sources` automatically runs the standard intake enrichment: For PPTX sources, `import-sources` automatically runs the standard intake enrichment:
```bash ```bash
@@ -518,9 +530,13 @@ A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `we
> ⚠️ **web path — batch multiple rows**: when ≥2 rows are `Acquire Via: web`, write all queries into `images/image_queries.json` and run `image_search.py --batch` once (concurrent acquisition, status written back), instead of one CLI call per row. A single web row may use the positional single-query form. See [image-searcher.md](../references/image-searcher.md) §5. > ⚠️ **web path — batch multiple rows**: when ≥2 rows are `Acquire Via: web`, write all queries into `images/image_queries.json` and run `image_search.py --batch` once (concurrent acquisition, status written back), instead of one CLI call per row. A single web row may use the positional single-query form. See [image-searcher.md](../references/image-searcher.md) §5.
> **Default — bounded multimodal web thumbnail selection**: when either the current agent or an available isolated reviewer can inspect images, add `--save-candidates` to the single or batch web command. Author explicit `query_variants` for materially different official translations, spellings, aliases, or Chinese names; the tool aggregates and deduplicates them, then saves only the first ranked page (8 previews by default), writes `candidates/<stem>/review_sheet.jpg`, marks the batch row `Needs-Selection`, and downloads no original. Run [`web-image-review`](stages/web-image-review.md): dispatch exactly one isolated reviewer for all current sheets when supported, passing only each row's locked Reference/Crop Policy plus candidate sidecar/sheet paths; otherwise the active image owner reads that stage and reviews locally. Only a stage-selected passing candidate may be used with `--promote` to download one original and write provenance (pass the same `--batch images/image_queries.json` to reconcile its row to `Sourced`). If none passes and `has_more_candidates` is true, advance that row to `next_candidate_page` before changing the query. Only after the pool is exhausted may the row receive materially different query variants and return to `Pending`. When no available context has vision, omit `--save-candidates`: best-only mode may download only a strict metadata-verified candidate, records `selection_method: metadata-ranked`, and otherwise stops at `Needs-Manual` without claiming visual confirmation.
> **Retained-page fallback**: only after that normal search is exhausted, a vision-capable image owner may open one relevant research page and test one inline-image URL at a time with `--from-url`. Never use retained pages as the initial pool or bulk-download them; without vision, skip this fallback.
> **Default — short provider query (may override for a complete entity name or necessary disambiguation)**: keep §VIII `Reference` as the locked subject/focal/crop intent and author a separate concrete `image_queries.json.query`. Search/review never rewrites the Design Spec or lock to fit a candidate. > **Default — short provider query (may override for a complete entity name or necessary disambiguation)**: keep §VIII `Reference` as the locked subject/focal/crop intent and author a separate concrete `image_queries.json.query`. Search/review never rewrites the Design Spec or lock to fit a candidate.
> **Default — one sheet for compatible AI spots (may override for different cell shape, detail, quality, or semantics)**: prefer one grid sheet for a same-family set; independent `ai` rows remain valid. When selected, choose a grid matching the planned cells, keep the sheet unplaced, and place/project each `slice` row. Contract: [image-generator.md](../references/image-generator.md) §4.3. > **Default — one sheet for compatible AI spots or decorative lettering elements (may override for different cell shape, detail, quality, or semantics)**: prefer one grid sheet for a same-family set; independent `ai` rows remain valid. A lettering sheet records every exact stable string and contains no scene or page chrome. When selected, choose a grid matching the planned cells, keep the sheet unplaced, and place/project each transparent `slice` row. Contract: [image-generator.md](../references/image-generator.md) §4.3.
> ⚠️ **Honor the Design Spec's confirmed image source before running any generation command**: the `ai` generation path (Path A = `image_gen.py` API / Path B = host-native tool / Offline Manual) is **not** auto-only — the production value recorded in `design_spec.md §I` wins. `host-native` forces Path B even when `IMAGE_BACKEND` is configured; `api` forces Path A; `manual` forces offline. Never reopen `result.json` here, and never run `image_gen.py --manifest` when the recorded value is `host-native` or `manual`. Full selection rule: [image-generator.md](../references/image-generator.md) §7 Path Selection. > ⚠️ **Honor the Design Spec's confirmed image source before running any generation command**: the `ai` generation path (Path A = `image_gen.py` API / Path B = host-native tool / Offline Manual) is **not** auto-only — the production value recorded in `design_spec.md §I` wins. `host-native` forces Path B even when `IMAGE_BACKEND` is configured; `api` forces Path A; `manual` forces offline. Never reopen `result.json` here, and never run `image_gen.py --manifest` when the recorded value is `host-native` or `manual`. Full selection rule: [image-generator.md](../references/image-generator.md) §7 Path Selection.
@@ -528,9 +544,9 @@ Workflow:
1. Extract all resource rows from the design spec. First separate rows whose `Reference` starts `Derived from <canonical bare filename>; treatment=` so they cannot re-enter ordinary ai/web/slice acquisition; reject source/output equality, a derivative parent, chains, cycles, or self-reference; then group canonical rows by `Acquire Via`. Every Pending/Failed canonical acquisition row and Pending derivative must reach a terminal state before Executor starts. 1. Extract all resource rows from the design spec. First separate rows whose `Reference` starts `Derived from <canonical bare filename>; treatment=` so they cannot re-enter ordinary ai/web/slice acquisition; reject source/output equality, a derivative parent, chains, cycles, or self-reference; then group canonical rows by `Acquire Via`. Every Pending/Failed canonical acquisition row and Pending derivative must reach a terminal state before Executor starts.
2. Generate prompts (ai rows) and/or run search (web rows) per [image-base.md](../references/image-base.md) §3 dispatch table 2. Generate prompts (ai rows) and/or run search (web rows) per [image-base.md](../references/image-base.md) §3 dispatch table
2.5. **Slice any spot-illustration sheets (only if `slice` rows exist).** For each generated `ai` **sheet** row, run `slice_images.py` (grid + the element `--names` matching the `slice` rows, `--trim --alpha`) so every element file lands in `images/`; mark each `slice` row `Generated`. A sheet still in `Needs-Manual` cannot be sliced — leave its `slice` rows `Needs-Manual` and surface them at the Step 7 readiness gate. Contract: [image-generator.md](../references/image-generator.md) §4.3. 2.5. **Slice any illustration or lettering sheets (only if `slice` rows exist).** For each generated `ai` **sheet** row, run `slice_images.py` (grid + the element `--names` matching the `slice` rows, `--trim --alpha`) so every transparent element file lands in `images/`; mark each `slice` row `Generated`. A sheet still in `Needs-Manual` cannot be sliced — leave its `slice` rows `Needs-Manual` and surface them at the Step 7 readiness gate. Contract: [image-generator.md](../references/image-generator.md) §4.3.
2.6. **Materialize planned prepared derivatives.** After each named canonical source reaches a usable terminal state, preserve it and write the separately named derivative only from its declared treatment. Use `image_treat.py` for per-pixel blur, desaturation/grayscale, duotone, brightness, or contrast; that row inherits the canonical `Acquire Via` and terminal class. Use `image-generator.md` §4.4 only for registered clean-base/layer work; a supplied final asset is `user / Existing`, while generated/reconstructed output remains `ai / Generated`. A standalone cutout must be prepared RGBA, a flat-key slice, or supplied by the active host; otherwise mark it `Needs-Manual`. Do not present `image_treat.py` as photo background removal. Do not bake crop/clip, rotation/mirror, opacity, frame, shadow, scrim/wash, vignette, or overlap into a bitmap. Any derivative of a web source copies that source's license/attribution record to the new filename. A parent without a usable status leaves the child `Needs-Manual`. 2.6. **Materialize planned prepared derivatives.** After each named canonical source reaches a usable terminal state, preserve it and write the separately named derivative only from its declared treatment. Use `image_treat.py` for per-pixel blur, desaturation/grayscale, duotone, brightness, or contrast; that row inherits the canonical `Acquire Via` and terminal class. Use `image-generator.md` §4.4 only for registered clean-base/layer work; a supplied final asset is `user / Existing`, while generated/reconstructed output remains `ai / Generated`. A standalone cutout must be prepared RGBA, a flat-key slice, or supplied by the active host; otherwise mark it `Needs-Manual`. Do not present `image_treat.py` as photo background removal. Do not bake crop/clip, rotation/mirror, opacity, frame, shadow, scrim/wash, vignette, or overlap into a bitmap. Any derivative of a web source copies that source's license/attribution record to the new filename. A parent without a usable status leaves the child `Needs-Manual`.
3. Verify every processed acquisition/derivative row reaches its source-class terminal status under [`svg-image-embedding.md`](../references/svg-image-embedding.md); no `Pending`/`Failed` remains. On `auto`, follow the owning fallback chain. For confirmed `api` or `host-native`, retry only that path, then mark unresolved rows `Needs-Manual` without switching provider. 3. Verify every processed acquisition/derivative row reaches its source-class terminal status under [`svg-image-embedding.md`](../references/svg-image-embedding.md); no `Pending`, `Failed`, or web `Needs-Selection` remains. On `auto`, follow the owning fallback chain. For confirmed `api` or `host-native`, retry only that path, then mark unresolved rows `Needs-Manual` without switching provider.
4. Re-derive image facts after canonical acquisition, slicing, and prepared derivatives are final — `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` — so `analysis/image_analysis.csv` reflects every image the Executor may place. Image facts are regenerated on use, never a stale store (see Step 4's image-facts note). 4. Re-derive image facts after canonical acquisition, slicing, and prepared derivatives are final — `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` — so `analysis/image_analysis.csv` reflects every image the Executor may place. Image facts are regenerated on use, never a stale store (see Step 4's image-facts note).
**✅ Internal checkpoint — acquisition complete**: verify conditional AI/web sidecars, all required slice outputs, terminal status for every resource row, and a refreshed `image_analysis.csv`. Do not print this checklist. On success, auto-proceed under the compact status rule above. **✅ Internal checkpoint — acquisition complete**: verify conditional AI/web sidecars, all required slice outputs, terminal status for every resource row, and a refreshed `image_analysis.csv`. Do not print this checklist. On success, auto-proceed under the compact status rule above.
@@ -27,7 +27,7 @@ Maintainer-only inventory for adding, moving, or removing workflow documents. Ru
| `create-style` | Template child workflow | [`create-template/create-style.md`](./create-template/create-style.md) | Create Template | | `create-style` | Template child workflow | [`create-template/create-style.md`](./create-template/create-style.md) | Create Template |
| `create-layout` | Template child workflow | [`create-template/create-layout.md`](./create-template/create-layout.md) | Create Template | | `create-layout` | Template child workflow | [`create-template/create-layout.md`](./create-template/create-layout.md) | Create Template |
| `create-deck` | Template child workflow | [`create-template/create-deck.md`](./create-template/create-deck.md) | Create Template | | `create-deck` | Template child workflow | [`create-template/create-deck.md`](./create-template/create-deck.md) | Create Template |
| `topic-research` | Factual-preparation stage | [`stages/topic-research.md`](./stages/topic-research.md) | Inside Generate Step 1 | | `topic-research` | Research/source-preparation stage | [`stages/topic-research.md`](./stages/topic-research.md) | Inside Generate Step 1 |
| `resume-execute` | Control stage | [`stages/resume-execute.md`](./stages/resume-execute.md) | Generate Step 6 resume | | `resume-execute` | Control stage | [`stages/resume-execute.md`](./stages/resume-execute.md) | Generate Step 6 resume |
| `refine-spec` | Planning stage | [`stages/refine-spec.md`](./stages/refine-spec.md) | After Design Spec Gate 1, before lock Gate 2 | | `refine-spec` | Planning stage | [`stages/refine-spec.md`](./stages/refine-spec.md) | After Design Spec Gate 1, before lock Gate 2 |
| `verify-charts` | Quality gate | [`stages/verify-charts.md`](./stages/verify-charts.md) | Before Generate Step 7 | | `verify-charts` | Quality gate | [`stages/verify-charts.md`](./stages/verify-charts.md) | Before Generate Step 7 |
@@ -92,7 +92,7 @@ Prepare source facts before initialization:
| Input | Action | | Input | Action |
|---|---| |---|---|
| Topic or requirements without supporting facts | Run [`topic-research`](../stages/topic-research.md) immediately and retain its Markdown supplement plus fact-provenance JSON for import | | Topic or requirements without supporting facts | Run [`topic-research`](../stages/topic-research.md) immediately and retain its Markdown supplement, fact-provenance JSON, and adopted webpage inputs for import |
| One or more PNG / JPEG / WebP files representing page frames under Image to PPTX | Do not call `source_to_md.py`; normalize single-page files and multi-frame contact sheets into the canonical ordered frame roster through that profile, then import the originals below | | One or more PNG / JPEG / WebP files representing page frames under Image to PPTX | Do not call `source_to_md.py`; normalize single-page files and multi-frame contact sheets into the canonical ordered frame roster through that profile, then import the originals below |
| PDF / DOCX / Office document / XLSX / XLSM / PPTX / EPUB / HTML / LaTeX / RST / web URL | Run `python3 ${SKILL_DIR}/scripts/source_to_md.py <file_or_URL_or_dir> [<file_or_URL_or_dir> ...]` | | PDF / DOCX / Office document / XLSX / XLSM / PPTX / EPUB / HTML / LaTeX / RST / web URL | Run `python3 ${SKILL_DIR}/scripts/source_to_md.py <file_or_URL_or_dir> [<file_or_URL_or_dir> ...]` |
| CSV / TSV | Read directly as a plain-text table source | | CSV / TSV | Read directly as a plain-text table source |
@@ -120,8 +120,10 @@ After reading every direct and converted source, assess factual sufficiency:
**Sufficiency test**: research only when the requested outcome would otherwise **Sufficiency test**: research only when the requested outcome would otherwise
require inventing, omitting, or leaving unsupported an externally verifiable require inventing, omitting, or leaving unsupported an externally verifiable
claim. File presence or length does not establish sufficiency. Research gathers claim. File presence or length does not establish sufficiency. Research records
facts only; image acquisition remains part of the resource preparation below. the needed facts and adopted webpages. Those pages import as text-only evidence;
independent AI / web / slice acquisition remains part of the
resource preparation below.
**Conditional video-delivery context**: when the intended use is recorded, **Conditional video-delivery context**: when the intended use is recorded,
self-running, or video-directed—or an explicit final/literal narration script self-running, or video-directed—or an explicit final/literal narration script
@@ -182,6 +184,10 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources \
[projects/<research_slug>.md projects/<research_slug>.facts.json] [projects/<research_slug>.md projects/<research_slug>.facts.json]
``` ```
The facts JSON owns retained URLs. `project_manager.py` imports them
automatically in text-only mode and fails incomplete reconciliation; do not
repeat URLs or add page images to `<project>/images/`.
Only inputs already under the repository's `projects/` tree move into the Only inputs already under the repository's `projects/` tree move into the
target project; every external path is copied and remains untouched. Use target project; every external path is copied and remains untouched. Use
`--copy` when a projects-local input must also remain in place. When conversion `--copy` when a projects-local input must also remain in place. When conversion
@@ -265,7 +271,7 @@ the roster after the whole-roster check:
- the narrative beats, mental-map arcs, candidate visible states, their semantic deltas, and enabled notes segments. Adopt continuity only when it clarifies the message. Profile-fixed count/order/content, including 1:1/fidelity, permits only existing-neighbor evaluation; never alter those invariants to manufacture endpoints; - the narrative beats, mental-map arcs, candidate visible states, their semantic deltas, and enabled notes segments. Adopt continuity only when it clarifies the message. Profile-fixed count/order/content, including 1:1/fidelity, permits only existing-neighbor evaluation; never alter those invariants to manufacture endpoints;
- the effective Speaker Notes, Custom Animations, and Narration Audio outcomes; narration requires notes, later recording alone forces neither audio nor object animation, while a Quick recorded/self-running/video delivery purpose follows [`video-design.md`](../../references/video-design.md) and enables Custom Animations before SVG authoring; direct narrated video additionally enables notes/narration/video and decides before audio whether narration governs group timing; - the effective Speaker Notes, Custom Animations, and Narration Audio outcomes; narration requires notes, later recording alone forces neither audio nor object animation, while a Quick recorded/self-running/video delivery purpose follows [`video-design.md`](../../references/video-design.md) and enables Custom Animations before SVG authoring; direct narrated video additionally enables notes/narration/video and decides before audio whether narration governs group timing;
- the resulting exact slide roster and one compact core message for every page, used to choose its composition and hierarchy; - the resulting exact slide roster and one compact core message for every page, used to choose its composition and hierarchy;
- the canvas, visual direction, wording, intended viewing distance, and effective reading mode: choose `presentation` for distance-first projected or recorded viewing, `balanced` for mixed viewing, or `text` for close content-heavy reading. Take the initial body anchor and sanity band from [`canvas-formats.md`](../../references/canvas-formats.md) § "Typography Scale Start" for the resolved canvas—PPT remains reading-mode-driven, while registered/custom non-PPT canvases use their canvas-derived start—then resolve one concrete typography plan using installed font families, with stable size anchors for title, body, annotation, and every other recurring role the roster uses. When content does not fit, preserve its core message and apply only fitting actions the source/profile invariants permit—restructure, shorten, or split; if none is permitted, surface the unresolved fit instead of shrinking a recurring role. Explicit user, template, fidelity-profile, or resolved-style requirements may call for a deliberate exception; - the canvas, visual direction, wording, intended viewing distance, and effective reading mode: choose `presentation` for distance-first projected or recorded viewing, `balanced` for mixed viewing, or `text` for close content-heavy reading. Take the initial body anchor and sanity band from [`canvas-formats.md`](../../references/canvas-formats.md) § "Typography Scale Start" for the resolved canvas—PPT remains reading-mode-driven, while registered/custom non-PPT canvases use their canvas-derived start—then resolve one concrete typography plan for the delivery target defined by [`shared-standards-core.md`](../../references/shared-standards-core.md) §4.1, never from the authoring host's font inventory, with stable size anchors for title, body, annotation, and every other recurring role the roster uses. When content does not fit, preserve its core message and apply only fitting actions the source/profile invariants permit—restructure, shorten, or split; if none is permitted, surface the unresolved fit instead of shrinking a recurring role. Explicit user, template, fidelity-profile, or resolved-style requirements may call for a deliberate exception;
- the semantic color roles actually needed by the roster, each with a concrete active-context color anchor, including background/surface, primary/secondary text, dominant/accent, and status roles as applicable. Honor explicit user, installed template/brand, fidelity-profile source-identity, and resolved-style color semantics before deriving only the missing roles that the active profile permits; decide which roles dominate, support, or remain rare, and preserve sufficient contrast for meaning-bearing text. Pair newly authored color-coded states, categories, or relationships with a label, symbol, line, or geometry cue; when fidelity forbids adding one, preserve the source encoding; - the semantic color roles actually needed by the roster, each with a concrete active-context color anchor, including background/surface, primary/secondary text, dominant/accent, and status roles as applicable. Honor explicit user, installed template/brand, fidelity-profile source-identity, and resolved-style color semantics before deriving only the missing roles that the active profile permits; decide which roles dominate, support, or remain rare, and preserve sufficient contrast for meaning-bearing text. Pair newly authored color-coded states, categories, or relationships with a label, symbol, line, or geometry cue; when fidelity forbids adding one, preserve the source encoding;
- an ordinary body-content frame and a density judgment for every page, adapted to the canvas and any user / template / style geometry; use `anchor`, `dense`, `breathing`, or an equivalent active-context distinction instead of one uniform fill level; - an ordinary body-content frame and a density judgment for every page, adapted to the canvas and any user / template / style geometry; use `anchor`, `dense`, `breathing`, or an equivalent active-context distinction instead of one uniform fill level;
- for each page not bound to literal supplied geometry, a primary visual zone and page-scale composition direction tied to its core message; use cards or equal grids when the content relationship calls for them, not as the automatic page grammar; - for each page not bound to literal supplied geometry, a primary visual zone and page-scale composition direction tied to its core message; use cards or equal grids when the content relationship calls for them, not as the automatic page grammar;
@@ -320,6 +326,38 @@ the complete carrier menu once and choose only the forms that communicate its
content best. A decision to use none of a carrier is valid; skipping the scan content best. A decision to use none of a carrier is valid; skipping the scan
because Quick is expected to be faster is not. because Quick is expected to be faster is not.
**Mandatory — proactive AI decorative lettering**: Apply
[`image-generator.md`](../../references/image-generator.md) §7's
callable-generator test before SVG authoring: a configured `IMAGE_BACKEND` is
Path A and a host-native image-generation tool is Path B; Offline Manual, web
search, or vision-only access does not qualify. When Path A/B is callable and
the user has not forbidden AI, scan the frozen roster for display strings
anywhere in the deck. Exactly two questions decide eligibility: is that wording
stable, and would an artistic treatment communicate better than native type?
Page role, string length, line count, and kind of noun never filter candidates —
a cover hook, chapter word, place or product name, dish or exhibit name, year,
hero number, pull quote, or recurring motif word all qualify when both answers
are yes. Read any such list as examples, never as the set of allowed cases; a
two-character mark, an eight-character phrase, and a two-line lockup are equally
valid, and a phrase is never trimmed toward one or two characters to feel more
"wordmark-like". Set over photography or a busy field is often exactly where
native type reads pasted-on. Eligibility is wide but use stays selective: build
one small coherent set for the deck rather than lettering every heading, and
keep a native title wherever the page needs a searchable, selectable, or
outline-visible heading, with the lettering as its display layer.
If a suitable set exists, prepare it without
a separate request: preserve the exact approved strings, use one ordinary AI
item for a single mark or batch several compatible marks through one
Illustration Sheet and transparent slices, and keep ordinary title/chrome copy
native. A prepared wordmark and an editable title are not mutually exclusive:
one page may carry the wordmark as its display layer while its subtitle, chrome,
and body stay native text, so a wish to keep that wording editable is answered
by the native layer rather than by dropping the lettering. Skip a scanned hook
only when the user forbade AI or no generator is callable; do not add another
disqualifier. Offline Manual
alone does not trigger this proactive path; explicit user-required lettering
still follows the normal resource contract.
| Communication job | Available carrier | | Communication job | Available carrier |
|---|---| |---|---|
| Real subject, place, product, evidence, atmosphere, or scene benefits from visual grounding | Supplied/extracted, web, AI, or sliced image | | Real subject, place, product, evidence, atmosphere, or scene benefits from visual grounding | Supplied/extracted, web, AI, or sliced image |
@@ -329,10 +367,15 @@ because Quick is expected to be faster is not.
| Sequence, hierarchy, role, region, or relationship determines page-local topology | Qualitative structure | | Sequence, hierarchy, role, region, or relationship determines page-local topology | Qualitative structure |
| Rows, columns, cells, headers, merges, and alignment form the information model | Cell-grid table | | Rows, columns, cells, headers, merges, and alignment form the information model | Cell-grid table |
| Mathematical notation is clearer as typeset math than ordinary text | PowerPoint-native inline or block math | | Mathematical notation is clearer as typeset math than ordinary text | PowerPoint-native inline or block math |
| Any stable display string in the deck — cover hook, chapter word, place or product name, dish or exhibit name, year, hero number, pull quote, motif word — reads better with a material, dimensional, hand-rendered, or otherwise illustrative treatment than as ordinary text | Apply the proactive rule above; place prepared lettering assets as images and keep ordinary editable title/chrome in separate text frames |
| Typography, spacing, and simple geometry already carry the message | Use no additional visual carrier | | Typography, spacing, and simple geometry already carry the message | Use no additional visual carrier |
This carrier menu does not satisfy or replace the per-page Structure decision in §3. This carrier menu does not satisfy or replace the per-page Structure decision in §3.
**Mandatory — per-image source decision, never inherited from the resolved style**: Outside Image to PPTX, whose closed page surface owns its reconstruction assets, decide each page image's source separately — supplied/extracted, web, AI, or slice. Prefer a supplied/extracted asset that already carries authority; use web when an externally verifiable subject must appear as itself; use AI when invented or deliberately stylized expression matters more than documentary identity. Mixed sources across one deck are normal.
Resolving one visual style, `Illus.` propensity, or generated-image rendering resolves how imagery **looks**; it resolves the source for no page. A named place, building, product, artwork, person, or other externally verifiable subject stays a web/supplied candidate no matter how illustrative the deck looks. When such a subject is deliberately not shown as itself, state that choice and its reason in the final report rather than leaving it implicit.
**Visualization recall — optional Chart/Table reference**: When a reusable **Visualization recall — optional Chart/Table reference**: When a reusable
Chart/Table reference would help, run recall with 38 English semantic tags. Chart/Table reference would help, run recall with 38 English semantic tags.
Use its default catalog search; add `--family chart|table` only when the page's Use its default catalog search; add `--family chart|table` only when the page's
@@ -372,7 +415,7 @@ Prepare only the resource paths needed by the decided pages:
| Formula | Create no resource file. Retain the exact source LaTeX, then choose ordinary text, an inline native marker, or a block native marker under §3; the registered SVG preview is discarded by native export | | Formula | Create no resource file. Retain the exact source LaTeX, then choose ordinary text, an inline native marker, or a block native marker under §3; the registered SVG preview is discarded by native export |
| AI image | Follow `image-base.md` + `image-generator.md`; apply only the chosen rendering preset or exact custom bases, never blend unselected catalog identities, and keep `image_prompts.json` plus its human-readable sidecar | | AI image | Follow `image-base.md` + `image-generator.md`; apply only the chosen rendering preset or exact custom bases, never blend unselected catalog identities, and keep `image_prompts.json` plus its human-readable sidecar |
| Web image | Follow `image-base.md` + `image-searcher.md`; keep query/status data and `image_sources.json`, including any required on-slide attribution | | Web image | Follow `image-base.md` + `image-searcher.md`; keep query/status data and `image_sources.json`, including any required on-slide attribution |
| Illustration slice | Generate or obtain the parent sheet, run `slice_images.py`, and place only the resulting element files | | Illustration / lettering slice | Generate or obtain the parent sheet, run `slice_images.py --trim --alpha`, and place only the resulting transparent element files; a lettering sheet names every exact stable string and contains no scene or page chrome |
| Registered reconstruction group | Follow `image-generator.md` §4.4; keep full-canvas members registered with `crop=no-crop`, and materialize every required shared-plate member as an independent picture object | | Registered reconstruction group | Follow `image-generator.md` §4.4; keep full-canvas members registered with `crop=no-crop`, and materialize every required shared-plate member as an independent picture object |
| Visualization | Keep Chart values, Table cell topology, and chosen treatment in active context; load the applicable Chart/Table authority in §3 and write native replacement metadata only for an independently selected native-ready object | | Visualization | Keep Chart values, Table cell topology, and chosen treatment in active context; load the applicable Chart/Table authority in §3 and write native replacement metadata only for an independently selected native-ready object |
@@ -392,11 +435,18 @@ Operational manifests and provenance are resource truth, not a hidden design
strategy. strategy.
Every required file-backed resource must reach a usable terminal state before Every required file-backed resource must reach a usable terminal state before
its page. `Needs-Manual` blocks Quick even when an unverified file exists. After its page. Web `Needs-Selection` blocks until one thumbnail is promoted or the
manual supply/replacement, validate evidence and reconcile to `Existing`, bounded ranked pages and materially different query variants are exhausted;
`Generated`, or `Sourced`; never bypass status by file presence or substitute only then may a vision-capable owner test one retained-page image URL at a time
unrelated material. Native formula markers are authored page content, not with `--from-url`; never bulk-download or use those pages as the initial pool.
file-backed resources or terminal-status rows. `Needs-Manual` blocks even when an unverified file exists. With no visual
capability, only the strict metadata-ranked web path may reach `Sourced`, and
its provenance must say `selection_method: metadata-ranked` rather than imply
visual confirmation. After selection or manual supply/replacement, validate
evidence and reconcile to `Existing`, `Generated`, or `Sourced`; never bypass
status by preview/file presence or substitute unrelated material. Native
formula markers are authored page content, not file-backed resources or
terminal-status rows.
--- ---
@@ -518,8 +568,9 @@ and omit Master/Layout/layer/placeholder metadata. A request that specifically
requires reusable native Master/Layout/placeholder output is incompatible with requires reusable native Master/Layout/placeholder output is incompatible with
the lockless Quick exporter and must use the default lock-backed profile. the lockless Quick exporter and must use the default lock-backed profile.
**Typography**: name an installed concrete font family in the SVG; do not depend **Typography**: name a concrete target-installed/approved PowerPoint family
on a lock or generated font asset. under [`shared-standards-core.md`](../../references/shared-standards-core.md)
§4.1; do not depend on a lock or generated font asset.
**Generation pacing**: the current main agent hand-writes the SVG roster in **Generation pacing**: the current main agent hand-writes the SVG roster in
order. Use P01 as the visual-system calibration baseline and continue order. Use P01 as the visual-system calibration baseline and continue
@@ -627,6 +678,7 @@ or lock.
- [x] One mode and visual style were resolved, and every catalog source actually used was read - [x] One mode and visual style were resolved, and every catalog source actually used was read
- [x] Every page considered the complete visual-carrier menu without a coverage quota - [x] Every page considered the complete visual-carrier menu without a coverage quota
- [x] Every image-bearing page made its one pre-geometry composition decision - [x] Every image-bearing page made its one pre-geometry composition decision
- [x] Every image decided its own source from that page's subject and job — not inherited from the resolved visual style — and every externally verifiable subject deliberately not shown as itself was stated with its reason
- [x] Every selected formula uses the checker-valid ordinary/inline/block form with a matching visible SVG preview and no formula image resource - [x] Every selected formula uses the checker-valid ordinary/inline/block form with a matching visible SVG preview and no formula image resource
- [x] Every selected hyperlink uses a checker-valid inline/whole-object anchor and an exact external or same-deck target - [x] Every selected hyperlink uses a checker-valid inline/whole-object anchor and an exact external or same-deck target
- [x] Resolved SVG pages and their project-local references exist - [x] Resolved SVG pages and their project-local references exist
@@ -636,5 +688,5 @@ or lock.
- [x] Enabled notes were validated/generated and split; enabled custom motion ran through its owning stage - [x] Enabled notes were validated/generated and split; enabled custom motion ran through its owning stage
- [x] One native PPTX exists under `exports/` or the explicit output path - [x] One native PPTX exists under `exports/` or the explicit output path
- [x] No Strategist, confirmation, root project Design Spec, or lock artifact was created - [x] No Strategist, confirmation, root project Design Spec, or lock artifact was created
- [ ] **Next**: Report the base PPTX and any enabled narrated PPTX, raw/mixed/captured MP4, or capture-ready PPTX handoff - [ ] **Next**: Report the base PPTX and any enabled narrated PPTX, raw/mixed/captured MP4, or capture-ready PPTX handoff, plus the resolved mode, visual style, and the image sources actually used
``` ```
@@ -102,13 +102,13 @@ The output is a flat list of all available voices for the selected provider. Fro
- **For MiniMax / Qwen / CosyVoice**: if the user provides a cloned `voice_id`, use it directly. Do not attempt voice cloning inside this narration stage. - **For MiniMax / Qwen / CosyVoice**: if the user provides a cloned `voice_id`, use it directly. Do not attempt voice cloning inside this narration stage.
- **For CosyVoice subtitles**: use a cloned voice from a supported v3.5/v3/v2 model or a system voice marked timestamp-supported. Model and voice families must match. Use `--cosyvoice-audio-only` only when the user accepts no page-local SRT. - **For CosyVoice subtitles**: use a cloned voice from a supported v3.5/v3/v2 model or a system voice marked timestamp-supported. Model and voice families must match. Use `--cosyvoice-audio-only` only when the user accepts no page-local SRT.
- **Match the deck's tone** — pick the strongest recommendation based on style: - **Match the deck's tone** — pick the strongest recommendation based on style:
- Consultant / data-driven / 财报 → 稳重男声(如 `zh-CN-YunjianNeural`or 清晰女声(如 `zh-CN-XiaoxiaoNeural` - Chinese consultant / data-driven / financial-report deck → a steady male voice (e.g. `zh-CN-YunjianNeural`) or a clear female voice (e.g. `zh-CN-XiaoxiaoNeural`)
- General / 教学 / 产品介绍 → 明亮女声 / 年轻男声(如 `zh-CN-XiaoyiNeural` / `zh-CN-YunxiNeural` - Chinese general / teaching / product-introduction deck → a bright female or young male voice (e.g. `zh-CN-XiaoyiNeural` / `zh-CN-YunxiNeural`)
- 发布会 / 播报 → 播报感男声(如 `zh-CN-YunyangNeural` - Chinese launch event / broadcast deck → a broadcast-toned male voice (e.g. `zh-CN-YunyangNeural`)
- English consultant deck → `en-US-GuyNeural` (steady) or `en-US-JennyNeural` (clear) - English consultant deck → `en-US-GuyNeural` (steady) or `en-US-JennyNeural` (clear)
- Japanese / Korean → pick from `ja-JP-*` / `ko-KR-*` neural voices, mark gender + tone - Japanese / Korean → pick from `ja-JP-*` / `ko-KR-*` neural voices, mark gender + tone
For each candidate, write a **one-line Chinese description** covering: 性别 · 调性 · 适用场景。For cloud providers, include the voice name/ID exactly as it must be passed to `--voice-id`. For each candidate, write a **one-line description in the user's chat language** covering: gender · tone · best-fit scenario. For cloud providers, include the voice name/ID exactly as it must be passed to `--voice-id`.
--- ---
@@ -158,11 +158,11 @@ uses the manual Windows playback handoff below.
> 直接回"好"用全部推荐值,或告诉我想改的部分(如"音色 2,语速 -5%"或"用 MiniMax 的 voice_id xxx")。 > 直接回"好"用全部推荐值,或告诉我想改的部分(如"音色 2,语速 -5%"或"用 MiniMax 的 voice_id xxx")。
**Recommended-value rules**: **Recommended-value rules**:
- 生成模式:默认 `edge`;用户指定 cloud provider / voice ID 时按其选择。需要逐页 SRT、字幕动画或视频字幕时不推荐 Qwen;用户坚持时,说明仅交付音频并跳过 SRT 步骤。 - **Generation mode**: default `edge`; follow the user's choice when they name a cloud provider / voice ID. Do not recommend Qwen when page-local SRT, subtitle animation, or video subtitles are needed; if the user insists, state that only audio is delivered and skip the SRT step.
- 音色:从 Step 2 候选里挑最贴合 deck 调性的那一个。 - **Voice**: pick the Step 2 candidate that fits the deck's tone best.
- 语速:edge 默认 `+0%`notes 字数密集(页均 >4 句长句)建议 `-5%`notes 简短紧凑建议 `+5%`;超出此范围需说明理由。Cloud providers 默认用 provider defaults,除非用户明确要调速或改风格。 - **Rate**: edge defaults to `+0%`; recommend `-5%` for dense notes (>4 long sentences per page) and `+5%` for short, tight notes; going outside this range needs a stated reason. Cloud providers keep provider defaults unless the user explicitly asks to change speed or style.
- 嵌入:默认推荐"是";除非用户已有定制 PPTX 不希望覆盖。 - **Embed**: recommend yes by default, unless the user already has a customized PPTX they do not want overwritten.
- 视频:`powerpoint_video.py --check` 成功时默认推荐"原生编码";只有用户明确选择时才使用"实时放映录制"。自动化不可用时交付带音频 PPTX;不得静默改用录屏或第三方渲染器。 - **Video**: recommend native encoding when `powerpoint_video.py --check` succeeds; use slideshow capture only on an explicit user choice. When automation is unavailable, deliver the narrated PPTX; never silently switch to screen recording or a third-party renderer.
--- ---
@@ -1,5 +1,5 @@
--- ---
description: Generate source-intake stage that fills externally verifiable factual gaps before planning or direct SVG authoring. description: Generate source-intake stage that fills factual gaps and retains adopted webpage text evidence before planning or direct SVG authoring.
--- ---
# Topic Research Stage # Topic Research Stage
@@ -9,13 +9,15 @@ description: Generate source-intake stage that fills externally verifiable factu
> consumes the same output. Run immediately for topic-only input, or after > consumes the same output. Run immediately for topic-only input, or after
> supplied material is converted and read when it leaves planning-critical > supplied material is converted and read when it leaves planning-critical
> factual gaps. Output is a research supplement plus stable fact provenance for > factual gaps. Output is a research supplement plus stable fact provenance for
> project import. > project import; its retained webpage URLs are imported as text evidence in
> the active Generate profile's project-initialization handoff.
This stage supplies facts needed to build the requested deck. It does not select, This stage supplies facts needed to build the requested deck and preserves the
download, or generate images. Default Generate resolves image selection in the webpages actually adopted during that research. It makes no deck image
final Strategist plan and acquires AI / web / slice assets after confirmation; selection and performs no independent image search or generation. During the
Quick Generate resolves and acquires them later in its resource-preparation handoff, `project_manager.py import-sources` converts each retained URL, archives
phase without adding a confirmation gate. its Markdown as text evidence, and retains remote inline-image links without
downloading them. Those links are not an initial image pool.
## When to Run ## When to Run
@@ -67,11 +69,11 @@ confirmation stage.
| Actor | Contract | | Actor | Contract |
|---|---| |---|---|
| Main agent | Supply the topic/outcome, baseline or relevant source paths, declared gaps, output language, two exact unused output paths, and this stage's absolute path as execution authority; use paths instead of pasting source bodies when possible | | Main agent | Supply the topic/outcome, baseline or relevant source paths, declared gaps, output language, two exact unused output paths, and this stage's absolute path as execution authority; use paths instead of pasting source bodies when possible |
| Research worker | Read the supplied stage file completely, then follow Steps 23 using the brief and declared source paths as its baseline; limit project writes to the two output artifacts; acquire no images and make no deck-planning or design decisions | | Research worker | Read the supplied stage file completely, then follow Steps 23 using the brief and declared source paths as its baseline; limit project writes to the two output artifacts; perform no independent image search/generation and make no deck-planning, image-selection, or design decisions |
**Hard rule — isolate retrieval, not research**: Raw page content and fetch transcripts stay in the worker context. The 250-word limit applies only to its chat receipt: return `status`, exact artifact paths, covered/unresolved gap counts, external-fact count, and material conflicts. It does not cap or replace the two artifacts. After validation and import, the active content owner reads the complete imported research supplement and fact-provenance JSON into the main context before planning or direct SVG authoring; never use the receipt or validation summary as content. **Hard rule — isolate retrieval, not research**: Raw page content and fetch transcripts stay in the worker context. The 250-word limit applies only to its chat receipt: return `status`, exact artifact paths, covered/unresolved gap counts, external-fact count, and material conflicts. It does not cap or replace the two artifacts. After validation and import, the active content owner reads the complete imported research supplement and fact-provenance JSON into the main context before planning or direct SVG authoring; never use the receipt or validation summary as content.
**Validation**: Before import, the main agent verifies both exact files exist, the Markdown contains `## Research Brief` and `## Sources`, the JSON parses with schema `ppt-master.fact-provenance.v1` and unique sequential IDs, and the two files agree. Return an invalid pair to the research worker for owning-artifact repair; use main-context web research only when isolated execution is unavailable. **Validation**: Before import, the main agent verifies both exact files exist, the Markdown contains `## Research Brief` and no source list or URL, the JSON parses with schema `ppt-master.fact-provenance.v1` and unique sequential IDs, and the two files agree. Return an invalid pair to the research worker for owning-artifact repair; use main-context web research only when isolated execution is unavailable.
--- ---
@@ -80,9 +82,13 @@ confirmation stage.
Use the web search and fetch tools available in the active research context. An isolated worker without them returns `blocked: web-tools-unavailable`. If no usable research context has search/fetch tools, the main agent pauses and asks the user for authoritative URLs covering the declared gaps, then fetches each with: Use the web search and fetch tools available in the active research context. An isolated worker without them returns `blocked: web-tools-unavailable`. If no usable research context has search/fetch tools, the main agent pauses and asks the user for authoritative URLs covering the declared gaps, then fetches each with:
```bash ```bash
python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL> python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL> \
-o projects/<research_slug>_web_sources/<source_slug>.md --no-images
``` ```
Preserve the resulting Markdown and conversion profile for research. Remote
inline-image links remain in the Markdown; no image files are downloaded.
| Phase | Action | | Phase | Action |
|---|---| |---|---|
| Orient | Search only far enough to map authoritative sources to the declared gaps | | Orient | Search only far enough to map authoritative sources to the declared gaps |
@@ -96,6 +102,11 @@ python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>
| 3 | Reputable reporting or analysis when primary evidence is unavailable | | 3 | Reputable reporting or analysis when primary evidence is unavailable |
| Avoid | Unsourced reposts, unverifiable summaries, and stock-aggregator pages | | Avoid | Unsourced reposts, unverifiable summaries, and stock-aggregator pages |
**Retained webpage boundary**: Record a page URL only in the matching fact's
`source_url`, and only when it materially supports that retained fact. Do not
retain a page merely because its images may be useful, and do not add unopened
search results or pages found through a separate image-search pass.
**Stop condition**: stop when every declared gap has enough sourced evidence for **Stop condition**: stop when every declared gap has enough sourced evidence for
the active content owner to decide whether and how to include it. Do not expand the active content owner to decide whether and how to include it. Do not expand
into unrelated overview / history / outlook sections merely to make the into unrelated overview / history / outlook sections merely to make the
@@ -112,9 +123,9 @@ Write two artifacts under `projects/`:
| Research supplement | `projects/<research_slug>.md` | | Research supplement | `projects/<research_slug>.md` |
| Fact provenance | `projects/<research_slug>.facts.json` | | Fact provenance | `projects/<research_slug>.facts.json` |
**Hard rule — location and preservation**: write both files under `projects/`, never the repository root. Do not overwrite an existing user file; choose a new research stem instead. This stage creates no image folder. **Hard rule — location and preservation**: write both files under `projects/`, never the repository root. Do not overwrite an existing user file; choose a new research stem instead. Do not create a research-image manifest or download embedded images.
Begin the research Markdown with a compact `## Research Brief` containing the supplied-material baseline, declared gaps, audience / intent already known, and requested outcome. Organize the body by gap, include concrete facts only, flag material conflicts, and end with `## Sources` listing every URL used. Begin the research Markdown with a compact `## Research Brief` containing the supplied-material baseline, declared gaps, audience / intent already known, and requested outcome. Organize the body by gap, include concrete facts only, flag material conflicts, and cite claims by `fact_id`. Do not add `## Sources` or URLs; the facts JSON is the only URL authority.
Write every externally sourced claim that may enter the deck to `<research_slug>.facts.json` with a stable sequential ID, especially quantitative, date, ranking, attribution, and named-entity claims. Do not include user-supplied claims or invented scenario values. When no external claim is retained, write the schema with an empty `facts` array. Write every externally sourced claim that may enter the deck to `<research_slug>.facts.json` with a stable sequential ID, especially quantitative, date, ranking, attribution, and named-entity claims. Do not include user-supplied claims or invented scenario values. When no external claim is retained, write the schema with an empty `facts` array.
@@ -141,25 +152,42 @@ IDs are immutable within the file. Correct a claim under the same ID; never reus
## Hand-off ## Hand-off
Import the research supplement and provenance alongside any user-supplied After project initialization, import the research pair and user-supplied
sources through the active profile's source intake: sources. `project_manager.py` reads unique `source_url` values from the v1 facts
JSON automatically; do not repeat those URLs in the command or Markdown.
```bash ```bash
python3 ${SKILL_DIR}/scripts/project_manager.py import-sources projects/<project_name> [<source_paths...>] projects/<research_slug>.md projects/<research_slug>.facts.json python3 ${SKILL_DIR}/scripts/project_manager.py import-sources \
projects/<project_name> [<source_paths...>] \
projects/<research_slug>.md projects/<research_slug>.facts.json
``` ```
The imported pair remains evidence-facing context, not a locked presentation For retained URLs, `project_manager.py` invokes the webpage converter in
contract. Default Generate has Strategist read both files completely before text-only mode and fails the import when any registered URL cannot be archived.
confirmation and use them to select the content, page roster, and image resource It never copies page images into `<project>/images/`. A URL explicitly supplied
plan. Quick Generate has the current agent read both completely before its as initial material keeps normal source-import behavior even when a later fact
active-context content, design, and resource decisions. cites it; text-only mode applies only to URLs auto-expanded from the facts JSON.
Only after normal web-image providers, ranked thumbnail pages, and materially
different queries fail may an image owner with visual capability open one
relevant retained page, choose one inline-image URL, download it with the
existing `image_search.py --from-url`, and inspect it. Try another only after
rejection; never bulk-download a page. Without vision, skip this fallback.
The imported research pair remains the compact evidence-facing content
authority, not a locked presentation contract. Default Generate has Strategist
read both files completely before confirmation and use them with the imported
source inventory to select the content, page roster, and image resource plan.
Quick Generate has the current agent do the same before its active-context
content, design, and resource decisions. Reopen an imported webpage Markdown
only for missing factual detail or the post-exhaustion single-image fallback.
```markdown ```markdown
## ✅ Topic Research Complete ## ✅ Topic Research Complete
- [x] Research execution: <isolated worker | main-context fallback> - [x] Research execution: <isolated worker | main-context fallback>
- [x] Research supplement: `projects/<research_slug>.md` (N declared gaps covered) - [x] Research supplement: `projects/<research_slug>.md` (N declared gaps covered)
- [x] Fact provenance: `projects/<research_slug>.facts.json` (N external facts) - [x] Fact provenance: `projects/<research_slug>.facts.json` (N external facts)
- [x] Artifact contract validated: `## Research Brief`, `## Sources`, `ppt-master.fact-provenance.v1`, unique sequential IDs, and Markdown/JSON agreement - [x] Artifact contract validated: `## Research Brief`, no Markdown source list, `ppt-master.fact-provenance.v1`, unique sequential IDs, and Markdown/JSON agreement
- [x] No images acquired inside this factual-research stage - [x] Retained webpage URLs: N unique `source_url` values in the facts JSON; no page images downloaded
- [ ] **Next**: Default returns to [`generate-pptx`](../generate-pptx.md) Step 2; Quick returns to [`quick-generate`](../profiles/quick-generate.md) §2. Import all source artifacts, then fully read the imported research pair before planning or direct SVG authoring - [ ] **Next**: Default returns to [`generate-pptx`](../generate-pptx.md) Step 2; Quick returns to [`quick-generate`](../profiles/quick-generate.md) §2. Import the source artifacts plus research pair, then fully read the imported pair before planning or direct SVG authoring
``` ```
@@ -0,0 +1,87 @@
---
description: Conditional isolated multimodal review of bounded web-image thumbnail pages.
---
# Web Image Review Stage
> Supporting Generate stage for choosing from thumbnail pages already prepared
> by the web-image acquisition path. It reviews pixels only: it never searches,
> downloads, changes the locked image intent, or writes project artifacts.
## When to Run
| Review capability | Action |
|---|---|
| An isolated worker can inspect the declared local images | Dispatch exactly one reviewer for all pending sheets in the current acquisition batch |
| Only the active image owner can inspect images | Read this stage and review the same batch locally |
| No available context can inspect images | Skip this stage and use the strict metadata-only acquisition path |
Run after `--save-candidates` has produced `Needs-Selection` rows and before any
`--promote` command. When the host supports follow-up messages, reuse the same
reviewer for later candidate pages in that acquisition run; never dispatch one
reviewer per resource row.
---
## Execution Context
**Default — isolate thumbnail pixels when available**: The active image owner
retains query, search, pagination, promotion, status, and provenance ownership.
Supply the reviewer only this stage's absolute path and these per-row records:
| Input | Required value |
|---|---|
| Row identity | Resource filename or stable batch-row identifier |
| Acceptance intent | Exact locked `Reference` and `Crop Policy` |
| Candidate state | Current page, `has_more_candidates`, and `next_candidate_page` when present |
| Local evidence | Absolute `review_sheet.jpg` and `candidates.json` paths |
The isolated reviewer reads this file completely, then inspects only the
declared sidecars and review images. It does not read `image-base.md`,
`image-searcher.md`, the Design Spec, the lock, or source files. It runs no
network request, command, or project write. If any declared path is unreadable
or image inspection is unavailable, return `blocked` with the exact reason.
---
## Review Contract
Apply the gates in order for every row:
| Order | Gate |
|---:|---|
| 1 | Reject a candidate unless its `license_tier` is `no-attribution` or `attribution-required`; also reject unreadable previews or known dimensions that cannot serve the planned placement |
| 2 | Confirm the exact subject or identity; `visual-verification-required` passes only when the pixels establish the missing identity evidence |
| 3 | Check orientation, focal placement, crop safety, and usable quiet region against the locked intent |
| 4 | Check the requested view, action, and mood |
| 5 | Among passing candidates, prefer lower expected crop loss and higher usable resolution, then no-attribution |
**Mandatory — bounded detail inspection**: Triage with `review_sheet.jpg`.
Open an individual `review/candidate_NN.jpg` only when exact identity or a fine
detail cannot be resolved from the sheet; never bulk-open every candidate.
**Hard rule — no least-bad promotion**: Select only a candidate that passes all
applicable gates. When none passes, return `no-pass`; do not weaken the locked
Reference or Crop Policy.
---
## Receipt and Hand-off
Return one compact table and no embedded images:
```markdown
| row | decision | candidate | reason | next |
|---|---|---|---|---|
| <id> | selected / no-pass / blocked | candidate_NN.jpg / — | <short evidence> | promote / next-page / pool-exhausted / repair-input |
```
For `selected`, name exactly one candidate from that row's current page. For
`no-pass`, use `next-page` when `has_more_candidates` is true; otherwise use
`pool-exhausted`. Keep the entire chat receipt under 200 words.
The active image owner validates every selected filename against
`candidates.json`, runs `--promote`, and verifies the downloaded original's
readable dimensions and provenance. A no-pass row advances to the next ranked
page before query replacement. An invalid receipt returns to the same reviewer
for correction; it never authorizes an arbitrary promotion.