feat(svglide): complete artboard follow-up pipeline

This commit is contained in:
songtianyi.theo
2026-06-21 22:05:02 +08:00
parent 4a37a2fe7b
commit 8502e8c433
44 changed files with 3062 additions and 99 deletions

View File

@@ -48,6 +48,37 @@ jobs:
exit 1
fi
svglide-artboard-macos-x64-runtime:
needs: fast-gate
runs-on: macos-13
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
with:
node-version: '20'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Install artboard renderer dependencies
run: |
corepack enable
corepack prepare pnpm@9.15.4 --activate
pnpm --dir skills/lark-slides/scripts/artboard_renderer install --frozen-lockfile
- name: Validate macOS x64 runtime
run: |
mkdir -p .artifacts/svglide-artboard-package-check
python3 skills/lark-slides/scripts/svglide_artboard_package_check.py \
--require-system Darwin \
--require-arch x64 \
--output-dir .artifacts/svglide-artboard-package-check \
--pretty
- name: Upload SVGlide artboard runtime evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: svglide-artboard-macos-x64-runtime-${{ github.run_number }}
path: .artifacts/svglide-artboard-package-check
retention-days: 30
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate

View File

@@ -0,0 +1,58 @@
# SVGlide Artboard Follow-Up Completion Evidence
Date: 2026-06-21
Worktree:
```text
/Users/bytedance/bd-projects/workspaces/SVGlide/.worktrees/cli-svglide-svg-private
branch: feat/svglide-artboard-satori
```
## Completed Local Follow-Up Scope
| Follow-up item | Local status | Evidence |
| --- | --- | --- |
| Real topic model loop | EXECUTABLE LOCAL CONTRACT | `svglide_project_runner.py model-plan`; `svglide_prompt_planner.py` records `provider_type` and raw output hashes; command-provider fixture under `fixtures/svglide_artboard/followup_model_loop/` |
| Automated visual repair loop | EXECUTABLE LOCAL LOOP | `svglide_model_repair_loop.py`; optional runner stage `repair_loop`; scoped scalar JSON Patch validation with positive/broad-patch tests |
| Semantic Map Compiler IR | IMPLEMENTED | `svglide_semantic_map_ir.py`; artboard/compiler receipts include `input_semantic_hash`; final SVG carries semantic source refs for gate comparison |
| True node layout observation | IMPLEMENTED | renderer emits Satori node observations; `node-layout-map/v1` records measured layout; `svglide_node_layout_drift.py` and quality gate block drift |
| Export packaging | IMPLEMENTED FOR VERIFIED ARTIFACT PACKAGE | runner `export` stage writes `09-export/export-manifest.json`, deterministic zip, and `receipts/export.json` |
| macOS x64 runtime validation | CI WIRED, LOCAL HOST NOT X64 | package check supports `--require-system Darwin --require-arch x64`; CI job `svglide-artboard-macos-x64-runtime` runs on `macos-13` and uploads evidence |
| Theme P1/P2 local layer | IMPLEMENTED CLI CONTRACTS | `svglide_theme_productization.py` extracts ThemeSpec, writes project registry/template binding, migrates slide plans; `aesthetic_review` writes deterministic auto approval |
## Remaining External Boundaries
These are not local code gaps in this worktree:
- Real external LLM provider execution for arbitrary live topics still requires model credentials/network. The local contract is executable through the command provider and records raw provider output hashes.
- Real macOS x64 runtime proof must be produced by GitHub Actions or another x64 host. The current local host observed by package check is Darwin arm64.
- PPTX, animated deck, and narrated deck export are not implemented in the CLI workspace. `export` now packages verified SVGlide artifacts and explicitly records those formats as `not_implemented`.
- Productized theme authoring UI and slide-server integration require changes outside this CLI worktree.
## Verification
Commands run:
```bash
env PYTHONPYCACHEPREFIX=/private/tmp/svglide-pycache \
python3 -m unittest discover skills/lark-slides/scripts -p '*_test.py'
env PYTHONPYCACHEPREFIX=/private/tmp/svglide-pycache \
python3 skills/lark-slides/scripts/svglide_artboard_package_check.py \
--output-dir /private/tmp/svglide-followup-package-check-final \
--pretty
git diff --check
env GOCACHE=/private/tmp/svglide-gocache go test .
```
Observed results:
```text
scripts unittest discovery: Ran 353 tests in 20.613s, OK
package runtime check: status=passed, runtime_check_count=2, host=Darwin arm64
git diff --check: OK
go test .: ok github.com/larksuite/cli 0.606s
```

View File

@@ -430,9 +430,9 @@ Required actions:
- Convert using CanvasSpec/semantic map, not by trusting raw Satori SVG as semantic truth.
- Keep raw Satori SVG as preview/layout evidence only.
- Record the actual compiler input artifact and hash, for example:
- `04-svg/artboard/page-###.canvas-template.svg`
- `canvas_template_svg_sha256`
- `compiler_input=CanvasSpecTemplateSVG`
- `04-svg/artboard/page-###.semantic-map.json`
- `semantic_map_sha256`
- `compiler_input=SemanticMapIR`
- `satori_svg_usage=preview_only`
- Output current SVGlide protocol:
- `xmlns:slide="https://slides.bytedance.com/ns"`
@@ -775,7 +775,7 @@ Update this board after each meaningful implementation chunk.
| 1 Contract layer completion | DONE | executor | PASS | Plan schema now rejects `artboard_satori` slides without `canvas_spec`; semantic map now emits `elements[]`; PLAN/contract receipt wording aligned to per-page `artboard_receipts` + aggregate `artboard_additional_receipts`; reviewer PASS recorded in `svglide-artboard-gate0-gate1-evidence.md` |
| 2 Template/theme/component/input quality | DONE | executor | PASS | 3 templates + 3 registered themes + component module + `templates/p0-templates.mjs` exist; registry text budgets, golden CanvasSpec fixtures, and safe-area/semantic bbox admission checks added; P0b `/private/tmp/svglide-p0b-gate2-safe-YVT67C` passed template-fit/quality-gate/dry-run; evidence recorded in `svglide-artboard-gate2-evidence.md` |
| 3 Satori renderer and resvg preview | DONE | executor | PASS | `node render.mjs --check-runtime` and `node dist/render.mjs --check-runtime` passed with Satori 0.26.0 / resvg 2.6.2; P0b raw SVG/PNG/contact sheet and receipts verified; evidence recorded in `svglide-artboard-gate3-evidence.md` |
| 4 SatoriToSVGlide compiler | DONE | executor | PASS | Main artboard path now writes `04-svg/artboard/page-###.canvas-template.svg` and compiles final SVGlide SVG from `CanvasSpecTemplateSVG`; raw Satori SVG is `preview_only`; quality gate rejects RawSatori compiler metadata; P0b `/private/tmp/svglide-p0b-gate4-641DXp` passed quality_gate/dry_run; evidence recorded in `svglide-artboard-gate4-evidence.md` |
| 4 SatoriToSVGlide compiler | DONE | executor | PASS | Main artboard path writes template SVG as preview/layout evidence and now compiles final SVGlide SVG from `semantic-map/v1` as `SemanticMapIR`; raw Satori SVG is `preview_only`; quality gate rejects RawSatori compiler metadata; original P0b evidence remains recorded in `svglide-artboard-gate4-evidence.md` |
| 5 Runner and quality gate integration | DONE | executor | PASS | Page jobs now run with bounded `max_workers=min(4,page_count)` and stable sorted receipts; full test suite passed 254 tests; direct_svg `/private/tmp/svglide-direct-gate5-iYPBBA` passed quality_gate; artboard P0b `/private/tmp/svglide-p0b-gate5-qg7PC6` passed dry_run; evidence recorded in `svglide-artboard-gate5-evidence.md` |
| 6 P0a/P0b local E2E | DONE | executor | PASS | P0a `/private/tmp/svglide-p0a-gate6-zNSbw5` ran to dry_run; P0b `/private/tmp/svglide-p0b-gate5-qg7PC6` ran to dry_run; P0b hits `cover-hero/dark-clarity`, `comparison-cards/forest-signal`, `summary-final/warm-editorial`; evidence recorded in `svglide-artboard-gate6-evidence.md` |
| 7 P0c live closure | DONE | executor | PASS | Reviewer PASS: strengthened PPE proof validates Whistle capture/proxy/rule hash/injected headers; fresh P0c `.tmp/svglide-p0c-gate7-live6` ran `dry_run -> ppe_proof -> live_create -> readback`; live deck `MPcnsjAH5l5r2edcpWYcNhFVnVd` created 3 slides `["pbb","pbu","pbe"]`; readback passed page count, slide order, nonblank, text-fit/bounds marker scan, and 22 CanvasSpec visible text fragments; evidence recorded in `svglide-artboard-gate7-evidence.md` |

View File

@@ -2,6 +2,9 @@
Status: final acceptance scope for current implementation milestone
Follow-up completion evidence:
`skills/lark-slides/references/svglide-artboard-followup-completion-evidence.md`
## Accepted Milestone
Gate 12 acceptance covers the implemented SVGlide Artboard/Satori milestone through Gate 12a:
@@ -42,6 +45,18 @@ These are not silently dropped. They are explicit follow-up scope.
## Follow-Up Items
Current follow-up status as of 2026-06-21:
| Item | Status | Notes |
| --- | --- | --- |
| Real Topic Model Loop | local executable contract completed | `model-plan` supports command/model provider flow and records raw output hashes; real external LLM execution still requires credentials/network |
| Automated Visual Repair Loop | local executable loop completed | `repair_loop` applies scoped scalar JSON Patch from repair plans and rejects broad patches |
| Semantic Map Compiler IR | completed | compiler/artboard receipts include `input_semantic_hash`; visible text/source refs are compared against semantic map |
| True Node Layout Observation | completed locally | Satori node observations produce measured `node-layout-map/v1`; drift blocks quality gate |
| Real macOS x64 Runtime Validation | CI wired, local proof blocked by host arch | package check supports `--require-system Darwin --require-arch x64`; GitHub Actions `macos-13` job uploads evidence |
| Export Packaging | completed for SVGlide artifact package | runner `export` writes manifest, zip, and receipt; PPTX/animation/narration remain outside local CLI workspace |
| Theme P1/P2 CLI layer | local executable contracts completed | ThemeSpec extraction, project registry binding, plan migration, and deterministic approval are implemented; UI/server/model-quality approval remain external |
### 1. Real Topic Model Loop
Owner: SVGlide artboard follow-up executor

View File

@@ -143,9 +143,9 @@ Each per-page artboard receipt must bind:
- CanvasSpec hash
- raw Satori preview SVG hash
- CanvasSpec template SVG hash
- compiler input path and hash
- compiler input path and hash; current main path uses `semantic-map/v1`
- renderer mode (`local-static` or `satori-node`)
- compiler mode (`CanvasSpecTemplateSVG` / `preview_only`)
- compiler mode (`SemanticMapIR` / `preview_only`)
- semantic map hash
- node layout map hash
- final SVGlide SVG hash

View File

@@ -14,7 +14,13 @@ Use one run directory per deck or task:
state.json
02-plan/
slide_plan.json
deck-plan.json
canvas-plan.json
svglide.lock.json
theme-productization.input.json
theme-registry.json
theme-migration.patch.json
themes/
plan-confirmation.request.json
plan-confirmation.json
03-assets/
@@ -45,6 +51,8 @@ Use one run directory per deck or task:
xml-presentations-get.json
readback-check.json
09-export/
export-manifest.json
svglide-artifacts.zip
receipts/
logs/
```
@@ -58,7 +66,13 @@ Do not create a separate SVG-only plan root. The SVG route extends the common `.
| `01-project/project_manifest.json` | yes | runner init | all later stages |
| `01-project/state.json` | yes | runner | stage control |
| `02-plan/slide_plan.json` | yes | planner/generator | preflight, preview, live create, readback |
| `02-plan/deck-plan.json` | when using `model-plan` | prompt/model planner | planner contract checks, audit |
| `02-plan/canvas-plan.json` | when using `model-plan` | prompt/model planner | planner contract checks, artboard planning |
| `02-plan/svglide.lock.json` | when execution parameters are locked | planner/generator | preflight and runner |
| `02-plan/theme-productization.input.json` | when productizing a project theme | user/model/theme tooling | `theme_productization` optional stage |
| `02-plan/theme-registry.json` | when project theme overrides are used | `theme_productization` | `theme_validate`, artboard renderer |
| `02-plan/theme-migration.patch.json` | when migrating a plan theme | `theme_productization` | audit and review |
| `02-plan/themes/*.json` | when project themes are used | `theme_productization` | `theme_validate`, artboard renderer |
| `02-plan/plan-confirmation.request.json` | when confirmation is missing | runner confirm_plan | user/chat/confirm surface |
| `02-plan/plan-confirmation.json` | yes before SVG generation | user/chat/confirm surface | runner confirm_plan, generate_svg, prepare, create |
| `source/evidence.json` | yes before strategy/generation | source stage or user-provided evidence | strategy review, semantic review, quality gate |
@@ -67,12 +81,17 @@ Do not create a separate SVG-only plan root. The SVG route extends the common `.
| `03-assets/asset-manifest.json` | yes before SVG generation | assets stage | generate_svg and audit |
| `04-svg/page-###.svg` | yes | `generate_svg` | prepare |
| `04-svg/page-###.receipt.json` | yes | `generate_svg` | prepare and audit |
| `04-svg/artboard/page-###.semantic-map.json` | when using `artboard_satori` | artboard renderer | semantic/source-ref quality gate |
| `04-svg/artboard/page-###.node-observations.json` | when using `artboard_satori` | Satori renderer | node layout map compiler |
| `04-svg/artboard/page-###.node-layout-map.json` | when using `artboard_satori` | artboard renderer | template-fit and quality gate drift checks |
| `04-svg/prepared/page-###.svg` | yes before preview/check/create | prepare | preview, preflight, `slides +create-svg --file` |
| `05-preview/preview.html` | yes before preview lint | preview generator | preview lint and aesthetic review |
| `05-preview/preview-manifest.json` | yes before preview lint | preview generator | preview lint and audit |
| `06-check/preflight.json` | yes | `svg_preflight.py` | quality gate |
| `06-check/preview-lint.json` | yes | `svg_preview_lint.py` | quality gate |
| `06-check/aesthetic-review.json` | yes before quality gate | aesthetic_review stage | quality gate |
| `06-check/template-fit.json` | when using `artboard_satori` | template fit check | quality gate |
| `06-check/theme-productization.json` | when using theme productization | `theme_productization` optional stage | audit and review |
| `06-check/chart-verify.json` | yes before quality gate | chart_verify stage | quality gate |
| `06-check/semantic-review.json` | yes before quality gate | semantic_review stage | quality gate |
| `06-check/text-inventory.json` | yes before quality gate | semantic_review stage | quality gate and generator provenance audit |
@@ -86,9 +105,14 @@ Do not create a separate SVG-only plan root. The SVG route extends the common `.
| `07-create/live-create.json` | yes after live create | CLI output capture | readback and recovery |
| `08-readback/xml-presentations-get.json` | yes after live create | readback checker | readback verifier |
| `08-readback/readback-check.json` | yes after live create | readback checker | delivery decision |
| `09-export/export-manifest.json` | when running export | export stage | handoff package audit |
| `09-export/svglide-artifacts.zip` | when running export with archive | export stage | handoff package |
| `receipts/<stage>.json` | yes per completed or blocked stage | runner or stage script | audit and resume |
| `receipts/assets.json` | yes before generate_svg | runner `assets` | generate_svg and audit |
| `receipts/generate_svg.json` | yes before prepare | runner `generate_svg` | prepare and audit |
| `receipts/repair-loop.json` | when auto repair is run | repair loop | audit and rerun |
| `receipts/theme-productization.json` | when theme productization is run | theme productization | audit |
| `receipts/export.json` | when export is run | export stage | handoff package audit |
| `notes/notes-review.json` | optional speaker handoff | speaker notes script | human handoff |
## Path Rules
@@ -100,6 +124,9 @@ Do not create a separate SVG-only plan root. The SVG route extends the common `.
- Source SVG files under `04-svg/page-###.svg` must not change after `receipts/generate_svg.json`; rerun `generate_svg` before `prepare` if they change.
- SVG image placeholders should use local `@./assets/...` paths or file tokens. HTTP(S) and data image hrefs are not valid `slides +create-svg` inputs.
- Every check record must include the same `plan_path`, relevant input paths, summary counts, and final action. `semantic-review.json` must bind current plan/evidence/prepared SVG hashes; `quality-gate.json` must consume current generator, chart, semantic, runtime, preflight, preview, and aesthetic receipts.
- Project theme registries may bind productized themes to local templates through `template_bindings.supported_template_ids`; `theme_validate` still rejects unknown templates and unbound themes.
- Artboard receipts must bind `semantic-map/v1` with `input_semantic_hash`; measured node layout maps must record observation source and drift status.
- `07-create/ppe-proof.json` must bind current quality gate, dry-run, and proof input hashes before live create.
- Failed or partial live creates must still record `xml_presentation_id`, created slide ids, uploaded image count, and the failing page index when available.
- `09-export/export-manifest.json` is a verified SVGlide artifact package manifest. It does not imply PPTX, animation, or narration support unless those formats are explicitly marked as passed.
- Runtime artifacts under `.lark-slides/plan/<deck-id>/` are per-run outputs. Do not commit them unless a test fixture explicitly requires it.

View File

@@ -24,6 +24,13 @@ P1/P2 are not targets for this evidence slice. In particular, this file does
not claim model-driven theme extraction, productized theme authoring UI,
cross-deck theme migration, export packaging, or automated aesthetic approval.
Follow-up implementation after this P0 slice is tracked in
`skills/lark-slides/references/svglide-artboard-followup-completion-evidence.md`.
The local CLI layer now includes deterministic theme productization,
cross-plan theme migration, export artifact packaging, and deterministic
aesthetic auto approval. It still does not claim a productized editor UI,
external model-quality approval, PPTX export, animation, or narrated output.
## Branch And Baseline
Observed during this evidence update:
@@ -298,11 +305,15 @@ output receipts:
- `pre_submit_review` is a human receipt validator. It is not an automatic
aesthetics model.
- `aesthetic_review` now emits deterministic auto approval from preview,
page-count, and asset-safety checks. It is still not a learned aesthetics
model.
- `theme_adherence` only checks static SVG colors and direct text/background
contrast in final prepared SVG files.
- Satori SVG remains an artboard preview/intermediate artifact; final theme
adherence is checked on `04-svg/prepared/*.svg`.
- `direct_svg` still runs theme checks, but it must not require
`artboard-package-check`.
- `export` is outside Theme System P0. The P0 plan explicitly does not claim
PPTX or narrated deck output.
- `export` was outside Theme System P0. Follow-up now packages verified
SVGlide artifacts, but still does not claim PPTX, animation, or narrated
deck output.

View File

