diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3349fb447..4cf11207e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/skills/lark-slides/references/svglide-artboard-followup-completion-evidence.md b/skills/lark-slides/references/svglide-artboard-followup-completion-evidence.md new file mode 100644 index 000000000..d1bc309ed --- /dev/null +++ b/skills/lark-slides/references/svglide-artboard-followup-completion-evidence.md @@ -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 +``` diff --git a/skills/lark-slides/references/svglide-artboard-full-plan-action.md b/skills/lark-slides/references/svglide-artboard-full-plan-action.md index 611b4952a..c1c0989ab 100644 --- a/skills/lark-slides/references/svglide-artboard-full-plan-action.md +++ b/skills/lark-slides/references/svglide-artboard-full-plan-action.md @@ -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` | diff --git a/skills/lark-slides/references/svglide-artboard-gate12-scope.md b/skills/lark-slides/references/svglide-artboard-gate12-scope.md index bc724f19b..1485a7422 100644 --- a/skills/lark-slides/references/svglide-artboard-gate12-scope.md +++ b/skills/lark-slides/references/svglide-artboard-gate12-scope.md @@ -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 diff --git a/skills/lark-slides/references/svglide-artboard-satori.contract.md b/skills/lark-slides/references/svglide-artboard-satori.contract.md index 65d43d16b..d36b75b22 100644 --- a/skills/lark-slides/references/svglide-artboard-satori.contract.md +++ b/skills/lark-slides/references/svglide-artboard-satori.contract.md @@ -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 diff --git a/skills/lark-slides/references/svglide-artifacts.spec.md b/skills/lark-slides/references/svglide-artifacts.spec.md index e1ef558ef..de15defcb 100644 --- a/skills/lark-slides/references/svglide-artifacts.spec.md +++ b/skills/lark-slides/references/svglide-artifacts.spec.md @@ -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/.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//` are per-run outputs. Do not commit them unless a test fixture explicitly requires it. diff --git a/skills/lark-slides/references/svglide-theme-system-p0-evidence.md b/skills/lark-slides/references/svglide-theme-system-p0-evidence.md index 6d911b458..e8706c583 100644 --- a/skills/lark-slides/references/svglide-theme-system-p0-evidence.md +++ b/skills/lark-slides/references/svglide-theme-system-p0-evidence.md @@ -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. diff --git a/skills/lark-slides/references/svglide-workflow.spec.md b/skills/lark-slides/references/svglide-workflow.spec.md index 57743d019..7e5043d1b 100644 --- a/skills/lark-slides/references/svglide-workflow.spec.md +++ b/skills/lark-slides/references/svglide-workflow.spec.md @@ -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//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 diff --git a/skills/lark-slides/scripts/artboard_renderer/dist/render.mjs b/skills/lark-slides/scripts/artboard_renderer/dist/render.mjs index b1b8e4e52..e51471a3a 100644 --- a/skills/lark-slides/scripts/artboard_renderer/dist/render.mjs +++ b/skills/lark-slides/scripts/artboard_renderer/dist/render.mjs @@ -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: diff --git a/skills/lark-slides/scripts/artboard_renderer/render.mjs b/skills/lark-slides/scripts/artboard_renderer/render.mjs index f78e8e341..ec63e9223 100644 --- a/skills/lark-slides/scripts/artboard_renderer/render.mjs +++ b/skills/lark-slides/scripts/artboard_renderer/render.mjs @@ -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() diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/failing-receipt.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/failing-receipt.json new file mode 100644 index 000000000..8b5f99b31 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/failing-receipt.json @@ -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." + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/fixture_model_provider.py b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/fixture_model_provider.py new file mode 100644 index 000000000..749e02459 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/fixture_model_provider.py @@ -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()) diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/repair-plan.broad.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/repair-plan.broad.json new file mode 100644 index 000000000..e7d20778e --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/repair-plan.broad.json @@ -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." + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/repair-plan.scoped.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/repair-plan.scoped.json new file mode 100644 index 000000000..a383af726 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/repair-plan.scoped.json @@ -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 文本预算内。" + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/topic.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/topic.json new file mode 100644 index 000000000..67c8357bf --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_model_loop/topic.json @@ -0,0 +1,6 @@ +{ + "prompt": "spacex IPO 分析", + "target_slide_count": 1, + "language": "zh-CN", + "audience": "投资/战略分析读者" +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.node-layout-drift.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.node-layout-drift.json new file mode 100644 index 000000000..3249e3128 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.node-layout-drift.json @@ -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" + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.node-layout-map.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.node-layout-map.json new file mode 100644 index 000000000..597482f10 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.node-layout-map.json @@ -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" + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.semantic-map.json b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.semantic-map.json new file mode 100644 index 000000000..563478a5b --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.semantic-map.json @@ -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} + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.svg b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.svg new file mode 100644 index 000000000..b064a0718 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_artboard/followup_semantic_layout/page-001.svg @@ -0,0 +1,5 @@ + + +
Semantic IR Title
+
+
diff --git a/skills/lark-slides/scripts/svglide_aesthetic_review.py b/skills/lark-slides/scripts/svglide_aesthetic_review.py index d7f98d0cb..ca461ffac 100644 --- a/skills/lark-slides/scripts/svglide_aesthetic_review.py +++ b/skills/lark-slides/scripts/svglide_aesthetic_review.py @@ -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, diff --git a/skills/lark-slides/scripts/svglide_aesthetic_review_test.py b/skills/lark-slides/scripts/svglide_aesthetic_review_test.py index a0fda3ea2..b72a1503d 100644 --- a/skills/lark-slides/scripts/svglide_aesthetic_review_test.py +++ b/skills/lark-slides/scripts/svglide_aesthetic_review_test.py @@ -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: diff --git a/skills/lark-slides/scripts/svglide_artboard_package_check.py b/skills/lark-slides/scripts/svglide_artboard_package_check.py index 0e2ff5369..be5836db0 100644 --- a/skills/lark-slides/scripts/svglide_artboard_package_check.py +++ b/skills/lark-slides/scripts/svglide_artboard_package_check.py @@ -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: diff --git a/skills/lark-slides/scripts/svglide_artboard_package_check_test.py b/skills/lark-slides/scripts/svglide_artboard_package_check_test.py index 623107ad1..03b78f9c0 100644 --- a/skills/lark-slides/scripts/svglide_artboard_package_check_test.py +++ b/skills/lark-slides/scripts/svglide_artboard_package_check_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_artboard_renderer.py b/skills/lark-slides/scripts/svglide_artboard_renderer.py index 60114e148..6eb552e74 100644 --- a/skills/lark-slides/scripts/svglide_artboard_renderer.py +++ b/skills/lark-slides/scripts/svglide_artboard_renderer.py @@ -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' 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' 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' 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' 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' 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'', f'', @@ -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'{escape(title)}', f'', f'', + f'', f'{escape(left_title)}', f'{escape(right_title)}', ] @@ -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'' + f"" f'
{escape(text)}
' "
" ) @@ -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'\n' + + "\n".join(f" {child}" for child in children) + + "\n\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'
{escape(text)}
' + 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"" + 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"" + 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"" + 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"" + 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(" 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), diff --git a/skills/lark-slides/scripts/svglide_artboard_renderer_test.py b/skills/lark-slides/scripts/svglide_artboard_renderer_test.py index de98d9b84..0f418150e 100644 --- a/skills/lark-slides/scripts/svglide_artboard_renderer_test.py +++ b/skills/lark-slides/scripts/svglide_artboard_renderer_test.py @@ -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: diff --git a/skills/lark-slides/scripts/svglide_export_package.py b/skills/lark-slides/scripts/svglide_export_package.py new file mode 100644 index 000000000..23f72492b --- /dev/null +++ b/skills/lark-slides/scripts/svglide_export_package.py @@ -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()) diff --git a/skills/lark-slides/scripts/svglide_export_package_test.py b/skills/lark-slides/scripts/svglide_export_package_test.py new file mode 100644 index 000000000..7ea9ac3ec --- /dev/null +++ b/skills/lark-slides/scripts/svglide_export_package_test.py @@ -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("Demo", 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() diff --git a/skills/lark-slides/scripts/svglide_model_repair_loop.py b/skills/lark-slides/scripts/svglide_model_repair_loop.py new file mode 100644 index 000000000..2e99f50c1 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_model_repair_loop.py @@ -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()) diff --git a/skills/lark-slides/scripts/svglide_model_repair_loop_test.py b/skills/lark-slides/scripts/svglide_model_repair_loop_test.py new file mode 100644 index 000000000..a6983a99a --- /dev/null +++ b/skills/lark-slides/scripts/svglide_model_repair_loop_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_node_layout_drift.py b/skills/lark-slides/scripts/svglide_node_layout_drift.py new file mode 100644 index 000000000..9f2a12310 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_node_layout_drift.py @@ -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 diff --git a/skills/lark-slides/scripts/svglide_project_runner.py b/skills/lark-slides/scripts/svglide_project_runner.py index 39ebf0f93..33857e5f4 100644 --- a/skills/lark-slides/scripts/svglide_project_runner.py +++ b/skills/lark-slides/scripts/svglide_project_runner.py @@ -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": diff --git a/skills/lark-slides/scripts/svglide_project_runner_test.py b/skills/lark-slides/scripts/svglide_project_runner_test.py index 3f1aca285..9adedabe0 100644 --- a/skills/lark-slides/scripts/svglide_project_runner_test.py +++ b/skills/lark-slides/scripts/svglide_project_runner_test.py @@ -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" diff --git a/skills/lark-slides/scripts/svglide_prompt_planner.py b/skills/lark-slides/scripts/svglide_prompt_planner.py index 733d7a613..6442993bf 100644 --- a/skills/lark-slides/scripts/svglide_prompt_planner.py +++ b/skills/lark-slides/scripts/svglide_prompt_planner.py @@ -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, diff --git a/skills/lark-slides/scripts/svglide_prompt_planner_test.py b/skills/lark-slides/scripts/svglide_prompt_planner_test.py index ccb53e43c..40da0e31b 100644 --- a/skills/lark-slides/scripts/svglide_prompt_planner_test.py +++ b/skills/lark-slides/scripts/svglide_prompt_planner_test.py @@ -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"): diff --git a/skills/lark-slides/scripts/svglide_quality_gate.py b/skills/lark-slides/scripts/svglide_quality_gate.py index 596877b2e..b1d120b57 100644 --- a/skills/lark-slides/scripts/svglide_quality_gate.py +++ b/skills/lark-slides/scripts/svglide_quality_gate.py @@ -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 diff --git a/skills/lark-slides/scripts/svglide_quality_gate_test.py b/skills/lark-slides/scripts/svglide_quality_gate_test.py index d326db4ca..af72dd043 100644 --- a/skills/lark-slides/scripts/svglide_quality_gate_test.py +++ b/skills/lark-slides/scripts/svglide_quality_gate_test.py @@ -219,6 +219,14 @@ def attach_passing_artboard_receipt(project: Path) -> None: 'Title', encoding="utf-8", ) + (project / "04-svg/page-001.svg").write_text( + '' + '' + '
Title
' + '
' + '
', + 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("", 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) diff --git a/skills/lark-slides/scripts/svglide_semantic_map_ir.py b/skills/lark-slides/scripts/svglide_semantic_map_ir.py new file mode 100644 index 000000000..9cbe18e88 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_semantic_map_ir.py @@ -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 diff --git a/skills/lark-slides/scripts/svglide_semantic_map_ir_test.py b/skills/lark-slides/scripts/svglide_semantic_map_ir_test.py new file mode 100644 index 000000000..cc6a35e22 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_semantic_map_ir_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_template_fit_check.py b/skills/lark-slides/scripts/svglide_template_fit_check.py index d4387d4ed..03a3b74f0 100644 --- a/skills/lark-slides/scripts/svglide_template_fit_check.py +++ b/skills/lark-slides/scripts/svglide_template_fit_check.py @@ -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: diff --git a/skills/lark-slides/scripts/svglide_template_fit_check_test.py b/skills/lark-slides/scripts/svglide_template_fit_check_test.py index e7471fb90..01e6babf0 100644 --- a/skills/lark-slides/scripts/svglide_template_fit_check_test.py +++ b/skills/lark-slides/scripts/svglide_template_fit_check_test.py @@ -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}, ], ) diff --git a/skills/lark-slides/scripts/svglide_theme_productization.py b/skills/lark-slides/scripts/svglide_theme_productization.py new file mode 100644 index 000000000..f978afd81 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_theme_productization.py @@ -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()) diff --git a/skills/lark-slides/scripts/svglide_theme_productization_test.py b/skills/lark-slides/scripts/svglide_theme_productization_test.py new file mode 100644 index 000000000..0eac65e8b --- /dev/null +++ b/skills/lark-slides/scripts/svglide_theme_productization_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_theme_validate.py b/skills/lark-slides/scripts/svglide_theme_validate.py index feeca4e0a..8eed7b703 100644 --- a/skills/lark-slides/scripts/svglide_theme_validate.py +++ b/skills/lark-slides/scripts/svglide_theme_validate.py @@ -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( { diff --git a/skills/lark-slides/scripts/svglide_theme_validate_test.py b/skills/lark-slides/scripts/svglide_theme_validate_test.py index aabeae5dc..056939144 100644 --- a/skills/lark-slides/scripts/svglide_theme_validate_test.py +++ b/skills/lark-slides/scripts/svglide_theme_validate_test.py @@ -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)