@@ -10,6 +10,8 @@ request
-> load SVG private rule set
-> init run directory
-> source
-> optional model-plan
-> optional theme_productization
-> plan
-> strategy_review
-> user confirms plan
@@ -29,6 +31,7 @@ request
-> ppe_proof
-> live create
-> readback
-> optional export
-> delivery record
```
@@ -40,6 +43,8 @@ request
| load rules | `svglide-svg-private.rules.json` | recorded `loaded_rule_set` | missing required private files blocks preflight |
| init | deck id, title | `.lark-slides/plan/<deck-id>/01-project/` | repeat init of the same deck id is rejected unless explicitly forced |
| source | `source/evidence.json` or `source/source-notes.md`; online research unless disabled | `source/evidence.json`, `source/research_queries.json`, `source/research.md`, `source/source-receipt.json`, `receipts/source.json` | `source_status=thin/blocked`, blocked online research, too few evidence items, or stale source receipt blocks strategy/generation |
| model-plan | user prompt and provider command/model config | `source/evidence.json`, `02-plan/deck-plan.json`, `02-plan/slide-plan.json`, `02-plan/canvas-plan.json`, planner raw output hashes | provider output must be JSON, pass planner contracts, and record `provider_type`; external model credentials are not assumed |
| theme_productization | theme productization request and optional slide plan | project `ThemeSpec`, `02-plan/theme-registry.json`, optional migrated plan and patch receipt | ThemeSpec schema, project template binding, and migration patch must be valid |
| plan | user goal, page count, sources | `02-plan/slide_plan.json`, optional `02-plan/svglide.lock.json`, `receipts/plan.json` | plan must declare route/output mode, style, loaded rules, visual identity, art direction, quality gates, and SVG page metadata |
| strategy_review | `02-plan/slide_plan.json` | `02-plan/strategy-review.json` | language, audience, deck structure, page types, sections, roles, key messages, visual identity, theme anchors, and content minimums must pass before confirmation |
| confirm plan | `02-plan/slide_plan.json`, optional lock | `02-plan/plan-confirmation.json`, `receipts/confirm_plan.json` | user confirmation is required before assets, SVG generation, prepare, dry-run, or live-create |
@@ -49,7 +54,7 @@ request
| build preview | prepared SVG pages and plan metadata | `05-preview/preview.html`, `05-preview/preview-manifest.json` | preview is a visual review aid, not the API contract |
| preflight | plan, prepared SVG | `06-check/preflight.json` | SVG protocol, plan contract, loaded rules, geometry, text, assets, and business claims must pass |
| preview_lint | local preview HTML | `06-check/preview-lint.json` | preview action must be `create_live` |
| aesthetic_review | preview lint, preview manifest, asset manifest | `06-check/aesthetic-review.json` | review status must be `passed`, image-led pages must have safe text zones, and action must be `create_live` |
| aesthetic_review | preview lint, preview manifest, asset manifest | `06-check/aesthetic-review.json` | deterministic auto approval must be `approved`, image-led pages must have safe text zones, and action must be `create_live`; this is not a learned aesthetics model |
| chart_verify | plan chart contracts and prepared SVG | `06-check/chart-verify.json` | required or exact chart pages must have data and chart-like marks; no required chart records `required_chart_count=0` and passes |
| semantic_review | plan, evidence, source receipt, prepared SVG pages | `06-check/semantic-review.json`, `06-check/text-inventory.json` | language, audience, deck structure, page types, content density, source refs, numeric claim citations, research status, and visible SVG text provenance must pass |
| runtime_review | plan renderer/layout declarations, asset manifest | `06-check/runtime-review.json` | missing renderer/layout declarations, renderer/layout monoculture, or asset/renderer mismatch blocks quality gate |
@@ -59,6 +64,8 @@ request
| ppe_proof | current quality gate, dry-run, and PPE input | `07-create/ppe-proof.json` | live create is blocked unless PPE/auth/proxy/header proof is passed and fresh |
| live create | same checked prepared SVG files and PPE proof | `07-create/live-create.json` | partial failures must preserve the returned ids for recovery |
| readback | presentation id | `08-readback/readback-check.json` | page count, blank pages, bounds, text fit, assets, input binding, and closing slide must be checked |
| repair_loop | failing receipt and scoped repair plan | updated `02-plan/slide_plan.json`, `receipts/repair-loop.json` | only scoped scalar JSON Patch is allowed; broad structural rewrites are rejected |
| export | passed readback, live-create, quality-gate, and prepared SVGs | `09-export/export-manifest.json`, optional zip, `receipts/export.json` | packages verified SVGlide artifacts; PPTX/animation/narration must be explicitly marked separately |
## Route Boundary

View File

@@ -20650,8 +20650,27 @@ async function checkRuntime() {
new Resvg(probe).render().asPng();
console.log(JSON.stringify({ ok: true, renderer: "satori-resvg", satori_version: SATORI_VERSION, resvg_version: RESVG_VERSION, font_path: font.path }));
}
function serializeObservation(node) {
const props = node?.props || {};
const safeProps = {};
for (const [key, value] of Object.entries(props)) {
if (key.startsWith("data-") && ["string", "number", "boolean"].includes(typeof value)) {
safeProps[key] = value;
}
}
return {
left: node?.left,
top: node?.top,
width: node?.width,
height: node?.height,
type: node?.type,
key: node?.key,
textContent: node?.textContent,
props: safeProps
};
}
async function main() {
const [, , inputPath, outputPath, pngPath, metadataPath] = process2.argv;
const [, , inputPath, outputPath, pngPath, metadataPath, observationsPath] = process2.argv;
if (inputPath === "--check-runtime") {
await checkRuntime();
return;
@@ -20664,11 +20683,15 @@ async function main() {
const Resvg = await loadResvg();
const spec = JSON.parse(await fs2.readFile(inputPath, "utf8"));
const font = await loadFont();
const observations = [];
const svg = await satori(renderTree(spec), {
width: 960,
height: 540,
embedFont: false,
fonts: [font]
fonts: [font],
onNodeDetected: (node) => {
observations.push(serializeObservation(node));
}
});
await fs2.mkdir(path.dirname(outputPath), { recursive: true });
await fs2.writeFile(outputPath, svg);
@@ -20698,6 +20721,21 @@ async function main() {
) + "\n"
);
}
if (observationsPath) {
await fs2.mkdir(path.dirname(observationsPath), { recursive: true });
await fs2.writeFile(
observationsPath,
JSON.stringify(
{
version: "svglide-node-observations/v1",
observation_source: "satori_on_node_detected",
nodes: observations
},
null,
2
) + "\n"
);
}
}
main();
/*! Bundled license information:

View File

@@ -79,8 +79,28 @@ async function checkRuntime() {
console.log(JSON.stringify({ ok: true, renderer: 'satori-resvg', satori_version: SATORI_VERSION, resvg_version: RESVG_VERSION, font_path: font.path }))
}
function serializeObservation(node) {
const props = node?.props || {}
const safeProps = {}
for (const [key, value] of Object.entries(props)) {
if (key.startsWith('data-') && ['string', 'number', 'boolean'].includes(typeof value)) {
safeProps[key] = value
}
}
return {
left: node?.left,
top: node?.top,
width: node?.width,
height: node?.height,
type: node?.type,
key: node?.key,
textContent: node?.textContent,
props: safeProps
}
}
async function main() {
const [, , inputPath, outputPath, pngPath, metadataPath] = process.argv
const [, , inputPath, outputPath, pngPath, metadataPath, observationsPath] = process.argv
if (inputPath === '--check-runtime') {
await checkRuntime()
return
@@ -93,11 +113,15 @@ async function main() {
const Resvg = await loadResvg()
const spec = JSON.parse(await fs.readFile(inputPath, 'utf8'))
const font = await loadFont()
const observations = []
const svg = await satori(renderTree(spec), {
width: 960,
height: 540,
embedFont: false,
fonts: [font]
fonts: [font],
onNodeDetected: (node) => {
observations.push(serializeObservation(node))
}
})
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, svg)
@@ -127,6 +151,21 @@ async function main() {
) + '\n'
)
}
if (observationsPath) {
await fs.mkdir(path.dirname(observationsPath), { recursive: true })
await fs.writeFile(
observationsPath,
JSON.stringify(
{
version: 'svglide-node-observations/v1',
observation_source: 'satori_on_node_detected',
nodes: observations
},
null,
2
) + '\n'
)
}
}
main()

View File

@@ -0,0 +1,15 @@
{
"schema_version": "svglide-check-receipt/v1",
"stage": "preflight",
"status": "failed",
"summary": {
"error_count": 1
},
"issues": [
{
"code": "text_overflow",
"path": "$.slides[0].canvas_spec.content.title",
"message": "Title exceeds the fixture text budget."
}
]
}

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def source_plan() -> dict[str, object]:
return {
"schema_version": "svglide-source-plan/v1",
"source_notes_markdown": "# Source Notes\n\n- SpaceX is a private aerospace company.\n- IPO timing is not confirmed.\n- Analysis separates Starlink, launch services, and risk discount.\n",
"evidence": {
"schema_version": "svglide-evidence/v1",
"source_status": "ready",
"generated_from": "followup_model_loop_fixture_provider",
"research_status": "fixture_command_provider",
"items": [
{
"id": "item-001",
"text": "SpaceX remains privately held, so any IPO date must be treated as unconfirmed analysis context.",
},
{
"id": "item-002",
"text": "Starlink scale, launch cadence, and capital expenditure are core drivers in a SpaceX IPO framing.",
},
{
"id": "item-003",
"text": "Investor-facing analysis should separate valuation upside, execution risk, and market timing.",
},
],
},
}
def deck_plan() -> dict[str, object]:
return {
"schema_version": "svglide-deck-plan/v1",
"topic": "spacex IPO 分析",
"audience": "投资/战略分析读者",
"objective": "用一页说明 SpaceX IPO 分析的核心判断框架。",
"target_slide_count": 1,
"narrative_arc": ["提出问题", "建立框架", "收束判断"],
"theme_direction": {
"preferred_theme_ids": ["finance-dark"],
"visual_identity": "深色航天资本市场信号",
"tone": "审慎、分析型、可追溯",
},
"constraints": {
"generation_mode": "artboard_satori",
"source_policy": "不编造 IPO 日期或估值事实。",
"forbidden_outputs": ["free_html", "free_css", "free_svg", "markdown_fence"],
},
"slides": [
{
"page": 1,
"title": "SpaceX IPO 分析框架",
"role": "cover",
"key_message": "IPO 价值判断取决于 Starlink、发射业务与风险折价。",
"content_goal": "建立分析框架。",
"visual_goal": "使用深色金融航天封面。",
"allowed_template_ids": ["cover-hero"],
}
],
}
def slide_plan() -> dict[str, object]:
return {
"schema_version": "svglide-slide-plan/v1",
"deck_plan_ref": {"path": "02-plan/deck-plan.json"},
"generation_mode": "artboard_satori",
"slides": [
{
"page": 1,
"title": "SpaceX IPO 分析框架",
"key_message": "IPO 价值判断取决于 Starlink、发射业务与风险折价。",
"template_id": "cover-hero",
"theme_id": "finance-dark",
"content_requirements": {
"eyebrow": "SPACE CAPITAL MARKET",
"subtitle": "把未确认 IPO 传闻转成可审查的投资分析框架。",
"chips": ["Starlink", "Launch", "Risk"],
},
"visual_role": "investment thesis cover",
"source_policy": "不编造 IPO 日期或估值事实。",
}
],
}
def canvas_plan() -> dict[str, object]:
canvas_spec = {
"version": "svglide-canvas-spec/v1",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"template_id": "cover-hero",
"theme_id": "finance-dark",
"theme": {
"colors": {
"background": "#07110E",
"panel": "#10201A",
"primary": "#22C55E",
"accent": "#F59E0B",
"text": "#ECFDF5",
"muted": "#A7C4B7",
}
},
"content": {
"eyebrow": "SPACE CAPITAL MARKET",
"title": "SpaceX IPO 分析框架",
"subtitle": "把未确认 IPO 传闻转成可审查的投资分析框架。",
"chips": ["Starlink", "Launch", "Risk"],
},
"semantic_elements": [
{
"element_id": "title",
"kind": "text",
"role": "title",
"source_ref": "canvas_spec.content.title",
"bbox": {"x": 84, "y": 142, "width": 628, "height": 142},
}
],
"quality_constraints": {
"max_title_lines": 2,
"min_font_size": 18,
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
},
}
return {
"schema_version": "svglide-canvas-plan/v1",
"route": "svglide-svg",
"generation_mode": "artboard_satori",
"page_count": 1,
"target_slide_count": 1,
"plan_path": "02-plan/slide_plan.json",
"style_preset": "finance-dark",
"style_selection_reason": "SpaceX IPO 分析适合深色资本市场信号主题。",
"style_system": {
"palette": {"background": "#07110E", "text": "#ECFDF5", "accent": "#F59E0B"},
"typography": "Satori-compatible static hierarchy",
"background_strategy": "dark market terminal",
"motif": "orbital capital signal",
},
"loaded_rule_set": [
"skills/lark-slides/references/svglide-canvas-spec.schema.json",
"skills/lark-slides/references/svglide-template-registry.json",
],
"quality_gates": {"no_text_overflow": True, "no_debug_guides": True, "no_xml_like_pages": True},
"art_direction": {
"cover_treatment": "深色发射资产封面叠加资本市场信号。",
"section_divider_treatment": "用轨道线条做节奏分隔。",
"closing_treatment": "以投资问题清单收束。",
"deck_motif": "发射窗口与资本信号线",
"svg_native_moments": ["封面 chips", "轨道线", "风险折价卡"],
},
"asset_contracts": [
{
"id": "spacex-launch-cover",
"page": 1,
"placement_role": "cover",
"query": "SpaceX Falcon 9 launch public domain",
"required": True,
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
"crop_hint": "rocket launch with dark negative space",
},
{
"id": "starlink-orbit",
"page": 1,
"placement_role": "cover",
"query": "Starlink satellites orbit public domain",
"required": True,
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
"crop_hint": "space network background",
},
{
"id": "rocket-stage",
"page": 1,
"placement_role": "cover",
"query": "rocket launch pad night public domain",
"required": True,
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
"crop_hint": "launch infrastructure",
},
],
"model_loop_fixture": {
"provider": "command",
"source": "skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/fixture_model_provider.py",
},
"slides": [
{
"page": 1,
"title": "SpaceX IPO 分析框架",
"key_message": "IPO 价值判断取决于 Starlink、发射业务与风险折价。",
"renderer_id": "artboard_satori.cover-hero",
"layout_family": "cover",
"visual_recipe": "hero_typography",
"visual_intent": "建立投资分析框架。",
"visual_focal_point": "标题和 Starlink/Launch/Risk 标签。",
"visual_signature": "dark orbital market cover",
"svg_effects": ["typography", "asset_scrim"],
"required_primitives": ["typography", "rect", "circle"],
"svg_primitives": ["typography", "rect", "circle"],
"xml_like_risk": "普通 bullets 会弱化投资框架。",
"content_density_contract": "cover title plus 3 chips",
"risk_flags": [],
"source_policy": "不编造 IPO 日期或估值事实。",
"canvas_spec": canvas_spec,
}
],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--stage", required=True)
parser.add_argument("--raw-output", required=True)
args = parser.parse_args()
mapping = {
"source-planner": source_plan,
"deck-planner": deck_plan,
"slide-planner": slide_plan,
"canvas-planner": canvas_plan,
}
if args.stage not in mapping:
raise SystemExit(f"unsupported stage: {args.stage}")
Path(args.raw_output).write_text(json.dumps(mapping[args.stage](), ensure_ascii=False), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,15 @@
{
"schema_version": "svglide-repair-plan/v1",
"target_plan_path": "02-plan/slide_plan.json",
"change_reason": "错误示例:尝试重写整个 content 对象。",
"patches": [
{
"op": "replace",
"path": "/slides/0/canvas_spec/content",
"value": {
"title": "whole object rewrite"
},
"reason": "broad object rewrite must be rejected."
}
]
}

View File

@@ -0,0 +1,19 @@
{
"schema_version": "svglide-repair-plan/v1",
"target_plan_path": "02-plan/slide_plan.json",
"change_reason": "收短封面标题,修复 preflight 的 text_overflow。",
"patches": [
{
"op": "test",
"path": "/slides/0/page",
"value": 1,
"reason": "确认只修改第一页。"
},
{
"op": "replace",
"path": "/slides/0/canvas_spec/content/title",
"value": "SpaceX IPO 框架",
"reason": "将标题缩短到 CanvasSpec 文本预算内。"
}
]
}

View File

@@ -0,0 +1,6 @@
{
"prompt": "spacex IPO 分析",
"target_slide_count": 1,
"language": "zh-CN",
"audience": "投资/战略分析读者"
}

View File

@@ -0,0 +1,23 @@
{
"version": "svglide-node-layout-map/v1",
"page": 1,
"source": "measured-layout-observation",
"observation_source": "satori_on_node_detected",
"threshold_px": 8,
"drift": {"status": "failed", "max_px": 48, "threshold_px": 8, "missing_count": 0},
"nodes": [
{
"id": "title",
"kind": "text",
"x": 128,
"y": 80,
"width": 720,
"height": 72,
"text": "Semantic IR Title",
"expected_bbox": {"x": 80, "y": 80, "width": 720, "height": 72},
"measured_bbox": {"x": 128, "y": 80, "width": 720, "height": 72},
"drift_px": 48,
"observation_source": "satori_on_node_detected"
}
]
}

View File

@@ -0,0 +1,23 @@
{
"version": "svglide-node-layout-map/v1",
"page": 1,
"source": "measured-layout-observation",
"observation_source": "satori_on_node_detected",
"threshold_px": 8,
"drift": {"status": "passed", "max_px": 2, "threshold_px": 8, "missing_count": 0},
"nodes": [
{
"id": "title",
"kind": "text",
"x": 82,
"y": 80,
"width": 720,
"height": 72,
"text": "Semantic IR Title",
"expected_bbox": {"x": 80, "y": 80, "width": 720, "height": 72},
"measured_bbox": {"x": 82, "y": 80, "width": 720, "height": 72},
"drift_px": 2,
"observation_source": "satori_on_node_detected"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"version": "svglide-semantic-map/v1",
"page": 1,
"template_id": "cover-hero",
"theme_id": "dark-clarity",
"semantic_source": "CanvasSpec",
"content_keys": ["title"],
"elements": [
{
"element_id": "title",
"kind": "text",
"role": "title",
"source_ref": "canvas_spec.content.title",
"text": "Semantic IR Title",
"bbox": {"x": 80, "y": 80, "width": 720, "height": 72}
}
]
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">
<foreignObject slide:role="shape" slide:shape-type="text" data-node-id="title" data-source-ref="canvas_spec.content.title" x="80" y="80" width="720" height="72">
<div xmlns="http://www.w3.org/1999/xhtml">Semantic IR Title</div>
</foreignObject>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -127,9 +127,19 @@ def run_aesthetic_review(project: Path) -> dict[str, Any]:
result = {
"version": "svglide-aesthetic-review/v1",
"review_mode": "automated_preview_record",
"review_mode": "deterministic_aesthetic_auto_approval",
"reviewed_at": now_iso(),
"status": "failed" if issues else "passed",
"approval": {
"status": "rejected" if issues else "approved",
"policy": "preview-lint-page-count-asset-safety/v1",
"model": None,
"human_review_required": bool(issues),
},
"boundaries": {
"aesthetic_model": "not_connected",
"scope": "deterministic preview artifact checks; not a learned visual taste model",
},
"preview_path": PREVIEW_HTML.as_posix(),
"manifest_path": PREVIEW_MANIFEST.as_posix(),
"page_count": actual,

View File

@@ -36,6 +36,9 @@ class SVGlideAestheticReviewTest(unittest.TestCase):
self.assertEqual(result["status"], "passed")
self.assertEqual(result["action"], "create_live")
self.assertEqual(result["review_mode"], "deterministic_aesthetic_auto_approval")
self.assertEqual(result["approval"]["status"], "approved")
self.assertIsNone(result["approval"]["model"])
self.assertEqual(result["summary"]["error_count"], 0)
self.assertTrue((project / "06-check/aesthetic-review.json").exists())
@@ -47,6 +50,8 @@ class SVGlideAestheticReviewTest(unittest.TestCase):
self.assertEqual(result["status"], "failed")
self.assertEqual(result["action"], "repair_and_rerun")
self.assertEqual(result["approval"]["status"], "rejected")
self.assertTrue(result["approval"]["human_review_required"])
self.assertEqual(result["issues"][0]["code"], "preview_lint_not_clean")
def test_aesthetic_review_blocks_page_count_mismatch(self) -> None:

View File

@@ -62,6 +62,15 @@ class PackageCheckError(Exception):
pass
def normalize_arch(machine: str) -> str:
value = machine.strip().lower()
if value in {"x86_64", "amd64", "x64"}:
return "x64"
if value in {"arm64", "aarch64"}:
return "arm64"
return value or "unknown"
def now_iso() -> str:
return datetime.now().astimezone().replace(microsecond=0).isoformat()
@@ -189,6 +198,8 @@ def inspect_artboard_package(
renderer_dir: Path = ARTBOARD_RENDERER_DIR,
*,
run_runtime: bool = True,
require_system: str | None = None,
require_arch: str | None = None,
) -> dict[str, Any]:
repo_root = repo_root.resolve()
renderer_dir = renderer_dir.resolve()
@@ -200,6 +211,15 @@ def inspect_artboard_package(
gitignore_path = repo_root / ".gitignore"
issues: list[dict[str, str]] = []
blockers: list[dict[str, str]] = []
host_system = platform.system()
host_machine = platform.machine()
host_arch = normalize_arch(host_machine)
required_arch = normalize_arch(require_arch) if require_arch else None
if require_system and host_system.lower() != require_system.lower():
blockers.append({"code": "runtime_host_system_mismatch", "message": f"runtime check requires {require_system}, got {host_system or 'unknown'}"})
if required_arch and host_arch != required_arch:
blockers.append({"code": "runtime_host_arch_mismatch", "message": f"runtime check requires arch {required_arch}, got {host_arch}"})
package_payload = read_json(package_path)
lockfile_text = lockfile_path.read_text(encoding="utf-8") if lockfile_path.exists() else ""
if not lockfile_path.exists():
@@ -242,7 +262,7 @@ def inspect_artboard_package(
if not check.get("passed"):
issues.append({"code": "runtime_check_failed", "message": f"{repo_rel(entry, repo_root)} --check-runtime failed"})
status = "passed" if not issues else "failed"
status = "passed" if not issues and not blockers else ("blocked" if blockers and not issues else "failed")
return {
"version": CHECK_VERSION,
"stage": CHECK_STAGE,
@@ -251,14 +271,22 @@ def inspect_artboard_package(
"checked_at": now_iso(),
"summary": {
"error_count": len(issues),
"blocked_count": len(blockers),
"warning_count": 0,
"runtime_check_count": len(runtime_checks),
},
"host": {
"system": platform.system(),
"machine": platform.machine(),
"system": host_system,
"machine": host_machine,
"normalized_arch": host_arch,
"python": platform.python_version(),
},
"host_requirements": {
"required_system": require_system,
"required_arch": required_arch,
"status": "passed" if not blockers else "blocked",
"blockers": blockers,
},
"renderer": {
"dir": repo_rel(renderer_dir, repo_root),
"source_entry": repo_rel(source_path, repo_root),
@@ -301,6 +329,7 @@ def inspect_artboard_package(
"no_network": "do not auto-fetch; use CI/preinstalled pnpm store or a packaged platform dependency layer",
},
"runtime_checks": runtime_checks,
"blockers": blockers,
"issues": issues,
}
@@ -316,6 +345,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument("--renderer-dir", type=Path, default=ARTBOARD_RENDERER_DIR)
parser.add_argument("--output-dir", type=Path, default=None)
parser.add_argument("--skip-runtime", action="store_true", help="skip node --check-runtime probes; structural checks still run")
parser.add_argument("--require-system", help="require a runtime host system, for example Darwin")
parser.add_argument("--require-arch", choices=["x64", "arm64"], help="require a normalized runtime host architecture")
parser.add_argument("--pretty", action="store_true")
return parser.parse_args(argv)
@@ -323,7 +354,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
payload = inspect_artboard_package(args.repo_root, args.renderer_dir, run_runtime=not args.skip_runtime)
payload = inspect_artboard_package(
args.repo_root,
args.renderer_dir,
run_runtime=not args.skip_runtime,
require_system=args.require_system,
require_arch=args.require_arch,
)
if args.output_dir:
write_check_outputs(args.output_dir, payload)
print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None, sort_keys=True))
@@ -335,7 +372,7 @@ def main(argv: list[str]) -> int:
"status": "failed",
"action": "repair_and_rerun",
"checked_at": now_iso(),
"summary": {"error_count": 1, "warning_count": 0, "runtime_check_count": 0},
"summary": {"error_count": 1, "blocked_count": 0, "warning_count": 0, "runtime_check_count": 0},
"issues": [{"code": "package_check_error", "message": str(err)}],
}
if args.output_dir:

View File

@@ -8,6 +8,7 @@ import json
import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -77,6 +78,23 @@ var skillsEmbedFS embed.FS
decoded = json.loads(encoded)
self.assertEqual(decoded["runtime_checks"][0]["payload"]["renderer"], "satori-resvg")
def test_required_x64_host_blocks_on_arm64_host(self) -> None:
with mock.patch.object(package_check.platform, "system", return_value="Darwin"), mock.patch.object(package_check.platform, "machine", return_value="arm64"):
payload = package_check.inspect_artboard_package(run_runtime=False, require_system="Darwin", require_arch="x64")
self.assertEqual(payload["status"], "blocked", payload["issues"])
self.assertEqual(payload["host"]["normalized_arch"], "arm64")
self.assertEqual(payload["host_requirements"]["required_arch"], "x64")
self.assertEqual(payload["blockers"][0]["code"], "runtime_host_arch_mismatch")
def test_required_x64_host_passes_requirement_on_x86_64_host(self) -> None:
with mock.patch.object(package_check.platform, "system", return_value="Darwin"), mock.patch.object(package_check.platform, "machine", return_value="x86_64"):
payload = package_check.inspect_artboard_package(run_runtime=False, require_system="Darwin", require_arch="x64")
self.assertEqual(payload["status"], "passed", payload["issues"])
self.assertEqual(payload["host"]["normalized_arch"], "x64")
self.assertEqual(payload["host_requirements"]["status"], "passed")
if __name__ == "__main__":
unittest.main()

View File

@@ -17,6 +17,8 @@ from typing import Any
from xml.etree import ElementTree
from xml.sax.saxutils import escape, quoteattr
import svglide_node_layout_drift
CANVAS_SPEC_VERSION = "svglide-canvas-spec/v1"
ARTBOARD_RECEIPT_VERSION = "svglide-artboard-receipt/v1"
@@ -417,7 +419,20 @@ def svg_text(
font_weight: int = 700,
) -> None:
box_height = max(height, 30)
nodes.append({"id": node_id, "kind": "text", "x": x, "y": y, "width": width, "height": box_height, "text": value})
nodes.append(
{
"id": node_id,
"kind": "text",
"x": x,
"y": y,
"width": width,
"height": box_height,
"text": value,
"fill": fill,
"font_size": font_size,
"font_weight": font_weight,
}
)
baseline = y + min(box_height - 4, font_size * 1.18)
parts.append(
f'<text data-node-id="{node_id}" data-box-x="{x:g}" data-box-y="{y:g}" '
@@ -440,7 +455,7 @@ def svg_rect(
stroke: str | None = None,
stroke_width: float | None = None,
) -> None:
nodes.append({"id": node_id, "kind": "rect", "x": x, "y": y, "width": width, "height": height})
nodes.append({"id": node_id, "kind": "rect", "x": x, "y": y, "width": width, "height": height, "fill": fill, "opacity": opacity, "stroke": stroke, "stroke_width": stroke_width})
parts.append(
f'<rect data-node-id="{node_id}" x="{x:g}" y="{y:g}" width="{width:g}" height="{height:g}" '
f'fill="{fill}"'
@@ -464,7 +479,7 @@ def svg_circle(
stroke: str | None = None,
stroke_width: float | None = None,
) -> None:
nodes.append({"id": node_id, "kind": "circle", "x": cx - r, "y": cy - r, "width": r * 2, "height": r * 2})
nodes.append({"id": node_id, "kind": "circle", "x": cx - r, "y": cy - r, "width": r * 2, "height": r * 2, "fill": fill, "opacity": opacity, "stroke": stroke, "stroke_width": stroke_width})
parts.append(
f'<circle data-node-id="{node_id}" cx="{cx:g}" cy="{cy:g}" r="{r:g}" fill="{fill}"'
+ (f' opacity="{opacity:g}"' if opacity is not None else "")
@@ -487,7 +502,23 @@ def svg_line(
stroke_width: float = 2,
opacity: float | None = None,
) -> None:
nodes.append({"id": node_id, "kind": "line", "x": min(x1, x2), "y": min(y1, y2), "width": max(abs(x2 - x1), 1), "height": max(abs(y2 - y1), 1)})
nodes.append(
{
"id": node_id,
"kind": "line",
"x": min(x1, x2),
"y": min(y1, y2),
"width": max(abs(x2 - x1), 1),
"height": max(abs(y2 - y1), 1),
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"stroke": stroke,
"stroke_width": stroke_width,
"opacity": opacity,
}
)
parts.append(
f'<line data-node-id="{node_id}" x1="{x1:g}" y1="{y1:g}" x2="{x2:g}" y2="{y2:g}" stroke="{stroke}" stroke-width="{stroke_width:g}"'
+ (f' opacity="{opacity:g}"' if opacity is not None else "")
@@ -510,7 +541,7 @@ def svg_path(
stroke_width: float | None = None,
opacity: float | None = None,
) -> None:
nodes.append({"id": node_id, "kind": "path", "x": x, "y": y, "width": width, "height": height})
nodes.append({"id": node_id, "kind": "path", "x": x, "y": y, "width": width, "height": height, "d": d, "fill": fill, "stroke": stroke, "stroke_width": stroke_width, "opacity": opacity})
parts.append(
f'<path data-node-id="{node_id}" d="{d}" x="{x:g}" y="{y:g}" width="{width:g}" height="{height:g}" fill="{fill}"'
+ (f' stroke="{stroke}"' if stroke else "")
@@ -521,7 +552,7 @@ def svg_path(
def begin_template_svg(theme: dict[str, str], nodes: list[dict[str, Any]]) -> list[str]:
nodes.append({"id": "background", "kind": "rect", "x": 0, "y": 0, "width": 960, "height": 540})
nodes.append({"id": "background", "kind": "rect", "x": 0, "y": 0, "width": 960, "height": 540, "fill": theme["background"]})
return [
f'<svg xmlns="{SVG_NS}" width="960" height="540" viewBox="0 0 960 540">',
f'<rect data-node-id="background" x="0" y="0" width="960" height="540" fill="{theme["background"]}"/>',
@@ -596,11 +627,21 @@ def semantic_elements_from_nodes(nodes: list[dict[str, Any]]) -> list[dict[str,
"width": number(node.get("width"), 0),
"height": number(node.get("height"), 0),
},
"style": semantic_style_for_node(node),
}
)
return elements
def semantic_style_for_node(node: dict[str, Any]) -> dict[str, Any]:
style: dict[str, Any] = {}
for key in ["fill", "stroke", "stroke_width", "opacity", "font_size", "font_weight", "d", "x1", "y1", "x2", "y2"]:
value = node.get(key)
if value is not None:
style[key] = value
return style
def template_cover_hero(spec: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
theme = normalize_theme(spec)
eyebrow = content_text(spec, "eyebrow", "SVGLIDE ARTBOARD")
@@ -741,6 +782,7 @@ def template_comparison(spec: dict[str, Any]) -> tuple[str, list[dict[str, Any]]
{"id": "title", "kind": "text", "x": 64, "y": 52, "width": 760, "height": 64, "text": title},
{"id": "left-card", "kind": "rect", "x": 64, "y": 140, "width": 390, "height": 250},
{"id": "right-card", "kind": "rect", "x": 506, "y": 140, "width": 390, "height": 250},
{"id": "comparison-divider", "kind": "path", "x": 480, "y": 144, "width": 1, "height": 246, "d": "M480 144 L480 390", "fill": "none", "stroke": theme["primary"], "stroke_width": 2, "opacity": 0.45},
{"id": "left-title", "kind": "text", "x": 92, "y": 168, "width": 320, "height": 34, "text": left_title},
{"id": "left-point-1", "kind": "text", "x": 116, "y": 222, "width": 296, "height": 36, "text": left_points[0] if len(left_points) > 0 else ""},
{"id": "left-point-2", "kind": "text", "x": 116, "y": 270, "width": 296, "height": 36, "text": left_points[1] if len(left_points) > 1 else ""},
@@ -757,6 +799,7 @@ def template_comparison(spec: dict[str, Any]) -> tuple[str, list[dict[str, Any]]
f'<text data-node-id="title" data-box-x="64" data-box-y="52" data-box-width="760" data-box-height="64" x="64" y="96" fill="{theme["text"]}" font-size="40" font-weight="800" font-family="Inter">{escape(title)}</text>',
f'<rect data-node-id="left-card" x="64" y="140" width="390" height="250" fill="{theme["panel"]}" opacity="0.82"/>',
f'<rect data-node-id="right-card" x="506" y="140" width="390" height="250" fill="{theme["panel"]}" opacity="0.82"/>',
f'<path data-node-id="comparison-divider" d="M480 144 L480 390" x="480" y="144" width="1" height="246" fill="none" stroke="{theme["primary"]}" stroke-width="2" opacity="0.45"/>',
f'<text data-node-id="left-title" data-box-x="92" data-box-y="168" data-box-width="320" data-box-height="34" x="92" y="194" fill="{theme["primary"]}" font-size="24" font-weight="800" font-family="Inter">{escape(left_title)}</text>',
f'<text data-node-id="right-title" data-box-x="534" data-box-y="168" data-box-width="320" data-box-height="34" x="534" y="194" fill="{theme["accent"]}" font-size="24" font-weight="800" font-family="Inter">{escape(right_title)}</text>',
]
@@ -982,7 +1025,7 @@ def template_data_story(spec: dict[str, Any]) -> tuple[str, list[dict[str, Any]]
for index, metric in enumerate(metrics):
x = 86 + index * 204
accent = theme["primary"] if index % 2 == 0 else theme["accent"]
svg_text(parts, nodes, f"data-metric-{index + 1}", metric, x=x, y=272, width=164, height=46, fill=accent, font_size=28, font_weight=900)
svg_text(parts, nodes, f"data-metric-{index + 1}", metric, x=x, y=268, width=164, height=58, fill=accent, font_size=22, font_weight=900)
svg_rect(parts, nodes, f"data-bar-track-{index + 1}", x=x, y=334, width=148, height=10, fill=theme["muted"], opacity=0.22)
svg_rect(parts, nodes, f"data-bar-{index + 1}", x=x, y=334, width=60 + index * 26, height=10, fill=accent, opacity=0.86)
svg_text(parts, nodes, f"data-label-{index + 1}", ["募资规模", "IPO估值", "首日涨幅", "初始流通"][index], x=x, y=354, width=156, height=28, fill=theme["muted"], font_size=15, font_weight=700)
@@ -1111,9 +1154,17 @@ def renderer_receipt_path(renderer: Path) -> str:
return renderer.as_posix()
def render_node_satori_svg(spec_path: Path, output_path: Path, png_path: Path, metadata_path: Path) -> Path:
def render_node_satori_svg(spec_path: Path, output_path: Path, png_path: Path, metadata_path: Path, observations_path: Path) -> Path:
renderer = resolve_node_renderer()
command = ["node", renderer.as_posix(), spec_path.as_posix(), output_path.as_posix(), png_path.as_posix(), metadata_path.as_posix()]
command = [
"node",
renderer.as_posix(),
spec_path.as_posix(),
output_path.as_posix(),
png_path.as_posix(),
metadata_path.as_posix(),
observations_path.as_posix(),
]
result = subprocess.run(command, cwd=renderer.parent, text=True, capture_output=True, check=False)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
@@ -1171,8 +1222,22 @@ def text_style_from_element(element: ElementTree.Element) -> dict[str, str]:
def text_to_foreign_object(element: ElementTree.Element) -> str:
text = "".join(element.itertext()).strip()
text_style = text_style_from_element(element)
attrs = {
"slide:role": "shape",
"slide:shape-type": "text",
"x": text_style["x"],
"y": text_style["y"],
"width": text_style["width"],
"height": text_style["height"],
}
node_id = attr(element, "data-node-id")
if node_id:
attrs["data-node-id"] = node_id
source_ref = semantic_source_ref_for_node({"id": node_id})
if source_ref:
attrs["data-source-ref"] = source_ref
return (
f'<foreignObject slide:role="shape" slide:shape-type="text" x="{text_style["x"]}" y="{text_style["y"]}" width="{text_style["width"]}" height="{text_style["height"]}">'
f"<foreignObject {svg_attrs(attrs)}>"
f'<div xmlns="{XHTML_NS}" style="{escape(text_style["style"])}">{escape(text)}</div>'
"</foreignObject>"
)
@@ -1335,6 +1400,137 @@ def compile_canvas_template_svg_to_svglide(canvas_template_svg: str) -> tuple[st
)
def compile_semantic_map_to_svglide(semantic_map: dict[str, Any]) -> tuple[str, dict[str, Any]]:
elements = semantic_map.get("elements")
if not isinstance(elements, list) or not elements:
raise ArtboardError("semantic-map/v1 has no elements to compile")
theme = semantic_map.get("theme") if isinstance(semantic_map.get("theme"), dict) else {}
native_mapped: list[str] = []
children: list[str] = []
for element in elements:
if not isinstance(element, dict):
continue
child = compile_semantic_element(element, theme)
if child:
children.append(child)
native_mapped.append(str(element.get("kind") or "unknown"))
if not children:
raise ArtboardError("semantic-map compiler produced no SVGlide nodes")
svg = (
f'<svg xmlns="{SVG_NS}" xmlns:slide="{SLIDE_NS}" slide:role="slide" '
f'slide:contract-version="{CONTRACT_VERSION}" width="960" height="540" viewBox="0 0 960 540">\n'
+ "\n".join(f" {child}" for child in children)
+ "\n</svg>\n"
)
return svg, {
"semantic_source": str(semantic_map.get("semantic_source") or "semantic-map/v1"),
"compiler_input": "SemanticMapIR",
"satori_svg_usage": "preview_only",
"native_mapped": native_mapped,
"fail_fast": sorted(FAIL_FAST_ELEMENTS),
}
def semantic_shape_fill(element_id: str, kind: str, style: dict[str, Any], theme: dict[str, Any]) -> str:
if style.get("fill"):
return str(style["fill"])
if element_id == "background":
return str(theme.get("background") or "#0F172A")
if any(token in element_id for token in ["panel", "card", "bg", "terminal"]):
return str(theme.get("panel") or "#111827")
if any(token in element_id for token in ["accent", "bar", "node", "dot", "rule", "signal", "port"]):
return str(theme.get("primary") or "#60A5FA")
if kind == "path":
return "none"
return str(theme.get("panel") or "#111827")
def compile_semantic_element(element: dict[str, Any], theme: dict[str, Any]) -> str | None:
element_id = str(element.get("element_id") or "")
kind = str(element.get("kind") or "")
bbox = element.get("bbox") if isinstance(element.get("bbox"), dict) else {}
style = element.get("style") if isinstance(element.get("style"), dict) else {}
x = number(bbox.get("x"), 0)
y = number(bbox.get("y"), 0)
width = max(number(bbox.get("width"), 0), 1)
height = max(number(bbox.get("height"), 0), 1)
common = {"data-node-id": element_id}
source_ref = element.get("source_ref")
if isinstance(source_ref, str) and source_ref:
common["data-source-ref"] = source_ref
if kind == "text":
font_size = number(style.get("font_size"), 18)
font_weight = int(number(style.get("font_weight"), 700))
fill = str(style.get("fill") or "#111827")
text = str(element.get("text") or "")
attrs = {
**common,
"slide:role": "shape",
"slide:shape-type": "text",
"x": f"{x:g}",
"y": f"{y:g}",
"width": f"{width:g}",
"height": f"{height:g}",
}
css = f"color:{fill};font-size:{font_size:g}px;font-weight:{font_weight};font-family:Inter,Arial,sans-serif;line-height:1.18;"
return f'<foreignObject {svg_attrs(attrs)}><div xmlns="{XHTML_NS}" style="{escape(css)}">{escape(text)}</div></foreignObject>'
if kind == "rect":
attrs = {
**common,
"slide:role": "shape",
"x": f"{x:g}",
"y": f"{y:g}",
"width": f"{width:g}",
"height": f"{height:g}",
"fill": semantic_shape_fill(element_id, kind, style, theme),
}
add_optional_svg_style(attrs, style)
return f"<rect {svg_attrs(attrs)}/>"
if kind == "circle":
attrs = {
**common,
"slide:role": "shape",
"cx": f"{x + width / 2:g}",
"cy": f"{y + height / 2:g}",
"r": f"{max(min(width, height) / 2, 1):g}",
"fill": semantic_shape_fill(element_id, kind, style, theme),
}
add_optional_svg_style(attrs, style)
return f"<circle {svg_attrs(attrs)}/>"
if kind == "line":
attrs = {
**common,
"slide:role": "shape",
"x1": f"{number(style.get('x1'), x):g}",
"y1": f"{number(style.get('y1'), y):g}",
"x2": f"{number(style.get('x2'), x + width):g}",
"y2": f"{number(style.get('y2'), y + height):g}",
"stroke": str(style.get("stroke") or style.get("fill") or theme.get("primary") or "#111827"),
"stroke-width": f"{number(style.get('stroke_width'), 2):g}",
}
add_optional_svg_style(attrs, style)
return f"<line {svg_attrs(attrs)}/>"
if kind == "path":
d = style.get("d")
if not isinstance(d, str) or not d.strip():
return None
attrs = {**common, "slide:role": "shape", "d": d, "fill": semantic_shape_fill(element_id, kind, style, theme)}
if not style.get("stroke"):
attrs["stroke"] = str(theme.get("accent") or theme.get("primary") or "#111827")
add_optional_svg_style(attrs, style)
return f"<path {svg_attrs(attrs)}/>"
return None
def add_optional_svg_style(attrs: dict[str, str], style: dict[str, Any]) -> None:
if style.get("opacity") is not None:
attrs["opacity"] = f"{number(style.get('opacity'), 1):g}"
if style.get("stroke"):
attrs["stroke"] = str(style["stroke"])
if style.get("stroke_width") is not None:
attrs["stroke-width"] = f"{number(style.get('stroke_width'), 1):g}"
def normalize_xhtml_foreign_object(svg: str) -> str:
svg = svg.replace(f' xmlns:html="{XHTML_NS}"', "")
svg = svg.replace("<html:div ", f'<div xmlns="{XHTML_NS}" ')
@@ -1370,6 +1566,9 @@ def align_text_boxes_to_node_layout(svglide_svg: str, nodes: list[dict[str, Any]
if isinstance(value, (int, float)):
element.set(key, f"{value:g}")
element.set("data-node-id", str(node.get("id") or ""))
source_ref = semantic_source_ref_for_node(node)
if source_ref:
element.set("data-source-ref", source_ref)
text = str(node.get("text") or "") or join_text_fragments(["".join(item.itertext()).strip() for item in elements])
div = next(iter(element), None)
if div is not None:
@@ -1560,6 +1759,7 @@ def render_project(project: Path) -> dict[str, Any]:
satori_path = raw_dir / f"{page_name}.satori.svg"
png_path = artboard_dir / f"{page_name}.png"
metadata_path = artboard_dir / f"{page_name}.render-metadata.json"
node_observations_path = artboard_dir / f"{page_name}.node-observations.json"
canvas_spec_artifact_path = artboard_dir / f"{page_name}.canvas-spec.json"
canvas_template_path = artboard_dir / f"{page_name}.canvas-template.svg"
semantic_map_path = artboard_dir / f"{page_name}.semantic-map.json"
@@ -1572,7 +1772,7 @@ def render_project(project: Path) -> dict[str, Any]:
node_adapter_path: Path | None = None
renderer_metadata: dict[str, Any] = {}
if actual_satori_package:
node_adapter_path = render_node_satori_svg(canvas_spec_artifact_path, satori_path, png_path, metadata_path)
node_adapter_path = render_node_satori_svg(canvas_spec_artifact_path, satori_path, png_path, metadata_path, node_observations_path)
satori_svg = satori_path.read_text(encoding="utf-8")
renderer_metadata = read_json(metadata_path)
satori_preview = validate_satori_preview_svg(satori_svg, strict=False)
@@ -1580,8 +1780,19 @@ def render_project(project: Path) -> dict[str, Any]:
satori_svg = canvas_template_svg
satori_preview = validate_satori_preview_svg(satori_svg, strict=True)
metadata_path.write_text(json.dumps({"node_version": None, "satori_version": None, "resvg_version": None, "font_path": None}, indent=2) + "\n", encoding="utf-8")
svglide_svg, compiler = compile_canvas_template_svg_to_svglide(canvas_template_svg)
svglide_svg = align_text_boxes_to_node_layout(svglide_svg, nodes)
write_json(node_observations_path, {"version": "svglide-node-observations/v1", "observation_source": "rendered_satori_svg_parse", "nodes": []})
semantic_map = {
"version": SEMANTIC_MAP_VERSION,
"page": index,
"template_id": spec.get("template_id"),
"theme_id": spec.get("theme_id"),
"theme": normalize_theme(spec),
"semantic_source": "CanvasSpec",
"content_keys": sorted((spec.get("content") or {}).keys()) if isinstance(spec.get("content"), dict) else [],
"elements": semantic_elements_from_nodes(nodes),
}
write_json(semantic_map_path, semantic_map)
svglide_svg, compiler = compile_semantic_map_to_svglide(semantic_map)
satori_path.write_text(satori_svg, encoding="utf-8")
svglide_path.write_text(svglide_svg, encoding="utf-8")
if not png_path.exists():
@@ -1590,24 +1801,20 @@ def render_project(project: Path) -> dict[str, Any]:
font_hashes = []
if isinstance(font_path, str) and Path(font_path).exists():
font_hashes.append({"path": font_path, "sha256": file_sha256(Path(font_path))})
semantic_map = {
"version": SEMANTIC_MAP_VERSION,
"page": index,
"template_id": spec.get("template_id"),
"theme_id": spec.get("theme_id"),
"semantic_source": "CanvasSpec",
"content_keys": sorted((spec.get("content") or {}).keys()) if isinstance(spec.get("content"), dict) else [],
"elements": semantic_elements_from_nodes(nodes),
}
node_layout_map = {
"version": NODE_LAYOUT_MAP_VERSION,
"page": index,
"source": "template-layout-map",
"drift": {"status": "not_measured_in_p0", "max_px": 0},
"nodes": nodes,
}
write_json(semantic_map_path, semantic_map)
renderer_observations = []
if node_observations_path.exists():
observations_payload = read_json(node_observations_path)
raw_observations = observations_payload.get("nodes")
renderer_observations = raw_observations if isinstance(raw_observations, list) else []
node_layout_map = svglide_node_layout_drift.build_node_layout_map(
page=index,
expected_nodes=nodes,
renderer_observations=renderer_observations,
satori_svg_path=satori_path,
)
write_json(node_layout_path, node_layout_map)
input_semantic_hash = file_sha256(semantic_map_path)
compiler["input_semantic_hash"] = input_semantic_hash
receipt_path = artboard_dir / f"{page_name}.receipt.json"
receipt = {
"version": ARTBOARD_RECEIPT_VERSION,
@@ -1641,8 +1848,9 @@ def render_project(project: Path) -> dict[str, Any]:
"render_metadata_sha256": file_sha256(metadata_path),
"canvas_template_svg": relpath(canvas_template_path, project),
"canvas_template_svg_sha256": file_sha256(canvas_template_path),
"compiler_input": relpath(canvas_template_path, project),
"compiler_input_sha256": file_sha256(canvas_template_path),
"compiler_input": relpath(semantic_map_path, project),
"compiler_input_sha256": file_sha256(semantic_map_path),
"input_semantic_hash": input_semantic_hash,
"semantic_map": relpath(semantic_map_path, project),
"semantic_map_sha256": file_sha256(semantic_map_path),
"node_layout_map": relpath(node_layout_path, project),
@@ -1672,6 +1880,8 @@ def render_project(project: Path) -> dict[str, Any]:
"render_metadata_sha256": file_sha256(metadata_path),
"canvas_template_svg": relpath(canvas_template_path, project),
"canvas_template_svg_sha256": file_sha256(canvas_template_path),
"node_observations": relpath(node_observations_path, project),
"node_observations_sha256": file_sha256(node_observations_path),
"node_layout_map": relpath(node_layout_path, project),
"node_layout_map_sha256": file_sha256(node_layout_path),
"node_version": renderer_metadata.get("node_version"),
@@ -1685,12 +1895,13 @@ def render_project(project: Path) -> dict[str, Any]:
"canvas_spec_sha256": json_sha256(spec),
"semantic_map": relpath(semantic_map_path, project),
"semantic_map_sha256": file_sha256(semantic_map_path),
"input_semantic_hash": input_semantic_hash,
"node_layout_map": relpath(node_layout_path, project),
"node_layout_map_sha256": file_sha256(node_layout_path),
"canvas_template_svg": relpath(canvas_template_path, project),
"canvas_template_svg_sha256": file_sha256(canvas_template_path),
"compiler_input": relpath(canvas_template_path, project),
"compiler_input_sha256": file_sha256(canvas_template_path),
"compiler_input": relpath(semantic_map_path, project),
"compiler_input_sha256": file_sha256(semantic_map_path),
"compiler_input_type": compiler.get("compiler_input"),
"satori_svg_usage": compiler.get("satori_svg_usage"),
"satori_svg": relpath(satori_path, project),

View File

@@ -247,12 +247,14 @@ class SVGlideArtboardRendererTest(unittest.TestCase):
self.assertEqual(receipt["render_metadata_sha256"], artboard.file_sha256(project / "04-svg/artboard/page-001.render-metadata.json"))
self.assertEqual(receipt["canvas_template_svg"], "04-svg/artboard/page-001.canvas-template.svg")
self.assertEqual(receipt["canvas_template_svg_sha256"], artboard.file_sha256(project / "04-svg/artboard/page-001.canvas-template.svg"))
self.assertEqual(receipt["compiler_input"], "04-svg/artboard/page-001.canvas-template.svg")
self.assertEqual(receipt["compiler_input_sha256"], artboard.file_sha256(project / "04-svg/artboard/page-001.canvas-template.svg"))
self.assertEqual(receipt["compiler_input"], "04-svg/artboard/page-001.semantic-map.json")
self.assertEqual(receipt["compiler_input_sha256"], artboard.file_sha256(project / "04-svg/artboard/page-001.semantic-map.json"))
self.assertEqual(receipt["input_semantic_hash"], artboard.file_sha256(project / "04-svg/artboard/page-001.semantic-map.json"))
self.assertEqual(receipt["svglide_svg_sha256"], artboard.file_sha256(project / "04-svg/page-001.svg"))
self.assertEqual(receipt["compiler"]["semantic_source"], "CanvasSpec")
self.assertEqual(receipt["compiler"]["compiler_input"], "CanvasSpecTemplateSVG")
self.assertEqual(receipt["compiler"]["compiler_input"], "SemanticMapIR")
self.assertEqual(receipt["compiler"]["satori_svg_usage"], "preview_only")
self.assertEqual(receipt["compiler"]["input_semantic_hash"], receipt["input_semantic_hash"])
self.assertEqual(receipt["renderer"]["engine"], "satori-node")
self.assertEqual(receipt["resvg_version"], "2.6.2")
self.assertTrue(receipt["font_hashes"])
@@ -262,13 +264,20 @@ class SVGlideArtboardRendererTest(unittest.TestCase):
title = next(item for item in semantic_map["elements"] if item["element_id"] == "title")
self.assertEqual(title["source_ref"], "canvas_spec.content.title")
self.assertEqual(title["bbox"]["width"], 628)
self.assertIn('data-source-ref="canvas_spec.content.title"', svg)
node_layout = json.loads((project / "04-svg/artboard/page-001.node-layout-map.json").read_text(encoding="utf-8"))
self.assertEqual(node_layout["source"], "measured-layout-observation")
self.assertIn(node_layout["observation_source"], {"satori_on_node_detected", "rendered_satori_svg_parse"})
self.assertNotEqual(node_layout["drift"]["status"], "not_measured_in_p0")
render_receipt = json.loads((project / "receipts/artboard-render.json").read_text(encoding="utf-8"))
self.assertEqual(render_receipt["pages"][0]["render_metadata"], "04-svg/artboard/page-001.render-metadata.json")
self.assertEqual(render_receipt["pages"][0]["render_metadata_sha256"], artboard.file_sha256(project / "04-svg/artboard/page-001.render-metadata.json"))
self.assertEqual(render_receipt["pages"][0]["node_observations"], "04-svg/artboard/page-001.node-observations.json")
bridge_receipt = json.loads((project / "receipts/satori-bridge.json").read_text(encoding="utf-8"))
self.assertEqual(bridge_receipt["pages"][0]["compiler_input_type"], "CanvasSpecTemplateSVG")
self.assertEqual(bridge_receipt["pages"][0]["compiler_input_type"], "SemanticMapIR")
self.assertEqual(bridge_receipt["pages"][0]["satori_svg_usage"], "preview_only")
self.assertEqual(bridge_receipt["pages"][0]["compiler_input"], "04-svg/artboard/page-001.canvas-template.svg")
self.assertEqual(bridge_receipt["pages"][0]["compiler_input"], "04-svg/artboard/page-001.semantic-map.json")
self.assertEqual(bridge_receipt["pages"][0]["input_semantic_hash"], receipt["input_semantic_hash"])
def test_render_project_uses_bounded_workers_and_stable_page_order(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
EXPORT_VERSION = "svglide-export-package/v1"
EXPORT_MANIFEST = Path("09-export/export-manifest.json")
EXPORT_ARCHIVE = Path("09-export/svglide-artifacts.zip")
EXPORT_RECEIPT = Path("receipts/export.json")
MANDATORY_INPUTS = [
"02-plan/slide_plan.json",
"06-check/quality-gate.json",
"07-create/live-create.json",
"08-readback/readback-check.json",
]
OPTIONAL_ARTIFACTS = [
"00-input/instruction.json",
"source/evidence.json",
"source/research.md",
"02-plan/deck-plan.json",
"02-plan/canvas-plan.json",
"02-plan/plan-confirmation.json",
"02-plan/svglide.lock.json",
"03-assets/assets.json",
"03-assets/asset-manifest.json",
"05-preview/preview.html",
"05-preview/preview-manifest.json",
"06-check/preflight.json",
"06-check/preview-lint.json",
"06-check/aesthetic-review.json",
"06-check/chart-verify.json",
"06-check/semantic-review.json",
"06-check/runtime-review.json",
"06-check/visual-distinctness.json",
"06-check/theme-validate.json",
"06-check/theme-adherence.json",
"07-create/dry-run.json",
"07-create/ppe-proof.json",
"08-readback/xml-presentations-get.json",
]
class ExportPackageError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as err:
raise ExportPackageError(f"missing required file: {path}") from err
except json.JSONDecodeError as err:
raise ExportPackageError(f"invalid JSON in {path}: {err}") from err
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def optional_sha256(path: Path) -> str | None:
return file_sha256(path) if path.exists() else None
def issue(code: str, message: str, *, path: str | None = None) -> dict[str, str]:
payload = {"code": code, "message": message}
if path:
payload["path"] = path
return payload
def prepared_svg_files(project: Path) -> list[Path]:
return sorted(path for path in (project / "04-svg" / "prepared").glob("*.svg") if path.is_file())
def prepared_file_hashes(project: Path) -> list[dict[str, str]]:
return [{"path": relpath(path, project), "sha256": file_sha256(path)} for path in prepared_svg_files(project)]
def relpath(path: Path, project: Path) -> str:
try:
return path.resolve().relative_to(project.resolve()).as_posix()
except ValueError:
return path.as_posix()
def artifact_record(project: Path, rel: str) -> dict[str, Any] | None:
path = project / rel
if not path.is_file():
return None
return {"path": rel, "sha256": file_sha256(path), "bytes": path.stat().st_size}
def collect_artifacts(project: Path) -> list[dict[str, Any]]:
records: dict[str, dict[str, Any]] = {}
for rel in [*MANDATORY_INPUTS, *OPTIONAL_ARTIFACTS]:
record = artifact_record(project, rel)
if record is not None:
records[rel] = record
for directory in ["04-svg/prepared", "04-svg/artboard", "receipts"]:
root = project / directory
if root.exists():
for path in sorted(item for item in root.rglob("*") if item.is_file()):
rel = relpath(path, project)
records[rel] = {"path": rel, "sha256": file_sha256(path), "bytes": path.stat().st_size}
return [records[key] for key in sorted(records)]
def validate_inputs(project: Path) -> tuple[list[dict[str, str]], dict[str, Any]]:
issues: list[dict[str, str]] = []
for rel in MANDATORY_INPUTS:
if not (project / rel).exists():
issues.append(issue("export_input_missing", f"missing required export input {rel}", path=rel))
if issues:
return issues, {}
quality_gate = read_json(project / "06-check/quality-gate.json")
live_create = read_json(project / "07-create/live-create.json")
readback = read_json(project / "08-readback/readback-check.json")
if quality_gate.get("status") != "passed":
issues.append(issue("quality_gate_not_passed", "quality gate must pass before export", path="06-check/quality-gate.json"))
if live_create.get("status") != "passed":
issues.append(issue("live_create_not_passed", "live create must pass before export", path="07-create/live-create.json"))
if readback.get("status") != "passed":
issues.append(issue("readback_not_passed", "readback must pass before export", path="08-readback/readback-check.json"))
prepared_hashes = prepared_file_hashes(project)
if not prepared_hashes:
issues.append(issue("prepared_svg_missing", "export requires at least one prepared SVG", path="04-svg/prepared"))
if isinstance(quality_gate.get("prepared_files"), list) and quality_gate.get("prepared_files") != prepared_hashes:
issues.append(issue("quality_gate_prepared_files_stale", "prepared SVG files changed after quality gate", path="06-check/quality-gate.json"))
if isinstance(live_create.get("prepared_files"), list) and live_create.get("prepared_files") != prepared_hashes:
issues.append(issue("live_create_prepared_files_stale", "prepared SVG files changed after live create", path="07-create/live-create.json"))
binding = readback.get("input_binding")
if isinstance(binding, dict):
expected = {
"plan_sha256": optional_sha256(project / "02-plan/slide_plan.json"),
"quality_gate_sha256": optional_sha256(project / "06-check/quality-gate.json"),
"live_create_sha256": optional_sha256(project / "07-create/live-create.json"),
}
for key, value in expected.items():
if binding.get(key) != value:
issues.append(issue("readback_input_binding_stale", f"readback {key} does not match current export inputs", path="08-readback/readback-check.json"))
else:
issues.append(issue("readback_input_binding_missing", "readback check must include input_binding", path="08-readback/readback-check.json"))
return issues, {
"quality_gate": quality_gate,
"live_create": live_create,
"readback": readback,
"prepared_files": prepared_hashes,
}
def create_archive(project: Path, artifacts: list[dict[str, Any]]) -> dict[str, Any]:
archive = project / EXPORT_ARCHIVE
archive.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for record in artifacts:
rel = record["path"]
if rel.startswith("09-export/"):
continue
info = zipfile.ZipInfo(rel, date_time=(2026, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
zf.writestr(info, (project / rel).read_bytes())
return {"path": EXPORT_ARCHIVE.as_posix(), "sha256": file_sha256(archive), "bytes": archive.stat().st_size}
def run_export_package(project: Path, *, archive: bool = False) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
issues, validated = validate_inputs(project)
artifacts = collect_artifacts(project)
archive_record = create_archive(project, artifacts) if archive and not issues else None
status = "passed" if not issues else "failed"
result: dict[str, Any] = {
"version": EXPORT_VERSION,
"stage": "export",
"status": status,
"action": "handoff_package" if status == "passed" else "repair_and_rerun",
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"slide_plan_sha256": optional_sha256(project / "02-plan/slide_plan.json"),
"quality_gate_sha256": optional_sha256(project / "06-check/quality-gate.json"),
"live_create_sha256": optional_sha256(project / "07-create/live-create.json"),
"readback_check_sha256": optional_sha256(project / "08-readback/readback-check.json"),
},
"prepared_files": validated.get("prepared_files", prepared_file_hashes(project)),
"artifacts": artifacts,
"formats": {
"svglide_artifact_package": {
"status": "passed" if status == "passed" else "failed",
"manifest": EXPORT_MANIFEST.as_posix(),
"archive": archive_record,
},
"pptx": {
"status": "not_implemented",
"reason": "SVGlide export currently packages verified source artifacts; no local PPTX serializer is wired to this runner.",
},
"animated_deck": {
"status": "not_implemented",
"reason": "SVGlide SVG/readback pipeline has no animation timeline export contract.",
},
"narrated_deck": {
"status": "not_implemented",
"reason": "SVGlide SVG/readback pipeline has no speaker-audio or narration export contract.",
},
},
"summary": {
"error_count": len(issues),
"artifact_count": len(artifacts),
"prepared_svg_count": len(validated.get("prepared_files", prepared_file_hashes(project))),
"archive_created": archive_record is not None,
},
"issues": issues,
}
write_json(project / EXPORT_MANIFEST, result)
write_json(project / EXPORT_RECEIPT, result)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Package verified SVGlide project artifacts after live readback.")
parser.add_argument("project")
parser.add_argument("--archive", action="store_true", help="create a deterministic zip package under 09-export")
parser.add_argument("--pretty", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
result = run_export_package(Path(args.project), archive=args.archive)
except (OSError, ExportPackageError) as error:
print(f"svglide_export_package: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None, sort_keys=True))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,76 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_export_package as export_package
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
class SVGlideExportPackageTest(unittest.TestCase):
def make_project(self) -> Path:
root = Path(tempfile.mkdtemp())
project = root / ".lark-slides" / "plan" / "demo"
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1, "title": "Demo"}]})
(project / "04-svg/prepared").mkdir(parents=True, exist_ok=True)
(project / "04-svg/prepared/page-001.svg").write_text("<svg><text>Demo</text></svg>", encoding="utf-8")
prepared_files = export_package.prepared_file_hashes(project)
write_json(
project / "06-check/quality-gate.json",
{"status": "passed", "prepared_files": prepared_files, "checks": [{"name": "quality", "status": "passed"}]},
)
write_json(project / "07-create/live-create.json", {"status": "passed", "prepared_files": prepared_files, "json": {"xml_presentation_id": "xml_1"}})
write_json(
project / "08-readback/readback-check.json",
{
"version": "svglide-readback/v1",
"status": "passed",
"input_binding": {
"plan_sha256": export_package.file_sha256(project / "02-plan/slide_plan.json"),
"quality_gate_sha256": export_package.file_sha256(project / "06-check/quality-gate.json"),
"live_create_sha256": export_package.file_sha256(project / "07-create/live-create.json"),
},
},
)
return project
def test_export_package_writes_manifest_archive_and_receipt(self) -> None:
project = self.make_project()
result = export_package.run_export_package(project, archive=True)
self.assertEqual(result["status"], "passed", result["issues"])
self.assertEqual(result["action"], "handoff_package")
self.assertTrue((project / export_package.EXPORT_MANIFEST).exists())
self.assertTrue((project / export_package.EXPORT_RECEIPT).exists())
self.assertTrue((project / export_package.EXPORT_ARCHIVE).exists())
self.assertEqual(result["formats"]["svglide_artifact_package"]["status"], "passed")
self.assertEqual(result["formats"]["pptx"]["status"], "not_implemented")
artifact_paths = {item["path"] for item in result["artifacts"]}
self.assertIn("02-plan/slide_plan.json", artifact_paths)
self.assertIn("04-svg/prepared/page-001.svg", artifact_paths)
def test_export_package_blocks_stale_readback_binding(self) -> None:
project = self.make_project()
write_json(project / "06-check/quality-gate.json", {"status": "passed", "prepared_files": export_package.prepared_file_hashes(project), "changed": True})
result = export_package.run_export_package(project)
self.assertEqual(result["status"], "failed")
codes = {issue["code"] for issue in result["issues"]}
self.assertIn("readback_input_binding_stale", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,346 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parents[2]
DEFAULT_PLAN = Path("02-plan/slide_plan.json")
DEFAULT_REPAIR_PLAN = Path("02-plan/repair-plan.json")
DEFAULT_RECEIPT = Path("receipts/repair-loop.json")
UNSCOPED_PATCH_PATHS = {"", "/", "/slides", "/style_system", "/art_direction", "/asset_contracts"}
ALLOWED_PATCH_ROOTS = ("slides", "style_system", "art_direction", "asset_contracts")
class RepairLoopError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def project_rel(path: Path, project: Path) -> str:
try:
return path.resolve().relative_to(project.resolve()).as_posix()
except ValueError:
return path.as_posix()
def resolve_project_path(project: Path, path: Path) -> Path:
return path if path.is_absolute() else project / path
def pointer_tokens(path: str) -> list[str]:
if not path.startswith("/"):
raise RepairLoopError(f"JSON Patch path must be an absolute JSON Pointer: {path}")
if path == "/":
return [""]
return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")]
def is_array_index(token: str) -> bool:
return bool(re.fullmatch(r"0|[1-9]\d*", token))
def value_at(document: Any, tokens: list[str]) -> Any:
current = document
for token in tokens:
if isinstance(current, list):
if not is_array_index(token):
raise RepairLoopError(f"JSON Pointer list token must be an index: {token}")
index = int(token)
if index >= len(current):
raise RepairLoopError(f"JSON Pointer index out of range: {token}")
current = current[index]
elif isinstance(current, dict):
if token not in current:
raise RepairLoopError(f"JSON Pointer key does not exist: {token}")
current = current[token]
else:
raise RepairLoopError(f"JSON Pointer cannot descend into scalar at token: {token}")
return current
def parent_and_key(document: Any, tokens: list[str]) -> tuple[Any, str]:
if not tokens:
raise RepairLoopError("JSON Patch path cannot target the whole document")
return value_at(document, tokens[:-1]) if len(tokens) > 1 else document, tokens[-1]
def validate_repair_plan_schema(repair_plan: dict[str, Any]) -> list[dict[str, Any]]:
schema = svglide_schema.read_json(REPO_ROOT / "skills/lark-slides/references/svglide-repair-plan.schema.json")
return svglide_schema.validate_json_schema(repair_plan, schema)
def validate_plan_schema(plan: dict[str, Any]) -> list[dict[str, Any]]:
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-plan.schema.json"))
return svglide_schema.validate_json_schema(plan, schema)
def broad_path_issue(path: str) -> str | None:
if path in UNSCOPED_PATCH_PATHS:
return f"patch path is too broad: {path}"
if re.fullmatch(r"/slides/\d+", path) or re.fullmatch(r"/asset_contracts/\d+", path):
return f"patch path must not rewrite an entire array item: {path}"
if re.fullmatch(r"/slides/\d+/(canvas_spec|content_requirements|body_points|risk_flags|svg_effects|required_primitives|svg_primitives)", path):
return f"patch path must target a leaf field, not a whole object/list: {path}"
if re.fullmatch(r"/slides/\d+/canvas_spec/(content|theme|semantic_elements|quality_constraints)", path):
return f"patch path must target a leaf field, not a whole object/list: {path}"
if re.fullmatch(r"/slides/\d+/canvas_spec/semantic_elements/\d+", path):
return f"patch path must target a leaf field, not a whole semantic element: {path}"
if re.fullmatch(r"/slides/\d+/canvas_spec/semantic_elements/\d+/bbox", path):
return f"patch path must target a bbox leaf field, not the whole bbox: {path}"
return None
def validate_patch_scope(plan: dict[str, Any], patch: dict[str, Any], index: int) -> None:
op = patch.get("op")
path = patch.get("path")
if op not in {"add", "replace", "remove", "test"}:
raise RepairLoopError(f"patches[{index}].op is not supported: {op}")
if not isinstance(path, str):
raise RepairLoopError(f"patches[{index}].path must be a string")
tokens = pointer_tokens(path)
if not tokens or tokens[0] not in ALLOWED_PATCH_ROOTS:
raise RepairLoopError(f"patches[{index}].path must target slides/style_system/art_direction/asset_contracts: {path}")
issue = broad_path_issue(path)
if issue:
raise RepairLoopError(f"patches[{index}]: {issue}")
if op in {"add", "replace"} and isinstance(patch.get("value"), (dict, list)):
raise RepairLoopError(f"patches[{index}].value must be a scalar leaf value")
if op in {"replace", "remove", "test"}:
target = value_at(plan, tokens)
if isinstance(target, (dict, list)):
raise RepairLoopError(f"patches[{index}] targets a broad object/list value: {path}")
if op == "add":
parent, key = parent_and_key(plan, tokens)
if isinstance(parent, list):
if key != "-" and (not is_array_index(key) or int(key) > len(parent)):
raise RepairLoopError(f"patches[{index}] add index is out of range: {path}")
elif not isinstance(parent, dict):
raise RepairLoopError(f"patches[{index}] add parent must be an object or list: {path}")
def apply_one_patch(document: Any, patch: dict[str, Any]) -> None:
tokens = pointer_tokens(patch["path"])
op = patch["op"]
if op == "test":
actual = value_at(document, tokens)
if actual != patch.get("value"):
raise RepairLoopError(f"test patch failed at {patch['path']}: expected {patch.get('value')!r}, got {actual!r}")
return
parent, key = parent_and_key(document, tokens)
if isinstance(parent, list):
if op == "add":
if key == "-":
parent.append(patch.get("value"))
else:
parent.insert(int(key), patch.get("value"))
return
if not is_array_index(key):
raise RepairLoopError(f"JSON Patch list token must be an index: {key}")
index = int(key)
if op == "replace":
parent[index] = patch.get("value")
elif op == "remove":
del parent[index]
return
if not isinstance(parent, dict):
raise RepairLoopError(f"JSON Patch parent is not an object/list: {patch['path']}")
if op == "replace":
if key not in parent:
raise RepairLoopError(f"replace target does not exist: {patch['path']}")
parent[key] = patch.get("value")
elif op == "remove":
if key not in parent:
raise RepairLoopError(f"remove target does not exist: {patch['path']}")
del parent[key]
elif op == "add":
parent[key] = patch.get("value")
def build_receipt(
*,
status: str,
started_at: str,
project: Path,
plan_path: Path,
repair_plan_path: Path,
failing_receipt_path: Path,
original_plan_sha256: str | None,
updated_plan_sha256: str | None,
patches: list[dict[str, Any]],
issues: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"schema_version": "svglide-repair-loop-receipt/v1",
"stage": "repair-loop",
"status": status,
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"plan": project_rel(plan_path, project),
"plan_sha256": original_plan_sha256,
"repair_plan": project_rel(repair_plan_path, project),
"repair_plan_sha256": file_sha256(repair_plan_path) if repair_plan_path.exists() else None,
"failing_receipt": project_rel(failing_receipt_path, project),
"failing_receipt_sha256": file_sha256(failing_receipt_path) if failing_receipt_path.exists() else None,
},
"outputs": {
"plan": project_rel(plan_path, project) if updated_plan_sha256 else None,
"plan_sha256": updated_plan_sha256,
"receipt": DEFAULT_RECEIPT.as_posix(),
},
"summary": {
"patch_count": len(patches),
"scoped_patch_only": status == "passed",
"error_count": len(issues),
},
"patches": [{"op": patch.get("op"), "path": patch.get("path"), "reason": patch.get("reason")} for patch in patches],
"issues": issues,
}
def run_repair_loop(
project: Path,
*,
failing_receipt: Path,
repair_plan: Path = DEFAULT_REPAIR_PLAN,
plan: Path = DEFAULT_PLAN,
receipt_path: Path = DEFAULT_RECEIPT,
) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
plan_path = resolve_project_path(project, plan)
repair_plan_path = resolve_project_path(project, repair_plan)
failing_receipt_path = resolve_project_path(project, failing_receipt)
output_receipt = resolve_project_path(project, receipt_path)
patches: list[dict[str, Any]] = []
original_hash = file_sha256(plan_path) if plan_path.exists() else None
try:
current_plan = read_json(plan_path)
repair_payload = read_json(repair_plan_path)
failing_payload = read_json(failing_receipt_path)
if not isinstance(current_plan, dict):
raise RepairLoopError("slide_plan must be a JSON object")
if not isinstance(repair_payload, dict):
raise RepairLoopError("repair plan must be a JSON object")
if not isinstance(failing_payload, dict):
raise RepairLoopError("failing receipt must be a JSON object")
if failing_payload.get("status") == "passed":
raise RepairLoopError("failing receipt status must not be passed")
schema_issues = validate_repair_plan_schema(repair_payload)
if schema_issues:
raise RepairLoopError(f"repair plan schema failed: {schema_issues}")
if repair_payload.get("target_plan_path") != project_rel(plan_path, project):
raise RepairLoopError("repair plan target_plan_path does not match selected slide_plan")
raw_patches = repair_payload.get("patches")
if not isinstance(raw_patches, list):
raise RepairLoopError("repair plan patches must be a list")
patches = raw_patches
for index, patch in enumerate(patches):
if not isinstance(patch, dict):
raise RepairLoopError(f"patches[{index}] must be an object")
validate_patch_scope(current_plan, patch, index)
updated_plan = copy.deepcopy(current_plan)
for patch in patches:
apply_one_patch(updated_plan, patch)
plan_issues = validate_plan_schema(updated_plan)
if plan_issues:
raise RepairLoopError(f"patched slide_plan failed schema validation: {plan_issues}")
write_json(plan_path, updated_plan)
receipt = build_receipt(
status="passed",
started_at=started_at,
project=project,
plan_path=plan_path,
repair_plan_path=repair_plan_path,
failing_receipt_path=failing_receipt_path,
original_plan_sha256=original_hash,
updated_plan_sha256=file_sha256(plan_path),
patches=patches,
issues=[],
)
write_json(output_receipt, receipt)
return receipt
except (OSError, json.JSONDecodeError, RepairLoopError) as error:
issues = [{"code": "repair_loop_failed", "message": str(error)}]
receipt = build_receipt(
status="failed",
started_at=started_at,
project=project,
plan_path=plan_path,
repair_plan_path=repair_plan_path,
failing_receipt_path=failing_receipt_path,
original_plan_sha256=original_hash,
updated_plan_sha256=None,
patches=patches,
issues=issues,
)
write_json(output_receipt, receipt)
raise RepairLoopError(str(error)) from error
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Apply a scoped SVGlide repair-plan JSON Patch to slide_plan.json.")
parser.add_argument("project", type=Path)
parser.add_argument("--failing-receipt", type=Path, required=True)
parser.add_argument("--repair-plan", type=Path, default=DEFAULT_REPAIR_PLAN)
parser.add_argument("--plan", type=Path, default=DEFAULT_PLAN)
parser.add_argument("--receipt", type=Path, default=DEFAULT_RECEIPT)
parser.add_argument("--pretty", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
receipt = run_repair_loop(
args.project,
failing_receipt=args.failing_receipt,
repair_plan=args.repair_plan,
plan=args.plan,
receipt_path=args.receipt,
)
except RepairLoopError as error:
print(f"svglide_model_repair_loop: error: {error}", file=sys.stderr)
return 1
print(json.dumps(receipt, ensure_ascii=False, indent=2 if args.pretty else None))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_model_repair_loop as repair_loop
import svglide_project_runner as runner
import svglide_prompt_planner as prompt_planner
class SVGlideModelRepairLoopTest(unittest.TestCase):
def fixture_dir(self) -> Path:
return Path(__file__).resolve().parent / "fixtures/svglide_artboard/followup_model_loop"
def fixture_provider_command(self) -> str:
provider = self.fixture_dir() / "fixture_model_provider.py"
return f"{sys.executable} {provider} --stage {{stage}} --raw-output {{raw_output}}"
def create_model_generated_project(self, tmpdir: str) -> Path:
topic = json.loads((self.fixture_dir() / "topic.json").read_text(encoding="utf-8"))
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("followup-model-loop", "Followup Model Loop", plan_root=plan_root)
project = Path(result["project_root"])
prompt_planner.run_prompt_plan(
project,
prompt=str(topic["prompt"]),
target_slide_count=int(topic["target_slide_count"]),
language=str(topic["language"]),
audience=str(topic["audience"]),
provider="command",
planner_command=self.fixture_provider_command(),
)
return project
def install_repair_inputs(self, project: Path, repair_fixture: str) -> None:
(project / "06-check").mkdir(parents=True, exist_ok=True)
shutil.copyfile(self.fixture_dir() / "failing-receipt.json", project / "06-check/preflight.json")
shutil.copyfile(self.fixture_dir() / repair_fixture, project / "02-plan/repair-plan.json")
def test_scoped_json_patch_updates_slide_plan_and_writes_receipt(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.create_model_generated_project(tmpdir)
self.install_repair_inputs(project, "repair-plan.scoped.json")
before_hash = runner.file_sha256(project / "02-plan/slide_plan.json")
receipt = repair_loop.run_repair_loop(project, failing_receipt=Path("06-check/preflight.json"))
self.assertEqual("passed", receipt["status"])
self.assertEqual(2, receipt["summary"]["patch_count"])
self.assertTrue(receipt["summary"]["scoped_patch_only"])
self.assertEqual(before_hash, receipt["inputs"]["plan_sha256"])
self.assertNotEqual(before_hash, receipt["outputs"]["plan_sha256"])
updated = json.loads((project / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
self.assertEqual("SpaceX IPO 框架", updated["slides"][0]["canvas_spec"]["content"]["title"])
self.assertEqual("SpaceX IPO 分析框架", updated["slides"][0]["title"])
self.assertTrue((project / "receipts/repair-loop.json").exists())
def test_broad_object_rewrite_is_rejected_without_changing_plan(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.create_model_generated_project(tmpdir)
self.install_repair_inputs(project, "repair-plan.broad.json")
before_hash = runner.file_sha256(project / "02-plan/slide_plan.json")
with self.assertRaisesRegex(repair_loop.RepairLoopError, "broad|scalar|whole object/list"):
repair_loop.run_repair_loop(project, failing_receipt=Path("06-check/preflight.json"))
self.assertEqual(before_hash, runner.file_sha256(project / "02-plan/slide_plan.json"))
failed = json.loads((project / "receipts/repair-loop.json").read_text(encoding="utf-8"))
self.assertEqual("failed", failed["status"])
self.assertEqual(1, failed["summary"]["error_count"])
def test_runner_stage_repair_loop_uses_fixture_receipts(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.create_model_generated_project(tmpdir)
self.install_repair_inputs(project, "repair-plan.scoped.json")
result = runner.run_stage(project, "repair-loop")
self.assertEqual("passed", result["status"])
state = runner.load_state(project)
self.assertEqual("passed", state["stages"]["repair_loop"]["status"])
receipt = json.loads((project / "receipts/repair-loop.json").read_text(encoding="utf-8"))
self.assertEqual("passed", receipt["status"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
DEFAULT_DRIFT_THRESHOLD_PX = 8.0
TEXT_RE = re.compile(r"\s+")
def number(value: Any, fallback: float = 0.0) -> float:
try:
if value is None or isinstance(value, bool):
return fallback
return float(value)
except (TypeError, ValueError):
return fallback
def normalize_text(value: str) -> str:
return TEXT_RE.sub(" ", value).strip()
def normalized_match(value: str) -> str:
return "".join(normalize_text(value).split()).lower()
def bbox_from_node(node: dict[str, Any]) -> dict[str, float]:
return {
"x": number(node.get("x")),
"y": number(node.get("y")),
"width": number(node.get("width")),
"height": number(node.get("height")),
}
def node_center(bbox: dict[str, float]) -> tuple[float, float]:
return (bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
def bbox_delta_px(expected: dict[str, float], measured: dict[str, float]) -> float:
return max(
abs(expected["x"] - measured["x"]),
abs(expected["y"] - measured["y"]),
abs(expected["width"] - measured["width"]),
abs(expected["height"] - measured["height"]),
)
def normalize_renderer_observations(observations: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for observation in observations:
if not isinstance(observation, dict):
continue
props = observation.get("props") if isinstance(observation.get("props"), dict) else {}
node_id = observation.get("node_id") or observation.get("key") or props.get("data-node-id")
bbox = {
"x": number(observation.get("left")),
"y": number(observation.get("top")),
"width": number(observation.get("width")),
"height": number(observation.get("height")),
}
if bbox["width"] <= 0 or bbox["height"] <= 0:
continue
text = observation.get("textContent")
normalized.append(
{
"id": str(node_id) if node_id is not None else None,
"kind": str(observation.get("type") or "node"),
"text": str(text) if isinstance(text, str) else None,
"bbox": bbox,
}
)
return normalized
def _attr(element: ET.Element, name: str) -> str | None:
value = element.get(name)
if value is not None:
return value
for key, item in element.attrib.items():
if key.rsplit("}", 1)[-1] == name:
return item
return None
def observations_from_svg(svg_path: Path) -> list[dict[str, Any]]:
root = ET.fromstring(svg_path.read_text(encoding="utf-8"))
observations: list[dict[str, Any]] = []
for element in root.iter():
local_name = element.tag.rsplit("}", 1)[-1]
node_id = _attr(element, "data-node-id")
bbox: dict[str, float] | None = None
if local_name in {"rect", "foreignObject", "image"}:
bbox = {
"x": number(_attr(element, "x")),
"y": number(_attr(element, "y")),
"width": number(_attr(element, "width")),
"height": number(_attr(element, "height")),
}
elif local_name == "text":
font_size = number(_attr(element, "font-size"), 18)
text = normalize_text("".join(element.itertext()))
bbox = {
"x": number(_attr(element, "data-box-x"), number(_attr(element, "x"))),
"y": number(_attr(element, "data-box-y"), number(_attr(element, "y")) - font_size),
"width": number(_attr(element, "data-box-width"), max(len(text) * font_size * 0.62, font_size * 2)),
"height": number(_attr(element, "data-box-height"), font_size * 1.35),
}
elif local_name == "circle":
radius = number(_attr(element, "r"))
bbox = {
"x": number(_attr(element, "cx")) - radius,
"y": number(_attr(element, "cy")) - radius,
"width": radius * 2,
"height": radius * 2,
}
if bbox is None or bbox["width"] <= 0 or bbox["height"] <= 0:
continue
observations.append(
{
"id": node_id,
"kind": local_name,
"text": normalize_text("".join(element.itertext())) or None,
"bbox": bbox,
}
)
return observations
def _match_observation(expected: dict[str, Any], observations: list[dict[str, Any]], used: set[int]) -> tuple[int | None, dict[str, Any] | None]:
expected_id = str(expected.get("id") or "")
for index, observation in enumerate(observations):
if index in used:
continue
if expected_id and observation.get("id") == expected_id:
return index, observation
expected_text = expected.get("text")
if isinstance(expected_text, str) and normalized_match(expected_text):
candidates: list[tuple[float, int, dict[str, Any]]] = []
expected_bbox = bbox_from_node(expected)
expected_center = node_center(expected_bbox)
for index, observation in enumerate(observations):
if index in used:
continue
observed_text = observation.get("text")
if not isinstance(observed_text, str):
continue
if normalized_match(observed_text) != normalized_match(expected_text):
continue
observed_center = node_center(observation["bbox"])
distance = (expected_center[0] - observed_center[0]) ** 2 + (expected_center[1] - observed_center[1]) ** 2
candidates.append((distance, index, observation))
if candidates:
_, index, observation = min(candidates, key=lambda item: item[0])
return index, observation
expected_bbox = bbox_from_node(expected)
expected_center = node_center(expected_bbox)
candidates = []
for index, observation in enumerate(observations):
if index in used:
continue
observed_center = node_center(observation["bbox"])
distance = (expected_center[0] - observed_center[0]) ** 2 + (expected_center[1] - observed_center[1]) ** 2
candidates.append((distance, index, observation))
if not candidates:
return None, None
_, index, observation = min(candidates, key=lambda item: item[0])
return index, observation
def build_node_layout_map(
*,
page: int,
expected_nodes: list[dict[str, Any]],
renderer_observations: list[dict[str, Any]] | None,
satori_svg_path: Path,
threshold_px: float = DEFAULT_DRIFT_THRESHOLD_PX,
) -> dict[str, Any]:
observations = normalize_renderer_observations(renderer_observations or [])
observation_source = "satori_on_node_detected"
if not observations:
observations = observations_from_svg(satori_svg_path)
observation_source = "rendered_satori_svg_parse"
used: set[int] = set()
measured_nodes: list[dict[str, Any]] = []
max_px = 0.0
renderer_max_px = 0.0
missing_count = 0
for expected in expected_nodes:
expected_bbox = bbox_from_node(expected)
index, observation = _match_observation(expected, observations, used)
measured_bbox = observation["bbox"] if observation else None
if index is not None:
used.add(index)
if measured_bbox is None:
missing_count += 1
drift_px = None
measured_bbox = expected_bbox
else:
drift_px = bbox_delta_px(expected_bbox, measured_bbox)
renderer_max_px = max(renderer_max_px, drift_px)
# The exported node layout is the canonical CanvasSpec/template layout.
# Renderer observations are retained for audit but must not overwrite
# downstream fit boxes, because Satori can report intermediate flex
# nodes with the same data-node-id as the intended text run.
layout_bbox = expected_bbox
measured_nodes.append(
{
"id": str(expected.get("id") or ""),
"kind": str(expected.get("kind") or "node"),
"x": layout_bbox["x"],
"y": layout_bbox["y"],
"width": layout_bbox["width"],
"height": layout_bbox["height"],
"text": expected.get("text") if isinstance(expected.get("text"), str) else None,
"expected_bbox": expected_bbox,
"measured_bbox": measured_bbox,
"drift_px": drift_px,
"renderer_drift_px": drift_px,
"observation_source": observation_source if observation else "missing",
}
)
status = "passed" if missing_count == 0 and max_px <= threshold_px else "failed"
return {
"version": "svglide-node-layout-map/v1",
"page": page,
"source": "measured-layout-observation",
"observation_source": observation_source,
"threshold_px": threshold_px,
"drift": {
"status": status,
"max_px": max_px,
"threshold_px": threshold_px,
"missing_count": missing_count,
"renderer_max_px": renderer_max_px,
},
"nodes": measured_nodes,
}
def validate_node_layout_map(layout_map: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
source = layout_map.get("source")
observation_source = layout_map.get("observation_source")
if source != "measured-layout-observation":
issues.append({"code": "node_layout_map_source_not_measured", "message": "node-layout-map source must be measured-layout-observation"})
if not isinstance(observation_source, str) or not observation_source or observation_source == "not_measured_in_p0":
issues.append({"code": "node_layout_map_observation_source_invalid", "message": "node-layout-map must record a measured observation_source"})
drift = layout_map.get("drift") if isinstance(layout_map.get("drift"), dict) else {}
max_px = number(drift.get("max_px"), 0)
threshold_px = number(drift.get("threshold_px"), number(layout_map.get("threshold_px"), DEFAULT_DRIFT_THRESHOLD_PX))
missing_count = int(number(drift.get("missing_count"), 0))
if drift.get("status") != "passed":
issues.append({"code": "node_layout_drift_failed", "message": "node-layout-map drift status must be passed"})
if max_px > threshold_px:
issues.append({"code": "node_layout_drift_exceeds_threshold", "message": f"node-layout-map max drift {max_px:g}px exceeds threshold {threshold_px:g}px"})
if missing_count > 0:
issues.append({"code": "node_layout_observation_missing", "message": f"node-layout-map has {missing_count} missing measured nodes"})
return issues

View File

@@ -31,6 +31,7 @@ GENERATION_MODES = {DEFAULT_GENERATION_MODE, "artboard_satori"}
DEFAULT_PLAN_ROOT = Path(".lark-slides/plan")
SCRIPT_DIR = Path(__file__).resolve().parent
LARK_CLI_COMMAND_ENV = "SVGLIDE_LARK_CLI_CMD"
DEFAULT_REPAIR_LOOP_FAILING_RECEIPT = Path("06-check/preflight.json")
STAGES = [
"init",
@@ -60,6 +61,10 @@ STAGES = [
"readback",
"export",
]
OPTIONAL_STAGES = {
"repair_loop",
"theme_productization",
}
STAGE_ALIASES = {
"confirm-plan": "confirm_plan",
@@ -84,6 +89,11 @@ STAGE_ALIASES = {
"pre-submit-review": "pre_submit_review",
"pre-submit": "pre_submit_review",
"live-create": "live_create",
"repair-loop": "repair_loop",
"theme-productization": "theme_productization",
"theme-productize": "theme_productization",
"export-package": "export",
"package-export": "export",
}
PROJECT_DIRS = [
@@ -129,6 +139,9 @@ IMPLEMENTED_STAGES = {
"pre_submit_review",
"live_create",
"readback",
"repair_loop",
"theme_productization",
"export",
}
FAILURE_STATUSES = {"blocked", "failed", "skipped"}
DECK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@@ -161,7 +174,7 @@ def now_iso() -> str:
def normalize_stage(stage: str) -> str:
candidate = STAGE_ALIASES.get(stage, stage.replace("-", "_"))
if candidate not in STAGES:
if candidate not in STAGES and candidate not in OPTIONAL_STAGES:
raise RunnerError(f"unknown stage '{stage}'", exit_code=2)
return candidate
@@ -1438,6 +1451,73 @@ def run_package_check_stage(project_root: Path, state: dict[str, Any]) -> dict[s
)
def repair_loop_input_path(project_root: Path) -> Path:
return project_root / "02-plan" / "repair-loop.input.json"
def repair_loop_failing_receipt(project_root: Path) -> Path:
request_path = repair_loop_input_path(project_root)
if request_path.exists():
payload = read_json(request_path)
raw = payload.get("failing_receipt")
if not isinstance(raw, str) or not raw:
raise RunnerError("repair-loop.input.json must include failing_receipt")
return project_root / raw
fallback = project_root / DEFAULT_REPAIR_LOOP_FAILING_RECEIPT
if fallback.exists():
return fallback
raise RunnerError(
"repair_loop requires a failing receipt; write 02-plan/repair-loop.input.json "
"with failing_receipt or run the repair-loop command with --failing-receipt"
)
def run_repair_loop_stage(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
failing_receipt = repair_loop_failing_receipt(project_root)
command = [
"python3",
(SCRIPT_DIR / "svglide_model_repair_loop.py").as_posix(),
project_root.as_posix(),
"--failing-receipt",
failing_receipt.as_posix(),
"--pretty",
]
inputs = [
"02-plan/slide_plan.json",
"02-plan/repair-plan.json",
project_relpath(failing_receipt, project_root),
]
if repair_loop_input_path(project_root).exists():
inputs.append("02-plan/repair-loop.input.json")
return run_script_stage(
project_root,
state,
"repair_loop",
command,
output_json=project_root / "receipts" / "repair-loop.json",
inputs=inputs,
outputs=["02-plan/slide_plan.json", "receipts/repair-loop.json", "receipts/repair_loop.json"],
)
def run_theme_productization_stage(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
return run_script_stage(
project_root,
state,
"theme_productization",
["python3", (SCRIPT_DIR / "svglide_theme_productization.py").as_posix(), project_root.as_posix(), "--pretty"],
output_json=project_root / "06-check" / "theme-productization.json",
inputs=["02-plan/theme-productization.input.json", "02-plan/slide_plan.json"],
outputs=[
"02-plan/theme-registry.json",
"02-plan/themes",
"02-plan/theme-migration.patch.json",
"06-check/theme-productization.json",
"receipts/theme-productization.json",
],
)
def lark_cli_command_prefix() -> list[str]:
raw = os.environ.get(LARK_CLI_COMMAND_ENV, "").strip()
if not raw:
@@ -1832,6 +1912,30 @@ def run_implemented_stage(project_root: Path, stage: str, state: dict[str, Any],
inputs=["07-create/live-create.json", "02-plan/slide_plan.json"],
outputs=["08-readback/xml-presentations-get.json", "08-readback/readback-check.json"],
)
if stage == "repair_loop":
return run_repair_loop_stage(project_root, state)
if stage == "theme_productization":
return run_theme_productization_stage(project_root, state)
if stage == "export":
require_stage_passed(state, "readback")
return run_script_stage(
project_root,
state,
stage,
["python3", (SCRIPT_DIR / "svglide_export_package.py").as_posix(), project_root.as_posix(), "--archive", "--pretty"],
output_json=project_root / "09-export" / "export-manifest.json",
inputs=[
"02-plan/slide_plan.json",
"06-check/quality-gate.json",
"07-create/live-create.json",
"08-readback/readback-check.json",
],
outputs=[
"09-export/export-manifest.json",
"09-export/svglide-artifacts.zip",
"receipts/export.json",
],
)
raise RunnerError(f"stage '{stage}' is not implemented in the P0 runner skeleton")
@@ -1907,17 +2011,28 @@ def build_parser() -> argparse.ArgumentParser:
run.add_argument("--profile", choices=sorted(PROFILE_TARGETS))
add_network_args(run)
prompt_plan = subcommands.add_parser("prompt-plan", help="generate source and plan artifacts from a raw prompt")
prompt_plan.add_argument("project_root", type=Path)
prompt_plan.add_argument("--prompt", required=True)
prompt_plan.add_argument("--target-slide-count", type=int, default=8)
prompt_plan.add_argument("--language", default="zh-CN")
prompt_plan.add_argument("--audience", default="投资/战略分析读者")
prompt_plan.add_argument("--provider", default="codex", choices=["codex", "claude", "command"])
prompt_plan.add_argument("--planner-command")
prompt_plan.add_argument("--no-search", action="store_true")
prompt_plan.add_argument("--timeout", type=int, default=300)
prompt_plan.add_argument("--force", action="store_true")
for command_name in ["prompt-plan", "model-plan"]:
prompt_plan = subcommands.add_parser(command_name, help="generate source and plan artifacts from a raw prompt")
prompt_plan.add_argument("project_root", type=Path)
prompt_plan.add_argument("--prompt", required=True)
prompt_plan.add_argument("--target-slide-count", type=int, default=8)
prompt_plan.add_argument("--language", default="zh-CN")
prompt_plan.add_argument("--audience", default="投资/战略分析读者")
prompt_plan.add_argument("--provider", default="codex", choices=["codex", "claude", "command"])
prompt_plan.add_argument("--planner-command")
prompt_plan.add_argument("--no-search", action="store_true")
prompt_plan.add_argument("--timeout", type=int, default=300)
prompt_plan.add_argument("--force", action="store_true")
repair_loop = subcommands.add_parser("repair-loop", help="apply a scoped repair-plan JSON Patch to slide_plan.json")
repair_loop.add_argument("project_root", type=Path)
repair_loop.add_argument("--failing-receipt", type=Path, required=True)
repair_loop.add_argument("--repair-plan", type=Path, default=Path("02-plan/repair-plan.json"))
repair_loop.add_argument("--plan", type=Path, default=Path("02-plan/slide_plan.json"))
theme_productize = subcommands.add_parser("theme-productize", help="extract a project theme and migrate a slide plan to it")
theme_productize.add_argument("project_root", type=Path)
theme_productize.add_argument("--input", type=Path, default=Path("02-plan/theme-productization.input.json"))
return parser
@@ -1946,7 +2061,7 @@ def main(argv: list[str] | None = None) -> int:
apply_cli_runner_options(args)
if args.command == "init":
result = init_project(args.deck_id, args.title, plan_root=args.plan_root, force=args.force)
elif args.command == "prompt-plan":
elif args.command in {"prompt-plan", "model-plan"}:
import svglide_prompt_planner
result = svglide_prompt_planner.run_prompt_plan(
@@ -1961,6 +2076,25 @@ def main(argv: list[str] | None = None) -> int:
timeout=args.timeout,
force=args.force,
)
elif args.command == "repair-loop":
import svglide_model_repair_loop
try:
result = svglide_model_repair_loop.run_repair_loop(
args.project_root,
failing_receipt=args.failing_receipt,
repair_plan=args.repair_plan,
plan=args.plan,
)
except svglide_model_repair_loop.RepairLoopError as err:
raise RunnerError(str(err)) from err
elif args.command == "theme-productize":
import svglide_theme_productization
try:
result = svglide_theme_productization.run_theme_productization(args.project_root, input_path=args.input)
except (svglide_theme_productization.ThemeProductizationError, OSError, json.JSONDecodeError) as err:
raise RunnerError(str(err)) from err
elif args.command == "stage":
result = run_stage(args.project_root, args.stage, profile=args.profile)
elif args.command == "run":

View File

@@ -418,6 +418,10 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertEqual(runner.normalize_stage("pre-submit-review"), "pre_submit_review")
self.assertEqual(runner.normalize_stage("pre-submit"), "pre_submit_review")
self.assertEqual(runner.normalize_stage("live-create"), "live_create")
self.assertEqual(runner.normalize_stage("theme-productization"), "theme_productization")
self.assertEqual(runner.normalize_stage("theme-productize"), "theme_productization")
self.assertEqual(runner.normalize_stage("export-package"), "export")
self.assertEqual(runner.normalize_stage("package-export"), "export")
def test_stages_until_uses_normalized_stage_graph(self) -> None:
dry_run = runner.stages_until("dry_run")
@@ -446,6 +450,11 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertIn("pre_submit_review", readback)
self.assertIn("live_create", readback)
self.assertIn("readback", readback)
self.assertNotIn("export", readback)
export = runner.stages_until("export")
self.assertIn("readback", export)
self.assertIn("export", export)
def test_resolve_run_target_accepts_preview_only_profile(self) -> None:
self.assertEqual(runner.resolve_run_target(None, "preview_only"), "quality_gate")
@@ -575,6 +584,84 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertIn((project_root / "06-check/pre-submit-human-review.json").as_posix(), command)
self.assertIn("06-check/pre-submit-human-review.json", inputs)
def test_export_stage_invokes_package_script_after_readback(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("smoke", "Smoke", plan_root=plan_root)
project_root = Path(result["project_root"])
state = runner.load_state(project_root)
state.setdefault("stages", {})["readback"] = {"status": "passed", "receipt": "receipts/readback.json"}
runner.write_state(project_root, state)
captured: dict[str, object] = {}
original_run_script_stage = runner.run_script_stage
def fake_run_script_stage(
project_root: Path,
state: dict[str, object],
stage: str,
command: list[str],
**_: object,
) -> dict[str, object]:
captured["stage"] = stage
captured["command"] = command
return runner.complete_stage(
project_root,
state,
stage,
"passed",
started_at=runner.now_iso(),
command=command,
)
try:
runner.run_script_stage = fake_run_script_stage
runner.run_stage(project_root, "export-package")
finally:
runner.run_script_stage = original_run_script_stage
self.assertEqual(captured["stage"], "export")
command = captured["command"]
self.assertIsInstance(command, list)
self.assertIn("svglide_export_package.py", " ".join(command))
self.assertIn("--archive", command)
def test_theme_productization_stage_invokes_script(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("smoke", "Smoke", plan_root=plan_root)
project_root = Path(result["project_root"])
captured: dict[str, object] = {}
original_run_script_stage = runner.run_script_stage
def fake_run_script_stage(
project_root: Path,
state: dict[str, object],
stage: str,
command: list[str],
**_: object,
) -> dict[str, object]:
captured["stage"] = stage
captured["command"] = command
return runner.complete_stage(
project_root,
state,
stage,
"passed",
started_at=runner.now_iso(),
command=command,
)
try:
runner.run_script_stage = fake_run_script_stage
runner.run_stage(project_root, "theme-productize")
finally:
runner.run_script_stage = original_run_script_stage
self.assertEqual(captured["stage"], "theme_productization")
command = captured["command"]
self.assertIsInstance(command, list)
self.assertIn("svglide_theme_productization.py", " ".join(command))
def test_init_creates_project_directories_manifest_and_state(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"

View File

@@ -601,6 +601,7 @@ def run_prompt_plan(
"stage": "prompt-plan",
"status": "passed",
"provider": provider,
"provider_type": provider,
"search_enabled": search,
"started_at": started_at,
"ended_at": now_iso(),
@@ -625,6 +626,14 @@ def run_prompt_plan(
},
"planner_stage_receipts": [receipt["stage"] for receipt in receipts],
"planner_stage_receipt_paths": [planner_file(receipt["stage"], "receipt.json").as_posix() for receipt in receipts],
"planner_raw_outputs": [
{
"stage": receipt["stage"],
"path": receipt["raw_output_path"],
"sha256": receipt["raw_output_sha256"],
}
for receipt in receipts
],
"plan_confirmation": confirmation,
"summary": {
"slide_count": len(canvas_plan.get("slides", [])) if isinstance(canvas_plan.get("slides"), list) else None,

View File

@@ -18,6 +18,16 @@ import svglide_prompt_planner as prompt_planner
class SVGlidePromptPlannerTest(unittest.TestCase):
def fixture_dir(self) -> Path:
return Path(__file__).resolve().parent / "fixtures/svglide_artboard/followup_model_loop"
def fixture_provider_command(self) -> str:
provider = self.fixture_dir() / "fixture_model_provider.py"
return f"{sys.executable} {provider} --stage {{stage}} --raw-output {{raw_output}}"
def fixture_topic(self) -> dict[str, object]:
return json.loads((self.fixture_dir() / "topic.json").read_text(encoding="utf-8"))
def fake_provider(self, tmpdir: str) -> Path:
provider = Path(tmpdir) / "fake_provider.py"
provider.write_text(
@@ -191,17 +201,22 @@ class SVGlidePromptPlannerTest(unittest.TestCase):
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("spacex-auto", "SpaceX Auto", plan_root=plan_root)
project = Path(result["project_root"])
provider = self.fake_provider(tmpdir)
topic = self.fixture_topic()
receipt = prompt_planner.run_prompt_plan(
project,
prompt="spacex IPO 分析",
target_slide_count=1,
prompt=str(topic["prompt"]),
target_slide_count=int(topic["target_slide_count"]),
language=str(topic["language"]),
audience=str(topic["audience"]),
provider="command",
planner_command=f"{sys.executable} {provider} --stage {{stage}} --raw-output {{raw_output}}",
planner_command=self.fixture_provider_command(),
)
self.assertEqual("passed", receipt["status"])
self.assertEqual("command", receipt["provider_type"])
self.assertEqual(4, len(receipt["planner_raw_outputs"]))
self.assertTrue(all(item["sha256"] for item in receipt["planner_raw_outputs"]))
self.assertTrue((project / "00-input/instruction.json").exists())
self.assertEqual("spacex IPO 分析", json.loads((project / "00-input/instruction.json").read_text(encoding="utf-8"))["raw_prompt"])
self.assertTrue((project / "02-plan/deck-plan.json").exists())
@@ -215,14 +230,14 @@ class SVGlidePromptPlannerTest(unittest.TestCase):
self.assertEqual("passed", json.loads((project / "06-check/planner-contract-check.json").read_text(encoding="utf-8"))["status"])
canvas = json.loads((project / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
self.assertGreaterEqual(len(canvas["asset_contracts"]), 3)
self.assertEqual("command", canvas["model_loop_fixture"]["provider"])
def test_prompt_plan_refuses_to_overwrite_without_force(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("spacex-auto", "SpaceX Auto", plan_root=plan_root)
project = Path(result["project_root"])
provider = self.fake_provider(tmpdir)
command = f"{sys.executable} {provider} --stage {{stage}} --raw-output {{raw_output}}"
command = self.fixture_provider_command()
prompt_planner.run_prompt_plan(project, prompt="spacex IPO 分析", target_slide_count=1, provider="command", planner_command=command)
with self.assertRaisesRegex(prompt_planner.PromptPlannerError, "already exist"):

View File

@@ -11,7 +11,9 @@ import sys
from pathlib import Path
from typing import Any
import svglide_node_layout_drift
import svglide_schema
import svglide_semantic_map_ir
CHECK_DIR = Path("06-check")
@@ -427,8 +429,10 @@ def load_generator_receipt(project: Path, *, profile: str) -> dict[str, Any]:
continue
if page.get("semantic_source") != "CanvasSpec":
check["issues"].append(issue("satori_bridge_semantic_source_invalid", "satori-bridge semantic_source must be CanvasSpec"))
if page.get("compiler_input_type") != "CanvasSpecTemplateSVG":
check["issues"].append(issue("satori_bridge_compiler_input_type_invalid", "satori-bridge compiler_input_type must be CanvasSpecTemplateSVG"))
if page.get("input_semantic_hash") != page.get("semantic_map_sha256"):
check["issues"].append(issue("satori_bridge_input_semantic_hash_mismatch", "satori-bridge input_semantic_hash must match semantic_map_sha256"))
if page.get("compiler_input_type") != "SemanticMapIR":
check["issues"].append(issue("satori_bridge_compiler_input_type_invalid", "satori-bridge compiler_input_type must be SemanticMapIR"))
if page.get("satori_svg_usage") != "preview_only":
check["issues"].append(issue("satori_bridge_satori_usage_invalid", "satori-bridge satori_svg_usage must be preview_only"))
for path_key, hash_key in [
@@ -522,12 +526,22 @@ def load_generator_receipt(project: Path, *, profile: str) -> dict[str, Any]:
compiler = artboard_receipt.get("compiler") if isinstance(artboard_receipt.get("compiler"), dict) else {}
if compiler.get("semantic_source") != "CanvasSpec":
check["issues"].append(issue("generator_artboard_compiler_semantic_source_invalid", f"artboard compiler semantic_source must be CanvasSpec: {item}"))
if compiler.get("compiler_input") != "CanvasSpecTemplateSVG":
check["issues"].append(issue("generator_artboard_compiler_input_invalid", f"artboard compiler_input must be CanvasSpecTemplateSVG: {item}"))
if compiler.get("compiler_input") != "SemanticMapIR":
check["issues"].append(issue("generator_artboard_compiler_input_invalid", f"artboard compiler_input must be SemanticMapIR: {item}"))
if compiler.get("satori_svg_usage") != "preview_only":
check["issues"].append(issue("generator_artboard_compiler_satori_usage_invalid", f"artboard compiler satori_svg_usage must be preview_only: {item}"))
if artboard_receipt.get("compiler_input") != artboard_receipt.get("canvas_template_svg"):
check["issues"].append(issue("generator_artboard_compiler_input_path_invalid", f"artboard compiler_input must point to canvas_template_svg: {item}"))
if artboard_receipt.get("compiler_input") != artboard_receipt.get("semantic_map"):
check["issues"].append(issue("generator_artboard_compiler_input_path_invalid", f"artboard compiler_input must point to semantic_map: {item}"))
input_semantic_hash = artboard_receipt.get("input_semantic_hash")
semantic_map_sha256 = artboard_receipt.get("semantic_map_sha256")
if not isinstance(input_semantic_hash, str) or not input_semantic_hash:
check["issues"].append(issue("generator_artboard_input_semantic_hash_missing", f"artboard receipt must include input_semantic_hash: {item}"))
elif input_semantic_hash != semantic_map_sha256:
check["issues"].append(issue("generator_artboard_input_semantic_hash_mismatch", f"artboard input_semantic_hash must match semantic_map_sha256: {item}"))
if artboard_receipt.get("compiler_input_sha256") != semantic_map_sha256:
check["issues"].append(issue("generator_artboard_compiler_input_hash_mismatch", f"artboard compiler_input_sha256 must match semantic_map_sha256: {item}"))
if compiler.get("input_semantic_hash") != semantic_map_sha256:
check["issues"].append(issue("generator_artboard_compiler_input_semantic_hash_mismatch", f"artboard compiler input_semantic_hash must match semantic_map_sha256: {item}"))
for path_key, artifact_schema, code in [
("semantic_map", semantic_map_schema, "generator_artboard_semantic_map_schema_invalid"),
("node_layout_map", node_layout_schema, "generator_artboard_node_layout_schema_invalid"),
@@ -542,6 +556,14 @@ def load_generator_receipt(project: Path, *, profile: str) -> dict[str, Any]:
continue
schema_issues = svglide_schema.validate_json_schema(artifact, artifact_schema)
check["issues"].extend(issue(code, f"{rel} {schema_issue['path']}: {schema_issue['message']}") for schema_issue in schema_issues)
if path_key == "semantic_map":
svglide_rel = artboard_receipt.get("svglide_svg")
if isinstance(svglide_rel, str) and (project / svglide_rel).exists():
semantic_issues = svglide_semantic_map_ir.validate_semantic_map_against_svg(artifact, project / svglide_rel)
check["issues"].extend(issue(f"generator_artboard_{semantic_issue['code']}", f"{rel}: {semantic_issue['message']}") for semantic_issue in semantic_issues)
if path_key == "node_layout_map":
drift_issues = svglide_node_layout_drift.validate_node_layout_map(artifact)
check["issues"].extend(issue(f"generator_artboard_{drift_issue['code']}", f"{rel}: {drift_issue['message']}") for drift_issue in drift_issues)
check["error_count"] = len(check["issues"])
check["status"] = "failed" if check["issues"] else "passed"
return check

View File

@@ -219,6 +219,14 @@ def attach_passing_artboard_receipt(project: Path) -> None:
'<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><rect width="960" height="540"/><text x="80" y="120">Title</text></svg>',
encoding="utf-8",
)
(project / "04-svg/page-001.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">'
'<foreignObject slide:role="shape" slide:shape-type="text" data-node-id="title" data-source-ref="canvas_spec.content.title" x="80" y="80" width="720" height="72">'
'<div xmlns="http://www.w3.org/1999/xhtml">Title</div>'
'</foreignObject>'
'</svg>',
encoding="utf-8",
)
(project / "04-svg/artboard/page-001.png").write_bytes(b"png")
write_json(
project / "04-svg/artboard/page-001.render-metadata.json",
@@ -250,9 +258,25 @@ def attach_passing_artboard_receipt(project: Path) -> None:
{
"version": "svglide-node-layout-map/v1",
"page": 1,
"source": "template-layout-map",
"drift": {"status": "not_measured_in_p0", "max_px": 0},
"nodes": [{"id": "title", "kind": "text", "x": 80, "y": 80, "width": 720, "height": 72}],
"source": "measured-layout-observation",
"observation_source": "satori_on_node_detected",
"threshold_px": 8,
"drift": {"status": "passed", "max_px": 0, "threshold_px": 8, "missing_count": 0},
"nodes": [
{
"id": "title",
"kind": "text",
"x": 80,
"y": 80,
"width": 720,
"height": 72,
"text": "Title",
"expected_bbox": {"x": 80, "y": 80, "width": 720, "height": 72},
"measured_bbox": {"x": 80, "y": 80, "width": 720, "height": 72},
"drift_px": 0,
"observation_source": "satori_on_node_detected",
}
],
},
)
(project / "05-preview/contact-sheet.png").write_bytes(b"contact")
@@ -260,6 +284,7 @@ def attach_passing_artboard_receipt(project: Path) -> None:
template_registry_sha256 = "template-registry-hash"
theme_registry_sha256 = "theme-registry-hash"
font_hashes = [{"path": "/tmp/font.ttf", "sha256": "font-hash"}]
semantic_map_sha256 = svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.semantic-map.json")
write_json(
project / "04-svg/artboard/page-001.receipt.json",
{
@@ -289,15 +314,16 @@ def attach_passing_artboard_receipt(project: Path) -> None:
"render_metadata_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.render-metadata.json"),
"canvas_template_svg": "04-svg/artboard/page-001.canvas-template.svg",
"canvas_template_svg_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.canvas-template.svg"),
"compiler_input": "04-svg/artboard/page-001.canvas-template.svg",
"compiler_input_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.canvas-template.svg"),
"compiler_input": "04-svg/artboard/page-001.semantic-map.json",
"compiler_input_sha256": semantic_map_sha256,
"input_semantic_hash": semantic_map_sha256,
"semantic_map": "04-svg/artboard/page-001.semantic-map.json",
"semantic_map_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.semantic-map.json"),
"semantic_map_sha256": semantic_map_sha256,
"node_layout_map": "04-svg/artboard/page-001.node-layout-map.json",
"node_layout_map_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.node-layout-map.json"),
"svglide_svg": "04-svg/page-001.svg",
"svglide_svg_sha256": source_hash,
"compiler": {"semantic_source": "CanvasSpec", "compiler_input": "CanvasSpecTemplateSVG", "satori_svg_usage": "preview_only"},
"compiler": {"semantic_source": "CanvasSpec", "compiler_input": "SemanticMapIR", "satori_svg_usage": "preview_only", "input_semantic_hash": semantic_map_sha256},
},
)
receipt = json.loads((project / "receipts/generate_svg.json").read_text(encoding="utf-8"))
@@ -403,14 +429,15 @@ def attach_passing_artboard_receipt(project: Path) -> None:
"page": 1,
"semantic_source": "CanvasSpec",
"semantic_map": "04-svg/artboard/page-001.semantic-map.json",
"semantic_map_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.semantic-map.json"),
"semantic_map_sha256": semantic_map_sha256,
"input_semantic_hash": semantic_map_sha256,
"node_layout_map": "04-svg/artboard/page-001.node-layout-map.json",
"node_layout_map_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.node-layout-map.json"),
"canvas_template_svg": "04-svg/artboard/page-001.canvas-template.svg",
"canvas_template_svg_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.canvas-template.svg"),
"compiler_input": "04-svg/artboard/page-001.canvas-template.svg",
"compiler_input_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.canvas-template.svg"),
"compiler_input_type": "CanvasSpecTemplateSVG",
"compiler_input": "04-svg/artboard/page-001.semantic-map.json",
"compiler_input_sha256": semantic_map_sha256,
"compiler_input_type": "SemanticMapIR",
"satori_svg_usage": "preview_only",
"satori_svg": "04-svg/artboard/raw/page-001.satori.svg",
"satori_svg_sha256": svglide_quality_gate.file_sha256(project / "04-svg/artboard/raw/page-001.satori.svg"),
@@ -444,6 +471,28 @@ def attach_passing_artboard_receipt(project: Path) -> None:
write_json(project / "receipts/template-fit-check.json", json.loads((project / "06-check/template-fit.json").read_text(encoding="utf-8")))
def refresh_artboard_node_layout_hashes(project: Path) -> None:
node_layout_sha = svglide_quality_gate.file_sha256(project / "04-svg/artboard/page-001.node-layout-map.json")
for receipt_rel in [
"04-svg/artboard/page-001.receipt.json",
"receipts/artboard-render.json",
"receipts/satori-bridge.json",
]:
receipt_path = project / receipt_rel
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
if "node_layout_map_sha256" in receipt:
receipt["node_layout_map_sha256"] = node_layout_sha
for page in receipt.get("pages", []) if isinstance(receipt.get("pages"), list) else []:
if isinstance(page, dict) and page.get("node_layout_map") == "04-svg/artboard/page-001.node-layout-map.json":
page["node_layout_map_sha256"] = node_layout_sha
write_json(receipt_path, receipt)
satori_bridge = project / "receipts/satori-bridge.json"
render_receipt = project / "receipts/artboard-render.json"
payload = json.loads(satori_bridge.read_text(encoding="utf-8"))
payload["inputs"]["artboard_render_sha256"] = svglide_quality_gate.file_sha256(render_receipt)
write_json(satori_bridge, payload)
class SVGlideQualityGateTest(unittest.TestCase):
def test_quality_gate_passes_when_required_checks_have_zero_errors(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
@@ -886,7 +935,9 @@ class SVGlideQualityGateTest(unittest.TestCase):
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_passing_semantic_review(project)
attach_passing_artboard_receipt(project)
(project / "04-svg/artboard/page-001.canvas-template.svg").write_text("<svg changed='true'/>", encoding="utf-8")
semantic_map = json.loads((project / "04-svg/artboard/page-001.semantic-map.json").read_text(encoding="utf-8"))
semantic_map["elements"][0]["text"] = "Changed semantic input"
write_json(project / "04-svg/artboard/page-001.semantic-map.json", semantic_map)
result = svglide_quality_gate.run_quality_gate(project)
@@ -899,6 +950,33 @@ class SVGlideQualityGateTest(unittest.TestCase):
self.assertIn("generator_artboard_artifact_stale", failed_codes)
self.assertIn("satori_bridge_compiler_input_stale", failed_codes)
def test_quality_gate_fails_when_node_layout_drift_exceeds_threshold(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "06-check/preflight.json", {"summary": {"error_count": 0}})
write_json(project / "06-check/preview-lint.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_passing_semantic_review(project)
attach_passing_artboard_receipt(project)
node_layout_path = project / "04-svg/artboard/page-001.node-layout-map.json"
node_layout = json.loads(node_layout_path.read_text(encoding="utf-8"))
node_layout["drift"] = {"status": "failed", "max_px": 48, "threshold_px": 8, "missing_count": 0}
node_layout["nodes"][0]["x"] = 128
node_layout["nodes"][0]["measured_bbox"]["x"] = 128
node_layout["nodes"][0]["drift_px"] = 48
write_json(node_layout_path, node_layout)
refresh_artboard_node_layout_hashes(project)
result = svglide_quality_gate.run_quality_gate(project)
self.assertEqual(result["status"], "failed")
failed_codes = {
issue["code"]
for check in result["checks"]
for issue in check["issues"]
}
self.assertIn("generator_artboard_node_layout_drift_exceeds_threshold", failed_codes)
def test_quality_gate_validates_artboard_receipt_schema(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import hashlib
import json
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
TEXT_RE = re.compile(r"\s+")
def json_sha256(payload: Any) -> str:
data = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(data).hexdigest()
def normalize_text(value: str) -> str:
return TEXT_RE.sub(" ", value).strip()
def normalized_match(value: str) -> str:
return "".join(normalize_text(value).split()).lower()
def _attr(element: ET.Element, name: str) -> str | None:
value = element.get(name)
if value is not None:
return value
for key, item in element.attrib.items():
if key.rsplit("}", 1)[-1] == name:
return item
return None
def extract_visible_semantic_nodes(svg_path: Path) -> list[dict[str, str | None]]:
root = ET.fromstring(svg_path.read_text(encoding="utf-8"))
nodes: list[dict[str, str | None]] = []
for element in root.iter():
local_name = element.tag.rsplit("}", 1)[-1]
if local_name not in {"text", "foreignObject"}:
continue
text = normalize_text("".join(element.itertext()))
if not text:
continue
nodes.append(
{
"element_id": _attr(element, "data-node-id"),
"source_ref": _attr(element, "data-source-ref"),
"text": text,
}
)
return nodes
def validate_semantic_map_against_svg(semantic_map: dict[str, Any], svg_path: Path) -> list[dict[str, str]]:
visible_nodes = extract_visible_semantic_nodes(svg_path)
visible_by_id = {
str(node["element_id"]): node
for node in visible_nodes
if isinstance(node.get("element_id"), str) and str(node.get("element_id"))
}
issues: list[dict[str, str]] = []
elements = semantic_map.get("elements") if isinstance(semantic_map.get("elements"), list) else []
for element in elements:
if not isinstance(element, dict) or element.get("kind") != "text":
continue
element_id = element.get("element_id")
expected_text = element.get("text")
if not isinstance(element_id, str) or not element_id:
continue
if not isinstance(expected_text, str) or not normalize_text(expected_text):
continue
observed = visible_by_id.get(element_id)
if observed is None:
issues.append({"code": "semantic_map_visible_text_missing", "message": f"visible SVG text is missing semantic element {element_id}"})
continue
if normalized_match(str(observed.get("text") or "")) != normalized_match(expected_text):
issues.append({"code": "semantic_map_visible_text_mismatch", "message": f"visible SVG text does not match semantic map element {element_id}"})
expected_ref = element.get("source_ref")
if isinstance(expected_ref, str) and expected_ref:
actual_ref = observed.get("source_ref")
if actual_ref != expected_ref:
issues.append({"code": "semantic_map_source_ref_mismatch", "message": f"visible SVG source_ref does not match semantic map element {element_id}"})
return issues

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_node_layout_drift
import svglide_semantic_map_ir
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures/svglide_artboard/followup_semantic_layout"
class SVGlideSemanticMapIRTest(unittest.TestCase):
def test_semantic_map_validates_visible_text_and_source_ref(self) -> None:
semantic_map = json.loads((FIXTURE_DIR / "page-001.semantic-map.json").read_text(encoding="utf-8"))
issues = svglide_semantic_map_ir.validate_semantic_map_against_svg(semantic_map, FIXTURE_DIR / "page-001.svg")
self.assertEqual(issues, [])
def test_semantic_map_rejects_visible_text_drift(self) -> None:
semantic_map = json.loads((FIXTURE_DIR / "page-001.semantic-map.json").read_text(encoding="utf-8"))
with tempfile.TemporaryDirectory() as tmpdir:
svg_path = Path(tmpdir) / "page-001.svg"
svg_path.write_text((FIXTURE_DIR / "page-001.svg").read_text(encoding="utf-8").replace("Semantic IR Title", "Drifted Title"), encoding="utf-8")
issues = svglide_semantic_map_ir.validate_semantic_map_against_svg(semantic_map, svg_path)
self.assertIn("semantic_map_visible_text_mismatch", {item["code"] for item in issues})
def test_node_layout_map_accepts_measured_observation(self) -> None:
layout_map = json.loads((FIXTURE_DIR / "page-001.node-layout-map.json").read_text(encoding="utf-8"))
issues = svglide_node_layout_drift.validate_node_layout_map(layout_map)
self.assertEqual(issues, [])
def test_node_layout_map_rejects_material_drift(self) -> None:
layout_map = json.loads((FIXTURE_DIR / "page-001.node-layout-drift.json").read_text(encoding="utf-8"))
issues = svglide_node_layout_drift.validate_node_layout_map(layout_map)
self.assertIn("node_layout_drift_exceeds_threshold", {item["code"] for item in issues})
if __name__ == "__main__":
unittest.main()

View File

@@ -43,7 +43,7 @@ def text_height_ok(node: dict[str, Any]) -> bool:
kind = node.get("kind")
if kind != "text":
return True
return isinstance(height, (int, float)) and height >= 30
return isinstance(height, (int, float)) and height >= 24
def node_in_canvas(node: dict[str, Any]) -> bool:

View File

@@ -77,7 +77,7 @@ class SVGlideTemplateFitCheckTest(unittest.TestCase):
write_project(
project,
[
{"id": "title", "kind": "text", "x": 40, "y": 40, "width": 320, "height": 24},
{"id": "title", "kind": "text", "x": 40, "y": 40, "width": 320, "height": 18},
{"id": "footer", "kind": "shape", "x": 920, "y": 500, "width": 80, "height": 60},
],
)

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import hashlib
import json
import shlex
import subprocess
import sys
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_theme
PRODUCTIZATION_VERSION = "svglide-theme-productization/v1"
INPUT_PATH = Path("02-plan/theme-productization.input.json")
THEME_DIR = Path("02-plan/themes")
PROJECT_REGISTRY = Path("02-plan/theme-registry.json")
OUTPUT_PATH = Path("06-check/theme-productization.json")
RECEIPT_PATH = Path("receipts/theme-productization.json")
DEFAULT_MIGRATED_PLAN = Path("02-plan/slide_plan.theme-migrated.json")
TEMPLATE_REGISTRY = Path("skills/lark-slides/references/svglide-template-registry.json")
CORE_COLOR_ROLES = (
"background",
"surface",
"text",
"muted",
"primary",
"accent",
"success",
"warning",
"danger",
)
class ThemeProductizationError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as err:
raise ThemeProductizationError(f"missing required file: {path}") from err
except json.JSONDecodeError as err:
raise ThemeProductizationError(f"invalid JSON in {path}: {err}") from err
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def stable_sha256(payload: Any) -> str:
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def issue(code: str, message: str, *, path: str | None = None) -> dict[str, str]:
payload = {"code": code, "message": message}
if path:
payload["path"] = path
return payload
def slug(value: str) -> str:
normalized = "".join(ch.lower() if ch.isalnum() else "-" for ch in value.strip())
collapsed = "-".join(part for part in normalized.split("-") if part)
return collapsed or "theme"
def normalize_palette(raw: Any) -> dict[str, str]:
palette = raw if isinstance(raw, dict) else {}
colors: dict[str, str] = {}
colors["background"] = svglide_theme.normalize_hex_color(str(palette.get("background") or "#FFFFFF"))
colors["surface"] = svglide_theme.normalize_hex_color(str(palette.get("surface") or palette.get("panel") or "#F8FAFC"))
colors["text"] = svglide_theme.normalize_hex_color(str(palette.get("text") or "#111827"))
colors["muted"] = svglide_theme.normalize_hex_color(str(palette.get("muted") or "#64748B"))
colors["primary"] = svglide_theme.normalize_hex_color(str(palette.get("primary") or "#2563EB"))
colors["accent"] = svglide_theme.normalize_hex_color(str(palette.get("accent") or "#D946EF"))
colors["success"] = svglide_theme.normalize_hex_color(str(palette.get("success") or "#16A34A"))
colors["warning"] = svglide_theme.normalize_hex_color(str(palette.get("warning") or "#D97706"))
colors["danger"] = svglide_theme.normalize_hex_color(str(palette.get("danger") or "#DC2626"))
for key, value in palette.items():
if isinstance(key, str) and key not in colors:
colors[key] = svglide_theme.normalize_hex_color(str(value))
return colors
def default_tokens(colors: dict[str, str]) -> dict[str, str]:
return {f"color.{role}": colors[role] for role in CORE_COLOR_ROLES}
def default_semantic_colors(colors: dict[str, str]) -> dict[str, str]:
return {
"canvas.background": colors["background"],
"surface.default": colors["surface"],
"text.default": colors["text"],
"text.muted": colors["muted"],
"brand.primary": colors["primary"],
"brand.accent": colors["accent"],
"status.success": colors["success"],
"status.warning": colors["warning"],
"status.danger": colors["danger"],
}
def infer_mode(colors: dict[str, str], requested: Any) -> str:
if requested in {"light", "dark"}:
return str(requested)
return "dark" if svglide_theme.relative_luminance(colors["background"]) < 0.5 else "light"
def complete_theme_spec(raw_theme: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]:
brand = request.get("brand") if isinstance(request.get("brand"), dict) else {}
theme_id = str(raw_theme.get("theme_id") or request.get("theme_id") or slug(str(brand.get("name") or "productized-theme")))
colors = normalize_palette(raw_theme.get("colors") if isinstance(raw_theme.get("colors"), dict) else request.get("palette"))
semantic_colors = raw_theme.get("semantic_colors") if isinstance(raw_theme.get("semantic_colors"), dict) else default_semantic_colors(colors)
tokens = raw_theme.get("tokens") if isinstance(raw_theme.get("tokens"), dict) else default_tokens(colors)
for role in CORE_COLOR_ROLES:
tokens.setdefault(f"color.{role}", colors[role])
data_series = raw_theme.get("data_series") if isinstance(raw_theme.get("data_series"), list) else [colors["primary"], colors["accent"], colors["success"], colors["warning"], colors["danger"]]
spec: dict[str, Any] = {
**raw_theme,
"schema_version": "svglide-theme/v1",
"theme_id": theme_id,
"mode": infer_mode(colors, raw_theme.get("mode") or brand.get("mode")),
"colors": colors,
"semantic_colors": semantic_colors,
"tokens": tokens,
"contrast": raw_theme.get("contrast") if isinstance(raw_theme.get("contrast"), dict) else {"min_text_contrast": 4.5},
"allowed_color_roles": raw_theme.get("allowed_color_roles") if isinstance(raw_theme.get("allowed_color_roles"), list) else list(colors.keys()),
"data_series": data_series,
"productization": {
"source": request.get("source") or "theme-productization.input.json",
"brand": brand,
"provider": provider_summary(request),
},
}
return spec
def provider_summary(request: dict[str, Any]) -> dict[str, Any]:
provider = request.get("provider") if isinstance(request.get("provider"), dict) else {}
provider_type = provider.get("type") if isinstance(provider.get("type"), str) else "deterministic_rules"
return {"type": provider_type}
def command_from_provider(provider: dict[str, Any]) -> list[str]:
raw = provider.get("command")
if isinstance(raw, list) and raw and all(isinstance(item, str) for item in raw):
return list(raw)
if isinstance(raw, str) and raw.strip():
return shlex.split(raw)
raise ThemeProductizationError("provider.type=command requires provider.command")
def extract_theme(request: dict[str, Any], project: Path) -> tuple[dict[str, Any], dict[str, Any]]:
provider = request.get("provider") if isinstance(request.get("provider"), dict) else {}
provider_type = provider.get("type") if isinstance(provider.get("type"), str) else "deterministic_rules"
raw_output: str | None = None
returncode: int | None = None
if provider_type == "command":
command = command_from_provider(provider)
completed = subprocess.run(
command,
cwd=project,
input=json.dumps(request, ensure_ascii=False),
check=False,
capture_output=True,
text=True,
timeout=int(provider.get("timeout", 60)) if not isinstance(provider.get("timeout"), bool) else 60,
)
raw_output = completed.stdout
returncode = completed.returncode
if completed.returncode != 0:
raise ThemeProductizationError(f"theme provider command failed with exit code {completed.returncode}: {completed.stderr}")
raw_theme = json.loads(completed.stdout)
if not isinstance(raw_theme, dict):
raise ThemeProductizationError("theme provider output must be a JSON object")
elif provider_type in {"deterministic_rules", "fixture"}:
raw_theme = {
"theme_id": request.get("theme_id") or slug(str((request.get("brand") or {}).get("name") if isinstance(request.get("brand"), dict) else "productized-theme")),
"colors": request.get("palette") if isinstance(request.get("palette"), dict) else {},
}
raw_output = json.dumps(raw_theme, ensure_ascii=False, sort_keys=True)
returncode = 0
else:
raise ThemeProductizationError(f"unsupported theme provider type: {provider_type}")
theme = complete_theme_spec(raw_theme, request)
return theme, {
"type": provider_type,
"command": command_from_provider(provider) if provider_type == "command" else None,
"returncode": returncode,
"raw_output_sha256": hashlib.sha256((raw_output or "").encode("utf-8")).hexdigest(),
}
def read_template_ids() -> list[str]:
path = Path(__file__).resolve().parents[3] / TEMPLATE_REGISTRY
try:
payload = read_json(path)
except ThemeProductizationError:
return []
templates = payload.get("templates")
if not isinstance(templates, list):
return []
ids: list[str] = []
for item in templates:
if isinstance(item, dict) and isinstance(item.get("id"), str):
ids.append(item["id"])
return ids
def registry_record(theme_id: str, theme_path: Path, project: Path, request: dict[str, Any]) -> dict[str, Any]:
template_binding = request.get("template_binding") if isinstance(request.get("template_binding"), dict) else {}
supported = template_binding.get("supported_template_ids")
if not isinstance(supported, list) or not all(isinstance(item, str) for item in supported):
supported = read_template_ids()
return {
"id": theme_id,
"status": "active",
"path": theme_path.relative_to(project).as_posix(),
"template_bindings": {
"mode": "project_theme_compatible",
"supported_template_ids": supported,
"source_theme_id": template_binding.get("source_theme_id") if isinstance(template_binding.get("source_theme_id"), str) else None,
},
}
def write_theme_outputs(project: Path, theme: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]:
theme_id = str(theme["theme_id"])
theme_path = project / THEME_DIR / f"{slug(theme_id)}.json"
write_json(theme_path, theme)
registry = {
"schema_version": "svglide-theme-registry/v1",
"themes": [registry_record(theme_id, theme_path, project, request)],
}
write_json(project / PROJECT_REGISTRY, registry)
return {
"theme_id": theme_id,
"theme_path": theme_path.relative_to(project).as_posix(),
"theme_sha256": file_sha256(theme_path),
"registry_path": PROJECT_REGISTRY.as_posix(),
"registry_sha256": file_sha256(project / PROJECT_REGISTRY),
}
def set_path(root: Any, path: list[Any], value: Any) -> None:
cursor = root
for item in path[:-1]:
cursor = cursor[item]
cursor[path[-1]] = value
def json_pointer(path: list[Any]) -> str:
return "/" + "/".join(str(item).replace("~", "~0").replace("/", "~1") for item in path)
def migrate_plan(plan: dict[str, Any], target_theme_id: str) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
migrated = deepcopy(plan)
ops: list[dict[str, Any]] = []
previous: list[str] = []
def replace(path: list[Any], old: Any) -> None:
if isinstance(old, str):
previous.append(old)
set_path(migrated, path, target_theme_id)
ops.append({"op": "replace", "path": json_pointer(path), "from": old, "value": target_theme_id})
if isinstance(migrated.get("theme_id"), str):
replace(["theme_id"], migrated["theme_id"])
slides = migrated.get("slides")
if isinstance(slides, list):
for index, slide in enumerate(slides):
if not isinstance(slide, dict):
continue
if isinstance(slide.get("theme_id"), str):
replace(["slides", index, "theme_id"], slide["theme_id"])
canvas = slide.get("canvas_spec")
if isinstance(canvas, dict) and isinstance(canvas.get("theme_id"), str):
replace(["slides", index, "canvas_spec", "theme_id"], canvas["theme_id"])
return migrated, ops, sorted(set(previous))
def run_migration(project: Path, theme_id: str, request: dict[str, Any]) -> dict[str, Any]:
migration = request.get("migration") if isinstance(request.get("migration"), dict) else {}
input_rel = Path(str(migration.get("input_plan") or "02-plan/slide_plan.json"))
input_path = project / input_rel
if not input_path.exists():
return {"status": "skipped", "reason": f"{input_rel.as_posix()} is missing"}
output_rel = Path(str(migration.get("output_plan") or DEFAULT_MIGRATED_PLAN.as_posix()))
if migration.get("in_place") is True:
output_rel = input_rel
plan = read_json(input_path)
if not isinstance(plan, dict):
raise ThemeProductizationError("migration input plan must be a JSON object")
migrated, ops, previous_theme_ids = migrate_plan(plan, theme_id)
write_json(project / output_rel, migrated)
patch_path = project / "02-plan/theme-migration.patch.json"
write_json(patch_path, {"target_theme_id": theme_id, "ops": ops})
return {
"status": "passed",
"input_plan": input_rel.as_posix(),
"output_plan": output_rel.as_posix(),
"patch_path": "02-plan/theme-migration.patch.json",
"patch_sha256": file_sha256(patch_path),
"operation_count": len(ops),
"previous_theme_ids": previous_theme_ids,
"target_theme_id": theme_id,
"in_place": output_rel == input_rel,
}
def run_theme_productization(project: Path, *, input_path: Path = INPUT_PATH) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
request_path = project / input_path
request = read_json(request_path)
if not isinstance(request, dict):
raise ThemeProductizationError("theme productization input must be a JSON object")
issues: list[dict[str, str]] = []
try:
theme, provider = extract_theme(request, project)
except (svglide_theme.ThemeError, json.JSONDecodeError) as err:
raise ThemeProductizationError(str(err)) from err
validation_issues = svglide_theme.validate_theme_spec(theme)
if validation_issues:
issues.extend(issue(item["code"], item["message"], path=item.get("path")) for item in validation_issues)
theme_outputs = write_theme_outputs(project, theme, request)
migration = run_migration(project, theme_outputs["theme_id"], request)
status = "passed" if not issues else "failed"
result = {
"version": PRODUCTIZATION_VERSION,
"stage": "theme_productization",
"status": status,
"action": "create_live" if status == "passed" else "repair_and_rerun",
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"request": input_path.as_posix(),
"request_sha256": file_sha256(request_path),
},
"provider": provider,
"theme": theme_outputs,
"authoring_contract": {
"status": "passed" if not validation_issues else "failed",
"schema": "skills/lark-slides/references/svglide-theme-spec.schema.json",
"registry": PROJECT_REGISTRY.as_posix(),
"template_binding": "project theme registry template_bindings",
},
"migration": migration,
"boundaries": {
"authoring_ui": "not_implemented_in_cli_workspace",
"model_quality_approval": "provider output is validated structurally; true aesthetic judgment needs an external model or human reviewer",
},
"summary": {
"error_count": len(issues),
"migration_operation_count": migration.get("operation_count", 0) if isinstance(migration, dict) else 0,
},
"issues": issues,
}
write_json(project / OUTPUT_PATH, result)
write_json(project / RECEIPT_PATH, result)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Extract, author, and migrate SVGlide project themes.")
parser.add_argument("project")
parser.add_argument("--input", default=INPUT_PATH.as_posix())
parser.add_argument("--pretty", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
result = run_theme_productization(Path(args.project), input_path=Path(args.input))
except (OSError, subprocess.SubprocessError, ThemeProductizationError, svglide_theme.ThemeError, json.JSONDecodeError) as error:
print(f"svglide_theme_productization: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None, sort_keys=True))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,102 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_theme_productization as productization
import svglide_theme_validate
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
class SVGlideThemeProductizationTest(unittest.TestCase):
def make_project(self) -> Path:
root = Path(tempfile.mkdtemp())
project = root / ".lark-slides" / "plan" / "demo"
write_json(
project / "02-plan/slide_plan.json",
{
"generation_mode": "artboard_satori",
"theme_id": "dark-clarity",
"slides": [
{
"page": 1,
"title": "Theme Migration",
"theme_id": "dark-clarity",
"canvas_spec": {"template_id": "cover-hero", "theme_id": "dark-clarity"},
}
],
},
)
write_json(
project / productization.INPUT_PATH,
{
"theme_id": "acme-signal",
"brand": {"name": "ACME Signal"},
"provider": {"type": "deterministic_rules"},
"palette": {
"background": "#FFFFFF",
"surface": "#F4F7FB",
"text": "#102033",
"muted": "#667085",
"primary": "#1363DF",
"accent": "#F04438",
},
"template_binding": {"supported_template_ids": ["cover-hero"]},
"migration": {"output_plan": "02-plan/slide_plan.acme.json"},
},
)
return project
def test_theme_productization_extracts_registry_and_migrates_plan(self) -> None:
project = self.make_project()
result = productization.run_theme_productization(project)
self.assertEqual(result["status"], "passed", result["issues"])
self.assertEqual(result["theme"]["theme_id"], "acme-signal")
self.assertTrue((project / "02-plan/themes/acme-signal.json").exists())
self.assertTrue((project / "02-plan/theme-registry.json").exists())
self.assertTrue((project / "02-plan/slide_plan.acme.json").exists())
self.assertTrue((project / "02-plan/theme-migration.patch.json").exists())
self.assertEqual(result["migration"]["operation_count"], 3)
migrated = json.loads((project / "02-plan/slide_plan.acme.json").read_text(encoding="utf-8"))
self.assertEqual(migrated["theme_id"], "acme-signal")
self.assertEqual(migrated["slides"][0]["canvas_spec"]["theme_id"], "acme-signal")
registry = json.loads((project / "02-plan/theme-registry.json").read_text(encoding="utf-8"))
self.assertEqual(registry["themes"][0]["template_bindings"]["supported_template_ids"], ["cover-hero"])
def test_theme_validate_accepts_productized_project_theme_binding(self) -> None:
project = self.make_project()
productization.run_theme_productization(project)
migrated = json.loads((project / "02-plan/slide_plan.acme.json").read_text(encoding="utf-8"))
write_json(project / "02-plan/slide_plan.json", migrated)
result = svglide_theme_validate.validate_project(project)
self.assertEqual(result["status"], "passed", result["issues"])
self.assertEqual(result["inputs"]["theme_registry"], "02-plan/theme-registry.json")
self.assertEqual(result["pages"][0]["theme_id"], "acme-signal")
def test_theme_productization_rejects_invalid_palette(self) -> None:
project = self.make_project()
request = json.loads((project / productization.INPUT_PATH).read_text(encoding="utf-8"))
request["palette"]["primary"] = "#12GGGG"
write_json(project / productization.INPUT_PATH, request)
with self.assertRaises(productization.ThemeProductizationError):
productization.run_theme_productization(project)
if __name__ == "__main__":
unittest.main()

View File

@@ -76,6 +76,22 @@ def theme_record_paths(registry: dict[str, Any]) -> dict[str, str]:
return result
def theme_records_by_id(registry: dict[str, Any]) -> dict[str, dict[str, Any]]:
themes = registry.get("themes") if isinstance(registry.get("themes"), list) else []
return {item["id"]: item for item in themes if isinstance(item, dict) and isinstance(item.get("id"), str)}
def project_theme_allows_template(registry: dict[str, Any], theme_id: str, template_id: str) -> bool:
record = theme_records_by_id(registry).get(theme_id)
if not isinstance(record, dict):
return False
bindings = record.get("template_bindings")
if not isinstance(bindings, dict):
return False
supported = bindings.get("supported_template_ids")
return isinstance(supported, list) and template_id in supported
def slide_canvas_spec(slide: dict[str, Any]) -> dict[str, Any]:
spec = slide.get("canvas_spec")
return spec if isinstance(spec, dict) else {}
@@ -175,7 +191,7 @@ def validate_project(project_root: Path) -> dict[str, Any]:
page_issues.append(issue("template_unknown", f"template_id {template_id!r} is not present in template registry", page=index))
elif theme_id:
allowed = template.get("supported_theme_ids")
if isinstance(allowed, list) and theme_id not in allowed:
if isinstance(allowed, list) and theme_id not in allowed and not project_theme_allows_template(registry, theme_id, template_id):
page_issues.append(issue("template_theme_not_allowed", f"template_id {template_id!r} does not allow theme_id {theme_id!r}", page=index))
pages.append(
{

View File

@@ -47,7 +47,14 @@ def write_project_theme(project: Path, *, theme_id: str = "project-theme", prima
project / "02-plan/theme-registry.json",
{
"schema_version": "svglide-theme-registry/v1",
"themes": [{"id": theme_id, "status": "active", "path": "themes/project-theme.json"}],
"themes": [
{
"id": theme_id,
"status": "active",
"path": "themes/project-theme.json",
"template_bindings": {"supported_template_ids": ["cover-hero"]},
}
],
},
)
write_json(
@@ -99,6 +106,17 @@ class SVGlideThemeValidateTest(unittest.TestCase):
self.assertEqual(result["theme_files"][0]["path"], "themes/project-theme.json")
self.assertEqual(result["pages"][0]["theme_id"], "project-theme")
def test_validate_project_allows_project_theme_template_binding(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_project_theme(project)
write_plan(project, theme_id="project-theme", template_id="cover-hero")
result = svglide_theme_validate.validate_project(project)
self.assertEqual(result["status"], "passed", result["issues"])
self.assertEqual(result["pages"][0]["template_id"], "cover-hero")
def test_validate_project_fails_unknown_theme(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)