Compare commits

...

14 Commits

Author SHA1 Message Date
songtianyi.theo
94c5eb5b25 feat(svglide): add visual identity distinctness gate 2026-06-20 16:23:31 +08:00
songtianyi.theo
2af5dd41b9 feat(svglide): inject local assets into SVG preview 2026-06-20 00:12:31 +08:00
songtianyi.theo
c98b7719bf feat(svglide): migrate online-first source and asset gates 2026-06-19 19:40:10 +08:00
songtianyi.theo
071ec01247 chore(svglide): checkpoint svg private pipeline 2026-06-19 19:29:44 +08:00
songtianyi.theo
b99975bd5d feat(slides): gate SVGlide preview quality 2026-06-12 14:14:03 +08:00
songtianyi.theo
4aa6c1f56d feat(slides): isolate SVGlide private route knowledge 2026-06-11 21:26:42 +08:00
songtianyi.theo
07e57d7857 支持 SVGlide 图表 spec 标记 2026-06-11 19:51:06 +08:00
songtianyi.theo
566f6cfd47 feat(slides): support SVGlide chart markers 2026-06-11 15:56:24 +08:00
songtianyi.theo
cd0854e931 中文化 SVGlide 视觉规则文档
将 visual recipe 和 aesthetic review 两个执行入口改为中文说明,保留字段名、枚举、命令和 JSON 契约。
2026-06-11 15:23:49 +08:00
songtianyi.theo
be8a67b894 完善 SVGlide 视觉生成与预检门禁
新增 style presets、visual recipe 和 aesthetic review gate,并接入 lark-slides skill 执行链路。

扩展 create-svg 图片处理、协议说明与 svg_preflight 校验,补充对应测试。
2026-06-11 15:23:49 +08:00
songtianyi.theo
f5502416c8 docs: harden svglide workflow guidance 2026-06-11 15:22:23 +08:00
songtianyi.theo
182c541ea6 docs: strengthen svglide generation guidance 2026-06-11 15:22:23 +08:00
songtianyi.theo
d980e54ab8 docs: add svglide deck density planning 2026-06-11 15:22:23 +08:00
songtianyi.theo
85965e41e4 feat: add svglide create-svg shortcut 2026-06-11 15:22:22 +08:00
112 changed files with 22625 additions and 54 deletions

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesCreateSVG,
SlidesMediaUpload,
SlidesReplaceSlide,
}

View File

@@ -124,35 +124,19 @@ var SlidesCreate = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
if revisionID > 0 {
result["revision_id"] = revisionID
}
// Step 2: Add slides if provided
@@ -197,6 +181,9 @@ var SlidesCreate = common.Shortcut{
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
@@ -204,19 +191,7 @@ var SlidesCreate = common.Shortcut{
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
@@ -243,6 +218,44 @@ func buildPresentationXML(title string) string {
)
}
func createEmptyPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": buildPresentationXML(title),
},
},
)
if err != nil {
return "", 0, err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
}
revisionID := 0
if rev := common.GetFloat(data, "revision_id"); rev > 0 {
revisionID = int(rev)
}
return presentationID, revisionID, nil
}
func fillPresentationResult(runtime *common.RuntimeContext, presentationID string, result map[string]interface{}) {
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain. This avoids the prior
// best-effort drive metas/batch_query call, which needed an extra drive scope.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesCreateSVG creates a new Lark Slides presentation from one or more
// SVGlide SVG files by adding each page through the existing XML slide route.
var SlidesCreateSVG = common.Shortcut{
Service: "slides",
Command: "+create-svg",
Description: "Create a Lark Slides presentation from SVG",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{
"slides:presentation:create",
"slides:presentation:write_only",
"docs:document.media:upload",
},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{
Name: "file",
Type: "string_array",
Required: true,
Desc: "SVG file path; repeat for multiple pages",
},
{Name: "assets", Desc: "optional assets.json path mapping SVG @path placeholders to uploaded file tokens"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
return err
}
return validateSVGAssetsPath(runtime, runtime.Str("assets"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
pages, uploadPaths := dryRunRewriteSVGImagePlaceholders(svgs, assets)
dry := common.NewDryRunAPI()
total := 1 + len(uploadPaths) + len(pages)
descSuffix := ""
if len(uploadPaths) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(uploadPaths))
}
dry.Desc(fmt.Sprintf("Create presentation from %d SVG page(s)%s", len(pages), descSuffix)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
})
for i, path := range uploadPaths {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
}
slideStepStart := 2 + len(uploadPaths)
for i, page := range pages {
content, injectErr := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if injectErr != nil {
return common.NewDryRunAPI().Set("error", injectErr.Error())
}
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add SVG page %d", slideStepStart+i, total, i+1)).
Params(map[string]interface{}{"revision_id": -1}).
Body(buildCreateSVGBody(content))
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry.Set("title", title)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
if err != nil {
return err
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return err
}
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID > 0 {
result["revision_id"] = revisionID
}
pages, uploaded, err := rewriteSVGImagePlaceholders(runtime, presentationID, svgs, assets)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
}
if uploaded > 0 {
result["images_uploaded"] = uploaded
}
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, page := range pages {
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
buildCreateSVGBody(content),
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,502 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
const testSVGlidePage1 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="320" height="180"/></svg>`
const testSVGlidePage2 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><p xmlns="http://www.w3.org/1999/xhtml">second</p></foreignObject></svg>`
func TestSlidesCreateSVGMissingFileFlag(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--title", "missing file",
"--as", "user",
})
if err == nil {
t.Fatal("expected missing --file error")
}
if !strings.Contains(err.Error(), "file") {
t.Fatalf("err = %v, want mention of file", err)
}
}
func TestSlidesCreateSVGFileMissing(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "missing.svg",
"--title", "missing svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for missing SVG")
}
if !strings.Contains(err.Error(), "missing.svg") {
t.Fatalf("err = %v, want mention of missing.svg", err)
}
}
func TestSlidesCreateSVGEmptyFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("empty.svg", nil, 0o644); err != nil {
t.Fatalf("write empty.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "empty.svg",
"--title", "empty svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for empty SVG")
}
if !strings.Contains(err.Error(), "empty.svg") || !strings.Contains(err.Error(), "empty") {
t.Fatalf("err = %v, want empty.svg empty-file message", err)
}
}
func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg",
"revision_id": 1,
},
},
})
slideStub1 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
}
slideStub2 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}},
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_svg", "https://x.feishu.cn/slides/pres_svg")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "SVG Deck",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_svg" {
t.Fatalf("xml_presentation_id = %v, want pres_svg", data["xml_presentation_id"])
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
if data["revision_id"] != float64(3) {
t.Fatalf("revision_id = %v, want latest revision 3", data["revision_id"])
}
slideIDs, ok := data["slide_ids"].([]interface{})
if !ok || len(slideIDs) != 2 || slideIDs[0] != "slide_1" || slideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
}
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
}
func TestSlidesCreateSVGChartMarkerPassesThroughSlideContent(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`
if err := os.WriteFile("chart.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write chart.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_chart", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_chart/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_chart", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_chart", "https://x.feishu.cn/slides/pres_chart")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "chart.svg",
"--title", "chart marker",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
if len(body) != 1 {
t.Fatalf("slide create body should only contain slide wrapper, got: %v", body)
}
slide, ok := body["slide"].(map[string]interface{})
if !ok || len(slide) != 1 {
t.Fatalf("slide create body should be {slide:{content}}, got: %v", body)
}
content, ok := slide["content"].(string)
if !ok {
t.Fatalf("slide.content should be a string, got: %v", slide["content"])
}
for _, want := range []string{
`slide:contract-version="svglide-authoring-contract/v1"`,
`<g slide:role="chart" slide:chart-ref="chart-1" x="80" y="96" width="420" height="260">`,
`data-svglide-chart="svglide-chart-inline/v1"`,
`data-format="svglide-chart-spec-v1"`,
`data-encoding="base64url-json"`,
`data-payload-hash="sha256:`,
} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
}
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg_partial",
"revision_id": 1,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_ok", "revision_id": 2}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{
"code": 400,
"msg": "invalid svg",
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "partial svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected slide create failure")
}
errMsg := err.Error()
for _, want := range []string{"pres_svg_partial", "page 2/2", "1 slide(s) added", "slide_ok"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want mention of %q", err, want)
}
}
}
func TestSlidesCreateSVGFailureExtractsSVGlideMarker(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_marker", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_marker/slide",
Body: map[string]interface{}{
"code": 400,
"msg": `SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`,
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "marker",
"--as", "user",
})
if err == nil {
t.Fatal("expected marker failure")
}
errMsg := err.Error()
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject", "Use supported elements"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want marker field %q", err, want)
}
}
}
func TestSlidesCreateSVGAssetsReplaceImageAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_asset", "https://x.feishu.cn/slides/pres_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
if strings.Contains(content, "@./hero.png") || strings.Contains(content, "xlink:href") {
t.Fatalf("content should canonicalize asset placeholder: %s", content)
}
for _, want := range []string{`href="boxcn_asset"`, `<metadata data-svglide-assets="true">`, `<img src="boxcn_asset" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGNestedImageAssetsReplaceAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><g transform="translate(10 20)"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></g></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_nested_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_nested_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_nested_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_nested_asset", "https://x.feishu.cn/slides/pres_nested_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "nested assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{
`href="boxcn_asset"`,
`<metadata data-svglide-assets="true">`,
`<img src="boxcn_asset" />`,
`<g transform="translate(10 20)">`,
} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
for _, notWant := range []string{`xlink:href`, `@./hero.png`} {
if strings.Contains(content, notWant) {
t.Fatalf("content should not contain %s: %s", notWant, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" href="@hero.png" x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("hero.png", []byte("png"), 0o644); err != nil {
t.Fatalf("write hero.png: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_upload", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_uploaded"}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_upload/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_upload", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_upload", "https://x.feishu.cn/slides/pres_upload")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "upload",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["images_uploaded"] != float64(1) {
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{`href="boxcn_uploaded"`, `<img src="boxcn_uploaded" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
}
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
SlidesCreateSVG.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func assertSlideCreateBodyContains(t *testing.T, stub *httpmock.Stub, want string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v\nraw=%s", err, string(stub.CapturedBody))
}
slide, _ := body["slide"].(map[string]interface{})
content, _ := slide["content"].(string)
if !strings.Contains(content, want) {
t.Fatalf("slide content = %s\nwant to contain %s", content, want)
}
}
func registerBatchQueryStub(_ *httpmock.Registry, _, _ string) {
// fillPresentationResult now builds presentation URLs locally, so SVG create
// tests keep this helper as a no-op compatibility shim for older assertions.
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
t.Parallel()
svgs := []string{
`<svg><image slide:role="image" href="@./hero.png"/><a href="@./link.png"/></svg>`,
`<svg><image xlink:href='@./hero.png'/><image href = "@./other.png"/></svg>`,
}
got := extractSVGImagePlaceholderPaths(svgs, map[string]string{"@./other.png": "boxcn_other"})
want := []string{"./hero.png"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestRewriteSVGImagePlaceholdersWithTokens(t *testing.T) {
t.Parallel()
in := `<svg><image slide:role="image" href="@./hero.png"/><image xlink:href='@./logo.png'/><image data-href="@./ignored.png"/><a href="@./link.png">link</a><image href="https://example.com/noop.png"/></svg>`
got, tokens := rewriteSVGImagePlaceholdersWithTokens(in, map[string]string{
"./hero.png": "boxcn_hero",
"./logo.png": "boxcn_logo",
})
for _, want := range []string{`href="boxcn_hero"`, `href="boxcn_logo"`} {
if !strings.Contains(got, want) {
t.Fatalf("rewritten SVG missing %s: %s", want, got)
}
}
if strings.Contains(got, "xlink:href") {
t.Fatalf("rewritten SVG must not retain xlink:href: %s", got)
}
if !strings.Contains(got, `<a href="@./link.png">`) {
t.Fatalf("non-image href should be untouched: %s", got)
}
if !strings.Contains(got, `data-href="@./ignored.png"`) {
t.Fatalf("non-href image attribute should be untouched: %s", got)
}
wantTokens := []string{"boxcn_hero", "boxcn_logo"}
if !reflect.DeepEqual(tokens, wantTokens) {
t.Fatalf("tokens = %v, want %v", tokens, wantTokens)
}
}
func TestInjectSVGTransportAssetMetadata(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b", "boxcn_a"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rootIdx := strings.Index(got, "<svg")
metaIdx := strings.Index(got, `<metadata data-svglide-assets="true">`)
if rootIdx < 0 || metaIdx < rootIdx {
t.Fatalf("metadata should be injected inside root <svg>, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should be deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b missing, got: %s", got)
}
}
func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-assets="true"><img src="boxcn_a" /></metadata><image href="boxcn_a"/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Count(got, `<metadata data-svglide-assets="true">`) != 1 {
t.Fatalf("should keep a single transport metadata block, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should remain deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b should be appended, got: %s", got)
}
}
func TestEnsureSVGlideRootContractVersionInjectsMissingVersion(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
got, err := ensureSVGlideRootContractVersion(in, "page.svg")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("contract version missing after normalization: %s", got)
}
if strings.Index(got, `slide:contract-version`) > strings.Index(got, `><rect`) {
t.Fatalf("contract version should be injected on the root open tag: %s", got)
}
if err := validateSVGlideSVG(got, "page.svg"); err != nil {
t.Fatalf("normalized SVG should pass validation: %v", err)
}
}
func TestEnsureSVGlideRootContractVersionRejectsWrongVersion(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="old"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
_, err := ensureSVGlideRootContractVersion(in, "page.svg")
if err == nil {
t.Fatal("expected wrong contract-version to fail")
}
if !strings.Contains(err.Error(), `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("error = %v, want contract-version guidance", err)
}
}
func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
t.Parallel()
tests := []struct {
name string
svg string
wantErr string
}{
{
name: "supported shape rect",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported image href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported image xlink href before rewrite",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" xlink:href="@./hero.png" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported path commands",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M1e-3 0 L80 0 H120 V40 C120 60 100 80 80 80 Q40 80 20 40 Z" fill="#123456"/></svg>`,
},
{
name: "defs and metadata are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><defs><rect id="r"/></defs><metadata data-svglide-assets="true"><img src="boxcn_img"/></metadata><circle slide:role="shape" cx="50" cy="50" r="20"/></svg>`,
},
{
name: "group container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g fill="#112233" transform="translate(10 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "group container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="shape"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg slide:role="shape" viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "root chart marker with inline payload",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`,
},
{
name: "style and nested defs are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.primary{fill:#123456}</style><g><defs><linearGradient id="g"><stop offset="0%" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient></defs></g><rect slide:role="shape" class="primary" x="0" y="0" width="100" height="60" fill="url(#g)"/></svg>`,
},
{
name: "filter and shadow styles are preserved",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.card{filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2));box-shadow:0 8px 20px rgba(0,0,0,.18)}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><rect slide:role="shape" class="card" x="0" y="0" width="100" height="60" filter="url(#shadow)"/></svg>`,
},
{
name: "foreignObject XHTML subtree is not role-validated",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml"><span>hello</span></div></foreignObject></svg>`,
},
{
name: "foreignObject XHTML br is allowed",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml">hello<br />world</div></foreignObject></svg>`,
},
{
name: "namespaced root is rejected with precise message",
svg: `<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg:svg>`,
wantErr: `root element must be non-namespaced <svg>`,
},
{
name: "root child missing role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
},
{
name: "group child missing role is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
},
{
name: "unsupported text element remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="shape" x="0" y="20">bad</text></svg>`,
wantErr: `<text slide:role="shape"> is not supported by SVGlide`,
},
{
name: "rect shape requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" height="60"/></svg>`,
wantErr: `<rect slide:role="shape"> missing required attribute "width"`,
},
{
name: "path shape requires d",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" fill="#123456"/></svg>`,
wantErr: `<path slide:role="shape"> missing required attribute "d"`,
},
{
name: "rect rejects percent geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="50%" height="60"/></svg>`,
wantErr: `attribute "width" must be a number or px length`,
},
{
name: "rect rejects calc geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="calc(10px)" y="0" width="100" height="60"/></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "container transform rejects percent argument",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g transform="translate(10% 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `transform translate() argument must be a number or px length`,
},
{
name: "path rejects arc command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "A"`,
},
{
name: "path rejects smooth command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 S10 10 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "S"`,
},
{
name: "plain metadata remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
wantErr: `<metadata> must include slide:role="shape" or slide:role="image"`,
},
{
name: "whiteboard role is explicitly rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="whiteboard" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `slide:role="whiteboard" is not supported`,
},
{
name: "legacy whiteboard marker metadata is explicitly rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-whiteboard="svglide-whiteboard-inline/v1">abc</metadata></svg>`,
wantErr: `legacy SVGlide whiteboard marker metadata is not supported`,
},
{
name: "foreignObject shape requires text type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
},
{
name: "image role must be image tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,
wantErr: `<rect slide:role="image"> is not supported`,
},
{
name: "image requires href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must include href`,
},
{
name: "image requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" height="60"/></svg>`,
wantErr: `<image slide:role="image"> missing required attribute "width"`,
},
{
name: "image rejects external href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="https://images.unsplash.com/photo.jpg" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must not use external http(s) or data href`,
},
{
name: "unsupported role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="decor"/></svg>`,
wantErr: `unsupported slide:role="decor"`,
},
{
name: "nested chart marker is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g>` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</g></svg>`,
wantErr: `<g slide:role="chart"> must be a direct child of root <svg>`,
},
{
name: "chart marker requires ref",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" x="0" y="0" width="100" height="60">` + testSVGlideChartMetadata(testSVGlideChartSpecJSON()) + `</g></svg>`,
wantErr: `missing required attribute "slide:chart-ref"`,
},
{
name: "chart marker rejects bad bbox",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" slide:chart-ref="chart-1" x="10%" y="0" width="100" height="60">` + testSVGlideChartMetadata(testSVGlideChartSpecJSON()) + `</g></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "chart marker requires single metadata",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())+testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`,
wantErr: `must contain exactly one metadata child`,
},
{
name: "chart marker rejects duplicate chart refs",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideLineChartSpecJSON())) + `</svg>`,
wantErr: `duplicate slide:chart-ref "chart-1"`,
},
{
name: "chart marker rejects bad base64url",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="svglide-chart-spec-v1" data-encoding="base64url-json" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">bad+payload</metadata>`) + `</svg>`,
wantErr: `payload must be base64url`,
},
{
name: "chart marker rejects old sxsd chart format",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="sxsd-chart-v1" data-encoding="base64url" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">`+base64.RawURLEncoding.EncodeToString([]byte(`<chart />`))+`</metadata>`) + `</svg>`,
wantErr: `data-format="svglide-chart-spec-v1"`,
},
{
name: "chart marker rejects hash mismatch",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadataWithHash(testSVGlideChartSpecJSON(), "sha256:"+strings.Repeat("0", 64))) + `</svg>`,
wantErr: `data-payload-hash does not match`,
},
{
name: "chart marker decoded payload must be json",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart />`)) + `</svg>`,
wantErr: `decoded payload must be valid svglide-chart-spec-v1 JSON`,
},
{
name: "chart marker rejects unsupported chart type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"pie","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":[12]}]}}`)) + `</svg>`,
wantErr: `chartType must be one of bar,line`,
},
{
name: "chart marker rejects values length mismatch",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12]}]}}`)) + `</svg>`,
wantErr: `values length must match data.categories length`,
},
{
name: "chart marker rejects nonnumeric values",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"line","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":["12"]}]}}`)) + `</svg>`,
wantErr: `must be a finite number`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSVGlideSVG(withTestSVGlideContractVersion(tt.svg), "page.svg")
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr)
}
})
}
}
func testSVGlideChartMarker(metadata string) string {
return `<g slide:role="chart" slide:chart-ref="chart-1" x="80" y="96" width="420" height="260">` + metadata + `</g>`
}
func testSVGlideChartSpecJSON() string {
return `{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}`
}
func testSVGlideLineChartSpecJSON() string {
return `{"version":"svglide-chart-spec/v1","chartType":"line","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}`
}
func testSVGlideChartMetadata(chartJSON string) string {
sum := sha256.Sum256([]byte(chartJSON))
return testSVGlideChartMetadataWithHash(chartJSON, fmt.Sprintf("sha256:%x", sum))
}
func testSVGlideChartMetadataWithHash(chartJSON, hash string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(chartJSON))
return fmt.Sprintf(
`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="svglide-chart-spec-v1" data-encoding="base64url-json" data-payload-hash="%s">%s</metadata>`,
hash,
payload,
)
}
func withTestSVGlideContractVersion(svg string) string {
if strings.Contains(svg, `slide:contract-version=`) {
return svg
}
return strings.Replace(svg, `slide:role="slide"`, `slide:role="slide" slide:contract-version="svglide-authoring-contract/v1"`, 1)
}
func TestExtractSVGlideErrorJSON(t *testing.T) {
t.Parallel()
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
got := extractSVGlideErrorJSON(err)
if got["type"] != "svg_validation_error" {
t.Fatalf("type = %v", got["type"])
}
if got["tag_name"] != "foreignObject" {
t.Fatalf("tag_name = %v", got["tag_name"])
}
suffix := formatSVGlideErrorSuffix(err)
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
if !strings.Contains(suffix, want) {
t.Fatalf("suffix = %q, want %q", suffix, want)
}
}
}

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
version: 1.0.2
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML/SVG 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
metadata:
requires:
bins: ["lark-cli"]
@@ -15,27 +15,34 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| AI 生成 SVG 创建 PPT | 先做 SVG route admission命中后按 SVG 私有规则加载专属文档,优先用 runner 走到 quality gate再调用 `slides +create-svg` | `svglide-route-admission.md``svglide-svg-private.rules.json``svg-private-manifest.json``svglide_project_runner.py``slides +create-svg` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 上传或使用图片 | XML 路径先做资产元数据规划;创建时用 `slides +media-upload` 或 XML 图片占位符上传/替换 | `asset-planning.md``slides +media-upload``lark-slides-media-upload.md` |
| 在 slide 中绘制柱/条/折线等 MVP 支持的数据图表 | XML 路径使用原生 `<chart>`SVG 路径必须先通过 route admission | `xml-schema-quick-ref.md``svglide-route-admission.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | XML 路径必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素SVG 路径必须先通过 route admission | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)`svglide-route-admission.md` |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构**
**CRITICAL — route admission走 XML 创建/编辑路径时,只读取 XML schema、XML create/edit/validation 文档。只有当用户显式要求 SVG / SVGlide / `slides +create-svg`、输入 root 为 `<svg slide:role="slide">`,或 plan 声明 SVG route 时,才读取 [svglide-route-admission.md](references/svglide-route-admission.md)。SVG route 激活后,私有文档列表以 [svglide-svg-private.rules.json](references/svglide-svg-private.rules.json) 为准,[svg-private-manifest.json](references/svg-private-manifest.json) 仅作兼容索引XML route 不得读取**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免**
**CRITICAL — SVG route 激活后,生成前 MUST 在 `slide_plan.json` 记录 `loaded_rule_set`、`art_direction`、`quality_gates` 和必要的 `business_claims`。`loaded_rule_set` 必须覆盖 manifest 中的 SVG 设计与验证文档;`art_direction` 必须说明封面、章节/节奏页、结尾页、deck motif 和至少 3 个 SVG-native moments可见业务数字或推导性商业声明必须记录来源或假设**
**CRITICAL — 走 XML 创建/编辑路径时,生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径时,先完成 route admission再读取 SVG 私有协议和创建文档。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 plan再生成 XML 或已准入的 SVG route 产物。XML 路径使用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`SVG route 准入后使用 `.lark-slides/plan/<deck-or-task-id>/02-plan/slide_plan.json`。先创建对应目录XML 规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)SVG 扩展规划只在 route admission 后加载。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。SVG route 的额外验证只在 route admission 后加载。**
**CRITICAL — SVG route 创建前 MUST 先通过 [`scripts/svg_preflight.py`](scripts/svg_preflight.py) 的 plan/source gate再对本地 HTML/SVG preview 运行 [`scripts/svg_preview_lint.py`](scripts/svg_preview_lint.py),并运行 [`scripts/svglide_semantic_review.py`](scripts/svglide_semantic_review.py) 校验中文、页型、章节、内容厚度和 SVG 可见文本来源。preview 中不得展示 safe-area/debug guide文本溢出、大数字窄框、明显重叠、英文 plan、缺页型或 generator 硬编码文本都会阻断 `slides +create-svg`。live create 后仍需 readback gateHTML preview 不能替代服务端转换后的验证。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
@@ -80,7 +87,7 @@ lark-cli auth login --domain slides
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)SVG 创建先读 [`svglide-route-admission.md`](references/svglide-route-admission.md),准入后按 [`svglide-svg-private.rules.json`](references/svglide-svg-private.rules.json) 加载私有文档,旧 [`svg-private-manifest.json`](references/svg-private-manifest.json) 仅用于兼容检查
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
@@ -104,7 +111,7 @@ lark-cli auth login --domain slides
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
可优先考虑这些页面形态:
@@ -128,7 +135,7 @@ lark-cli auth login --domain slides
- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
- 不要把素材缺失表现为空白图片框;XML 路径必须按 `fallback_if_missing` 生成可见的 XML-native 视觉,并在结果中说明
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
@@ -137,6 +144,7 @@ lark-cli auth login --domain slides
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| AI 生成 SVGlide SVG希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
@@ -157,18 +165,18 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 澄清主题、受众、页数、风格;如用户要求 SVG / SVGlide / `slides +create-svg`,先执行 route admission模板需求按“模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- 新建 / 大幅改写必须先创建目录并写入 planXML 路径写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`SVG route 写 `.lark-slides/plan/<deck-or-task-id>/02-plan/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML → 创建
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行SVG route 准入后按私有清单执行,产物是 `.svg` 文件而不是 Slides XML使用同一个 run root 下的 `02-plan/slide_plan.json`
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
@@ -264,6 +272,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+create-svg`](references/lark-slides-create-svg.md) | 从一个或多个 SVGlide SVG 文件创建 PPT`--file` 顺序逐页调用现有 `/slide` 路由 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
@@ -278,13 +287,31 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,XML 路径必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`SVG route 准入后写入 `.lark-slides/plan/<deck-or-task-id>/02-plan/slide_plan.json`模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
8. **图片资产必须可见**XML 路径使用 `<img src="...">` 或本地占位符上传;如果没有可用素材,必须按 `asset-planning.md``fallback_if_missing` 生成可见兜底视觉,不要留下空图片框。**图片最大 20 MB**slides upload API 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +create-svg` | `slides:presentation:create`, `slides:presentation:write_only`, `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `slides +replace-slide` | `slides:presentation:update`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
| `xml_presentation.slide.create` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.delete` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
> **注意**XML 路径如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致以后两者为准SVG route 文档只在 route admission 后加载。
## SVG Route
`slides +create-svg` 只作为命令入口出现在顶层。出现 SVG / SVGlide / `slides +create-svg` 需求时,先读取 [`svglide-route-admission.md`](references/svglide-route-admission.md),命中后再按 [`svglide-svg-private.rules.json`](references/svglide-svg-private.rules.json) 加载 SVG 私有协议、规划、验证和排障文档,旧 [`svg-private-manifest.json`](references/svg-private-manifest.json) 仅作为兼容索引保留。新建或大幅改写 SVG deck 时,优先使用 [`svglide_project_runner.py`](scripts/svglide_project_runner.py) 管理控制面:`init` 建目录,`source` 归一化 `source/evidence.json` 并写入 source receipt`plan` 由 agent 或外部产物阶段完成并写入对应文件,`strategy_review` 先锁定语言、受众、页型、章节、内容厚度和 `visual_identity``confirm_plan` 必须拿到用户确认的 `02-plan/plan-confirmation.json`runner 再负责 `assets -> generate_svg -> prepare -> preview -> preflight -> preview_lint -> aesthetic_review -> chart_verify -> semantic_review -> runtime_review -> visual_distinctness_review -> quality_gate`;本地内容验收使用 `run .lark-slides/plan/<deck-id> --profile preview_only` 默认停在 `quality_gate``assets` 负责资产契约和 token/local-file 审计,`generate_svg` 负责登记或执行源 SVG 生成并写入 deck/page receipts`prepare` 只消费已登记的源 SVG`chart_verify` 只在页面声明 required/exact chart contract 时强制,`semantic_review` 负责 `semantic-review.json``text-inventory.json``runtime_review` 负责 renderer/layout 多样性,`visual_distinctness_review` 负责阻断不同主题复用同一套 palette、cover treatment 和 renderer/layout 序列。live create 前必须有新鲜的 `quality_gate.status=passed``dry-run.json``ppe-proof.json`live create 后必须进入 readback stage。XML route 不得读取 SVG 私有清单中的策略正文。

View File

@@ -0,0 +1,724 @@
# slides +create-svg
从一个或多个 SVGlide SVG 文件创建飞书幻灯片:
> 兼容说明:新建或大幅重生成 SVG deck 时,调用本命令前先使用 `svglide_project_runner.py` 和 `svglide-artifacts.spec.md` 的分阶段产物目录。本页保留为最终 create 步骤的命令级契约。
```bash
lark-cli slides +create-svg \
--as user \
--title "Demo" \
--file page1.svg \
--file page2.svg
```
## 适用场景
- AI 已经能生成符合 [svg-protocol.md](svg-protocol.md) 的 SVGlide SVG。
- 希望按文件逐页创建,避免把大段 XML/SVG 塞进 shell 参数。
- 需要 SVG 内本地图片占位符自动上传并替换为 file token。
- 需要把原生 chart 的 canonical JSON spec 作为 root chart spec marker 透传给服务端。
不适用:
- 你只有普通 SVG且没有 `slide:role` 协议标记。
- 复杂普通 SVG 不能直接提交;需要把实际可渲染元素标成 SVGlide role。`g` / 嵌套 `svg` 容器可以保留,但不能代替子元素 role。
- 你想通过 SVG 路径提交 whiteboard marker`slide:role="whiteboard"` 和旧 `data-svglide-whiteboard` marker 会被 CLI 拒绝。
- 你需要插入到指定页前MVP 只创建新 presentation 并按顺序追加页面。
## Flags
| Flag | 说明 |
|------|------|
| `--title` | presentation 标题,省略时为 `Untitled` |
| `--file` | SVG 文件路径;可重复,页面顺序就是 flag 顺序 |
| `--assets` | 可选 `assets.json`,把 SVG `@path` 映射到已上传 file token |
| `--dry-run` | 展示创建空白 presentation + N 次 `/slide` 调用,不真实创建 |
## 请求链路
CLI 先创建空白 presentation
```http
POST /open-apis/slides_ai/v1/xml_presentations
```
随后对每个 SVG 文件调用现有 slide create 路由:
```http
POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1
```
body
```json
{
"slide": {
"content": "<svg ...>...</svg>"
}
}
```
不会新增 `/svg_slide` 路由,也不会把 `file_meta_map` 当成 CLI 到服务端的契约。
chart spec marker 也不新增 API。CLI 不会上传 chart 资源,也不会调用任何 chart 创建接口;它只把通过 marker 外壳、hash 和 JSON spec 基础校验的 marker 留在同一个 `slide.content` SVG 中。
## 图片处理
SVG 内本地图片写成:
```xml
<image slide:role="image" href="@./hero.png" x="0" y="0" width="320" height="180" />
```
`<image>` 可以位于 `g` / 嵌套 `svg` 容器中CLI 会全局扫描 `<image href="@...">``<image xlink:href="@...">` 并替换为 canonical `href="file_token"`
CLI 会:
1. 上传本地图片到新 presentation。
2.`href="@./hero.png"``xlink:href="@./hero.png"` 替换为 canonical `href="file_token"`
3. 注入 transport metadata`<metadata data-svglide-assets="true"><img src="file_token" /></metadata>`
预上传资源可用 `--assets`
```json
{
"@./hero.png": "boxcn..."
}
```
## Chart Spec Marker
`slides +create-svg` 支持一种最小 chart marker用于透传 canonical JSON chart spec。payload 不是 SXSD `<chart>` XML也不是 chart snapshot/staticData服务端会在 SVGlide parser 内部把 spec 转成 chart 创建所需数据:
```xml
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
slide:contract-version="svglide-authoring-contract/v1"
width="960" height="540" viewBox="0 0 960 540">
<g slide:role="chart"
slide:chart-ref="chart-sales-001"
x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
</svg>
```
Decoded canonical JSON shape:
```json
{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}
```
CLI 校验范围只包括:
- marker 必须是 root `<svg>` 直系 `<g slide:role="chart">`
- `slide:chart-ref``x/y/width/height` bbox 必填bbox 只接受数字或 `px`
- marker 内必须且只能有一个 `<metadata>`
- metadata 必须使用 `data-svglide-chart="svglide-chart-inline/v1"``data-format="svglide-chart-spec-v1"``data-encoding="base64url-json"`
- payload 必须是无 padding base64url`data-payload-hash` 必须匹配 decoded canonical JSON bytes 的 sha256不要对 base64 文本计算 hash。
- decoded payload 必须是 JSON object且包含 `version="svglide-chart-spec/v1"``chartType``data.categories``data.series[].name``data.series[].values`
- MVP 只支持 `chartType="bar"` / `"line"``categories` 和每个 `values` 数组长度必须一致;`values` 只能是有限 JSON number。
`sxsd-chart-v1` / `base64url` 的 SXSD `<chart>` XML payload 不属于 SVGlide chart marker 协议面,会被 CLI 拒绝。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 明确不属于 `+create-svg` 协议面。
## 生成质量规则
这些规则用于生成阶段主动规避服务端降级、近似和泛化错误。几何数值、path 命令、role/必填属性、图片 href 等基础约束已由 CLI 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
### 与现有规划层对齐
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/02-plan/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
在通用 plan 字段基础上SVG deck 还应补充这些 SVG 专属字段:
```json
{
"output_mode": "svglide-svg",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"style_preset": "raw_grid",
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
"style_system": {
"palette": {
"background": "#F5F5F5",
"text": "#0A0A0A",
"accent": "#F2D4CF"
},
"typography": "strong title, readable native text labels",
"background_strategy": "muted grid panels with one stable background family",
"motif": "dense grid panels with restrained accent labels"
},
"svg_constraints": {
"text_element": "foreignObject slide:role=shape slide:shape-type=text",
"path_commands": "M/L/H/V/C/Q/Z only",
"image_href": "@./path or file token only",
"css": "explicit font-size/font-weight/color/line-height/text-align; no font shorthand"
},
"svg_files": [
{"page": 1, "path": ".lark-slides/plan/<deck-id>/04-svg/prepared/page-001.svg"}
],
"preflight": {
"command": "python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan/<deck-id>/02-plan/slide_plan.json --input .lark-slides/plan/<deck-id>/04-svg/prepared/page-001.svg",
"status": "pending"
},
"readback_verification": {
"status": "pending",
"checks": ["page_count", "blank_page", "canvas_bounds", "text_overlap", "asset_tokens", "closing_slide"]
}
}
```
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes不要照搬模板 XML也不要读取完整模板 XML。
SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
```json
{
"page": 3,
"page_type": "content",
"renderer_id": "dashboard_scorecard",
"layout_family": "dashboard",
"visual_recipe": "fake_ui_dashboard",
"visual_intent": "use a product-console dashboard surface to make metrics feel operational",
"visual_focal_point": "central metric card and trend line",
"visual_signature": "fake product console frame + micro chart geometry + status chips",
"svg_effects": ["chart_geometry", "connector_flow", "typography"],
"required_primitives": ["dashboard", "micro_chart"],
"svg_primitives": ["dashboard", "micro_chart", "typography", "geometric_shape"],
"xml_like_risk": "without SVG primitives this page would degrade into three metric cards plus bullets",
"recipe_fallback": "if dashboard micro charts are too dense, keep the fake UI frame and simplify charts to bar-like rects",
"density": "high",
"density_structure": "dashboard with four metric cards, trend line, and source note",
"content_density_contract": "dashboard >= 4 metrics",
"asset_contract": "none_required | {mode: preview|production, retrieval_query, source_type, license, local_path_or_href, usage_page, source_url/generated_by, replacement_required}",
"risk_flags": ["text_overflow", "image_license", "conversion_dasharray"],
"source_status": "source_verified | attachment_missing | user_prompt_only",
"source_policy": "when attachment_missing, show 待从附件补齐 / 来源缺失 and avoid numeric claims",
"layout_guardrails": [
"renderer_id must change actual geometry, not only the name",
"visual_recipe must map to SVGlide-safe primitives present in the SVG source",
"main text and chart labels stay inside safe area",
"dense page uses a structured visual carrier, not a long bullet box",
"avoid XML-like card layout unless the page has real SVG-native visual structure"
]
}
```
### Style Preset Catalog
SVGlide 高质量生成必须先从 [style-presets.json](style-presets.json) 选择一个 deck-level `style_preset`,并把它翻译成 `style_system``style_preset` 不替代 `visual_recipe`:前者定义视觉语言,后者定义页面结构和 SVG-native 表达价值。
生成前还必须读取 [svg-visual-recipes.md](svg-visual-recipes.md)。该文件是当前 CLI 执行链路的短规则入口,负责把研究 catalog 映射成可写入 `slide_plan.json` 的 underscore `visual_recipe` 枚举、安全效果边界和 deck 多样性要求。
生成顺序:
```text
semantic plan
-> visual_recipe
-> style_preset + style_system
-> layout boxes
-> SVG source
-> svg_preflight.py --plan
```
`style_system` 至少包含:
- `palette`: 背景、正文、强调色。
- `typography`: 标题、标签、正文的字号/字重策略。
- `background_strategy`: 全 deck 背景和例外页规则。
- `motif`: 可复用的视觉母题,例如 grid panels、stamp labels、court lanes、riso color plates。
每页必须声明:
- `visual_signature`: 这一页相对普通 XML/PPT 模板的独特 SVG 视觉记忆点。
- `svg_effects`: 真实使用或计划使用的 SVG 表达能力,例如 `path``connector_flow``gradient``texture``chart_geometry``image_overlay`
`svg_preflight.py` 会校验 preset 是否存在、`style_system` 是否完整、可见文本是否泄漏 preset 名称/source token/tool/path以及 declared `svg_effects` 是否能在 SVG source 中命中。
### SVG-native visual recipe catalog
SVG 不是普通矢量图文件的传输外壳。每页都必须选择一个 `visual_recipe`,并在 `svg_primitives` 中声明真实会绘制的 SVGlide-safe primitives。`renderer_id` 负责几何布局命名;`visual_recipe` 负责说明这页为什么值得走 SVG。
本节保留协议内置摘要;实际生成前优先读 [svg-visual-recipes.md](svg-visual-recipes.md),避免把研究文档里的 dotted recipe 名称直接写进运行时 plan。
| `visual_recipe` | 适用页型 | required primitives | forbidden patterns / fallback |
|---|---|---|---|
| `hero_typography` | 封面、章节页、观点页 | `typography`, `geometric_shape` | 不要只写普通标题;大字用 `foreignObject`,描边/裁切感用大字背板、路径轮廓或分层 shape 模拟 |
| `geometric_composition` | 战略框架、阶段划分、版式强分区 | `geometric_shape`, `path` | 不要只堆 3 个矩形卡片;斜切块、多边形全部用 `path` 写,不用 `polygon` |
| `path_flow` | 路线、旅程、流程、增长路径 | `path`, `annotation` | 不依赖 `marker` / `stroke-dasharray`;箭头用显式三角 `path`,虚线用短 line/dot 组合 |
| `infographic_scorecard` | 数据战报、OKR、业务复盘 | `typography`, `micro_chart` | 不要只放大数字;补环形/条形/标尺等微图表,圆环用双层填充圆或 path |
| `icon_capability_map` | 能力地图、模块总览、平台能力 | `icon`, `geometric_shape` | 图标用 SVGlide-safe path/line/rect 组合,不用外链 iconfont 或根级 `<text>` |
| `gradient_depth` | 能力升级、概念页、氛围页 | `gradient`, `geometric_shape` | 渐变只作为层次,不能替代信息结构;关键文字必须有深色承载底 |
| `mask_clip_showcase` | 成果展示、产品/品牌视觉页 | `typography`, `image_overlay` | 不直接依赖 `mask` / `clipPath`;用大字描边、半透明 shape 遮罩、裁切安全区模拟 |
| `technical_texture` | 技术架构封面、工程系统页 | `texture`, `path` | 不用 `<pattern>`;网格、点阵、扫描线用重复 line/circle/rect 显式绘制 |
| `metaphor_loop` | 闭环、反馈系统、运营机制 | `path`, `geometric_shape` | 不只画 4 个圆节点旁边必须补机制表、KPI 标签、输入输出或责任说明 |
| `spotlight_annotation` | 问题定位、架构标注、案例诊断 | `spotlight`, `annotation` | 发光用多层半透明 circle/rect/path 模拟,不依赖复杂 filter标注线和 callout 必须对齐目标 |
| `fake_ui_dashboard` | 产品能力、CLI/平台/监控展示 | `dashboard`, `micro_chart` | 不要把 3 张指标卡伪装成 dashboard必须有 UI frame、状态栏、图表/日志/趋势等操作界面细节 |
| `brand_system` | 系列化 deck、主题页、收尾页 | `typography`, `geometric_shape` | 不只换颜色;必须复用标题位置、边栏、编号、强调色、图标线宽或背景 motif |
`svg_preflight.py` 会校验 `visual_recipe` 枚举、必填字段、recipe required primitives、8 页以上 recipe family 多样性,以及 plan 声明的 primitives 是否能在 SVG source 中检测到。生成器不能只在 plan 里声明 recipe实际仍画 XML 式卡片。
### 生成阶段 Fail-Fast Gate
`slide_plan.json` 不是说明文档,而是生成阶段的硬契约。生成器必须先通过 plan gate再渲染 SVG本地 `svg_preflight.py --plan` 失败时禁止调用 live API。
每页 SVG plan 必填:
| Field | 作用 | 失败后处理 |
|---|---|---|
| `renderer_id` | 标识具体渲染器/几何结构 | 换真实 renderer不用 `two_column_1` 这类假命名 |
| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 |
| `visual_recipe` | 说明这页为什么值得走 SVG | 从 recipe catalog 选择,不能自造枚举 |
| `required_primitives` | 这页必须在 SVG source 中真实出现的 primitive | 至少覆盖 recipe required primitives |
| `svg_primitives` | 实际计划绘制的 primitive | 必须覆盖 `required_primitives` |
| `visual_intent` | SVG 视觉表达目的 | 写清楚 SVG-native 价值,不写空泛风格词 |
| `visual_focal_point` | 页面视觉焦点 | 用于判断布局是否围绕主视觉组织 |
| `xml_like_risk` | 退化成普通 XML 卡片页的风险 | 明确说明不用 SVG 会丢失什么结构 |
| `content_density_contract` | 信息密度硬契约 | 高密度页必须量化,例如 `dashboard >= 4 metrics` |
| `asset_contract` | 图片/素材来源与许可契约 | 无图写 `none_required`Preview 网络图必须记录 `retrieval_query` / `source_url`,授权未确认可写 `license=preview_unverified` 且不阻断;正式交付必须补 source/license/local path 或替换 |
| `risk_flags` | 生成风险显式登记 | 无风险用空数组;不要省略字段 |
| `source_policy` | 缺数据/数字声明处理策略 | 防止自动扩写时编造业务数字 |
deck 级硬门禁:
- 用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时默认 `page_count=10`;不要仅因页数缺失而停下来追问。明确“一页 / 单页 / onepage / one slide / 只要封面”才按 `page_count=1`。默认 10 页必须包含 closing slide并满足 10 页 deck 的 layout / renderer 多样性门禁。
- 8 页以上必须有明确 closing slide。
- 10 页以上至少 5 种 `layout_family`
- 不允许连续 3 页使用同一 `layout_family`
- 8 页以上至少 5 种 `visual_recipe` family。
- 10 页以上至少 5 种真实 `renderer_id`
- 高密度页必须有量化 `content_density_contract`,不能只写“信息丰富”。
量化密度契约建议:
```text
matrix/table >= 6 cells
timeline >= 4 nodes
dashboard >= 4 metrics
flow >= 4 stages
risk_grid >= 4 items
comparison >= 4 rows or columns
```
如果 SVG source 无法满足对应数量,`svg_preflight.py` 会报 `plan_content_density_contract_not_met`,生成器必须补真实结构,不要只改字段名。
### 生成前强约束
以下规则来自实际 SVGlide live 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。
- MUST: 默认使用 Lark Slides 当前回读画布 `960 x 540`,即 root 写成 `width="960" height="540" viewBox="0 0 960 540"`。不要默认用 `1280 x 720`,否则服务端回读后可能整页偏大并裁切。
- MUST: 主体元素使用安全区,建议 `safe = x:48 y:40 w:864 h:460`。除全屏背景外,文本、卡片、图表、标签、节点和图例都必须落在安全区内。
- MUST: 多页 deck 应包含明确的 closing slide。8 页以上讲解/汇报型 deck 不要把 roadmap / next-playbook 当作结束页;最后一页应包含 `closing``summary``Q&A``Thanks` 或下一步联系信息。
- MUST: `slides[]` 必须记录 `renderer_id`,且它要对应真实几何结构,而不是 `two-column-1` / `two-column-2` 这种名字变化。10 页以上 deck 至少 5 种 renderer/layout family不得连续 3 页使用同一 renderer。
- MUST: `slides[]` 必须记录 `layout_family``visual_recipe``visual_intent``visual_focal_point``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy``asset_contract` 应尽量记录MVP 阶段缺失只 warning。没有 SVG-native recipe 的页面不应走 `slides +create-svg`,应改用普通 Slides XML 或重新选择 SVG recipe。
- MUST: `visual_recipe` 必须来自 catalog`svg_primitives` 必须覆盖该 recipe 的 required primitives。`renderer_id` 不能替代 `visual_recipe`
- MUST: 8 页以上 SVG deck 至少使用 5 种 visual recipe family不能整套 deck 都是卡片、双栏或普通 dashboard。
- MUST: 高密度页必须声明 `density_structure` 和量化 `content_density_contract`,例如 `matrix/table >= 6 cells``timeline >= 4 nodes``dashboard >= 4 metrics``flow >= 4 stages``risk_grid >= 4 items`。只有“大标题 + 大图 + 2-3 个短 chip”不算高密度。
- MUST: 来源不足、附件缺失、用户未提供数据时,必须在 plan 中写 `source_status``source_policy`,并在页面上显式表达“待从附件补齐 / 来源缺失 / no numeric claims”。不要编造客户、排名、真实论文数据、金额、占比、链接、logo 或引用。
- MUST: `foreignObject` 文本样式使用显式 CSS`font-size``font-weight``font-family``color``line-height``text-align`。不要用 `font:` shorthand 表达关键字号和加粗。
- MUST: 白色或接近白色的文字必须完整落在深色 shape 承载底上。标题、封面副标题、CTA、页脚等不能跨出深色底压到浅色图片、白色蒙层或白底上需要时扩大色块、加深色背板/遮罩,或改用深色文字。
- MUST: 圆形/椭圆节点只承载短标签,不承载解释句。节点内 `foreignObject` bbox 必须小于节点 bbox微解释、指标、下一步和注释放到独立说明卡、图例、机制表或外侧 callout。
- MUST: 提交前和 live 回读后都检查边界和重叠:非背景元素不得越过 `960 x 540`,第 2/3 页等信息密集页必须额外检查 text bbox overlap。
- SHOULD: 如果本地预览使用更大画布,例如 `1280 x 720`,必须在输出给 `slides +create-svg` 前按比例换算为 `960 x 540`,而不是只改 root viewBox。
### 生成器实现约束与 Preflight
生成器必须先把高概率错误拦在本地,再调用 `lark-cli`。不要依赖 live 创建后的人工修补来发现基础问题。
实现约束:
- MUST: SVG 生成 helper 的返回类型保持一致。推荐统一返回 `string`,或统一返回 `string[]` 后在页面末尾 `flat().filter(Boolean).join("\n")`;不要混用 `...items.map(...).join("\n")`,这会把已拼好的 SVG 标签按字符展开,生成非法 XML。
- MUST: 所有组件都从稳定布局盒推导坐标,避免散点手调。文本、标签、图例、曲线端点和卡片内容应有明确的父盒和对齐规则。
- MUST: 生成脚本要先写 deck plan / asset list再写页面不能边补坐标边生成最终 SVG。
- MUST: 生成器要把 preflight 规则前移为本地 assert。写 SVG 前先由实际组件 manifest 反推出 `svg_primitives`,再检查 `visual_recipe` required primitives、`required_primitives``content_density_contract` 数量、主体 safe area、文本 bbox 和最小文本框高度;断言失败时修组件或布局,不要只改 `slide_plan.json` 字段。
- MUST: 高密度结构要由组件实际数量驱动,例如 `scorecard >= 4 metrics` 必须生成 4 个能被识别为 metric/bar/card 的元素;`timeline >= 4 nodes` 必须生成 4 个真实节点和标签;不要用文字描述冒充结构。
- MUST: 文本组件要按字号、行高和预估行数计算最小 `foreignObject` 高度。卡片、节点、脚注、图例的正文框不得出现 0、高度个位数或明显低于一行文字的 bbox。
- MUST: 主体文本、卡片、图表、标签、节点和图例必须落在 safe area全画布背景、边缘承载底、图片遮罩和装饰边框可以超出 safe area但应只承担背景/承载作用,不承载关键文本。
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
- SHOULD: 把每页的 `safe``titleBox``visualBox``textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。
推荐生成顺序:
```text
deck/page plan
-> layout boxes
-> components with emitted primitive manifest
-> generator asserts: recipe/primitives/density/text/safe-area
-> write SVG + slide_plan.json from the same manifest
-> svg_preflight.py --plan ...
-> dry-run / live create / readback
```
### 本地 HTML 预览(建议)
HTML 预览是生成阶段的轻量质检,不是 SVGlide 协议或 CLI API 的硬依赖。
- SHOULD: 生成 SVGlide deck 后、调用 `slides +create-svg` 前,生成本地 `05-preview/preview.html`,把每页 SVG 按 16:9 画布嵌入,并展示页码、标题、`renderer_id` / `visual_recipe`、图片资产状态、preview-only 图片来源和明显 warning。
- SHOULD: 如果当前 agent、IDE 或浏览器工具支持打开本地文件,打开 `05-preview/preview.html` 进行人工或截图式预览,优先检查:
- 页面是否空白、明显裁切或整体偏大。
- 标题、正文、图片和装饰元素是否重叠。
- 白色/浅色文字是否压到浅色背景或图片亮部。
- 相邻页面是否版式过度重复。
- 信息密度是否明显不足,尤其是高密度页是否真的有 matrix/table/timeline/dashboard/flow/risk grid。
- 结尾页是否存在。
- 图片是否显示,是否有破图、空图片框、图片过少或 preview-only 来源未记录。
- SHOULD: 在最终产物目录记录 `05-preview/preview.html` 路径;如果未生成或无法打开,说明原因,并继续执行 preflight / dry-run / readback。
- MUST NOT: 用 HTML 预览替代 `svg_preflight.py``slides +create-svg --dry-run` 或 live readback。HTML 预览主要提前发现审美、布局和素材问题服务端转换后的字体、path bbox、图片 token 和部分 SVG 效果仍必须通过 readback 验证。
打开预览后必须按 [svg-aesthetic-review.md](svg-aesthetic-review.md) 做一次人工或截图式审查。重点看所有页面的标题区、装饰线、badge、文本框、图片框、safe area、重复版式和 SVG 视觉优势;如果多页出现同类问题,修生成规则后重新生成,不要只逐页微调坐标。
本地 preflight 必须在 `slides +create-svg` 前执行,失败即停:
- `python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan/<deck-id>/02-plan/slide_plan.json --input .lark-slides/plan/<deck-id>/04-svg/prepared/page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
- root 是 `width="960" height="540" viewBox="0 0 960 540"`
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
- plan 中每页 `layout_family``visual_recipe``visual_intent``visual_focal_point``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy` 完整,且 recipe required primitives 能在对应 SVG source 中命中。`asset_contract` 在 MVP 阶段缺失只 warning有条件时仍应补全。
- 禁止 SVG 退化成 XML-like 卡片页:如果页面基本只有 `rect + foreignObject`,且没有 path、gradient、image overlay、annotation、micro chart、icon、texture、spotlight、flow 等 SVG-native primitivepreflight 必须失败。
- 禁止零尺寸元素;文本框、图片、卡片和圆/椭圆必须有正向宽高,不能生成 `height="0"` 的隐藏说明。
- `<image opacity="...">` 或图片 style 里写 `opacity:` 在 MVP 阶段只 warning当前转换链路不会稳定保留到 readback `<img>`。需要淡化图片时,优先把透明度预合成进 PNG/JPG或在图片上方加半透明 `rect` 遮罩。
- 禁止白色/浅色文字跨出深色承载底;如果 preflight 报 `light_text_without_dark_backing`,优先扩大深色背景或加文本背板,不要只缩小字号。
- 禁止把解释文字塞进圆形/椭圆节点;如果 preflight 报 `node_text_overflow`,节点内只保留短标签,把说明迁移到旁边卡片、表格或图例。
- 警惕 `circle` / `ellipse``stroke-width`;当前转换链路可能只保留 border color 而丢失 width。关键圆环、节点外圈和粗描边用双层填充圆/椭圆模拟,或改成 path/rect。
- 禁止关键路线、闭环、流程连接、timeline rail 使用 `stroke-dasharray`;普通装饰虚线也会 warning。关键路线必须用显式短线段或小圆点 markers 组成,不要把虚线作为唯一视觉表达。
- 禁止 `font:` shorthand 和空图片框。MVP 阶段 http(s) / data URL 图片、未下载远程图片只 warning正式交付和可见性要求高的 deck 仍应下载到本地并走 `@./path` 上传或使用 file token。
- 禁止 unsupported path command`path d` 只含 `M/L/H/V/C/Q/Z`
- 非背景元素不得越界;主体元素应在 safe area 内。
- 文本框做 bbox overlap 近似检查,尤其是目录、痛点、竞品表、案例图表和总结页。
- 图片资产文件存在、大小合理,或 http(s)/data URL 能在 preview 中显示。Preview 阶段来源/授权不完整只 warning但必须用 `asset_contract.license=preview_unverified``risk_flags=["image_preview_only"]` 显式标记;正式交付再补齐来源/授权或替换。
- deck plan 通过 renderer 多样性、layout family 多样性、closing slide、高密度结构、资产契约、来源保护六类校验。
创建顺序:
```text
generate deck plan -> user confirms plan -> assets -> generate_svg
-> prepare -> 05-preview/preview.html and browser preview when supported
-> local preflight with --plan -> preview lint -> aesthetic review -> quality gate
-> lark-cli slides +create-svg --dry-run
-> live create -> xml_presentations get readback
-> readback bbox / text overlap / closing slide checks
```
readback 不能省略。服务端会把 SVGlide 转成 Slides XML文字 bbox、path bounds 和图片 token 可能和本地 SVG 预估不同;本地 preflight 负责拦住确定错误readback 负责发现转换后的版式漂移。
### Deck 级密度规划
生成多页 SVG deck 前,先写 deck-level plan。页面类型只定义叙事职责密度由 `deck_type`、受众、页面目的和节奏共同决定,不要把某个 page type 永久绑定为固定密度。
最小 plan schema
```json
{
"deck_type": "explain | decision | product | brand | technical | education | report",
"audience": "who will read it",
"goal": "what the deck should make the audience understand or decide",
"density_strategy": "how low/medium/high density pages are distributed",
"asset_strategy": "which query/topic-related web images should be searched and fetched, where they will be used, preview source/url/license risk, and production replacement plan if needed",
"visual_rhythm": "how layout, imagery, charts, and text density vary across pages",
"slides": [
{
"page": 1,
"page_type": "cover",
"density": "low",
"density_mode": "visual-dense",
"takeaway": "one sentence the audience should remember",
"evidence": [],
"visual_structure": "full-bleed image with title overlay",
"layout_guardrails": ["large hero title", "no dense body copy"]
}
]
}
```
常用 `page_type`
```text
cover, opener, agenda, section-divider, context, problem, opportunity,
executive-summary, content, data, comparison, process, case-study, demo,
architecture, system, roadmap, timeline, decision, recommendation,
risk, tradeoff, summary, closing, q-and-a, appendix
```
密度规则:
- MUST: 每页都要有明确 `takeaway`,即使是封面、分隔页和结束页。
- MUST: 每个 SVG deck 默认都要包含真实图片资产,不要全程只用矢量 shape 冒充“配图”。Preview 阶段应优先根据用户 query、deck 标题和页面主题去网络检索并拉取强相关图片,再补充产品截图、网页截图、场景图、材质纹理、图鉴图和 AI 生成图增强视觉冲击;展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。
- MUST: 高密度页必须有承载信息的视觉结构,例如矩阵、流程、地图、时间线、标注图、案例卡或手绘微图表,不能只有装饰图形。
- MUST: 生成器必须先扩写页面“结构信息”,再绘制 SVG。信息密度不足时优先补结构化解释层例如编号标签、微解释、比较维度、轴线、图例、阶段、来源状态、下一步而不是把同一句话换写成多个 chip。
- MUST: 流程页、闭环页、机制页和产品体系页不能只有“4 个圆节点 + 短标签”。至少补 1 层结构化信息例如机制表、KPI 标签、触发条件、责任/频率、输入输出、风险提示或下一步动作。
- SHOULD: 高密度内容页通常包含 3-6 个信息块和若干可读细节,但 executive brief、品牌页、产品视觉页、短汇报可以降低数量只保留强结论、关键证据和视觉锚点。
- SHOULD NOT: 不要让所有高密度页长成同一种“主结论 + 3-6 卡片 + 3 个 callout”模板。
- MUST NOT: 缺少素材或数据时不要编造数字、客户名、logo、排名、引用或真实案例用 qualitative label、relative scale、hypothesis/assumption 标注兜底。
### 结构示例
8-10 页讲解型 deck 可参考这个节奏,但不要把它当成唯一模板;如果 deck 已经包含 roadmap / playbook仍建议再补一页 closing
```text
cover -> opener/context -> agenda/map -> content -> data/comparison
-> process/system breakdown -> case-study/demo -> content/implications
-> summary -> closing
```
5 页决策汇报优先前置结论:
```text
cover -> executive-summary -> options/comparison -> recommendation/risk -> next steps
```
6 页产品/品牌 deck 可以强化视觉叙事:
```text
cover -> value proposition -> user scenario -> feature map/demo
-> proof/roadmap -> closing
```
边界处理:
- 3-5 页短 deck 可以省略 agenda把 summary 并入 closing。
- 15 页以上长 deck 应增加 section-divider 或 recap避免连续高密度阅读疲劳。
- 技术方案要混合 architecture、process、tradeoff、risk不要连续堆文字。
- 教学讲解要前置 context / concept map逐步增加密度。
- 素材不足时用抽象视觉系统、定性矩阵、annotated wireframe、scenario card 兜底,并标明假设。
### 先定义布局盒
不要直接手写散点坐标。每页先定义稳定布局盒,再把文字、图形、图例和图片放进盒内:
```text
page = 960 x 540
safe = x:48 y:40 w:864 h:460
titleBox = x:54 y:52 w:600 h:96
visualBox = x:516 y:176 w:350 h:260
notesGrid = x:54 y:430 w:760 h:48
```
生成后检查:
- 关键元素必须在 safe area 内。
- 同组元素使用同一个父盒推导坐标。
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `x + 10``y + 10` 维持观感。
- 不要把 1280x720 的坐标直接提交给 `slides +create-svg`。当前服务端回读画布通常是 960x540错误坐标系会表现为每页偏大、右侧卡片裁切、底部标签越界。
### 文本安全余量
`foreignObject` 文本优先使用显式 CSS。为了服务端转换后保留样式字号、加粗、颜色、行距和对齐必须写成独立属性不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上:
```xml
<foreignObject slide:role="shape" slide:shape-type="text" x="54" y="62" width="600" height="42">
<div xmlns="http://www.w3.org/1999/xhtml"
style="margin:0;padding:0;font-size:30px;font-weight:900;font-family:Arial,'Source Han Sans SC';color:#111827;line-height:1.12;text-align:left;letter-spacing:0;">
关键结论:增长来自三件事
</div>
</foreignObject>
```
中文和混排字体要留安全高度:
- subtitle 不小于 64px。
- note / chip 单行文本盒不小于 20px。
- 小型标签文本盒不小于 14px。
- 多行文字要按行高预估高度,再额外留 8-12px。
- 右侧图例或矩阵格里的文字不得贴边,水平 padding 至少 10-14px。
- 白色/浅色文字的 bbox 必须完全落在深色 rect/card/overlay 内;封面标题如果跨出色块,应优先扩大色块或改成深色字,不要让白字压在浅色图片或白色蒙层上。
- 圆形/椭圆节点内只放短标签,解释文字移动到节点外的 callout、legend 或机制表;不要让圆内文本框宽度超过圆形直径。
- 服务端支持 `foreignObject` 内的 `<br />`。为了本地预览和标题排版稳定,标题/大段文本优先使用多个块级 `div``p` 控制行高,不要只靠 `<br />` 调整复杂布局。
- 如果需要垂直居中,优先通过更准确的文本框高度、段落行高和 y 坐标解决;布局 wrapper 可以使用,但实际文字节点仍要带显式 `font-size` / `font-weight` / `color`
### 几何与 path 安全线
leaf 几何属性必须写数字或 `px`,不要生成百分比、`em/rem``calc(...)`
```xml
<rect slide:role="shape" x="80" y="96" width="420px" height="240px" />
```
`path d` 只生成 `M/L/H/V/C/Q/Z` 命令。不要生成 `A``S``T` 等命令;需要圆角或弧线时,用 `C` / `Q` 近似,或改用 `circle` / `ellipse` / `rect`
Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`,先在布局盒里换算成绝对坐标。
### 版式节奏
同一 deck 不能连续复用同一种“暗色网格 + 左文案 + 右卡片 + 底部 chips”。10 页左右的讲解型 deck 至少混用这些结构:
- 封面 / 全幅图片背景页。
- 目录矩阵页或行业地图页。
- 左文右图 / 左图右文双栏页。
- 全幅路线图或时间线页。
- 2x2 / 2x4 总结矩阵页。
- 数据仪表页、流程页、对比页或案例页。
相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。
### 图片使用与 Preview Image Mode
默认必须规划和使用图片资产。Preview 阶段的目标是验证 SVGlide 的视觉表达上限,版权/授权不作为阻断条件;不要因为 license 未确认就退回纯矢量或低信息卡片页。推荐先从用户 query、deck 标题、章节标题和页面 takeaway 生成 2-5 个图片检索词,去网络检索并拉取主题强相关图片;再补充网页截图、产品截图、图库图、新闻/历史/艺术/科普图片、材质纹理或 AI 生成图做占位视觉。必须在 plan / README 里记录 `retrieval_query`、来源 URL或标记 `license=preview_unverified`,并避免明显不适当素材、敏感肖像和会造成商业背书误导的 logo/商标。正式交付时,再统一替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。
最稳流程仍然是先下载或生成到本地,再写成本地占位符:
```xml
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
```
推荐的网络拉图流程:
1. 从用户 query、deck title、page takeaway、章节标题中提取 `retrieval_query`,优先使用具体名词、场景、人物、作品、产品、地点、历史事件或学科对象,避免只搜抽象词。
2. 对封面、章节过渡页、案例页、教学解释页和产品/品牌页优先执行网络图片搜索或网页截图获取,选择和主题直接相关的真实图片,不用无关风景图凑数。
3. 能下载时先保存到 `assets/` 并用 `@./assets/...` 引用;来不及下载时可以先保留 http(s) URL 进入 preview但 live/readback 后必须确认可见。
4. 每张图在 `asset_contract` 记录 `retrieval_query``source_type``source_url``retrieved_at``license=preview_unverified``usage_page``replacement_required=true`
5. 网络不可用或无法找到强相关图片时,才退回 AI 生成图、程序化纹理或纯 SVG 视觉,并在 `risk_flags``network_image_fetch_unavailable`
图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图、网页/应用界面截图、人物/场景图、图鉴封面、历史/艺术/科学素材或产品细节局部。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。
图片数量与用法建议:
- MUST: 在 `asset_strategy` 或产物 README 中记录图片检索词、图片来源、授权/许可类型、下载 URL 或生成方式Preview 阶段无法确认授权时写 `license=preview_unverified``replacement_required=true`preflight 不阻断,最终交付应替换为可授权资产。
- MUST: 5 页以上 deck 至少使用 2 张真实图片8 页以上 deck 至少使用 4 张;宣传/产品/品牌/案例/教学型 deck 至少使用 5 张或至少 40% 页面含图片。
- MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。
- MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。
- MUST: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景、标注型截图、图鉴式小图和局部裁切特写,避免所有图片都只是小卡片背景。
- SHOULD: 对教育、历史、艺术、医学、产品讲解等主题,优先用图片建立具象认知:人物、器物、场景、局部特写、对比图、流程截图、资料封面或时间背景图。
- MUST NOT: 保留空图片框或破图。Preview/MVP 阶段允许 http(s) 外链或 data URL 先进入 preflight warning但 live/readback 后必须确认可见;正式交付应替换为本地 `@./path` 或 file token。
Preview 阶段优先使用这些来源来快速获得丰富视觉;正式交付时再逐图确认授权、署名和替换计划:
| Source | 适合用途 | Preview 规则 |
|--------|----------|------|
| Web image search / topic query | 和用户 query、页面主题、作品/人物/地点/产品直接相关的真实图片 | 优先使用;记录 `retrieval_query`、图片页 URL 和 `preview_unverified`,正式交付再确认或替换 |
| Unsplash / Pexels / Pixabay | 高质量摄影、封面背景、场景图 | 结合主题 query 检索;记录图片页 URLlicense 可先写 `preview_unverified`,正式交付再确认 |
| Openverse / Wikimedia Commons | 百科、历史、技术、公共领域素材 | 记录单图 URL 和作者/页面preview 可先用,正式交付补 license / attribution |
| The Met / Smithsonian / NASA Open Access | 艺术、科学、历史、航天视觉 | 记录条目 URLpreview 可先用,正式交付确认 Open Access / 第三方权利 |
| 官网 / 产品页 / 新闻图 / 搜索图 | 产品截图、竞品页、事件背景、真实语境 | Preview 可作为视觉占位;必须标记 `license=preview_unverified`,正式交付替换或删去 |
| AI 生成图 / 程序化纹理 | 抽象背景、材质、概念图 | 记录生成方式和提示词摘要;正式交付确认模型/平台授权 |
素材清单建议字段:
```json
{
"local_path": "./assets/hero.jpg",
"source": "Unsplash",
"retrieval_query": "Beethoven Symphony No. 5 concert hall orchestra",
"source_url": "https://...",
"retrieved_at": "2026-06-08",
"license": "preview_unverified",
"commercial_use": "unknown_in_preview",
"replacement_required": true,
"attribution_required": false,
"usage_page": 1,
"notes": "Preview-only visual placeholder; replace or verify license before production delivery"
}
```
### 信息密度与图鉴感
短 note 不要占一个很宽胶囊。优先写成“编号/标签 + 主句 + 微解释/数值”:
```text
03 GRID ENERGY 86% | storage demand peaks before grid balancing
```
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;如果需要原生 bar/line chart使用 root chart spec marker不要把外部图表截图当成唯一方案。
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
视觉区要补足可读细节,避免只有装饰符号:
- 局部标注、刻度、坐标轴、图例。
- 行业标签、材料纹理、指标卡。
- 路线节点、连接线、层级分区。
- 流程/闭环图旁边补机制表或说明卡,例如“触发条件 / 运营动作 / 衡量指标”,不要把说明句塞进圆形节点内部。
- 小型表格、雷达/柱状/散点等微图表。
### 转换稳定性经验
这些规则来自 live 创建后对比 source SVG 与 readback XML 的结果,属于生成侧必须规避的转换差异:
- `image opacity` 不稳定:本地 SVG 里的 `<image opacity="0.18">` / `<image opacity="0.22">` 可能会在 readback `<img>` 中丢失透明度。MVP preflight 只 warning生成器仍应把淡化效果烘焙进图片本身或使用半透明 shape 遮罩。
- shape opacity 稳定:`rect``circle``path` 等 shape 的 `opacity` 会转换为 XML `alpha`,可用于蒙层、暗角和装饰层。
- circle / ellipse stroke width 不稳定:圆形/椭圆描边可能只保留颜色、不保留宽度。关键外圈使用“外层有色圆 + 内层背景圆”的双 shape ring或用 path 绘制;不要用单个 stroked circle 承载关键视觉。
- dashed stroke 不稳定:`stroke-dasharray` 可能降级,尤其是自定义 path 的虚线闭环。关键路线用短 line segment 或 filled dot markers 手工排布;普通装饰虚线也要经 readback 复核。
- path 会转换为 `type="custom"` 并做 bbox 内坐标归一化,这是预期行为;只要 readback bbox 和视觉位置正确,不算差异。
- 字体会被转换为服务端支持字体,例如 `Noto Sans` / `思源黑体`,因此生成阶段要给 `foreignObject` 留足高度,不要按浏览器本地字体做极限排版。
### 生成后检查
生成脚本或人工复核必须检查:
- 是否已执行本地 preflight且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
- live 创建后是否已用 `xml_presentations get` 读回,重新检查画布、页数、越界、文本重叠和 closing slide。
- root / leaf role 是否完整。
- 每个 leaf 是否有 [svg-protocol.md](svg-protocol.md) 中列出的几何必填属性。
- 几何属性和 transform 参数是否只使用数字或 `px`
- `path d` 是否只包含 `M/L/H/V/C/Q/Z`
- 文本是否截断、重叠或贴边。
- 内容是否在 safe area 内,关键图例和外框是否对齐。
- 相邻页面是否明显换版式。
- 每页是否有明确 takeaway高密度页的视觉结构是否承载信息而不只是装饰。
- 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。
- 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
- 图片是否足够丰富并可见;如果 Preview/MVP 阶段暂时保留 http(s) / data URL 或 `preview_unverified` 来源,要记录 warning、确认 live/readback 可见,并在正式交付前列出替换项。
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/08-readback/readback-check.json`,并在最终回复中简述:
```text
验证记录:
- PreflightN/N SVG 通过 root/role/geometry/path/image/bbox 检查。
- Dry-run已确认 create presentation + N 次 /slide。
- Readback实际页数 N / 预期 N未发现空白页、破图或缺失 closing slide。
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
- 资产Preview 阶段优先丰富图片和 readback 可见性;若保留 http(s)/data URL 或 `preview_unverified` 来源,必须记录 warning。正式交付再替换为本地 @path 自动上传或 file token并补齐授权。
```
## 错误处理
任一页失败时,错误会包含:
- `xml_presentation_id`
- 失败页序号
- 已成功页数
- 已创建的 `slide_ids`
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` markerCLI 会提取并在错误中展示 `svglide_error`,用于定位 `type``page_index``tag_name``element_id``role``hint`
失败后不要假设没有创建任何资源。先把恢复状态写回 plan 的 `recovery` 字段:
```json
{
"xml_presentation_id": "slides...",
"failed_page": 3,
"failed_svg_file": ".lark-slides/plan/<deck-id>/04-svg/prepared/page-003.svg",
"successful_slide_ids": ["abc", "def"],
"svglide_error": {"type": "svg_validation_error", "tag_name": "foreignObject"},
"next_action": "fix source SVG and rerun preflight before retry"
}
```
恢复顺序:
1. 本地 preflight 已失败:修对应 SVG 文件,不要调用 live API。
2. live 添加页失败且带 `svglide_error`:按 `type` / `tag_name` / `hint` 收敛 SVG 子集,例如降级复杂 filter、path、CSS 或文本结构。
3. plain XML 在同一路由成功但 SVG 失败:优先确认目标 server lane 是否部署了 SVGlide parser不要盲目重写整套 deck。
4. SVG 通过本地 preflight 且失败在第 1 页,服务端只返回 generic `nodeServer invalid param`:优先检查 `lark-cli` 环境、代理和 PPE/BOE lane 是否命中目标 slide server。不要先把已通过协议校验的 deck 改回低质量 SVG。
5. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。
### 编辑已创建的 SVG deck
SVG deck 后续编辑走双轨,不承诺 source SVG id 能稳定映射到 readback XML block id
| 修改类型 | 推荐路径 | 说明 |
|----------|----------|------|
| 小改标题、文本、图片或坐标 | `xml_presentation.slide.get` 读回 XML -> 找当前 block_id -> `slides +replace-slide` | 使用转换后的 XML 做块级编辑,页序和 slide_id 不变 |
| 大幅换版式、重画图表、调整视觉系统 | 修改 source SVG -> 重新 preflight -> 重新创建或替换目标页 | 保持 SVG 的视觉表达优势,避免在转换后 XML 上手搓复杂 SVG 结构 |
| 无法定位 block_id 或映射不可信 | 回 source SVG 修改 | 不生成 `edit-map.json`,除非服务端或转换结果能证明 source id 可稳定保留 |
小改前必须重新 `slide.get` 拿最新 block id 和 revision大改后必须更新同一个 `.lark-slides/plan/<deck-or-task-id>/02-plan/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。

View File

@@ -6,7 +6,7 @@
## Required Flow
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
1. 理解用户需求,必要时澄清主题、受众、页数、风格。SVG / SVGlide 请求先走 `svglide-route-admission.md`,命中后再加载 SVG 私有规划扩展。
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
@@ -122,6 +122,8 @@ Each slide must include:
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
SVG route planning extensions are loaded only after route admission. XML route plans must not include SVG-only fields.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:

View File

@@ -0,0 +1,33 @@
{
"profile": "safe-native-v1",
"root_contract": "svglide-authoring-contract/v1",
"supported": [
"rect",
"circle",
"ellipse",
"line",
"path:M/L/H/V/C/Q/Z",
"foreignObject:text",
"image:file_token"
],
"requires_fallback": [
"filter",
"mask",
"clipPath",
"pattern",
"symbol",
"use",
"gradient:complex",
"small_font:<12px",
"complex_transform",
"path:arc"
],
"reject": [
"script",
"foreignObject:html_complex",
"table",
"javascript_url",
"remote_file_reference",
"non_identity_viewBox"
]
}

View File

@@ -0,0 +1,542 @@
{
"version": "2026-06-10",
"source": "beautiful-feishu-whiteboard",
"canvas": "960x540",
"selection_rule": [
"Choose intensity first: Restrained for quiet/formal decks, Balanced for most business or training decks, Bold for poster-like or high-energy decks.",
"Use preset style tokens to shape palette, panel treatment, connector density, typography scale, and texture.",
"Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content."
],
"groups": {
"Restrained": {"expected_count": 9, "use_when": "Serious, quiet, editorial, institutional, or text-first decks."},
"Balanced": {"expected_count": 15, "use_when": "General business, technical, educational, and explanatory decks."},
"Bold": {"expected_count": 11, "use_when": "Posters, showcases, events, playful explainers, and high-energy visual impact."}
},
"presets": [
{
"style_id": "avocado_press",
"display_name": "Avocado Press",
"group": "Restrained",
"source_token": "TIBNwZ6fLhfPh1bZlAQuFRnFswW",
"formality": "high",
"vibe": ["editorial", "fresh", "structured"],
"best_for": ["agenda.structured", "process.flow", "quote.insight"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#0055A4", "accent": "#DCF4A2", "support": ["#0055A4"]},
"shape_language": {"panel_treatment": "editorial blocks with bright accent labels", "corner_radius": "low", "border_weight": "medium", "texture": "clean print-like structure"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep text native and use explicit connector lines for process structure."},
"quality_oracle": {"expected_style_signals": ["blue structural labels", "avocado accent", "editorial spacing"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
},
{
"style_id": "grove",
"display_name": "Grove",
"group": "Restrained",
"source_token": "IOCVwTYCYhhUj9bbkAwuncDTslf",
"formality": "high",
"vibe": ["institutional", "organic", "calm"],
"best_for": ["architecture.layered", "section.divider", "process.flow"],
"avoid_for": ["brand_system"],
"palette": {"background": "#E8E4D6", "text": "#192B1B", "muted": "#D4CFBF", "accent": "#C8524A", "support": ["#DEDAD0"]},
"shape_language": {"panel_treatment": "soft editorial panels with deep green hierarchy", "corner_radius": "low", "border_weight": "light", "texture": "warm paper bands"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 62, "source_text": 44, "source_shapes": 13, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["architecture", "section", "process"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use grouped green panels and sparse connectors rather than dense arrows."},
"quality_oracle": {"expected_style_signals": ["deep green title mass", "muted paper background", "small warm accent"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 2}}
},
{
"style_id": "jade_lens",
"display_name": "Jade Lens",
"group": "Restrained",
"source_token": "T0eswEvY1h6uSZbbt1FujZp0sZf",
"formality": "high",
"vibe": ["research", "clean", "knowledge"],
"best_for": ["timeline.roadmap", "architecture.layered", "agenda.structured"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#F5F1EE", "text": "#0E5A3C", "muted": "#2BA483", "accent": "#2CAE8C", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "jade framed cards with layered labels", "corner_radius": "medium", "border_weight": "medium", "texture": "lens-like panels"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 72, "source_text": 44, "source_shapes": 19, "source_connectors": 9}},
"slide_translation": {"recommended_layouts": ["timeline", "architecture", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep jade labels as native text; simplify nested frames when crowded."},
"quality_oracle": {"expected_style_signals": ["jade green framing", "white content panels", "medium connector scaffolding"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "long_table",
"display_name": "Long Table",
"group": "Restrained",
"source_token": "VrJhwVUTwhjU2zbBpI7uEjy2szg",
"formality": "high",
"vibe": ["procedural", "responsibility", "planning"],
"best_for": ["table.visual-summary", "process.flow", "timeline.roadmap"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#FAF1E2", "text": "#1F2329", "muted": "#F2E5CF", "accent": "#B53D2A", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "long horizontal table bands with clear separators", "corner_radius": "low", "border_weight": "medium", "texture": "tabular rows"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 9, "source_connectors": 17}},
"slide_translation": {"recommended_layouts": ["table", "process", "timeline"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Prefer table/grid native shapes; connector count can be reduced if slide becomes cramped."},
"quality_oracle": {"expected_style_signals": ["long horizontal rows", "red emphasis", "explicit flow connectors"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "macchiato",
"display_name": "Macchiato",
"group": "Restrained",
"source_token": "Jhl9w3gZghgXzeb6WLwu44VXsMg",
"formality": "high",
"vibe": ["quiet", "editorial", "warm"],
"best_for": ["quote.insight", "section.divider", "comparison.two-column"],
"avoid_for": ["dashboard.kpi-grid"],
"palette": {"background": "#EDE7DD", "text": "#25211B", "muted": "#9A917F", "accent": "#6E6558", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "minimal editorial blocks with low contrast", "corner_radius": "low", "border_weight": "light", "texture": "coffee paper tone"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 5, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["quote", "section", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use whitespace and typographic hierarchy instead of many decorative nodes."},
"quality_oracle": {"expected_style_signals": ["low contrast warm neutrals", "large quiet text", "few panels"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 1}}
},
{
"style_id": "monochrome",
"display_name": "Monochrome",
"group": "Restrained",
"source_token": "ApDnwnul9hlwg8b4Jl1uDweAs2c",
"formality": "high",
"vibe": ["serious", "technical", "minimal"],
"best_for": ["architecture.layered", "quote.insight", "agenda.structured"],
"avoid_for": ["brand_system"],
"palette": {"background": "#FAFADF", "text": "#1A1A16", "muted": "#8A8A80", "accent": "#5E5E54", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "monochrome rules and restrained blocks", "corner_radius": "low", "border_weight": "light", "texture": "minimal editorial grid"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 3, "source_connectors": 8}},
"slide_translation": {"recommended_layouts": ["architecture", "quote", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Rely on black/gray hierarchy and simple connectors; avoid fake color accents."},
"quality_oracle": {"expected_style_signals": ["black text hierarchy", "single neutral accent", "minimal blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 1}}
},
{
"style_id": "papier_bleu",
"display_name": "Papier Bleu",
"group": "Restrained",
"source_token": "HWi5woaS8h1D4EbKutnulYWdsWc",
"formality": "medium",
"vibe": ["blueprint", "clear", "knowledge"],
"best_for": ["process.flow", "timeline.roadmap", "image.story"],
"avoid_for": ["brand_system"],
"palette": {"background": "#FAF3EB", "text": "#1A3C8F", "muted": "#4FB8D8", "accent": "#72D0E9", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "blue paper panels with airy blocks", "corner_radius": "medium", "border_weight": "medium", "texture": "light blueprint geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 64, "source_text": 38, "source_shapes": 22, "source_connectors": 4}},
"slide_translation": {"recommended_layouts": ["process", "timeline", "image"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use blue diagram cards and short labels; avoid overusing cyan fills."},
"quality_oracle": {"expected_style_signals": ["blue structural panels", "cream paper base", "clear diagram labels"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
},
{
"style_id": "reading_room",
"display_name": "Reading Room",
"group": "Restrained",
"source_token": "Wx8Ow5ThFhDn5Lb1VFzu5bDEskD",
"formality": "high",
"vibe": ["paper", "training", "reading"],
"best_for": ["quote.insight", "section.divider", "paper.explainer"],
"avoid_for": ["fake_ui_dashboard"],
"palette": {"background": "#F6EBD8", "text": "#0B0A09", "muted": "#F1DAB1", "accent": "#DE916A", "support": ["#D6C7CC"]},
"shape_language": {"panel_treatment": "reading-card panels with warm paper blocks", "corner_radius": "low", "border_weight": "light", "texture": "bookish paper"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 54, "source_text": 38, "source_shapes": 6, "source_connectors": 7}},
"slide_translation": {"recommended_layouts": ["quote", "section", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use warm panels and pull quotes; keep connectors secondary."},
"quality_oracle": {"expected_style_signals": ["book paper background", "warm orange accent", "quiet reading hierarchy"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
},
{
"style_id": "salmon_stamp",
"display_name": "Salmon Stamp",
"group": "Restrained",
"source_token": "IITLwQQ7Vhj0lzbBmhvuvtDWs4c",
"formality": "medium",
"vibe": ["stamp", "training", "emphasis"],
"best_for": ["section.divider", "kpi.big-number", "agenda.structured"],
"avoid_for": ["technical_texture"],
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#F0AE9E", "accent": "#049550", "support": ["#F0AE9E"]},
"shape_language": {"panel_treatment": "stamp-like salmon labels with green accents", "corner_radius": "low", "border_weight": "medium", "texture": "print stamp blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 60, "source_text": 40, "source_shapes": 9, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["section", "kpi", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep stamp labels editable; use green only for key emphasis."},
"quality_oracle": {"expected_style_signals": ["salmon blocks", "black type", "green action accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
},
{
"style_id": "apricot_arc",
"display_name": "Apricot Arc",
"group": "Balanced",
"source_token": "JvQewyVpphPc2MbOD4xuiTD5sbd",
"formality": "medium",
"vibe": ["warm", "motion", "roadmap"],
"best_for": ["timeline.roadmap", "process.flow", "cover.hero"],
"avoid_for": ["monochrome.audit"],
"palette": {"background": "#FFF8EE", "text": "#7A4A33", "muted": "#F9C2BD", "accent": "#C7561E", "support": ["#F69834"]},
"shape_language": {"panel_treatment": "warm arcs and rounded progression panels", "corner_radius": "medium", "border_weight": "medium", "texture": "arc motion geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 76, "source_text": 37, "source_shapes": 23, "source_connectors": 16}},
"slide_translation": {"recommended_layouts": ["timeline", "process", "cover"], "svglide_primitives": ["path", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit arc paths and staged labels; simplify connectors if crowded."},
"quality_oracle": {"expected_style_signals": ["apricot arcs", "warm motion path", "stage progression"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "berry_pop",
"display_name": "Berry Pop",
"group": "Balanced",
"source_token": "JAcFwmlcIh8NKNbJOzGupeTKscg",
"formality": "medium",
"vibe": ["business", "soft", "case"],
"best_for": ["comparison.two-column", "image.story", "dashboard.kpi-grid"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#EDF0FA", "text": "#6E1E3A", "muted": "#C7D2F0", "accent": "#9E2B50", "support": ["#9DB0E8"]},
"shape_language": {"panel_treatment": "soft berry panels with cool support areas", "corner_radius": "medium", "border_weight": "light", "texture": "soft editorial contrast"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 65, "source_text": 40, "source_shapes": 13, "source_connectors": 12}},
"slide_translation": {"recommended_layouts": ["comparison", "image", "dashboard"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use berry for key claims and blue support panels for evidence."},
"quality_oracle": {"expected_style_signals": ["berry headline", "cool soft panels", "balanced contrast"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "bold_poster",
"display_name": "Bold Poster",
"group": "Balanced",
"source_token": "TOwewbtxrhmxNgbzko3udFBvsBd",
"formality": "medium",
"vibe": ["poster", "direct", "high-contrast"],
"best_for": ["cover.hero", "quote.insight", "section.divider"],
"avoid_for": ["dense.table"],
"palette": {"background": "#F5F2EF", "text": "#1C1410", "muted": "#FFFFFF", "accent": "#D8000F", "support": ["#1C1410"]},
"shape_language": {"panel_treatment": "poster blocks with strong red hits", "corner_radius": "low", "border_weight": "heavy", "texture": "flat poster"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 40, "source_shapes": 6, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["cover", "quote", "section"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use large type and one strong red visual anchor; avoid dense micro text."},
"quality_oracle": {"expected_style_signals": ["red poster accent", "black mass", "large type"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 2}}
},
{
"style_id": "checker_bloom",
"display_name": "Checker Bloom",
"group": "Balanced",
"source_token": "S5tEwSOiAhbuvObGnNkuCAUps8d",
"formality": "medium",
"vibe": ["grid", "capability", "modular"],
"best_for": ["dashboard.kpi-grid", "icon_capability_map", "table.visual-summary"],
"avoid_for": ["quote.insight"],
"palette": {"background": "#E8F1DD", "text": "#151515", "muted": "#5E9E4A", "accent": "#2C6EE0", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "checker grid cells with bloom accents", "corner_radius": "medium", "border_weight": "medium", "texture": "modular checker geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 37, "source_shapes": 20, "source_connectors": 12}},
"slide_translation": {"recommended_layouts": ["dashboard", "capability_map", "table"], "svglide_primitives": ["geometric_shape", "icon", "micro_chart"], "fallback_policy": "Build true grid modules; do not reduce to three generic cards."},
"quality_oracle": {"expected_style_signals": ["checker grid", "blue-green accents", "capability modules"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}}
},
{
"style_id": "cobalt_bloom",
"display_name": "Cobalt Bloom",
"group": "Balanced",
"source_token": "Ts2iwaXOuhOBkYbKD80uLjyCsje",
"formality": "medium",
"vibe": ["modern", "tech", "status"],
"best_for": ["architecture.layered", "fake_ui_dashboard", "cover.hero"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#F4EFE9", "text": "#171717", "muted": "#DDA8A2", "accent": "#4746C6", "support": ["#CE968F"]},
"shape_language": {"panel_treatment": "modern cobalt panels with soft pink supports", "corner_radius": "medium", "border_weight": "medium", "texture": "tech editorial blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 56, "source_text": 40, "source_shapes": 11, "source_connectors": 5}},
"slide_translation": {"recommended_layouts": ["architecture", "dashboard", "cover"], "svglide_primitives": ["typography", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt as structural system color and pink as secondary highlight."},
"quality_oracle": {"expected_style_signals": ["cobalt blocks", "modern soft support panels", "technical editorial tone"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "coral",
"display_name": "Coral",
"group": "Balanced",
"source_token": "Ez05w4JTahrMIjb6hjcuJRDpsOy",
"formality": "medium",
"vibe": ["report", "warm", "clear"],
"best_for": ["agenda.structured", "comparison.two-column", "kpi.big-number"],
"avoid_for": ["monochrome.audit"],
"palette": {"background": "#F5F0E8", "text": "#1A1A1A", "muted": "#6B6B6B", "accent": "#E85D5D", "support": ["#D44A4A"]},
"shape_language": {"panel_treatment": "coral emphasis cards with neutral body text", "corner_radius": "medium", "border_weight": "light", "texture": "warm report blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 37, "source_shapes": 23, "source_connectors": 5}},
"slide_translation": {"recommended_layouts": ["agenda", "comparison", "kpi"], "svglide_primitives": ["typography", "geometric_shape", "micro_chart"], "fallback_policy": "Use coral only for claims, values, or section anchors."},
"quality_oracle": {"expected_style_signals": ["coral claim blocks", "neutral report structure", "clear emphasis hierarchy"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
},
{
"style_id": "cut_bloom",
"display_name": "Cut Bloom",
"group": "Balanced",
"source_token": "QHtIwZ4aeha6q8bDkfiuU7TCsgb",
"formality": "medium",
"vibe": ["geometric", "sectioned", "structured"],
"best_for": ["geometric_composition", "architecture.layered", "section.divider"],
"avoid_for": ["dense.table"],
"palette": {"background": "#FFFFFF", "text": "#2E3566", "muted": "#535D9E", "accent": "#F0CB65", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "cut color planes and strong section blocks", "corner_radius": "low", "border_weight": "medium", "texture": "cut-paper geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 40, "source_shapes": 26, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["geometric", "architecture", "section"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Translate angled blocks into safe path geometry, not polygons."},
"quality_oracle": {"expected_style_signals": ["cut planes", "navy structure", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "editorial_forest",
"display_name": "Editorial Forest",
"group": "Balanced",
"source_token": "DeJQwotDFhKucHbVvdUufxepsAe",
"formality": "high",
"vibe": ["research", "editorial", "ecosystem"],
"best_for": ["quote.insight", "comparison.two-column", "paper.explainer"],
"avoid_for": ["brand_system"],
"palette": {"background": "#EFE7D4", "text": "#1A1A17", "muted": "#243A21", "accent": "#E89CB1", "support": ["#2E4A2A"]},
"shape_language": {"panel_treatment": "forest editorial blocks with pink accent", "corner_radius": "low", "border_weight": "light", "texture": "journal-like composition"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 6, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["quote", "comparison", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Let text hierarchy carry the page; use pink accent sparingly."},
"quality_oracle": {"expected_style_signals": ["forest green editorial base", "pink accent", "quiet paper field"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 2}}
},
{
"style_id": "lime_slab",
"display_name": "Lime Slab",
"group": "Balanced",
"source_token": "T3oLwlQLohw8G7b4qfJuFqw9syd",
"formality": "medium",
"vibe": ["energetic", "technical", "slab"],
"best_for": ["cover.hero", "architecture.layered", "process.flow"],
"avoid_for": ["quiet.executive"],
"palette": {"background": "#FFFFF2", "text": "#0A0A05", "muted": "#2F2E25", "accent": "#EEFA79", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "thick slab panels with lime highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "high-energy slab geometry"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 82, "source_text": 43, "source_shapes": 28, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["cover", "architecture", "process"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use lime as high-energy focal surface, but keep text boxes large."},
"quality_oracle": {"expected_style_signals": ["lime slab title", "heavy black structure", "dense technical labels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "linen_cut",
"display_name": "Linen Cut",
"group": "Balanced",
"source_token": "WI3Yw4BakhfaoNbiEfRuCwq0slg",
"formality": "medium",
"vibe": ["business", "linen", "structured"],
"best_for": ["agenda.structured", "table.visual-summary", "comparison.two-column"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#E4D2C4", "text": "#1F1A14", "muted": "#044D99", "accent": "#F61B27", "support": ["#04B24F"]},
"shape_language": {"panel_treatment": "linen panels with sharp color tabs", "corner_radius": "low", "border_weight": "medium", "texture": "woven warm base"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 54, "source_text": 37, "source_shapes": 14, "source_connectors": 3}},
"slide_translation": {"recommended_layouts": ["agenda", "table", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Keep red/green/blue as role colors, not decoration."},
"quality_oracle": {"expected_style_signals": ["linen base", "sharp red/green/blue tabs", "business grid"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "pin_paper",
"display_name": "Pin & Paper",
"group": "Balanced",
"source_token": "NZ5bwYLs1hAat1bHo29uDdhSsJx",
"formality": "medium",
"vibe": ["workshop", "action", "paper"],
"best_for": ["agenda.structured", "process.flow", "quote.insight"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#2A3C99", "accent": "#F1E84E", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "pinned paper notes with blue rails", "corner_radius": "medium", "border_weight": "medium", "texture": "workshop paper"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 66, "source_text": 40, "source_shapes": 21, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use pinned note panels and explicit action labels; avoid showing the preset name."},
"quality_oracle": {"expected_style_signals": ["pinned paper cards", "blue rails", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "raw_grid",
"display_name": "Raw Grid",
"group": "Balanced",
"source_token": "Z6CbwF2oPhvXQ8biBRnufsyksqf",
"formality": "medium",
"vibe": ["technical", "dense", "grid"],
"best_for": ["dashboard.kpi-grid", "table.visual-summary", "architecture.layered"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#F5F5F5", "text": "#0A0A0A", "muted": "#333333", "accent": "#F2D4CF", "support": ["#FFFFFF", "#E5EDD6"]},
"shape_language": {"panel_treatment": "grid panels with dense labels", "corner_radius": "low", "border_weight": "medium", "texture": "explicit grid geometry"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 98, "source_text": 52, "source_shapes": 44, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["dashboard", "table", "architecture"], "svglide_primitives": ["geometric_shape", "typography", "texture", "annotation"], "fallback_policy": "Keep native text and basic shapes; fallback only for over-dense visual tables."},
"quality_oracle": {"expected_style_signals": ["grid geometry", "dense labels", "muted panels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "riptide_cobalt",
"display_name": "Riptide Cobalt",
"group": "Balanced",
"source_token": "Qpyow1AnZhU763b3d51ur42csZd",
"formality": "medium",
"vibe": ["technology", "flow", "cobalt"],
"best_for": ["path_flow", "architecture.layered", "fake_ui_dashboard"],
"avoid_for": ["quiet.executive"],
"palette": {"background": "#FDF0E0", "text": "#1A2240", "muted": "#2741C0", "accent": "#375DFE", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "cobalt panels with flowing technical emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "blue flow geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 69, "source_text": 41, "source_shapes": 26, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["path", "architecture", "dashboard"], "svglide_primitives": ["path", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt flow paths and layered blocks; do not overdo connectors."},
"quality_oracle": {"expected_style_signals": ["cobalt flow", "cream contrast", "layered tech panels"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}}
},
{
"style_id": "soft_editorial",
"display_name": "Soft Editorial",
"group": "Balanced",
"source_token": "T7A4w3ioHhgOjLbuFywuqZwbsUg",
"formality": "medium",
"vibe": ["soft", "editorial", "summary"],
"best_for": ["quote.insight", "agenda.structured", "comparison.two-column"],
"avoid_for": ["technical_texture"],
"palette": {"background": "#ECE9DC", "text": "#1C1A17", "muted": "#E7C6AD", "accent": "#E2A8CE", "support": ["#C9DA4F"]},
"shape_language": {"panel_treatment": "soft editorial fields with small text fragments", "corner_radius": "medium", "border_weight": "light", "texture": "gentle magazine layout"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 40, "source_shapes": 5, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["quote", "agenda", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Split text into readable editorial fragments; keep labels concise."},
"quality_oracle": {"expected_style_signals": ["soft editorial colors", "small text fragments", "gentle accent palette"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "violet_marker",
"display_name": "Violet Marker",
"group": "Balanced",
"source_token": "HISlwkosLhVgFVb8y3KucknAslb",
"formality": "medium",
"vibe": ["marker", "training", "concept"],
"best_for": ["section.divider", "process.flow", "icon_capability_map"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#666463", "accent": "#C5A1FF", "support": ["#CFEE30"]},
"shape_language": {"panel_treatment": "marker-like labels with violet and lime accents", "corner_radius": "medium", "border_weight": "medium", "texture": "hand-highlighted marker blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["section", "process", "capability_map"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use violet/lime marker tags for concepts, not all text."},
"quality_oracle": {"expected_style_signals": ["violet marker accent", "lime support", "concept labels"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "blockframe",
"display_name": "BlockFrame",
"group": "Bold",
"source_token": "Qu3Lwf4VRhyYzWbjI1xuBn5UsEc",
"formality": "low",
"vibe": ["showcase", "blocky", "colorful"],
"best_for": ["cover.hero", "brand_system", "image.story"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#C0F7FE", "accent": "#F7CB46", "support": ["#FE90E8", "#99E885", "#FFDC8B"]},
"shape_language": {"panel_treatment": "strong colored block frames", "corner_radius": "low", "border_weight": "heavy", "texture": "graphic frame system"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 75, "source_text": 41, "source_shapes": 31, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["cover", "brand", "image"], "svglide_primitives": ["geometric_shape", "typography", "brand_system"], "fallback_policy": "Use block frames as visual identity; keep content hierarchy simple."},
"quality_oracle": {"expected_style_signals": ["multi-color frames", "black outlines", "bold block identity"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}}
},
{
"style_id": "burst_panel",
"display_name": "Burst Panel",
"group": "Bold",
"source_token": "IUVZwJZirhaIZQbxkfnuj2jPskh",
"formality": "low",
"vibe": ["poster", "burst", "dense"],
"best_for": ["cover.hero", "dashboard.kpi-grid", "brand_system"],
"avoid_for": ["quiet.executive"],
"palette": {"background": "#FFFAF0", "text": "#1E1E1E", "muted": "#FBD65A", "accent": "#FFA76D", "support": ["#CFACE8", "#AAE4BA"]},
"shape_language": {"panel_treatment": "bursting panels with many colored regions", "corner_radius": "medium", "border_weight": "medium", "texture": "poster panel collage"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 89, "source_text": 50, "source_shapes": 37, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["cover", "dashboard", "brand"], "svglide_primitives": ["geometric_shape", "typography", "micro_chart"], "fallback_policy": "Preserve high-energy panels but cap text boxes to avoid crowding."},
"quality_oracle": {"expected_style_signals": ["burst panels", "warm poster palette", "high information blocks"], "warning_thresholds": {"text_boxes_max": 30, "accent_colors_max": 5}}
},
{
"style_id": "confetti_wedge",
"display_name": "Confetti Wedge",
"group": "Bold",
"source_token": "YDyLwTCiHhKJ0NbuP89uHUV8sNh",
"formality": "low",
"vibe": ["playful", "light", "wedge"],
"best_for": ["section.divider", "image.story", "quote.insight"],
"avoid_for": ["dense.table"],
"palette": {"background": "#F4F8FB", "text": "#000000", "muted": "#3A3C3E", "accent": "#62C0A5", "support": ["#F8BED4", "#65C8CD"]},
"shape_language": {"panel_treatment": "confetti wedges and light motion shapes", "corner_radius": "medium", "border_weight": "light", "texture": "playful wedge accents"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 37, "source_shapes": 13, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["section", "image", "quote"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Use a few wedge paths as memory points; avoid confetti over text."},
"quality_oracle": {"expected_style_signals": ["wedge accents", "light playful palette", "simple panel structure"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 4}}
},
{
"style_id": "court_press",
"display_name": "Court Press",
"group": "Bold",
"source_token": "JH9owJl4sh0PeIbUm9SujYCTsTc",
"formality": "low",
"vibe": ["sports", "team", "operations"],
"best_for": ["process.flow", "timeline.roadmap", "metaphor_loop"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#F2EFE6", "text": "#2F4224", "muted": "#66914C", "accent": "#DA9EB7", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "court-like lanes and team panels", "corner_radius": "medium", "border_weight": "medium", "texture": "court lane geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 61, "source_text": 37, "source_shapes": 11, "source_connectors": 13}},
"slide_translation": {"recommended_layouts": ["process", "timeline", "loop"], "svglide_primitives": ["connector_flow", "path", "geometric_shape"], "fallback_policy": "Use lanes and flows to encode motion; keep sports metaphor subtle."},
"quality_oracle": {"expected_style_signals": ["court lanes", "green team palette", "pink accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "crayon_stack",
"display_name": "Crayon Stack",
"group": "Bold",
"source_token": "JjKIwig1vhnXq7bbeCfutuKFseh",
"formality": "low",
"vibe": ["creative", "playful", "stacked"],
"best_for": ["cover.hero", "icon_capability_map", "brand_system"],
"avoid_for": ["executive.audit"],
"palette": {"background": "#FFFFFF", "text": "#222222", "muted": "#8A2E43", "accent": "#FF472B", "support": ["#D3FE79", "#FBB8FD", "#2A8F6D", "#7E90FC"]},
"shape_language": {"panel_treatment": "stacked crayon color bars", "corner_radius": "medium", "border_weight": "heavy", "texture": "handmade colorful blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 37, "source_shapes": 8, "source_connectors": 3}},
"slide_translation": {"recommended_layouts": ["cover", "capability_map", "brand"], "svglide_primitives": ["geometric_shape", "icon", "typography"], "fallback_policy": "Use colorful stacks for categories; avoid making every label a different color."},
"quality_oracle": {"expected_style_signals": ["crayon color stack", "playful strong accents", "bold category blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 6}}
},
{
"style_id": "grove_block",
"display_name": "Grove Block",
"group": "Bold",
"source_token": "Mq2XwYKDEhbnRmbIxAfuZGCQsOb",
"formality": "medium",
"vibe": ["ecosystem", "bold", "result"],
"best_for": ["kpi.big-number", "metaphor_loop", "process.flow"],
"avoid_for": ["quiet.editorial"],
"palette": {"background": "#FCF6F1", "text": "#01623F", "muted": "#008248", "accent": "#FCC715", "support": ["#F7F1EC", "#F6BDDA"]},
"shape_language": {"panel_treatment": "bold green blocks with yellow emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "organic block system"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 44, "source_shapes": 20, "source_connectors": 9}},
"slide_translation": {"recommended_layouts": ["kpi", "loop", "process"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use green blocks for system stages and yellow for outcome emphasis."},
"quality_oracle": {"expected_style_signals": ["bold green blocks", "yellow result accent", "ecosystem grouping"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 3}}
},
{
"style_id": "mint_brut",
"display_name": "Mint Brut",
"group": "Bold",
"source_token": "BMe4wBmwlhGfNCbooCvuShTfs4v",
"formality": "low",
"vibe": ["brutalist", "technical", "mint"],
"best_for": ["architecture.layered", "process.flow", "cover.hero"],
"avoid_for": ["quiet.training"],
"palette": {"background": "#FFFBF3", "text": "#000000", "muted": "#D0FDE4", "accent": "#70F0A8", "support": ["#F888C8", "#F0DE4E"]},
"shape_language": {"panel_treatment": "brutalist mint panels with loud highlights", "corner_radius": "low", "border_weight": "heavy", "texture": "brutalist blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 75, "source_text": 38, "source_shapes": 23, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["architecture", "process", "cover"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use heavy frames and mint surfaces; keep text large and sparse."},
"quality_oracle": {"expected_style_signals": ["mint brutalist panels", "black outlines", "pink/yellow highlights"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 4}}
},
{
"style_id": "neo_grid_bold",
"display_name": "Neo-Grid Bold",
"group": "Bold",
"source_token": "WwstwfAj1hIZXhbDwdDuwdpDsBh",
"formality": "medium",
"vibe": ["neo-grid", "technical", "high-contrast"],
"best_for": ["technical_texture", "architecture.layered", "fake_ui_dashboard"],
"avoid_for": ["paper.explainer"],
"palette": {"background": "#F5F4EF", "text": "#0A0A0A", "muted": "#8A8A85", "accent": "#E6FF3D", "support": ["#0A0A0A"]},
"shape_language": {"panel_treatment": "black grid rails with neon highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "explicit neo grid"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 12, "source_connectors": 17}},
"slide_translation": {"recommended_layouts": ["technical_texture", "architecture", "dashboard"], "svglide_primitives": ["texture", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit grid lines/dots; do not rely on SVG pattern."},
"quality_oracle": {"expected_style_signals": ["black grid structure", "neon lime focus", "technical rails"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
},
{
"style_id": "riso_brut",
"display_name": "Riso Brut",
"group": "Bold",
"source_token": "Mztnwj2ouhot6VbtbwMuWN4SsRb",
"formality": "low",
"vibe": ["riso", "poster", "multicolor"],
"best_for": ["brand_system", "cover.hero", "image.story"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#EFE9D9", "text": "#0F0F0F", "muted": "#136636", "accent": "#F5C518", "support": ["#1F8A4C", "#E85A1F", "#F06CA8", "#D14E8B"]},
"shape_language": {"panel_treatment": "riso-print blocks and overlapping color plates", "corner_radius": "low", "border_weight": "medium", "texture": "riso poster plates"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 71, "source_text": 38, "source_shapes": 29, "source_connectors": 4}},
"slide_translation": {"recommended_layouts": ["brand", "cover", "image"], "svglide_primitives": ["geometric_shape", "typography", "image_overlay"], "fallback_policy": "Use flat color plates; avoid opacity-heavy overlaps unless preflight accepts them."},
"quality_oracle": {"expected_style_signals": ["riso color plates", "poster energy", "cream paper"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 6}}
},
{
"style_id": "specimen_bold",
"display_name": "Specimen Bold",
"group": "Bold",
"source_token": "BNFmwKX6OhWRfbb3SX4uRoHesD6",
"formality": "medium",
"vibe": ["specimen", "concept", "modular"],
"best_for": ["icon_capability_map", "comparison.two-column", "section.divider"],
"avoid_for": ["paper.explainer"],
"palette": {"background": "#F3F3F3", "text": "#2E302E", "muted": "#30A1E5", "accent": "#3EC06A", "support": ["#FFFFFF", "#FBEF4A"]},
"shape_language": {"panel_treatment": "specimen cards with multiple focus markers", "corner_radius": "medium", "border_weight": "medium", "texture": "sample-card layout"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 57, "source_text": 37, "source_shapes": 8, "source_connectors": 12}},
"slide_translation": {"recommended_layouts": ["capability_map", "comparison", "section"], "svglide_primitives": ["icon", "geometric_shape", "connector_flow"], "fallback_policy": "Use specimen cards for concepts and blue/green markers for relationships."},
"quality_oracle": {"expected_style_signals": ["specimen cards", "blue/green markers", "modular concept blocks"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 4}}
},
{
"style_id": "stencil_tablet",
"display_name": "Stencil & Tablet",
"group": "Bold",
"source_token": "J1BKw4SxhhC3kTbD77auLJ5UsRc",
"formality": "medium",
"vibe": ["stencil", "method", "action"],
"best_for": ["quote.insight", "process.flow", "agenda.structured"],
"avoid_for": ["quiet.editorial"],
"palette": {"background": "#F4EFE0", "text": "#0A0A0A", "muted": "#E2DCC9", "accent": "#D8A93B", "support": ["#EE7A2E", "#C73B7A", "#2D7E73"]},
"shape_language": {"panel_treatment": "stencil panels and tablet-like blocks", "corner_radius": "low", "border_weight": "medium", "texture": "stencil print panels"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 7, "source_connectors": 5}},
"slide_translation": {"recommended_layouts": ["quote", "process", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use stencil panels for method/action emphasis; keep decorative colors role-bound."},
"quality_oracle": {"expected_style_signals": ["stencil panels", "tablet blocks", "gold/orange/magenta accents"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}}
}
]
}

View File

@@ -0,0 +1,89 @@
# SVGlide Style Presets
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets. This Markdown file is only a human-readable guide.
## Boundary
Style presets are not slide templates. They do not replace `visual_recipe`, `renderer_id`, or the page semantic plan.
- `visual_recipe`: explains the page structure and SVG-native value, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`.
- `style_preset`: selects the visual language, palette, panel treatment, connector density, label density, and texture.
- `style_system`: records how the selected preset is translated into the current deck.
Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content.
## Required Plan Fields
For `output_mode="svglide-svg"`, the deck plan must include:
```json
{
"style_preset": "raw_grid",
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
"style_system": {
"palette": {
"background": "#F5F5F5",
"text": "#0A0A0A",
"accent": "#F2D4CF"
},
"typography": "strong title, readable native text labels",
"background_strategy": "muted grid panels with one stable background family",
"motif": "dense grid panels with restrained accent labels"
}
}
```
Each slide must also include:
```json
{
"visual_recipe": "path_flow",
"visual_signature": "curved route path with explicit stage annotations",
"svg_effects": ["path", "connector_flow", "typography"],
"svg_primitives": ["path", "annotation"],
"required_primitives": ["path", "annotation"]
}
```
Use `visual_plan` as a nested container when useful. `svg_preflight.py` accepts both the nested shape and the existing flat fields; nested `visual_plan` wins when both are present.
## Selection Rule
1. Choose intensity first.
- `Restrained`: serious, quiet, institutional, text-first decks.
- `Balanced`: default for business, technical, training, and explanatory decks.
- `Bold`: posters, showcases, event material, playful explainers, high-energy pages.
2. Match the user's tone and topic.
3. Keep the semantic plan stable. Switching from `raw_grid` to `reading_room` should change visual treatment, not invent new facts or rearrange the story.
4. Pick page-level overrides only for cover, section divider, or poster-like moments. Most slides should inherit the deck-level `style_preset`.
## SVGlide-Safe Translation
Translate style into supported SVG primitives:
- Palette -> explicit `fill`, `stroke`, and text colors.
- Panel treatment -> `rect`, `path`, and grouped layout boxes.
- Connector density -> explicit `line` or supported `path`; do not rely on `marker` or key-path `stroke-dasharray`.
- Texture -> repeated native `line`, `circle`, or `rect`; do not rely on `<pattern>` as the only effect.
- Image overlay -> real `<image slide:role="image">` plus explicit shape masks/overlays when needed.
Unsafe effects such as `filter`, `mask_clip`, `pattern`, `symbol`, `stroke_dasharray`, and `image_opacity` may appear in the plan only when a safe rewrite or fallback is declared.
## Quality Gates
Before calling `slides +create-svg`, run:
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--plan .lark-slides/plan/<deck-id>/slide_plan.json \
--input .lark-slides/plan/<deck-id>/pages/page-001.svg
```
The preflight checks:
- preset exists in `style-presets.json`;
- `style_system` has palette, typography, background strategy, and motif;
- each page declares `visual_signature` and `svg_effects`;
- unsafe effects have fallback or rewrite notes;
- declared effects and primitives are present in the SVG source;
- visible slide text does not leak preset names, source tokens, prompts, tool names, or local file paths.

View File

@@ -0,0 +1,105 @@
# SVGlide 审美 Review
这份文档用于本地 SVG/HTML preview 生成之后、调用 `slides +create-svg` 之前。
它是从以下审美评分标准中提炼出的短版执行清单:
`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/svg_aesthetic_rubric.md`.
这份 review 补充 `svg_preflight.py`。Preflight 负责确定性的协议、plan 和
bbox 问题;这份清单负责需要人工或截图判断的渲染后视觉质量问题。
## 必须执行的 Review 流程
1. 生成本地 SVG 文件,并在条件允许时生成本地 `preview.html`
2. 运行 `svg_preflight.py --plan ... --input ...`;先修复所有 error。
3. 打开或检查 preview。必须审查所有页面不只看封面。
4. 重复出现的版式问题要修生成器或 source SVG不能只改 `slide_plan.json`
5. live 创建前重新运行 preflight 和 preview。
不要用 preview review 替代 live readback。服务端转换后仍可能改变文本框、
图片 token、path bounds 和不支持的效果。
## 阻断性视觉问题
调用 live API 前必须修复这些问题:
| 问题 | 处理方式 |
|---|---|
| 文本重叠、文本容器溢出、标题被裁切 | 重新生成 layout boxes 或减少文本;不要只是整体缩小 |
| badge、pill、章节标签或页码标签贴住/压住标题 | 把 badge 移出标题块,或保留至少 12-16px 垂直间距 |
| 装饰线或色带压迫标题 | 把线移到标题区上方,或下移标题,保留呼吸感 |
| 主体内容超出 `960 x 540` 或 safe area | 按 960x540 画布重新计算坐标 |
| 浅色图片/背景上的低对比文本 | 增加实色承载底、overlay或切换文字颜色 |
| 空图片框或 preview 破图 | live 创建前替换资产或使用视觉 fallback |
| 页面缺少视觉焦点 | 围绕一个主导数字、图解、图片、路径或标题重建页面 |
| 页面只是普通卡片/bullet缺少 SVG 优势 | 选择更合适的 `visual_recipe`,或不要走 SVG 路线 |
| 同类版式问题在多页重复出现 | 修共享生成规则,然后重新生成受影响页面 |
## Issue 严重级别
在 preview notes 和最终验证记录中使用这些级别:
| 级别 | 含义 | 处理方式 |
|---|---|---|
| P0 | 不应该 live 创建 | 在 `slides +create-svg` 前修复或重新生成 |
| P1 | 可以渲染,但用户可见质量明显低于目标 | 交付前修复;只有用户明确接受草稿时才继续 |
| P2 | 小幅打磨项或残余风险 | 记录下来,有时间再修 |
默认映射:
- P0preflight error、不安全 SVG、破图/空图、画布裁切、关键文本裁切或重叠、对比度不可读、必需资产缺失、不支持视觉缺少 fallback。
- P1焦点弱、布局骨架重复、装饰/标题拥挤、视觉层级弱、图表/图解意图不匹配、可见 SVG 优势弱。
- P2轻微对齐差异、小的颜色不一致、非关键来源元数据 warning、只影响打磨的间距问题。
## 评分标准
使用 0-100 分。用户可见 deck 的默认目标是 `>= 75`
低于 `65`live 创建前必须重新生成或修复。
| 维度 | 权重 | 好结果 |
|---|---:|---|
| 沟通匹配度 | 15 | 页面类型和视觉形式匹配用户意图 |
| 视觉层级 | 15 | 2 秒内能看到唯一焦点 |
| 布局稳定性 | 15 | 网格、间距、对齐和 safe area 一致 |
| 可读性 | 15 | 字号、行长、对比度和换行可读 |
| 颜色纪律 | 10 | 强调色数量少,且语义一致 |
| 数据/图解完整性 | 10 | 图表、流程和图解诚实表达关系 |
| 风格一致性 | 8 | 图标、圆角、线宽、阴影和 motif 像同一套 deck |
| SVG 优势 | 7 | 页面明显受益于 path、texture、chart geometry、flow 或 overlay |
| 来源/资产可追溯 | 5 | 使用外部参考和 preview 资产时有记录 |
## Review 问题
每页都问这些问题:
- 这一页的一句话 takeaway 是什么?
- 第一眼落点在哪里,是否就是预期的 `visual_focal_point`
- 视线顺序是否符合 title -> focal visual -> evidence -> detail
- 有没有 badge、线条、水印、标签或缩略图挤压文本
- 页面是否使用了 SVG-native 结构,还是只有普通盒子和文本?
- 如果这一页变成普通 XML/PPT 卡片布局,会损失什么?
- 图表/流程/表格选择是否适合它要表达的关系?
- 颜色和强调方式是否和整套 deck 保持一致?
## 修复优先级
1. 布局正确性画布、safe area、重叠、溢出、裁切。
2. 可读性:对比度、字号、行长、文本框高度充足。
3. 层级:一个焦点对象、清晰标题、支撑细节降级。
4. SVG 优势path/flow/chart/icon/texture/image overlay 真实存在。
5. Deck 节奏:避免只换文案却重复同一骨架。
6. 资产/来源治理preview 资产可见,来源元数据存在。
## 可接受的输出记录
报告验证结果时,明确说明检查过什么:
```text
SVG preview review:
- preflight: passed / fixed errors first
- preview_path: .lark-slides/plan/<deck-id>/preview.html
- preview: checked all N pages for overlap, safe area, readability, and repeated layout issues
- visual_score: 82 / threshold 75
- issue_ids: none / [P1 visual.layout.decorative_line_title_pressure page=3]
- action: create_live / repair_and_rerun / draft_only
- remaining risk: live readback may still change text bbox or unsupported effects
```

View File

@@ -0,0 +1,109 @@
{
"route": "svglide-svg",
"version": "svglide-private-manifest/compat-v1",
"base_ref": "origin/main",
"primary_rules_file": "skills/lark-slides/references/svglide-svg-private.rules.json",
"route_admission_files": [
"skills/lark-slides/references/svglide-route-admission.md"
],
"private_strategy_files": [
"skills/lark-slides/references/svglide-svg-private.rules.json",
"skills/lark-slides/references/svglide-ppt-master-migration.matrix.md",
"skills/lark-slides/references/svglide-workflow.spec.md",
"skills/lark-slides/references/svglide-artifacts.spec.md",
"skills/lark-slides/references/svglide-plan.contract.md",
"skills/lark-slides/references/svglide-lock.contract.md",
"skills/lark-slides/references/svglide-assets.contract.md",
"skills/lark-slides/references/svglide-generate-svg.contract.md",
"skills/lark-slides/references/svglide-preview.spec.md",
"skills/lark-slides/references/svglide-checks.checklist.md",
"skills/lark-slides/references/svglide-readback.contract.md",
"skills/lark-slides/references/svglide-create-svg.contract.md",
"skills/lark-slides/references/lark-slides-create-svg.md",
"skills/lark-slides/references/svg-protocol.md",
"skills/lark-slides/references/style-presets.json",
"skills/lark-slides/references/style-presets.md",
"skills/lark-slides/references/svg-visual-recipes.md",
"skills/lark-slides/references/svg-aesthetic-review.md",
"skills/lark-slides/references/svglide-planning-layer.md",
"skills/lark-slides/references/svglide-validation-checklist.md",
"skills/lark-slides/references/svglide-visual-planning.md",
"skills/lark-slides/references/svglide-asset-planning.md",
"skills/lark-slides/references/safe-native-v1.profile.json",
"skills/lark-slides/references/svglide-plan.schema.json",
"skills/lark-slides/references/svglide-strategy-review.schema.json",
"skills/lark-slides/references/svglide-ppt-master-asset-map.schema.json",
"skills/lark-slides/references/svglide-template-admission.schema.json",
"skills/lark-slides/references/svglide-evidence.schema.json",
"skills/lark-slides/references/svglide-source-receipt.schema.json",
"skills/lark-slides/references/svglide-generator-receipt.schema.json",
"skills/lark-slides/references/svglide-chart-verify.schema.json",
"skills/lark-slides/references/svglide-semantic-review.schema.json",
"skills/lark-slides/references/svglide-runtime-review.schema.json",
"skills/lark-slides/references/svglide-renderer-registry.schema.json",
"skills/lark-slides/references/svglide-renderer-registry.json",
"skills/lark-slides/references/svglide-text-inventory.schema.json",
"skills/lark-slides/references/svglide-ppe-proof.schema.json",
"skills/lark-slides/references/svglide-semantic-advisory.schema.json",
"skills/lark-slides/references/svglide-speaker-notes.schema.json",
"skills/lark-slides/references/svglide-preview-annotations.schema.json",
"skills/lark-slides/references/svglide-page-rerun.schema.json",
"skills/lark-slides/references/svglide-quality-gate.schema.json",
"skills/lark-slides/scripts/svg_preflight.py",
"skills/lark-slides/scripts/svg_preview_lint.py",
"skills/lark-slides/scripts/svglide_source.py",
"skills/lark-slides/scripts/svglide_assets.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
"skills/lark-slides/scripts/svglide_preview.py",
"skills/lark-slides/scripts/svglide_aesthetic_review.py",
"skills/lark-slides/scripts/svglide_schema.py",
"skills/lark-slides/scripts/svglide_chart_verify.py",
"skills/lark-slides/scripts/svglide_semantic_review.py",
"skills/lark-slides/scripts/svglide_semantic_advisory.py",
"skills/lark-slides/scripts/svglide_runtime_review.py",
"skills/lark-slides/scripts/svglide_quality_gate.py",
"skills/lark-slides/scripts/svglide_ppe_proof.py",
"skills/lark-slides/scripts/svglide_readback.py",
"skills/lark-slides/scripts/svglide_ppt_master_inventory.py",
"skills/lark-slides/scripts/svglide_template_admission.py",
"skills/lark-slides/scripts/svglide_golden_suite.py",
"skills/lark-slides/scripts/svglide_speaker_notes.py",
"skills/lark-slides/scripts/svglide_preview_annotations.py",
"skills/lark-slides/scripts/svglide_contact_sheet.py",
"skills/lark-slides/scripts/svglide_page_rerun.py",
"shortcuts/slides/internal/svglide/**",
"shortcuts/slides/svg_complexity.go",
"shortcuts/slides/svg_raster_fallback.go",
"shortcuts/slides/svg_helpers.go",
"shortcuts/slides/svg_plan.go"
],
"allowed_route_entrypoints": [
"skills/lark-slides/references/lark-slides-create-svg.md",
"skills/lark-slides/references/svg-protocol.md",
"skills/lark-slides/scripts/svglide_source.py",
"skills/lark-slides/scripts/svglide_assets.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
"skills/lark-slides/scripts/svglide_preview.py",
"skills/lark-slides/scripts/svglide_aesthetic_review.py",
"skills/lark-slides/scripts/svglide_chart_verify.py",
"skills/lark-slides/scripts/svglide_semantic_review.py",
"skills/lark-slides/scripts/svglide_semantic_advisory.py",
"skills/lark-slides/scripts/svglide_runtime_review.py",
"skills/lark-slides/scripts/svglide_quality_gate.py",
"skills/lark-slides/scripts/svglide_ppe_proof.py",
"skills/lark-slides/scripts/svglide_readback.py",
"skills/lark-slides/scripts/svglide_ppt_master_inventory.py",
"skills/lark-slides/scripts/svglide_template_admission.py",
"skills/lark-slides/scripts/svglide_golden_suite.py",
"skills/lark-slides/scripts/svglide_speaker_notes.py",
"skills/lark-slides/scripts/svglide_preview_annotations.py",
"skills/lark-slides/scripts/svglide_contact_sheet.py",
"skills/lark-slides/scripts/svglide_page_rerun.py",
"shortcuts/slides/slides_create_svg.go"
],
"runtime_artifact_root": ".lark-slides/plan/<deck-id>"
}

View File

@@ -0,0 +1,207 @@
# SVGlide SVG Protocol
最小模板:
```xml
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
slide:contract-version="svglide-authoring-contract/v1"
width="960"
height="540"
viewBox="0 0 960 540"
>
<rect slide:role="shape" x="60" y="60" width="240" height="135" fill="#E8EEF8" />
<foreignObject slide:role="shape" slide:shape-type="text" x="90" y="98" width="240" height="60">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:20px;font-weight:700;color:#1F2937;line-height:1.2">SVGlide</div>
</foreignObject>
<image slide:role="image" href="@./hero.png" x="390" y="60" width="240" height="135" />
</svg>
```
## 必须满足
- root 必须是非 namespaced 的 `<svg>`,不能是 `<svg:svg>`
- root 必须声明 `xmlns:slide="https://slides.bytedance.com/ns"`
- root 必须包含 `slide:role="slide"`
- root 应包含 `slide:contract-version="svglide-authoring-contract/v1"`,用于标识这是 SVGlide authoring contract 输入,而不是普通 SVG。
- 可渲染元素必须有对应 `slide:role`shape 使用 `slide:role="shape"`,图片使用 `slide:role="image"`
- `<g>` 和嵌套 `<svg>` 可以作为容器,用于继承样式和 transform容器内真正渲染的元素仍必须声明 `slide:role`
- `slide:role="shape"` 目前只支持 `rect``ellipse``circle``line``path``foreignObject`
- 文本使用文本型 shape`<foreignObject slide:role="shape" slide:shape-type="text">...</foreignObject>`
- 图片使用 `<image slide:role="image" href="file_token">`;本地占位符写成 `href="@./image.png"`
- 原生 chart 可使用 root 直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">` spec markermarker 内只能有一个 chart metadatametadata payload 是 base64url 编码后的 canonical JSON chart spec不是 SXSD `<chart>` XML也不是 chart snapshot/staticData。
- `<defs>``<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
## 坐标系与画布
- 当前 `slides +create-svg` 新建的 Lark Slides presentation 回读画布通常是 `960 x 540`。生成 SVG deck 时默认使用 `width="960" height="540" viewBox="0 0 960 540"`,不要默认用 `1280 x 720`
- 服务端不会保证把 `viewBox="0 0 1280 720"` 自动缩放到 `960 x 540`。如果用 1280x720 设计,必须在提交前整体换算到目标画布,或在回读 XML 后验证没有越界。
- 生成时为所有主体元素预留安全区,建议 `x >= 48``y >= 40``right <= 912``bottom <= 500`。全屏背景可以铺满 `0,0,960,540`,但主体文字、图表和卡片仍应留在安全区内。
- 回读 XML 后必须检查主体元素边界:非背景元素的 `topLeftX + width <= 960``topLeftY + height <= 540`。任何页面越界都视为生成失败,需要重排或缩放后重建。
## 几何必填属性
SVGlide leaf shape 必须显式写出服务端建模所需的几何属性,不依赖 SVG 默认值。缺失这些属性通常会被服务端包装成 `shape missing required attribute` 或 generic invalid param。
| Element | Required attributes |
|---------|---------------------|
| `rect slide:role="shape"` | `x`, `y`, `width`, `height` |
| `foreignObject slide:role="shape" slide:shape-type="text"` | `x`, `y`, `width`, `height` |
| `image slide:role="image"` | `href`, `x`, `y`, `width`, `height` |
| `circle slide:role="shape"` | `cx`, `cy`, `r` |
| `ellipse slide:role="shape"` | `cx`, `cy`, `rx`, `ry` |
| `line slide:role="shape"` | `x1`, `y1`, `x2`, `y2` |
| `path slide:role="shape"` | `d` |
| root 直系 `g slide:role="chart"` | `slide:chart-ref`, `x`, `y`, `width`, `height` |
这些属性即使取值为 `0` 也要写出来。例如背景图必须写成:
```xml
<image slide:role="image" href="@./background.jpg" x="0" y="0" width="960" height="540" />
```
CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px` 长度,例如 `0``1280``320.5``80px`。不要使用 `%``em``rem``calc(...)` 或省略单位后依赖 SVG 默认值。服务端可能会对部分非法几何值降级为 `0` 并给 warning但正式生成应在 CLI 侧提前修正。
## 当前支持的 SVG 子集
- Shape: `rect``ellipse``circle``line``path``foreignObject`
- Container: `g`、嵌套 `svg`
- Chart marker: root 直系 `g slide:role="chart"`,用于透传 canonical JSON chart spec。
- Definitions: `defs` 内的 `linearGradient``radialGradient``filter/feDropShadow`;支持嵌套 `defs` 和 gradient `href` / `xlink:href` 继承。
- CSS: tag、`.class``#id``.a.b``tag.class` 等简单 selector支持 specificity 和 source order复杂 selector、media query、伪类会被忽略。
- Paint: `fill``stroke``stroke-width``opacity``fill-opacity``stroke-opacity``stroke-dasharray``stroke-linecap``stroke-linejoin`
- Gradient: `stop-color` / `stop-opacity` 可来自属性、inline style 或 CSS`gradientTransform``spreadMethod`、focal 点等复杂能力会被近似或忽略。
- Effects: 支持 `filter="url(#...)"` 指向的 `feDropShadow`、CSS `filter: drop-shadow(...)`、以及首层 `box-shadow`;多层 shadow、spread、inset 会被近似或忽略。
- Transform: `translate``scale``matrix``rotate`transform 参数应写数字或 `px`,复杂 transform 会被近似或忽略。
- Path: 只使用 `M/L/H/V/C/Q/Z`CLI 会拒绝 arc `A`、smooth curve `S/T` 和其他未知命令。
- Text: `foreignObject slide:shape-type="text"` 内支持常见 XHTML 文本标签、`br` 和基础文字样式。
## SVG-native 效果的 SVGlide-safe 写法
视觉参考图、浏览器 SVG demo 或 `svglide-visual-effects-gallery.html` 只能作为效果方向,不能直接当作 `slides +create-svg` 输入。生成器必须把浏览器 SVG 能力改写为当前 SVGlide 支持面:
| 浏览器 SVG 常见写法 | SVGlide-safe 写法 |
|---|---|
| 根级 `<text>` / 普通 SVG text | `foreignObject slide:role="shape" slide:shape-type="text"`,并显式写 `font-size``font-weight``color``line-height` |
| `<polygon>` / `<polyline>` | 改成 `path slide:role="shape"`,只使用 `M/L/H/V/C/Q/Z` |
| `<marker>` 箭头 | 用独立三角形 `path` 或短 line + arrowhead path 显式绘制 |
| `<pattern>` 网格、点阵、纹理 | 用重复的 `line``circle``rect` 显式铺排;不要依赖 pattern 展开 |
| `mask` / `clipPath` 大字裁切 | 用大字描边、深色/渐变背板、半透明 shape overlay 或裁切后的本地图片替代 |
| 多层 `filter`、blur、glow | 用多层半透明 circle/rect/path 模拟光晕;仅把简单 drop-shadow 当增强,不当核心表达 |
| `stroke-dasharray` 关键路线 | 用短 line segment 或 filled dot markers 手工排布;关键流程不要只靠虚线;带 `route` / `path` / `flow` / `loop` / `timeline` / `rail` 等语义的虚线会被 preflight 视为错误 |
| `<image opacity="...">` | MVP preflight 只 warning高保真场景应预合成到图片或在图片上方加半透明 `rect slide:role="shape"` |
| iconfont / 外链 SVG 图标 | 用 SVGlide-safe path/line/rect/circle 组合本地绘制,或先转成受支持的本地图片资产 |
每个 SVG 页面应通过 `visual_recipe` 证明自己值得使用 SVG要么有强视觉主标题要么有路径/流向/隐喻/标注/图标系统/微图表/纹理/仪表盘等 SVG-native 结构。只有 `rect + foreignObject` 的普通卡片页应优先走 XML/SXSD。
文本样式应使用 parser 友好的显式 CSS 属性,例如 `font-size``font-weight``font-family``color``line-height``text-align``letter-spacing`。不要依赖 `font:` shorthand、复杂 flex 布局或浏览器默认样式来表达关键字号、加粗和行距;这些在转换到 SXSD/XML 时可能降级为默认样式。
白色或接近白色的文字必须完整落在深色 shape 承载底上;如果标题跨到浅色图片、白色蒙层或白底,生成器应扩大深色底、加背板/遮罩,或改用深色文字。圆形/椭圆节点内只放短标签,解释句、指标和说明放到独立 callout、legend 或机制表中。
生成 live smoke 或跨 lane 验证用 SVG 时,颜色优先写成 hex/rgb 加独立透明度属性,例如 `fill="#0F172A" opacity="0.72"``stroke="#38BDF8" stroke-opacity="0.8"`。不要在首轮验证里大量依赖 `rgba(...)` 作为 SVG leaf 的 `fill` / `stroke` 值;不同 server lane 的 paint 解析能力可能不一致hex + opacity 更容易定位问题。渐变仍按 XML 协议要求使用 `rgba(...)` 停靠点。
图片透明度当前不是稳定协议面:`<image opacity="...">` 在 SVG 输入中会通过 CLI 传给服务端,但转换后的 Slides XML `<img>` 不一定保留 alpha。MVP 阶段 preflight 只 warning生成器不得在高保真页面依赖 image opacity要么把淡化效果预合成到本地图片文件要么用一个半透明 `rect slide:role="shape"` 覆盖在图片上方。shape opacity 会转换为 Slides XML `alpha`,比 image opacity 更稳定。
圆形和椭圆描边宽度也不是稳定协议面:`circle` / `ellipse``stroke-width` 可能在 readback 中降级。关键圆环请用两层填充圆/椭圆模拟,或改用 path/rect普通细描边可以保留但需要视觉回读确认。
虚线描边也不是稳定协议面:`stroke-dasharray`,尤其是自定义 path 上的虚线闭环,可能在 readback 中降级。关键流程线、路线图和闭环图用短 line segment 或 filled dot markers 显式绘制;带 `route``path``flow``loop``timeline``rail` 等语义的 dashed path 会被 `svg_preflight.py` 作为 error 拦截。普通装饰虚线也需要 live readback 复核。
## Chart Spec Marker
当 SVG 页面需要创建原生 chart 时,使用 root 直系 chart marker。payload 是 canonical JSON bytes先对这段 decoded JSON bytes 计算 `sha256`,再用无 padding base64url 写入 metadata 文本:
```xml
<g slide:role="chart"
slide:chart-ref="chart-sales-001"
x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
```
Decoded canonical JSON shape:
```json
{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}
```
约束:
- `g slide:role="chart"` 必须是 root `<svg>` 的直系子节点,不能嵌套在普通 `g` / 嵌套 `svg` 中。
- `slide:chart-ref``x``y``width``height` 必填bbox 数值只能是数字或 `px` 长度。
- marker 内必须且只能有一个 `<metadata>` 子节点。
- metadata 必须声明 `data-svglide-chart="svglide-chart-inline/v1"``data-format="svglide-chart-spec-v1"``data-encoding="base64url-json"``data-payload-hash="sha256:<64 hex>"`
- metadata 文本是无 padding 的 base64url payload解码后必须是 canonical JSON chart spec。不要把 SXSD `<chart>` XML 放入 SVGlide chart marker。
- JSON spec 必须包含 `version="svglide-chart-spec/v1"``chartType``data.categories``data.series[].name``data.series[].values`。MVP 只支持 `chartType="bar"` / `"line"``categories` 和每个 `values` 数组长度必须一致;`values` 只能是有限 JSON number。
- CLI 校验上述结构、hash 和基础数据合法性,不会为 chart 调用任何额外 API。请求体仍是 `{ "slide": { "content": "<svg ...>" } }`
- 不要把 chart marker 当成 SVG 手绘微图表的替代品;如果只是视觉小图、指标条或仪表盘装饰,仍优先用 SVGlide-safe shape/path 直接绘制。
## 不支持
- 不要把普通 SVG 直接交给 `+create-svg`CLI 不会自动补齐 SVGlide 协议。
- 不支持缺少 role 的可渲染元素,例如 `<rect .../>`;必须写成 `<rect slide:role="shape" .../>`
- 不要把 `<g>` 当作可渲染 shape`<g>` 只是容器,实际 `rect``path``foreignObject``image` 等子元素仍需各自声明 `slide:role`
- 不支持根级 `<text slide:role="text">`;用 `foreignObject + slide:shape-type="text"`
- 不要在 `<image>` 上保留 `xlink:href`CLI 会统一输出 canonical `href`
- 不支持 `slide:role="whiteboard"`,也不支持旧的 `data-svglide-whiteboard` SVGlide whiteboard markerwhiteboard 内容必须走 XML/whiteboard 路径。
- Preview/MVP 阶段允许 http(s) 或 data URL 图片通过 preflight warning用于快速验证丰富视觉live 转换和 readback 可见性不保证,必须回读确认。正式交付优先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。
- `slides +create-svg` MVP 不支持指定 `beforeSlideBlockID` 插入到某一页前;它创建新 presentation 后按 `--file` 顺序追加。
这些能力依赖 slide server SVGlide parser 新版本。如果 BOE/线上未部署对应 server 分支CLI 放行后仍可能收到服务端 `SVGLIDE_ERROR_JSON` 或 generic invalid param。
## 图片与 Metadata
SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。Preview 阶段图片是拉开 SVGlide 和 XML 生成差距的关键能力:宣传、产品、品牌、案例、教学和视觉展示型 deck 应优先根据用户 query、deck 主题和页面标题去网络检索并拉取强相关图片,再包含封面/半出血主视觉/案例场景/产品截图/材质纹理/图鉴图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路完全不可用时,才退回纯矢量方案,并在结果中说明原因。
图片资产采用双模式:
- **Preview mode**:版权/授权不是阻断项。SHOULD 先从用户 query、deck 标题、章节标题和 page takeaway 生成图片检索词,去网络检索并拉取主题强相关图片;也可以使用公开可访问图片 URL、搜索图片、新闻/历史/艺术/科普图片、官网截图、产品截图、网页截图、材质纹理或 AI 生成图作为视觉占位。必须记录 `retrieval_query``source_url` 或生成方式,并把 `license` 写成 `preview_unverified`。不要使用明显不适当素材、敏感肖像,或会造成商业背书误导的 logo/商标。
- **Production mode**:正式交付必须替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library但每张图都应检查具体 license、署名和第三方权利。
MVP 阶段素材清单不完整只作为 warning但生成 deck 时仍应在素材清单或 README 中记录图片检索词、图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时应显式标记风险并在正式交付前替换。
当 SVG source 使用 `<image>` 时,对应 slide plan 应尽量有 `asset_contract`,并至少包含:
```json
{
"mode": "preview",
"source_type": "public_url | web_search_preview | screenshot | Unsplash | Pexels | procedural | ai_generated | user_provided | owned",
"retrieval_query": "topic-specific image query derived from user query and page topic",
"license": "preview_unverified",
"local_path": "@./assets/hero.jpg",
"href": "https://example.com/hero.jpg",
"usage_page": 1,
"source_url": "https://...",
"retrieved_at": "2026-06-08",
"generated_by": "optional when source_type is procedural/ai_generated",
"replacement_required": true
}
```
无图片页可以写 `"asset_contract": "none_required"`。如果 SVG source 检测到 image primitive`asset_contract` 缺少检索词、来源、许可、本地路径或使用页MVP 阶段 preflight 只 warningpreview 中可用 `license=preview_unverified` 明确标记,正式交付仍应补齐或替换为来源清晰的图片资产。
`slides +create-svg` 会把 `<image href="@./image.png">` 上传为 file token并注入
```xml
<metadata data-svglide-assets="true">
<img src="boxcn..." />
</metadata>
```
metadata 只用于让现有服务端链路生成 `FileMetaMap`。如果使用 `--assets assets.json` 传入预上传 tokenCLI 也会按同样规则替换和注入。
`assets.json` 格式:
```json
{
"@./image.png": "boxcn...",
"./other.png": "boxcn..."
}
```

View File

@@ -0,0 +1,103 @@
# SVGlide 视觉 Recipe
这份文档是 `slides +create-svg` 的短版可执行 recipe 指南。
它把研究目录提炼成生成阶段可放入 agent 上下文的规则。
更完整的研究资料仍保留在 CLI skill 外:
`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/visual_recipe_catalog.md`.
## 边界
- `visual_recipe` 定义页面结构,以及这一页为什么值得用 SVG。
- `style_preset` 定义视觉语言、配色、纹理、密度和 motif。
- `renderer_id` 定义具体几何渲染器。
不要用 `style_preset` 替代 `visual_recipe`。不要在 `slide_plan.json`
里发明新的 recipe id。
## 硬默认值
- 画布:`width="960" height="540" viewBox="0 0 960 540"`
- 安全区:关键文本、标签、图表、卡片、节点和图例保持在
`x=48..912` and `y=40..500`.
- 网格:使用稳定的 12 栏或 8px 步进布局,避免临时手调坐标。
- 文本:中文正文每行控制在约 28 个字;英文正文每行约 62 个字符。
- 装饰:装饰线、水印、纹理和背景几何不能抢夺标题/焦点内容的注意力,也不能贴住它们。
- Deck 多样性8 页以上 SVG deck 应至少使用 5 种 visual recipe family。
## Plan 字段
写 SVG 前,每个 SVG 页面 plan 都必须包含这些字段:
```json
{
"visual_recipe": "path_flow",
"visual_intent": "show a staged route from current state to target state",
"visual_focal_point": "curved route spine with the final target node",
"visual_signature": "curved route path plus stage annotations",
"svg_effects": ["path", "connector_flow", "typography"],
"required_primitives": ["path", "annotation"],
"svg_primitives": ["path", "annotation", "typography"],
"xml_like_risk": "without the route geometry this becomes ordinary bullets",
"content_density_contract": "flow >= 4 stages",
"risk_flags": [],
"source_policy": "do not invent unsupported numbers"
}
```
## Recipe Selection Matrix
`slide_plan.json` 中使用这些 CLI 支持的 underscore id。
| 用户意图 | `visual_recipe` | SVG source 中必须体现 |
|---|---|---|
| 封面、章节开场、hero 观点页 | `hero_typography` | 大字、几何承载体、清晰焦点对象 |
| 战略框架、强几何版式 | `geometric_composition` | 非卡片式几何、`path` 或异形区域 |
| 路线图、旅程、流程、路径 | `path_flow` | 显式 path/line 主线、箭头或阶段标记 |
| KPI、战报、数据复盘 | `infographic_scorecard` | 大数字加微图表或仪表几何 |
| 能力地图、模块总览 | `icon_capability_map` | 风格统一的 SVG-safe 图标和标注区域 |
| 层次、氛围、概念强调 | `gradient_depth` | 渐变或分层半透明几何,并保证文字可读 |
| 产品/成果/图片叙事 | `mask_clip_showcase` | 图片区域加安全的 overlay/crop 模拟 |
| 技术系统、网格、编码质感 | `technical_texture` | 重复 line/dot/rect、网格、扫描线或图解纹理 |
| 闭环、飞轮、反馈系统 | `metaphor_loop` | 闭合路径或循环流程,并带输入/输出标签 |
| 诊断、callout、焦点标注 | `spotlight_annotation` | 高亮区域、callout 线、标注目标 |
| Dashboard、控制台、监控界面 | `fake_ui_dashboard` | UI frame、状态栏、指标、微图表/日志行 |
| 品牌或系列身份页 | `brand_system` | 稳定标题系统、motif、配色和重复身份元素 |
## 安全 Effects
优先使用可以由 SVGlide-safe primitives 表达的效果:
- `path`:曲线、波形、路线、自定义形状。
- `gradient`:背景层次和重点强调;关键文字必须有稳定承载底。
- `texture`:重复的 `line``circle``rect`;不要只依赖 `<pattern>`
- `connector_flow`:显式 line/path 加箭头三角或圆点。
- `chart_geometry`:柱、点、线、仪表、坐标轴和标签。
- `grid_geometry`:矩阵、表格式视觉摘要、结构化对齐网格。
- `watermark_text`:低对比大字,不能影响阅读。
- `image_overlay`:真实图片加显式半透明 shape 覆盖层。
- `spotlight`:分层半透明形状,不依赖复杂 filter 光效。
## 高风险 Effects
这些效果只有在 `risk_flags` / `recipe_fallback` 中声明了安全改写或
fallback 时,才允许出现在 visual planning 中:
- `filter`
- `mask_clip`
- `pattern`
- `symbol`
- `stroke_dasharray`
- `image_opacity`
关键视觉在调用 `slides +create-svg` 前,应改写成显式 shape、line、dot、
overlay或预合成图片。
## 反退化规则
- 如果页面主要只是 `rect + foreignObject`,还不足以证明值得走 SVGlide
除非它同时具备真实 SVG-native 结构path、chart geometry、icon system、
texture、spotlight、dashboard frame、connector flow 或 image overlay。
- 第一眼看到的对象应该和该页 `visual_focal_point` 一致。
- 相似页面可以共享 `style_preset`,但不能只替换文案和背景色,布局骨架完全不变。
- 研究笔记里的 dotted recipe 名称,例如 `cover.hero`,不是有效运行时 id。
写入 plan 前必须映射到上面的 underscore id。

View File

@@ -0,0 +1,105 @@
# SVGlide Artifacts Spec
Read this file only after `svglide-svg` route admission. It defines the artifact layout expected by the SVG route checks and delivery records.
## Artifact Directories
Use one run directory per deck or task:
```text
.lark-slides/plan/<deck-id>/
00-input/
01-project/
project_manifest.json
state.json
02-plan/
slide_plan.json
svglide.lock.json
plan-confirmation.request.json
plan-confirmation.json
03-assets/
assets.json
asset-manifest.json
04-svg/
page-001.svg
page-001.receipt.json
page-002.svg
page-002.receipt.json
generate_svg.py
prepared/
page-001.svg
page-002.svg
05-preview/
preview.html
preview-manifest.json
06-check/
preflight.json
preview-lint.json
aesthetic-review.json
quality-gate.json
07-create/
create-command.txt
dry-run.json
live-create.json
08-readback/
xml-presentations-get.json
readback-check.json
09-export/
receipts/
logs/
```
Do not create a separate SVG-only plan root. The SVG route extends the common `.lark-slides/plan/<deck-id>/` layout.
## Required Artifacts
| Artifact | Required | Producer | Consumer |
|---|---:|---|---|
| `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/svglide.lock.json` | when execution parameters are locked | planner/generator | preflight and runner |
| `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 |
| `source/source-receipt.json` | yes before strategy/generation | source stage | assets, generate_svg, quality audit |
| `03-assets/assets.json` | yes before SVG generation | assets stage | prepare and CLI upload/rewrite |
| `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/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/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 |
| `06-check/runtime-review.json` | yes before quality gate | runtime_review stage | quality gate |
| `06-check/semantic-advisory.json` | optional advisory | semantic advisory script | human review |
| `06-check/ppt-master-inventory.json` | optional migration governance | ppt-master inventory script | human review |
| `06-check/quality-gate.json` | yes before create | quality gate | dry-run and live-create wrapper |
| `07-create/create-command.txt` | yes before create | create wrapper | audit |
| `07-create/dry-run.json` | yes before live create | CLI dry-run wrapper | live-create wrapper |
| `07-create/ppe-proof.json` | yes before live create | ppe_proof stage | live-create wrapper |
| `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 |
| `receipts/<stage>.json` | yes per completed or blocked stage | runner or stage script | audit and resume |
| `receipts/assets.json` | yes before generate_svg | runner `assets` | generate_svg and audit |
| `receipts/generate_svg.json` | yes before prepare | runner `generate_svg` | prepare and audit |
| `notes/notes-review.json` | optional speaker handoff | speaker notes script | human handoff |
## Path Rules
- `02-plan/slide_plan.json` must include `plan_path` pointing to itself.
- `02-plan/plan-confirmation.json` must bind the confirmed plan with `plan_sha256`; if `02-plan/svglide.lock.json` exists, it must also bind `lock_sha256`.
- `svg_files` must list `04-svg/prepared/page-###.svg` pages in the same order as `slides +create-svg --file`.
- `03-assets/asset-manifest.json` must bind current plan/lock/assets/source receipt hashes before `generate_svg`.
- 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.
- `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.
- Runtime artifacts under `.lark-slides/plan/<deck-id>/` are per-run outputs. Do not commit them unless a test fixture explicitly requires it.

View File

@@ -0,0 +1,35 @@
# SVGlide Asset Planning
Read this file only after `svglide-svg` route admission. Shared asset metadata guidance remains in `asset-planning.md`.
## Image Policy
SVG preview work should plan rich, relevant images when the topic benefits from them. Preview image licensing uncertainty is a warning and replacement obligation, not a reason to downgrade to blank or purely decorative pages.
Record image metadata in `asset_contract`:
- `mode`: `preview` or `production`.
- `retrieval_query`: the query used or planned.
- `source_type`: public URL, local asset, generated image, screenshot, or user-provided asset.
- `source_url` or `local_path_or_href`.
- `license`: use `preview_unverified` when not confirmed.
- `usage_page`.
- `replacement_required`: true when preview rights are not confirmed.
## Source Forms
Use local placeholders when possible:
```xml
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
```
`slides +create-svg` uploads local placeholders and rewrites them to file tokens. Pre-uploaded tokens can be supplied through the command's asset mapping.
HTTP(S) and data images can be useful for local preview, but live create and readback must confirm they render. Production delivery should use local placeholders or file tokens with clear rights.
## Fallbacks
Do not leave empty image boxes. If an image cannot be obtained or cannot be used, generate a visible fallback from SVG-safe shapes, labels, diagrams, or chart geometry, and record the source risk.
Avoid depending on `filter`, `mask`, `clipPath`, `pattern`, or complex remote assets for critical meaning unless the plan explicitly allows fallback or rasterization.

View File

@@ -0,0 +1,78 @@
# SVGlide Assets Contract
本文只在 `svglide-svg` route admission 之后读取。`assets` stage 负责在 SVG 生成前把素材依赖变成可审计状态。
## Inputs
- `02-plan/slide_plan.json`
- optional `02-plan/svglide.lock.json`
- optional existing `03-assets/assets.json`
## Outputs
- `03-assets/assets.json`
- `03-assets/asset-manifest.json`
- `03-assets/image-jobs.json`
- `receipts/assets.json`
`assets.json``@./path` 到 file token 的可选映射:
```json
{
"@./03-assets/hero.png": "boxcn_xxx"
}
```
`asset-manifest.json` 记录 plan/lock/assets hash、asset contracts、缺失素材、素材获取结果、来源 URL、license、digest、placement role、safe text zones、fallback 和不可创建素材。
## Contract Sources
`svglide_assets.py` 从这些字段收集素材契约:
- `slide_plan.json.asset_contracts`
- `slide_plan.json.assets`
- `slide_plan.json.images`
- `svglide.lock.json.asset_contracts`
- `svglide.lock.json.assets`
- `svglide.lock.json.images`
每个契约至少应包含:
```json
{
"id": "hero",
"href": "@./03-assets/hero.png",
"required": true,
"usage_page": 1,
"placement_role": "cover | background | body_visual | inline_figure | closing",
"query": "search phrase or image prompt seed",
"license": "owned | preview_unverified | generated | user_provided"
}
```
## Online Acquisition
`assets` stage 默认由 runner 传入 `--network-policy auto`,按以下顺序处理素材:
1. 已有本地 `@./...` 文件或 `assets.json` file token。
2. 外部 stage command 或用户预置文件。
3. 可联网时下载 HTTP 图片或通过 provider 搜索图片。
4. 配置 image backend 时写入 `03-assets/image-jobs.json`,由外部 backend 生成。
5. 不能获取真实图片时记录 `svg_fallback`,生成阶段必须用 SVG-native component 兜底。
测试、golden 和 CI 使用 `--network-policy fixture``--offline`,不得依赖真实网络。
`image-jobs.json` 只记录 prompt 和 backend 计划,不要求主模型具备多模态能力,也不在 runner 内强制调用图片生成服务。
## Blocking Rules
- Required `@./...` 本地文件不存在:阻断。
- Required `http://``https://``data:` 图片:如果 acquisition 不能下载成本地文件或换成 file token则阻断 live create。
- `assets.json` 非 object 或 key/value 不是 string阻断。
- Optional missing asset 可以记录为 `missing_optional`,但不能形成空图片框。
- Cover/background/closing 图片必须在 manifest 中记录 `safe_text_zones`,标题和结论必须使用 editable overlay。
- Body visual 和 inline figure 必须保留 source/caption/annotation 所需 metadata核心论点和数据不得烘焙进图片。
## Relationship With Prepare
`prepare` 只消费 `assets` stage 的结果,不负责决定素材策略。`prepare` 可以验证 SVG 内的 `@./...` 引用是否已映射或本地存在,但不能补全来源、授权或生成图片。

View File

@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-chart-verify.schema.json",
"title": "SVGlide chart verify receipt",
"type": "object",
"required": ["schema_version", "status", "action", "inputs", "prepared_files", "summary", "issues"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-chart-verify/v1"},
"status": {"enum": ["passed", "failed"]},
"action": {"enum": ["create_live", "repair_and_rerun"]},
"inputs": {"type": "object"},
"prepared_files": {
"type": "array",
"items": {
"type": "object",
"required": ["path", "sha256"],
"additionalProperties": true,
"properties": {
"path": {"type": "string", "minLength": 1},
"sha256": {"type": "string", "minLength": 1}
}
}
},
"summary": {
"type": "object",
"required": ["error_count", "required_chart_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0},
"required_chart_count": {"type": "integer", "minimum": 0}
}
},
"issues": {"type": "array"}
}
}

View File

@@ -0,0 +1,94 @@
# SVGlide Checks Checklist
Read this file only after `svglide-svg` route admission. Treat the checks as one blocking chain; do not skip ahead after an earlier error.
## Checks Chain
```text
route admission
-> loaded_rule_set recorded
-> source evidence recorded
-> strategy review passed
-> plan confirmation recorded
-> assets manifest recorded
-> generate_svg source pages recorded
-> prepare SVG input set
-> svg_preflight.py --plan
-> svg_preview_lint.py
-> svglide_aesthetic_review.py
-> svglide_chart_verify.py
-> svglide_semantic_review.py
-> svglide_runtime_review.py
-> svglide_quality_gate.py
-> slides +create-svg dry-run
-> svglide_ppe_proof.py
-> live create
-> xml_presentations get readback
```
## Checklist
- [ ] Confirm `svglide-svg` route admission.
- [ ] Record `loaded_rule_set` from `svglide-svg-private.rules.json` in `02-plan/slide_plan.json`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> source` and confirm `source/source-receipt.json` has `status: "passed"`.
- [ ] Verify `02-plan/slide_plan.json` has route, canvas, safe area, style system, art direction, source policy, business claims, assets, and ordered `svg_files`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> strategy_review` and confirm `02-plan/strategy-review.json` has `status: "passed"`.
- [ ] Confirm `02-plan/plan-confirmation.json` has `status: "confirmed"`, `confirmed_by: "user"`, and current `plan_sha256` / optional `lock_sha256`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> assets` and confirm `03-assets/asset-manifest.json` has `status: "passed"`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> generate_svg` to generate or register source SVG pages under `.lark-slides/plan/<deck-id>/04-svg/`.
- [ ] Confirm `receipts/generate_svg.json` has `status: "passed"` and lists the generated source SVG hashes.
- [ ] Run `svglide_prepare.py` and verify prepared SVG pages under `.lark-slides/plan/<deck-id>/04-svg/prepared/`.
- [ ] Run source preflight:
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--plan .lark-slides/plan/<deck-id>/02-plan/slide_plan.json \
--input .lark-slides/plan/<deck-id>/04-svg/prepared/page-001.svg
```
- [ ] Save preflight output as `06-check/preflight.json` and confirm `summary.error_count == 0`.
- [ ] Build `05-preview/preview.html`.
- [ ] Run preview lint:
```bash
python3 skills/lark-slides/scripts/svg_preview_lint.py \
.lark-slides/plan/<deck-id>/05-preview/preview.html --pretty
```
- [ ] Save preview lint output as `06-check/preview-lint.json`.
- [ ] Confirm preview lint `summary.error_count == 0` and `action == "create_live"`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> aesthetic_review` and confirm `06-check/aesthetic-review.json` action is `create_live`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> chart_verify` and confirm `06-check/chart-verify.json` status is `passed`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> semantic_review` and confirm `06-check/semantic-review.json` status is `passed`.
- [ ] Confirm `06-check/text-inventory.json` has no unmatched visible SVG text.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> runtime_review` and confirm `06-check/runtime-review.json` status is `passed`.
- [ ] Run `svglide_quality_gate.py` and confirm `06-check/quality-gate.json` status is `passed`.
- [ ] Run `slides +create-svg --dry-run` with the same ordered `--file` list; use repo-relative file paths, and set `SVGLIDE_LARK_CLI_CMD` when the current worktree implementation is not installed as the global `lark-cli`.
- [ ] Run `svglide_project_runner.py stage .lark-slides/plan/<deck-id> ppe_proof` and confirm `07-create/ppe-proof.json` status is `passed`.
- [ ] Run live `slides +create-svg` only after all blocking local gates, dry-run, and PPE proof pass.
- [ ] Read back with `slides xml_presentations get` and record `08-readback/readback-check.json` for page count, blank-page, bounds, text-fit, asset-token, and closing-slide checks.
## Blocking Conditions
Live create is blocked by:
- route not admitted
- missing `loaded_rule_set`
- missing, failed, stale, or thin source receipt/evidence
- missing or stale plan confirmation
- missing or failed assets manifest
- missing or stale `generate_svg` receipt
- preflight errors
- preview lint errors
- aesthetic review status other than `passed` or action other than `create_live`
- required chart verification missing, failed, or stale
- missing, failed, or stale semantic review
- missing, failed, or stale runtime review
- unmatched SVG visible text in `06-check/text-inventory.json`
- quality gate status other than `passed`
- missing, failed, or stale PPE proof before live create
- SVG page order mismatch between plan and command
- external HTTP(S) or data image hrefs in the `slides +create-svg` input
- missing readback plan for a live run
Warnings must be recorded with owners or replacement plans, but they do not block unless they affect visible correctness, licensing for production delivery, or command validity.

View File

@@ -0,0 +1,106 @@
# SVGlide Create SVG Contract
Read this file only after `svglide-svg` route admission. This is the command/API contract for `slides +create-svg`.
## Command
```bash
lark-cli slides +create-svg \
--as user \
--title "Deck title" \
--file .lark-slides/plan/<deck-id>/04-svg/prepared/page-001.svg \
--file .lark-slides/plan/<deck-id>/04-svg/prepared/page-002.svg
```
Flags:
| Flag | Contract |
|---|---|
| `--title` | presentation title; defaults to `Untitled` when omitted |
| `--file` | required, repeatable, ordered SVG page input |
| `--assets` | optional JSON mapping SVG `@path` placeholders to uploaded file tokens |
| `--dry-run` | common CLI dry-run mode; prints the create, upload, and per-page request chain |
The wrapper must build this command only after `06-check/quality-gate.json` has `status: "passed"`. The dry-run and live-create commands must consume the same ordered prepared SVG hash set.
Runner wrappers should pass repo-relative `--file` and `--assets` paths when the project lives under the CLI repository. The `+create-svg` shortcut rejects absolute input paths as unsafe. When validating an uninstalled worktree build, set `SVGLIDE_LARK_CLI_CMD` to the desired command prefix, for example:
```bash
SVGLIDE_LARK_CLI_CMD='env GOCACHE=/private/tmp/svglide-gocache go run .' \
python3 skills/lark-slides/scripts/svglide_project_runner.py run \
.lark-slides/plan/<deck-id> \
--until dry_run
```
## API Chain
`slides +create-svg` uses the existing XML presentation route:
```text
POST /open-apis/slides_ai/v1/xml_presentations
-> optional media uploads for local image placeholders
-> POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1
```
The per-page body is:
```json
{
"slide": {
"content": "<svg ...>...</svg>"
}
}
```
The command does not add a new `/svg_slide` endpoint. Chart markers remain inside the SVG page content; the CLI does not call a separate chart API.
## SVG Input Contract
Each input file must be a complete SVGlide SVG page:
- root is non-namespaced `<svg>`.
- root declares `xmlns:slide="https://slides.bytedance.com/ns"`.
- root includes `slide:role="slide"` and `slide:contract-version="svglide-authoring-contract/v1"`.
- default canvas is `width="960" height="540" viewBox="0 0 960 540"`.
- rendered leaf elements use `slide:role="shape"` or `slide:role="image"`.
- text uses `foreignObject slide:role="shape" slide:shape-type="text"`.
- shape geometry attributes are explicit numbers or `px` values.
- supported shape tags are `rect`, `ellipse`, `circle`, `line`, `path`, and `foreignObject`.
- `path d` uses only `M/L/H/V/C/Q/Z` commands.
- images use local `@./path` placeholders or file tokens; external HTTP(S) and data hrefs are invalid command inputs.
- `slide:role="whiteboard"` and legacy whiteboard metadata are invalid.
## Asset Rewrite Contract
For `<image slide:role="image" href="@./assets/hero.jpg" ...>`, the CLI:
1. uploads the local asset to the target presentation unless `--assets` supplies a token,
2. rewrites the SVG image to canonical `href="file_token"`,
3. injects transport metadata:
```xml
<metadata data-svglide-assets="true"><img src="file_token" /></metadata>
```
The metadata is transport-only and is not a visible slide element.
## Chart Marker Contract
Native chart markers are root-level direct children:
```xml
<g slide:role="chart" slide:chart-ref="chart-001" x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
```
Payload is canonical JSON bytes encoded as unpadded base64url. The hash is calculated on decoded JSON bytes. MVP chart types are `bar` and `line`.
## Failure Contract
The command validates files before the first API call. After the presentation is created, image upload or later page creation can still fail. Error records must preserve the presentation id, created slide ids, uploaded image count, and failing page index when available so the run can be inspected or repaired without losing state.

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-evidence.schema.json",
"title": "SVGlide evidence pack",
"type": "object",
"required": ["schema_version", "source_status", "items"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-evidence/v1"},
"source_status": {"enum": ["ready", "thin", "blocked"]},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "text"],
"additionalProperties": true,
"properties": {
"id": {"type": "string", "minLength": 1},
"text": {"type": "string", "minLength": 20},
"source": {"type": "string", "minLength": 1},
"url": {"type": "string", "minLength": 1},
"date": {"type": "string", "minLength": 1}
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
# SVGlide Generate SVG Contract
本文只在 `svglide-svg` route admission 之后读取。`generate_svg` 是源 SVG 生成阶段,对应产物是 `04-svg/page-###.svg`
## Stage Boundary
`generate_svg` 不做这些事:
- 不上传文件。
- 不调用 `slides +create-svg`
- 不生成线上 presentation。
- 不替代 `prepare``preflight``preview_lint``readback`
`generate_svg` 只做这些事:
- 读取确认后的 plan/lock/assets。
- 生成或登记源 SVG。
- 为整 deck 和每页写 hash receipt。
## Inputs
- `02-plan/slide_plan.json`
- optional `02-plan/svglide.lock.json`
- `03-assets/asset-manifest.json`
- optional generator script:
- `04-svg/generate_svg.py`
- `logs/generate_svg.py`
- `logs/generate_svgs.py`
## Outputs
- `04-svg/page-###.svg`
- `04-svg/page-###.receipt.json`
- `receipts/generate_svg.json`
## Page Receipt
每页 receipt 必须至少包含:
```json
{
"version": "svglide-page-generation/v1",
"stage": "generate_svg",
"page": 1,
"source_svg": "04-svg/page-001.svg",
"source_sha256": "<sha256>",
"lock_path": "02-plan/svglide.lock.json",
"lock_sha256": "<sha256>",
"asset_manifest_path": "03-assets/asset-manifest.json",
"asset_manifest_sha256": "<sha256>",
"generator_mode": "script | external",
"theme_archetype": "company_ecosystem",
"identity_fit_reason": "renderer and visual recipe fit the declared visual_identity",
"reuse_risk_score": 0,
"fallback_skeleton_used": false
}
```
The deck-level `receipts/generate_svg.json` must summarize these page identity records in `page_identity_summary` and expose `fallback_skeleton_used`. Strict profiles reject `fallback_skeleton_used=true`; preview-only profiles may treat it as a repair warning.
## Discipline
- 新建 deck 优先让 `generate_svg` 执行项目内 generator script。
- 如果源 SVG 已由外部 agent 生成,可登记为 `generator_mode=external`,但必须记录 hash。
- 生成后修改 `04-svg/page-###.svg` 必须重跑 `generate_svg`,否则 `prepare` 阻断。
- CLI 不迁移 ppt-master “禁止脚本生成”的规则CLI 允许脚本生成,但必须用 lock/assets/hash/receipt 把漂移控制住。
- Generator scripts must consume `slide_plan.visual_identity`; if they fall back to a generic skeleton, they must mark that fact in the page and deck receipts.

View File

@@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-generator-receipt.schema.json",
"title": "SVGlide generator receipt",
"type": "object",
"required": [
"stage",
"status",
"generator_mode",
"generated_files",
"page_receipts",
"plan_sha256",
"evidence_sha256",
"asset_manifest_sha256",
"source_receipt_sha256"
],
"additionalProperties": true,
"properties": {
"stage": {"const": "generate_svg"},
"status": {"enum": ["passed", "failed"]},
"generator_mode": {"enum": ["script", "external"]},
"generated_files": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["path", "sha256"],
"additionalProperties": true,
"properties": {
"path": {"type": "string", "minLength": 1},
"sha256": {"type": "string", "minLength": 1}
}
}
},
"page_receipts": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"plan_sha256": {"type": "string", "minLength": 1},
"evidence_sha256": {"type": "string", "minLength": 1},
"asset_manifest_sha256": {"type": "string", "minLength": 1},
"source_receipt_sha256": {"type": "string", "minLength": 1},
"lock_sha256": {"type": ["string", "null"]},
"generator_script_sha256": {"type": ["string", "null"]}
}
}

View File

@@ -0,0 +1,65 @@
# SVGlide Lock Contract
本文只在 `svglide-svg` route admission 之后读取。`02-plan/svglide.lock.json` 是执行锁,不是第二份完整 plan。它只锁定 SVG 生成和检查必须稳定使用的值。
## 角色边界
- `slide_plan.json`:用户可见计划,描述内容、页序、来源、风格意图。
- `svglide.lock.json`:机器执行锁,描述生成 SVG 时不能漂移的参数。
- `plan-confirmation.json`:用户确认的 plan/lock hash。plan 或 lock 变化后必须重新确认。
## Required Shape
```json
{
"version": "svglide-lock/v1",
"route": "svglide-svg",
"plan_path": "02-plan/slide_plan.json",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"quality_profile": "production",
"style_tokens": {
"colors": {},
"typography": {},
"spacing": {},
"shape_rules": {}
},
"generation_rules": {
"text_strategy": "foreignObject",
"image_strategy": "local_or_token_only",
"forbidden": []
},
"asset_contracts": [],
"business_claims": [],
"page_contracts": [
{
"page": 1,
"path": "04-svg/prepared/page-001.svg",
"rhythm": "anchor",
"layout_family": "cover",
"visual_recipe": "hero_metric",
"required_primitives": ["typography", "geometric_shape"],
"svg_effects": ["gradient"],
"asset_refs": [],
"chart_ref": null
}
],
"pages": []
}
```
## Field Rules
- `style_tokens.colors` 是生成时唯一的颜色来源;需要新颜色时先改 lock 并重新确认。
- `style_tokens.typography` 是字体和字号锚点;生成器不得临时发明一套字体。
- `asset_contracts` 声明页面可用图片、图标、图表或 file token 依赖。
- `business_claims` 记录可见事实片段和来源,用于 preflight/readback 追踪。
- `page_contracts` 是每页生成规则;`rhythm` 可取 `anchor``dense``breathing`
- `pages` 可保留兼容旧字段;新逻辑优先读取 `page_contracts`
## Drift Rules
- `assets` stage 必须记录 plan、lock、assets hash。
- `generate_svg` stage 必须读取当前 lock 和 assets manifest并把 hash 写入 deck receipt 和 page receipt。
- `prepare` 前如果 source SVG hash 和 `generate_svg` receipt 不一致,必须重跑 `generate_svg`
- `dry_run/live_create` 前如果 prepared SVG hash 和 `quality_gate` 不一致,必须重跑检查链。

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-page-rerun.schema.json",
"title": "SVGlide page-level rerun receipt",
"type": "object",
"required": ["schema_version", "status", "pages", "summary"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-page-rerun/v1"},
"status": {"enum": ["passed", "failed"]},
"pages": {"type": "array"},
"summary": {
"type": "object",
"required": ["page_count", "dirty_page_count"],
"additionalProperties": true,
"properties": {
"page_count": {"type": "integer", "minimum": 0},
"dirty_page_count": {"type": "integer", "minimum": 0}
}
}
}
}

View File

@@ -0,0 +1,112 @@
# SVGlide Plan Contract
Read this file only after `svglide-svg` route admission. It defines the execution plan files consumed by preflight, preview, create, and readback stages.
## Files
Primary plan:
```text
.lark-slides/plan/<deck-id>/02-plan/slide_plan.json
```
Optional execution lock:
```text
.lark-slides/plan/<deck-id>/02-plan/svglide.lock.json
```
`slide_plan.json` is the user-visible plan. `svglide.lock.json` is an execution profile: it locks route, canvas, safe area, style system, quality profile, ordered page paths, and required SVG constraints. It must not introduce new user-visible content.
For the expanded execution-lock contract, see `svglide-lock.contract.md`.
Plan confirmation:
```text
.lark-slides/plan/<deck-id>/02-plan/plan-confirmation.json
```
The runner writes `02-plan/plan-confirmation.request.json` when confirmation is missing. A human-confirmed `plan-confirmation.json` is required before asset collection, `generate_svg`, prepare, dry-run, or live-create.
## Minimum Plan Fields
`slide_plan.json` must include:
- `route` or `output_mode` set to `svglide-svg`
- `plan_path` pointing to `02-plan/slide_plan.json`
- `loaded_rule_set` with `svglide-svg-private.rules.json`
- `canvas` with `width: 960`, `height: 540`, and `viewBox: "0 0 960 540"`
- `safe_area`
- `style_system` or equivalent style profile
- `art_direction`
- `quality_gates`
- `business_claims` when visible claims are used
- `asset_contracts` when visible images, icons, chart data, or file tokens are required
- ordered `svg_files` pointing to `04-svg/prepared/page-###.svg`
- `slides` metadata matching the ordered SVG pages
## Lock Fields
When present, `svglide.lock.json` must include:
```json
{
"version": "svglide-lock/v1",
"route": "svglide-svg",
"plan_path": "02-plan/slide_plan.json",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {},
"style_tokens": {},
"quality_profile": "production",
"generation_rules": {},
"asset_contracts": [],
"business_claims": [],
"page_contracts": [
{
"page": 1,
"path": "04-svg/prepared/page-001.svg",
"rhythm": "anchor",
"layout_family": "cover",
"visual_recipe": "",
"required_primitives": [],
"svg_effects": []
}
],
"pages": [
{
"page": 1,
"path": "04-svg/prepared/page-001.svg",
"visual_recipe": "",
"required_primitives": [],
"svg_effects": []
}
]
}
```
## Confirmation Fields
`plan-confirmation.json` must include:
```json
{
"version": "svglide-plan-confirmation/v1",
"status": "confirmed",
"confirmed_by": "user",
"confirmed_at": "2026-06-18T00:00:00+08:00",
"plan_path": "02-plan/slide_plan.json",
"plan_sha256": "<sha256>",
"lock_path": "02-plan/svglide.lock.json",
"lock_sha256": "<sha256>"
}
```
`lock_path` and `lock_sha256` are required only when `02-plan/svglide.lock.json` exists. The hash fields must match the current files so stale confirmations cannot authorize changed plans.
## Conflict Rules
- If the lock exists, preflight treats it as the execution source for route, canvas, safe area, quality profile, and ordered page paths.
- If plan and lock disagree on route, page count, page path, canvas, safe area, or style profile, preflight reports `plan_lock_conflict`.
- If the lock is absent, P0 allows the equivalent execution profile to live inside `slide_plan.json` for compatibility.
- The lock must stay small. It is not a second full plan schema and must not duplicate page copy, speaker notes, or narrative outline.
- If plan confirmation is absent or stale, runner must stop before generation and create `02-plan/plan-confirmation.request.json`.

View File

@@ -0,0 +1,167 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-plan.schema.json",
"title": "SVGlide slide plan",
"type": "object",
"additionalProperties": true,
"properties": {
"route": {"const": "svglide-svg"},
"output_mode": {"const": "svglide-svg"},
"page_count": {"type": "integer", "minimum": 1},
"target_slide_count": {"type": "integer", "minimum": 1},
"fallback_policy": {"enum": ["strict-native", "auto"]},
"canvas": {
"type": "object",
"required": ["width", "height", "viewBox"],
"properties": {
"width": {"const": 960},
"height": {"const": 540},
"viewBox": {"const": "0 0 960 540"}
},
"additionalProperties": true
},
"safe_area": {
"type": "object",
"required": ["x", "y", "width", "height"],
"properties": {
"x": {"type": "number"},
"y": {"type": "number"},
"width": {"type": "number"},
"height": {"type": "number"}
},
"additionalProperties": true
},
"style_preset": {"type": "string", "minLength": 1},
"style_selection_reason": {"type": "string", "minLength": 1},
"style_system": {
"type": "object",
"required": ["palette", "typography", "background_strategy", "motif"],
"additionalProperties": true
},
"loaded_rule_set": {
"type": "array",
"items": {"type": "string", "minLength": 1}
},
"plan_path": {"type": "string", "minLength": 1},
"quality_gates": {
"type": "object",
"required": ["no_text_overflow", "no_debug_guides", "no_xml_like_pages"],
"properties": {
"no_text_overflow": {"const": true},
"no_debug_guides": {"const": true},
"no_xml_like_pages": {"const": true}
},
"additionalProperties": true
},
"art_direction": {
"type": "object",
"required": [
"cover_treatment",
"section_divider_treatment",
"closing_treatment",
"deck_motif",
"svg_native_moments"
],
"properties": {
"cover_treatment": {"type": "string", "minLength": 1},
"section_divider_treatment": {"type": "string", "minLength": 1},
"closing_treatment": {"type": "string", "minLength": 1},
"deck_motif": {"type": "string", "minLength": 1},
"svg_native_moments": {
"type": "array",
"minItems": 3,
"items": {"type": "string", "minLength": 1}
}
},
"additionalProperties": true
},
"business_claims": {
"type": "array",
"items": {
"type": "object",
"required": ["claim", "source_type"],
"properties": {
"claim": {"type": "string", "minLength": 1},
"source_type": {
"enum": ["prompt_provided", "user_provided", "attachment", "readback", "derived", "assumption", "pending_confirmation"]
},
"derivation": {"type": "string"},
"assumption": {"type": "string"},
"source_note": {"type": "string"}
},
"additionalProperties": true
}
},
"svg_files": {
"type": "array",
"items": {
"type": "object",
"required": ["page", "path"],
"properties": {
"page": {"type": "integer", "minimum": 1},
"path": {"type": "string", "minLength": 1}
},
"additionalProperties": true
}
},
"slides": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"page",
"title",
"key_message",
"renderer_id",
"layout_family",
"visual_recipe",
"visual_intent",
"visual_focal_point",
"visual_signature",
"svg_effects",
"required_primitives",
"svg_primitives",
"xml_like_risk",
"content_density_contract",
"risk_flags",
"source_policy"
],
"properties": {
"page": {"type": "integer", "minimum": 1},
"title": {"type": "string", "minLength": 1},
"key_message": {"type": "string", "minLength": 1},
"renderer_id": {"type": "string", "minLength": 1},
"layout_family": {"type": "string", "minLength": 1},
"visual_recipe": {"type": "string", "minLength": 1},
"visual_intent": {"type": "string", "minLength": 1},
"visual_focal_point": {"type": "string", "minLength": 1},
"visual_signature": {"type": "string", "minLength": 1},
"svg_effects": {"type": "array", "items": {"type": "string", "minLength": 1}},
"required_primitives": {"type": "array", "items": {"type": "string", "minLength": 1}},
"svg_primitives": {"type": "array", "items": {"type": "string", "minLength": 1}},
"xml_like_risk": {"type": "string", "minLength": 1},
"content_density_contract": {"type": "string", "minLength": 1},
"risk_flags": {"type": "array", "items": {"type": "string"}},
"source_policy": {"type": "string", "minLength": 1},
"asset_contract": {}
},
"additionalProperties": true
}
}
},
"required": [
"slides",
"style_preset",
"style_selection_reason",
"style_system",
"loaded_rule_set",
"plan_path",
"quality_gates",
"art_direction"
],
"anyOf": [
{"required": ["route"]},
{"required": ["output_mode"]}
]
}

View File

@@ -0,0 +1,153 @@
# SVGlide Planning Layer
Read this file only after `svglide-svg` route admission. It extends the shared `planning-layer.md`; it does not replace the common narrative, page role, layout, asset, and verification fields.
Compatibility note: new runner-first artifact paths are defined in `svglide-artifacts.spec.md` and `svglide-plan.contract.md`. Keep this file for planning-field semantics; use the staged `02-plan`, `04-svg`, `05-preview`, `06-check`, `07-create`, and `08-readback` layout for new work.
## Page Count
When the user asks for an SVG/SVGlide deck but does not specify page count, or uses ambiguous wording such as "a slide", "a PPT", "make a slide", or "generate a slide", default to `10` pages. Generate `1` page only when the user explicitly asks for one page, a single page, onepage, one slide, or only a cover. Explicit page counts always win.
Default 10-page SVG decks must record `page_count` or `target_slide_count: 10` and include an explicit closing slide.
## Required Top-Level Extensions
SVG route plans must include:
- `route` or `output_mode` with value `svglide-svg`.
- `canvas` with `width: 960`, `height: 540`, and `viewBox: "0 0 960 540"`.
- `safe_area` compatible with the current `48,40,864,460` safe area.
- `style_preset`: a preset id from `style-presets.json`.
- `style_selection_reason`: why the preset fits the audience, topic, density, and tone.
- `style_system`: executable palette, typography, background strategy, and motif derived from the preset.
- `visual_identity`: the theme-specific visual system that prevents unrelated decks from sharing the same skeleton. Required fields:
- `theme_archetype`: such as `company_ecosystem`, `space_capital_market`, `travel_destination`, or `academic_paper`.
- `design_dna`: `palette`, `layout_motif`, `shape_language`, `image_treatment`, `component_bias`, plus at least 3 theme-specific visual anchors.
- `forbidden_reuse`: recent-deck reuse rules for palette, cover structure, and default skeleton.
- `distinctness_target`: palette, renderer sequence, and layout sequence similarity thresholds.
- `loaded_rule_set`: exact SVG private rule files loaded after route admission. It must include the manifest-required design and validation references, not only protocol files.
- `plan_path`: the `.lark-slides/plan/<deck-or-task-id>/02-plan/slide_plan.json` path that later preflight, preview lint, live create, and readback records belong to.
- `quality_gates`: deterministic gates requested before source generation, including `no_text_overflow: true`, `no_debug_guides: true`, and `no_xml_like_pages: true`.
- `art_direction`: the deck-level visual strategy that must drive source geometry, not just prose. Required fields:
- `cover_treatment`
- `section_divider_treatment`
- `closing_treatment`
- `deck_motif`
- `svg_native_moments` with at least 3 source-backed moments
- `business_claims`: source records for visible numeric or business claims. Use `prompt_provided`, `user_provided`, `attachment`, `readback`, `derived`, `assumption`, or `pending_confirmation`; derived or assumed claims must include a derivation or assumption note.
- `svg_files`: ordered source files when `slides +create-svg --file` will consume generated pages.
- `fallback_policy`: `strict-native` or `auto` when the compiler gate is available.
## Required Slide Extensions
Each SVG slide must include:
- `renderer_id`: the concrete geometry/renderer used for the page.
- `layout_family`: deck-level layout family for diversity checks.
- `visual_recipe`: the SVG-native page recipe.
- `visual_intent`: the purpose of the SVG visual expression.
- `visual_focal_point`: the region or object that should dominate the page.
- `visual_signature`: the page's distinctive SVG memory point.
- `svg_effects`: canonical effects that are actually used or planned.
- `required_primitives`: primitives that must appear in the source.
- `svg_primitives`: primitives planned for the source.
- `xml_like_risk`: what would be lost if this were rendered as ordinary XML cards or bullets.
- `content_density_contract`: a measurable structure contract for dense pages.
- `risk_flags`: an array; use `[]` when no risk is known.
- `source_policy`: how missing data, attachments, claims, numbers, logos, and citations are handled.
- `asset_contract`: image/source metadata, or `none_required` when no image asset is used.
## Example Shape
```json
{
"route": "svglide-svg",
"output_mode": "svglide-svg",
"page_count": 10,
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"style_preset": "raw_grid",
"style_selection_reason": "raw_grid fits a technical training deck with dense but readable structure.",
"style_system": {
"palette": {"background": "#F5F5F5", "text": "#0A0A0A", "accent": "#F2D4CF"},
"typography": "strong title, readable native text labels",
"background_strategy": "muted grid panels with one stable background family",
"motif": "dense grid panels with restrained accent labels"
},
"visual_identity": {
"theme_archetype": "company_ecosystem",
"design_dna": {
"palette": "light corporate product ecosystem",
"layout_motif": "product ecosystem wall",
"shape_language": "low-radius app tiles and organization network nodes",
"image_treatment": "company imagery for cover/closing; editable SVG components for body pages",
"component_bias": "ecosystem_wall, org_network, editorial_profile",
"theme_visual_anchors": ["app tile wall", "product matrix", "organization network"]
},
"forbidden_reuse": {"recent_decks": 5, "avoid_same_palette": true, "avoid_same_cover_structure": true, "avoid_default_skeleton": true},
"distinctness_target": {"palette_overlap_max": 0.67, "renderer_sequence_similarity_max": 0.75, "layout_sequence_similarity_max": 0.75}
},
"loaded_rule_set": [
"skills/lark-slides/references/svglide-route-admission.md",
"skills/lark-slides/references/style-presets.md",
"skills/lark-slides/references/svg-visual-recipes.md",
"skills/lark-slides/references/svg-aesthetic-review.md",
"skills/lark-slides/references/svglide-planning-layer.md",
"skills/lark-slides/references/svglide-validation-checklist.md",
"skills/lark-slides/references/svglide-visual-planning.md"
],
"plan_path": ".lark-slides/plan/demo/02-plan/slide_plan.json",
"quality_gates": {
"no_text_overflow": true,
"no_debug_guides": true,
"no_xml_like_pages": true
},
"art_direction": {
"cover_treatment": "Hero typography with a single dominant claim and source-backed SVG geometry.",
"section_divider_treatment": "Sparse chapter reset with oversized section number and shared motif.",
"closing_treatment": "Closing loop or brand-system page that mirrors the cover motif and states the next action.",
"deck_motif": "dense grid panels with restrained accent labels",
"svg_native_moments": ["cover geometry", "data micro chart", "closing loop"]
},
"business_claims": [
{"claim": "All numeric claims are prompt-provided or marked pending.", "source_type": "prompt_provided"}
],
"svg_files": [{"page": 1, "path": ".lark-slides/plan/demo/04-svg/prepared/page-001.svg"}],
"slides": [
{
"page": 1,
"title": "Proposal Title",
"key_message": "The initiative is ready for a focused pilot.",
"renderer_id": "hero_path_cover",
"layout_family": "hero",
"visual_recipe": "hero_typography",
"visual_intent": "Use oversized type and layered geometry to establish the point of view.",
"visual_focal_point": "Large title block over a structured background motif.",
"visual_signature": "Oversized title mass with a layered path frame.",
"svg_effects": ["path", "typography"],
"required_primitives": ["typography", "geometric_shape"],
"svg_primitives": ["typography", "geometric_shape", "path"],
"xml_like_risk": "Without SVG-specific geometry this becomes a plain title slide.",
"content_density_contract": "hero >= 1 focal title",
"asset_contract": "none_required",
"risk_flags": [],
"source_policy": "Use prompt-provided content only; no invented metrics."
}
]
}
```
## Diversity Gates
- 8 or more SVG pages must end with an explicit closing, summary, thanks, Q&A, or next-step page.
- 8 or more SVG pages must declare cover, section-divider/tempo, and closing treatments in `art_direction`; the first and last page recipes must support those roles.
- 8 or more SVG pages should use at least 5 recipe families.
- 10 or more SVG pages should use at least 5 distinct `renderer_id` values and 5 `layout_family` values.
- Do not use the same renderer or layout family for 3 consecutive pages.
- High-density pages must quantify the density contract, such as `matrix >= 6 cells`, `timeline >= 4 nodes`, `dashboard >= 4 metrics`, `flow >= 4 stages`, or `risk_grid >= 4 items`.
- Topic-only decks must still declare a theme-specific `visual_identity`; using only default renderer sequences such as cover/chart/timeline/closing is a strategy failure.
- Different local projects should not reuse the same `style_preset`, palette, cover treatment, and renderer/layout sequence unless the theme archetype is intentionally the same.
## XML Boundary
These fields are SVG-only. Do not add them to XML route plans.

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-ppe-proof.schema.json",
"title": "SVGlide PPE proof receipt",
"type": "object",
"required": ["schema_version", "status", "inputs", "proof", "summary", "issues"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-ppe-proof/v1"},
"status": {"enum": ["passed", "failed"]},
"inputs": {"type": "object"},
"proof": {"type": "object"},
"summary": {
"type": "object",
"required": ["error_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0}
}
},
"issues": {"type": "array"}
}
}

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-ppt-master-asset-map.schema.json",
"title": "SVGlide ppt-master asset map",
"type": "object",
"required": ["schema_version", "items"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-ppt-master-asset-map/v1"},
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["source", "kind", "svglide_target", "activation_status", "copy_policy", "license_status", "unsupported_features"],
"additionalProperties": true,
"properties": {
"source": {"type": "string", "minLength": 1},
"kind": {"type": "string", "minLength": 1},
"svglide_target": {"type": "string", "minLength": 1},
"activation_status": {"enum": ["active", "candidate", "reference_only", "blocked"]},
"copy_policy": {"enum": ["svglide_native", "reference_only", "blocked_raw_runtime"]},
"license_status": {"enum": ["cleared", "unknown", "restricted"]},
"unsupported_features": {"type": "array", "items": {"type": "string"}}
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
# SVGlide 对照 ppt-master 迁移矩阵
本文只在 `svglide-svg` route admission 之后读取。它记录 ppt-master 参考规则如何迁移到 CLI SVGlide不是要求把 ppt-master 整套本地 PPTX 生成系统搬进 CLI。
## 迁移原则
- 迁移阶段思想、执行锁、资产可追溯、质量门和 readback 语义。
- 不迁移 ppt-master 的本地 `finalize_svg.py -> svg_to_pptx.py` 导出链。
- CLI 仍以 `slides +create-svg` 为交付入口prepared SVG 仍通过既有 XML presentation API 写入。
- 所有长期约束必须落到 JSON artifact、runner stage 或 check script不能只停留在提示词。
## Matrix
| ppt-master source | 抽取规则 | CLI SVGlide 落点 | 迁移 |
|---|---|---|---|
| `skills/ppt-master/SKILL.md` Core Pipeline | 串行阶段、每阶段输入输出明确,阻塞点不能跳过 | `svglide_project_runner.py` stage graph, `svglide-workflow.spec.md` | adapt |
| `skills/ppt-master/SKILL.md` Eight Confirmations | 生成前用户确认设计参数;确认后自动继续非阻塞步骤 | `confirm_plan` + `02-plan/plan-confirmation.json` hash 绑定 | yes |
| `templates/spec_lock_reference.md` | `design_spec` 讲 why`spec_lock` 锁 what执行时只读锁内值 | `02-plan/slide_plan.json` + `02-plan/svglide.lock.json` | adapt |
| `executor-base.md` per-page spec_lock reread | 防止长 deck 颜色、字体、图标、资产、页面节奏漂移 | `svglide.lock.json``style_tokens``page_contracts``asset_contracts` | yes |
| `executor-base.md` template/chart batch read | 模板和图表结构先锁定,生成时按页引用,不临时猜 | `page_contracts[].layout_ref` / `chart_ref`,未来接 template roster | adapt |
| `SKILL.md` Image_Generator | AI/web/user/formula 分流,失败有审计,不留空框 | `assets` stage, `03-assets/assets.json`, `03-assets/asset-manifest.json` | adapt |
| `technical-design.md` image embedding | 开发态可本地引用,交付态必须 token 化或可上传 | `svglide_assets.py` + `svglide_prepare.py` + `slides +create-svg --assets` | yes |
| `SKILL.md` Executor | 真正生成 SVG 的阶段必须在确认和资产后,不能提前写 SVG | `generate_svg` stage | yes |
| `SKILL.md` no sub-agent / no batch generation | ppt-master 禁止 subagent 和批量脚本CLI runner 不能完全照搬 | CLI 允许执行 generator script但必须记录 lock/assets/source hash 和 per-page receipt | adapt |
| `shared-standards.md` | SVG/PPT 兼容黑名单要在后处理/创建前阻断 | `svg_preflight.py` | yes |
| `technical-design.md` Quality Gate | 检查失败回到源 SVG 修复,不做静默自动修复 | `preflight -> preview_lint -> aesthetic_review -> semantic_review -> quality_gate` | yes |
| `visual-review` / live preview | 审美问题不是 API contract但要有可审查产物和阻断动作 | `05-preview/preview.html` + `06-check/aesthetic-review.json` | adapt |
| `technical-design.md` readback | 本地 preview 不能替代服务端转换后的回读 | `svglide_readback.py`, `08-readback/readback-check.json` | yes |
| `finalize_svg.py` / `svg_to_pptx.py` | ppt-master 本地 DrawingML 导出 | 不迁移CLI 使用 `slides +create-svg` API | no |
| `update_spec.py` | 颜色/字体窄范围批量传播 | 暂不迁移CLI 通过改 plan/lock 后 rerun stage | no |
## 当前 CLI 执行映射
```text
plan
-> confirm_plan
-> assets
-> generate_svg
-> prepare
-> preview
-> preflight
-> preview_lint
-> aesthetic_review
-> semantic_review
-> quality_gate
-> dry_run
-> live_create
-> readback
```
默认本地验证停在 `quality_gate``dry_run`。除非用户明确要求,不自动执行 `live_create`

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-preview-annotations.schema.json",
"title": "SVGlide preview annotations",
"type": "object",
"required": ["schema_version", "annotations"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-preview-annotations/v1"},
"annotations": {
"type": "array",
"items": {
"type": "object",
"required": ["page", "severity", "message"],
"additionalProperties": true,
"properties": {
"page": {"type": "integer", "minimum": 1},
"severity": {"enum": ["info", "warning", "error"]},
"message": {"type": "string", "minLength": 1},
"status": {"enum": ["open", "resolved", "wont_fix"]}
}
}
}
}
}

View File

@@ -0,0 +1,58 @@
# SVGlide Preview Spec
Read this file only after `svglide-svg` route admission. Preview validates local visual quality before API calls; it does not replace source preflight or live readback.
## Preview Contract
`preview.html` should:
- Read `04-svg/prepared/*.svg` in `svg_files` order.
- Write `05-preview/preview.html`.
- Write `05-preview/preview-manifest.json`.
- Embed every prepared SVG page in the preview HTML.
- Preserve the 16:9 `960 x 540` page box.
- Keep page labels and review metadata outside the SVG canvas.
- Show image assets as they will appear in local preview, with preview-only sources recorded in the plan.
- Avoid visible safe-area rectangles, debug guides, bbox guides, or layout helper marks.
## Lint Contract
Run:
```bash
python3 skills/lark-slides/scripts/svg_preview_lint.py \
.lark-slides/plan/<deck-id>/05-preview/preview.html --pretty
```
Required output shape:
```json
{
"rendering_mode": "static_dom_approximation",
"screenshot_paths": [],
"summary": {"error_count": 0, "warning_count": 0},
"page_issues": [],
"action": "create_live"
}
```
Any preview lint error sets `action` to `repair_and_rerun` and blocks live create.
Save the lint output as `06-check/preview-lint.json`. It is only one input to `quality_gate`; `svglide_semantic_review.py` must also pass before create.
## Review Contract
After lint passes, record an aesthetic review with:
- `preview_path`: `05-preview/preview.html`
- `plan_path`: `02-plan/slide_plan.json`
- checked page count
- score and threshold
- issue ids and affected pages
- action: `create_live` or `repair_and_rerun`
The review must inspect every page for blank output, clipping, text overlap, image visibility, weak SVG-native structure, repeated layouts, and closing slide presence. If a repeated issue appears across pages, repair the generator or source pattern and rerun preflight and preview lint.
## Boundary
Preview can catch local layout problems. It does not validate Chinese delivery quality, page type structure, content depth, source refs, or generator text provenance; those belong to `svglide_semantic_review.py`. Readback is still required because the server conversion can change text boxes, image tokens, path bounds, and supported SVG effects.

View File

@@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-quality-gate.schema.json",
"title": "SVGlide quality gate receipt",
"type": "object",
"required": ["version", "status", "profile", "inputs", "prepared_files", "summary", "checks"],
"additionalProperties": true,
"properties": {
"version": {"const": "svglide-quality-gate/v1"},
"status": {"enum": ["passed", "failed", "passed_with_waiver"]},
"profile": {"type": "string", "minLength": 1},
"inputs": {"type": "object"},
"prepared_files": {
"type": "array",
"items": {
"type": "object",
"required": ["path", "sha256"],
"additionalProperties": true,
"properties": {
"path": {"type": "string", "minLength": 1},
"sha256": {"type": "string", "minLength": 1}
}
}
},
"summary": {
"type": "object",
"required": ["check_count", "failed_check_count", "waiver_check_count", "source_error_count"],
"additionalProperties": true,
"properties": {
"check_count": {"type": "integer", "minimum": 0},
"failed_check_count": {"type": "integer", "minimum": 0},
"waiver_check_count": {"type": "integer", "minimum": 0},
"source_error_count": {"type": "integer", "minimum": 0}
}
},
"checks": {"type": "array"}
}
}

View File

@@ -0,0 +1,34 @@
# SVGlide Readback Contract
本文只在 `svglide-svg` route admission 之后读取。readback 是 live create 之后的服务端转换验证,不能用本地 preview 替代。
## Inputs
- `07-create/live-create.json`
- `02-plan/slide_plan.json`
- optional `03-assets/assets.json`
- optional `04-svg/prepared/page-###.svg`
## Outputs
- `08-readback/xml-presentations-get.json`
- `08-readback/readback-check.json`
## Required Checks
`svglide_readback.py` 必须尽力检查:
- `presentation_id`live create 是否返回 presentation id。
- `page_count`:计划页数和回读页数是否一致。
- `slide_ids`:创建出的 slide id 数量是否一致。
- `blank_page`:回读结构里是否存在空白页标记。
- `asset_tokens``assets.json` 中的 file token 是否能在回读结构中找到。
- `text_fit`:回读结构中是否出现文本溢出类标记。
- `bounds`:回读结构中是否出现越界/裁切类标记。
- `chart_markers`:源 SVG 含 chart marker 时,回读结构应保留 chart 相关信息。
- `business_claims`plan 中记录的可见业务事实片段应能在回读文本中找到。
- `input_binding`:记录 `plan_sha256``quality_gate_sha256``dry_run_sha256``ppe_proof_sha256``live_create_sha256``revision_id``expected_slide_count``created_slide_count`,用于证明 readback 绑定的是当前计划、当前质量门、当前 dry-run/PPE proof 和当前 live create 产物。
## Boundary
readback 是线上转换后的结构检查,不是审美检查,也不是内容策划检查。页面是否“好看”、是否重复由 `aesthetic_review` 和人工检查负责;中文、页型结构、内容厚度和 SVG 文本来源由 `semantic_review` 在 create 前负责。

View File

@@ -0,0 +1,110 @@
{
"schema_version": "svglide-renderer-registry/v1",
"renderers": [
{
"id": "cover",
"status": "active",
"family": "cover",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "cover_full_bleed",
"status": "active",
"family": "cover",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "chart",
"status": "active",
"family": "chart",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "dashboard_scorecard",
"status": "active",
"family": "chart",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "timeline",
"status": "active",
"family": "timeline",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "timeline_rail",
"status": "active",
"family": "timeline",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "closing",
"status": "active",
"family": "closing",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "closing_cta",
"status": "active",
"family": "closing",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "ecosystem_wall",
"status": "active",
"family": "ecosystem",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "org_network",
"status": "active",
"family": "network",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "editorial_profile",
"status": "active",
"family": "profile",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "destination_atlas",
"status": "active",
"family": "atlas",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "research_deep_dive",
"status": "active",
"family": "research",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "market_signal",
"status": "active",
"family": "market",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
},
{
"id": "test-renderer",
"status": "active",
"family": "test",
"runtime_module": "svglide_gen_runtime.py",
"allowed_style_presets": ["*"]
}
]
}

View File

@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-renderer-registry.schema.json",
"title": "SVGlide renderer registry",
"type": "object",
"required": ["schema_version", "renderers"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-renderer-registry/v1"},
"renderers": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "status", "family"],
"additionalProperties": true,
"properties": {
"id": {"type": "string", "minLength": 1},
"status": {"enum": ["active", "candidate", "blocked"]},
"family": {"type": "string", "minLength": 1},
"runtime_module": {"type": "string", "minLength": 1},
"allowed_style_presets": {"type": "array", "items": {"type": "string", "minLength": 1}}
}
}
}
}
}

View File

@@ -0,0 +1,64 @@
# SVGlide Route Admission
This file is the only SVG-specific reference that may be read before the route is known. It contains route activation rules and a private-document index only. It must not carry SVG style, recipe, fallback, parser, or readback strategy bodies.
## Activation
Activate `svglide-svg` only when at least one condition is true:
- The user explicitly asks for SVG, SVGlide, or `slides +create-svg`.
- The supplied source root is `<svg slide:role="slide">`.
- The planning file declares `route: "svglide-svg"` or `output_mode: "svglide-svg"`.
If none of these conditions is true, stay on the XML route and read only XML/SXSD planning, creation, edit, validation, and troubleshooting references.
## Allowed Before Activation
Before activation, top-level skill instructions may refer only to:
- `slides +create-svg` as the route command name.
- `svglide-route-admission.md` as this gate.
- `svglide-svg-private.rules.json` as the primary private-file index.
- `svg-private-manifest.json` as the compatibility private-file index.
Do not read or summarize private SVG strategy files for XML route work.
## Allowed After Activation
After activation, load SVG private files through `svglide-svg-private.rules.json`. `svg-private-manifest.json` remains as a compatibility index for older tooling.
Primary route entrypoints:
- `lark-slides-create-svg.md`
- `svg-protocol.md`
- `svglide-svg-private.rules.json`
Private planning and validation:
- `svglide-planning-layer.md`
- `svglide-plan.contract.md`
- `svglide-artifacts.spec.md`
- `svglide-workflow.spec.md`
- `svglide-validation-checklist.md`
- `svglide-checks.checklist.md`
- `svglide-preview.spec.md`
- `svglide-visual-planning.md`
- `svglide-asset-planning.md`
Private style and quality references:
- `style-presets.json`
- `style-presets.md`
- `svg-visual-recipes.md`
- `svg-aesthetic-review.md`
Private machine profiles and local gates:
- `safe-native-v1.profile.json`
- `svglide-plan.schema.json`
- `svg_preflight.py`
- `svglide_*` lint and preview scripts when present.
## Route Boundary
XML route documents may point to this admission gate, but must not quote or inherit private SVG strategy. If a request starts on XML and later supplies SVG route evidence, perform admission at that moment and then load private SVG files.

View File

@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-runtime-review.schema.json",
"title": "SVGlide runtime review receipt",
"type": "object",
"required": ["schema_version", "status", "action", "inputs", "registry", "pages", "summary", "issues"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-runtime-review/v1"},
"status": {"enum": ["passed", "failed"]},
"action": {"enum": ["create_live", "repair_and_rerun"]},
"inputs": {"type": "object"},
"registry": {
"type": "object",
"required": ["path", "sha256"],
"additionalProperties": true,
"properties": {
"path": {"type": "string", "minLength": 1},
"sha256": {"type": "string", "minLength": 1}
}
},
"pages": {"type": "array"},
"summary": {
"type": "object",
"required": ["error_count", "slide_count", "renderer_count", "layout_family_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0},
"slide_count": {"type": "integer", "minimum": 0},
"renderer_count": {"type": "integer", "minimum": 0},
"layout_family_count": {"type": "integer", "minimum": 0}
}
},
"issues": {"type": "array"}
}
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-semantic-advisory.schema.json",
"title": "SVGlide semantic advisory",
"type": "object",
"required": ["schema_version", "status", "summary", "warnings"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-semantic-advisory/v1"},
"status": {"const": "passed"},
"summary": {
"type": "object",
"required": ["warning_count"],
"additionalProperties": true,
"properties": {
"warning_count": {"type": "integer", "minimum": 0}
}
},
"warnings": {"type": "array"}
}
}

View File

@@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-semantic-review.schema.json",
"title": "SVGlide semantic review receipt",
"type": "object",
"required": ["schema_version", "status", "action", "inputs", "prepared_files", "text_inventory", "summary", "issues"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-semantic-review/v1"},
"status": {"enum": ["passed", "failed"]},
"action": {"enum": ["create_live", "repair_and_rerun"]},
"profile": {"type": "string", "minLength": 1},
"inputs": {
"type": "object",
"required": ["slide_plan", "plan_sha256", "svg_dir"],
"additionalProperties": true,
"properties": {
"slide_plan": {"type": "string", "minLength": 1},
"plan_sha256": {"type": "string", "minLength": 1},
"evidence": {"type": ["string", "null"]},
"evidence_sha256": {"type": ["string", "null"]},
"svg_dir": {"type": "string", "minLength": 1}
}
},
"prepared_files": {
"type": "array",
"items": {
"type": "object",
"required": ["path", "sha256"],
"additionalProperties": true,
"properties": {
"path": {"type": "string", "minLength": 1},
"sha256": {"type": "string", "minLength": 1}
}
}
},
"text_inventory": {"type": "string", "minLength": 1},
"summary": {
"type": "object",
"required": ["error_count", "warning_count", "slide_count", "prepared_svg_count", "unmatched_text_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0},
"warning_count": {"type": "integer", "minimum": 0},
"slide_count": {"type": "integer", "minimum": 0},
"prepared_svg_count": {"type": "integer", "minimum": 0},
"unmatched_text_count": {"type": "integer", "minimum": 0}
}
},
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["code", "message"],
"additionalProperties": true,
"properties": {
"code": {"type": "string", "minLength": 1},
"message": {"type": "string", "minLength": 1},
"page": {"type": "integer", "minimum": 1},
"path": {"type": "string", "minLength": 1}
}
}
}
}
}

View File

@@ -0,0 +1,47 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-source-receipt.schema.json",
"title": "SVGlide source receipt",
"type": "object",
"required": ["schema_version", "stage", "status", "inputs", "outputs", "summary", "issues"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-source-receipt/v1"},
"stage": {"const": "source"},
"status": {"enum": ["passed", "failed"]},
"inputs": {"type": "object"},
"outputs": {"type": "object"},
"research": {
"type": "object",
"additionalProperties": true,
"properties": {
"status": {"type": "string", "minLength": 1},
"network_policy": {"enum": ["auto", "online", "offline", "fixture"]},
"queries": {"type": "array", "items": {"type": "string"}},
"sources": {"type": "array"},
"claims": {"type": "array"}
}
},
"summary": {
"type": "object",
"required": ["error_count", "evidence_item_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0},
"evidence_item_count": {"type": "integer", "minimum": 0}
}
},
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["code", "message"],
"additionalProperties": true,
"properties": {
"code": {"type": "string", "minLength": 1},
"message": {"type": "string", "minLength": 1}
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-speaker-notes.schema.json",
"title": "SVGlide speaker notes receipt",
"type": "object",
"required": ["schema_version", "status", "summary", "issues"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-speaker-notes/v1"},
"status": {"enum": ["passed", "failed"]},
"summary": {
"type": "object",
"required": ["error_count", "expected_page_count", "notes_page_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0},
"expected_page_count": {"type": ["integer", "null"]},
"notes_page_count": {"type": "integer", "minimum": 0}
}
},
"issues": {"type": "array"}
}
}

View File

@@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-strategy-review.schema.json",
"title": "SVGlide strategy review receipt",
"type": "object",
"required": ["schema_version", "status", "language", "audience", "deck_structure", "slides", "summary"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-strategy-review/v1"},
"status": {"enum": ["passed", "failed"]},
"language": {"const": "zh-CN"},
"audience": {"type": "string", "minLength": 1},
"deck_structure": {
"type": "array",
"minItems": 1,
"items": {"enum": ["cover", "section", "content", "closing"]}
},
"slides": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["page", "page_type", "section", "role", "title", "key_message"],
"additionalProperties": true,
"properties": {
"page": {"type": "integer", "minimum": 1},
"page_type": {"enum": ["cover", "section", "content", "closing"]},
"section": {"type": "string", "minLength": 1},
"role": {"type": "string", "minLength": 1},
"title": {"type": "string", "minLength": 1},
"key_message": {"type": "string", "minLength": 1},
"body_points": {"type": "array", "items": {"type": "string", "minLength": 1}},
"source_refs": {"type": "array", "items": {"type": "string", "minLength": 1}}
}
}
},
"summary": {
"type": "object",
"required": ["error_count"],
"additionalProperties": true,
"properties": {
"error_count": {"type": "integer", "minimum": 0}
}
}
}
}

View File

@@ -0,0 +1,247 @@
{
"version": "svglide-private-rules/v1",
"schema_version": "svglide-private-rules/v1",
"route": "svglide-svg",
"scope": "Private SVGlide SVG route files loaded only after route admission.",
"route_admission_files": [
"skills/lark-slides/references/svglide-route-admission.md"
],
"private_strategy_files": [
"skills/lark-slides/references/svglide-ppt-master-migration.matrix.md",
"skills/lark-slides/references/svglide-workflow.spec.md",
"skills/lark-slides/references/svglide-artifacts.spec.md",
"skills/lark-slides/references/svglide-plan.contract.md",
"skills/lark-slides/references/svglide-lock.contract.md",
"skills/lark-slides/references/svglide-assets.contract.md",
"skills/lark-slides/references/svglide-generate-svg.contract.md",
"skills/lark-slides/references/svglide-preview.spec.md",
"skills/lark-slides/references/svglide-checks.checklist.md",
"skills/lark-slides/references/svglide-readback.contract.md",
"skills/lark-slides/references/svglide-create-svg.contract.md",
"skills/lark-slides/references/lark-slides-create-svg.md",
"skills/lark-slides/references/svg-protocol.md",
"skills/lark-slides/references/style-presets.json",
"skills/lark-slides/references/style-presets.md",
"skills/lark-slides/references/svg-visual-recipes.md",
"skills/lark-slides/references/svg-aesthetic-review.md",
"skills/lark-slides/references/svglide-planning-layer.md",
"skills/lark-slides/references/svglide-validation-checklist.md",
"skills/lark-slides/references/svglide-visual-planning.md",
"skills/lark-slides/references/svglide-asset-planning.md",
"skills/lark-slides/references/safe-native-v1.profile.json",
"skills/lark-slides/references/svglide-plan.schema.json",
"skills/lark-slides/references/svglide-strategy-review.schema.json",
"skills/lark-slides/references/svglide-ppt-master-asset-map.schema.json",
"skills/lark-slides/references/svglide-template-admission.schema.json",
"skills/lark-slides/references/svglide-evidence.schema.json",
"skills/lark-slides/references/svglide-source-receipt.schema.json",
"skills/lark-slides/references/svglide-generator-receipt.schema.json",
"skills/lark-slides/references/svglide-chart-verify.schema.json",
"skills/lark-slides/references/svglide-semantic-review.schema.json",
"skills/lark-slides/references/svglide-runtime-review.schema.json",
"skills/lark-slides/references/svglide-renderer-registry.schema.json",
"skills/lark-slides/references/svglide-renderer-registry.json",
"skills/lark-slides/references/svglide-text-inventory.schema.json",
"skills/lark-slides/references/svglide-ppe-proof.schema.json",
"skills/lark-slides/references/svglide-semantic-advisory.schema.json",
"skills/lark-slides/references/svglide-speaker-notes.schema.json",
"skills/lark-slides/references/svglide-preview-annotations.schema.json",
"skills/lark-slides/references/svglide-page-rerun.schema.json",
"skills/lark-slides/references/svglide-quality-gate.schema.json",
"skills/lark-slides/scripts/svg_preflight.py",
"skills/lark-slides/scripts/svg_preview_lint.py",
"skills/lark-slides/scripts/svglide_source.py",
"skills/lark-slides/scripts/svglide_assets.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
"skills/lark-slides/scripts/svglide_preview.py",
"skills/lark-slides/scripts/svglide_aesthetic_review.py",
"skills/lark-slides/scripts/svglide_schema.py",
"skills/lark-slides/scripts/svglide_chart_verify.py",
"skills/lark-slides/scripts/svglide_semantic_review.py",
"skills/lark-slides/scripts/svglide_semantic_advisory.py",
"skills/lark-slides/scripts/svglide_runtime_review.py",
"skills/lark-slides/scripts/svglide_quality_gate.py",
"skills/lark-slides/scripts/svglide_ppe_proof.py",
"skills/lark-slides/scripts/svglide_readback.py",
"skills/lark-slides/scripts/svglide_ppt_master_inventory.py",
"skills/lark-slides/scripts/svglide_template_admission.py",
"skills/lark-slides/scripts/svglide_golden_suite.py",
"skills/lark-slides/scripts/svglide_speaker_notes.py",
"skills/lark-slides/scripts/svglide_preview_annotations.py",
"skills/lark-slides/scripts/svglide_contact_sheet.py",
"skills/lark-slides/scripts/svglide_page_rerun.py",
"shortcuts/slides/slides_create_svg.go",
"shortcuts/slides/svg_helpers.go"
],
"allowed_route_entrypoints": [
"skills/lark-slides/references/lark-slides-create-svg.md",
"skills/lark-slides/references/svg-protocol.md",
"skills/lark-slides/scripts/svglide_source.py",
"skills/lark-slides/scripts/svglide_assets.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
"skills/lark-slides/scripts/svglide_preview.py",
"skills/lark-slides/scripts/svglide_aesthetic_review.py",
"skills/lark-slides/scripts/svglide_chart_verify.py",
"skills/lark-slides/scripts/svglide_semantic_review.py",
"skills/lark-slides/scripts/svglide_semantic_advisory.py",
"skills/lark-slides/scripts/svglide_runtime_review.py",
"skills/lark-slides/scripts/svglide_quality_gate.py",
"skills/lark-slides/scripts/svglide_ppe_proof.py",
"skills/lark-slides/scripts/svglide_readback.py",
"skills/lark-slides/scripts/svglide_ppt_master_inventory.py",
"skills/lark-slides/scripts/svglide_template_admission.py",
"skills/lark-slides/scripts/svglide_golden_suite.py",
"skills/lark-slides/scripts/svglide_speaker_notes.py",
"skills/lark-slides/scripts/svglide_preview_annotations.py",
"skills/lark-slides/scripts/svglide_contact_sheet.py",
"skills/lark-slides/scripts/svglide_page_rerun.py",
"shortcuts/slides/slides_create_svg.go"
],
"write_scope": [
"skills/lark-slides/SKILL.md",
"skills/lark-slides/references/svglide-route-admission.md",
"skills/lark-slides/references/svglide-ppt-master-migration.matrix.md",
"skills/lark-slides/references/svglide-workflow.spec.md",
"skills/lark-slides/references/svglide-artifacts.spec.md",
"skills/lark-slides/references/svglide-plan.contract.md",
"skills/lark-slides/references/svglide-lock.contract.md",
"skills/lark-slides/references/svglide-assets.contract.md",
"skills/lark-slides/references/svglide-generate-svg.contract.md",
"skills/lark-slides/references/svglide-preview.spec.md",
"skills/lark-slides/references/svglide-checks.checklist.md",
"skills/lark-slides/references/svglide-readback.contract.md",
"skills/lark-slides/references/svglide-create-svg.contract.md",
"skills/lark-slides/references/svglide-svg-private.rules.json",
"skills/lark-slides/references/svg-private-manifest.json",
"skills/lark-slides/references/svglide-strategy-review.schema.json",
"skills/lark-slides/references/svglide-ppt-master-asset-map.schema.json",
"skills/lark-slides/references/svglide-template-admission.schema.json",
"skills/lark-slides/references/svglide-evidence.schema.json",
"skills/lark-slides/references/svglide-source-receipt.schema.json",
"skills/lark-slides/references/svglide-generator-receipt.schema.json",
"skills/lark-slides/references/svglide-chart-verify.schema.json",
"skills/lark-slides/references/svglide-semantic-review.schema.json",
"skills/lark-slides/references/svglide-runtime-review.schema.json",
"skills/lark-slides/references/svglide-renderer-registry.schema.json",
"skills/lark-slides/references/svglide-renderer-registry.json",
"skills/lark-slides/references/svglide-text-inventory.schema.json",
"skills/lark-slides/references/svglide-ppe-proof.schema.json",
"skills/lark-slides/references/svglide-semantic-advisory.schema.json",
"skills/lark-slides/references/svglide-speaker-notes.schema.json",
"skills/lark-slides/references/svglide-preview-annotations.schema.json",
"skills/lark-slides/references/svglide-page-rerun.schema.json",
"skills/lark-slides/references/svglide-quality-gate.schema.json",
"skills/lark-slides/scripts/svglide_source.py",
"skills/lark-slides/scripts/svglide_assets.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
"skills/lark-slides/scripts/svglide_preview.py",
"skills/lark-slides/scripts/svglide_aesthetic_review.py",
"skills/lark-slides/scripts/svglide_schema.py",
"skills/lark-slides/scripts/svglide_chart_verify.py",
"skills/lark-slides/scripts/svglide_semantic_review.py",
"skills/lark-slides/scripts/svglide_semantic_advisory.py",
"skills/lark-slides/scripts/svglide_runtime_review.py",
"skills/lark-slides/scripts/svglide_quality_gate.py",
"skills/lark-slides/scripts/svglide_ppe_proof.py",
"skills/lark-slides/scripts/svglide_readback.py",
"skills/lark-slides/scripts/svglide_ppt_master_inventory.py",
"skills/lark-slides/scripts/svglide_template_admission.py",
"skills/lark-slides/scripts/svglide_golden_suite.py",
"skills/lark-slides/scripts/svglide_speaker_notes.py",
"skills/lark-slides/scripts/svglide_preview_annotations.py",
"skills/lark-slides/scripts/svglide_contact_sheet.py",
"skills/lark-slides/scripts/svglide_page_rerun.py"
],
"runtime_artifact_root": ".lark-slides/plan/<deck-id>",
"artifact_dirs": {
"run_root": ".lark-slides/plan/<deck-id>/",
"input": ".lark-slides/plan/<deck-id>/00-input/",
"project": ".lark-slides/plan/<deck-id>/01-project/",
"plan": ".lark-slides/plan/<deck-id>/02-plan/",
"assets": ".lark-slides/plan/<deck-id>/03-assets/",
"svg_source": ".lark-slides/plan/<deck-id>/04-svg/",
"svg_prepared": ".lark-slides/plan/<deck-id>/04-svg/prepared/",
"preview": ".lark-slides/plan/<deck-id>/05-preview/",
"checks": ".lark-slides/plan/<deck-id>/06-check/",
"create": ".lark-slides/plan/<deck-id>/07-create/",
"readback": ".lark-slides/plan/<deck-id>/08-readback/",
"export": ".lark-slides/plan/<deck-id>/09-export/",
"receipts": ".lark-slides/plan/<deck-id>/receipts/",
"logs": ".lark-slides/plan/<deck-id>/logs/"
},
"checks_chain": [
"route admission",
"loaded_rule_set recorded",
"svglide_project_runner.py init",
"svglide_source.py",
"02-plan/slide_plan.json",
"02-plan/plan-confirmation.json",
"svglide_template_admission.py",
"svglide_assets.py",
"generate_svg",
"svglide_prepare.py",
"svglide_preview.py",
"svglide_contact_sheet.py",
"svg_preflight.py --plan",
"svg_preview_lint.py",
"svglide_preview_annotations.py",
"svglide_aesthetic_review.py",
"svglide_chart_verify.py",
"svglide_semantic_review.py",
"svglide_runtime_review.py",
"svglide_quality_gate.py",
"svglide_page_rerun.py",
"slides +create-svg --dry-run",
"svglide_ppe_proof.py",
"live slides +create-svg",
"xml_presentations get readback"
],
"create_svg_contract": {
"command": "lark-cli slides +create-svg",
"required_flags": [
"--file"
],
"ordered_flags": [
"--file"
],
"optional_flags": [
"--title",
"--assets",
"--dry-run"
],
"api_chain": [
"POST /open-apis/slides_ai/v1/xml_presentations",
"optional media uploads",
"POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1"
],
"page_body_shape": {
"slide": {
"content": "<svg ...>...</svg>"
}
},
"requires_quality_gate": true,
"requires_readback": true
},
"route_boundary": {
"read_before_admission": [
"skills/lark-slides/references/svglide-route-admission.md"
],
"primary_private_index_after_admission": "skills/lark-slides/references/svglide-svg-private.rules.json",
"compatibility_private_index_after_admission": "skills/lark-slides/references/svg-private-manifest.json",
"private_strategy_loaded_after_admission": true,
"xml_route_must_not_load_private_strategy": true
},
"forbidden_on_xml_route": [
"skills/lark-slides/references/svglide-*.md",
"skills/lark-slides/references/svg-*.md",
"skills/lark-slides/scripts/svglide_*.py",
"skills/lark-slides/scripts/svg_preflight.py",
"skills/lark-slides/scripts/svg_preview_lint.py"
]
}

View File

@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-template-admission.schema.json",
"title": "SVGlide template and seed admission",
"type": "object",
"required": ["schema_version", "items"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-template-admission/v1"},
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "source", "kind", "activation_status", "copy_policy", "compatibility", "usage_proof"],
"additionalProperties": true,
"properties": {
"id": {"type": "string", "minLength": 1},
"source": {"type": "string", "minLength": 1},
"kind": {"enum": ["seed", "template", "recipe", "style", "reference"]},
"activation_status": {"enum": ["active", "candidate", "reference_only", "blocked"]},
"copy_policy": {"enum": ["svglide_native", "reference_only", "blocked_raw_runtime"]},
"compatibility": {"type": "object"},
"usage_proof": {"type": "object"},
"license_status": {"enum": ["cleared", "unknown", "restricted"]},
"unsupported_features": {"type": "array", "items": {"type": "string"}}
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-text-inventory.schema.json",
"title": "SVGlide visible text inventory",
"type": "object",
"required": ["schema_version", "slides", "summary"],
"additionalProperties": true,
"properties": {
"schema_version": {"const": "svglide-text-inventory/v1"},
"slides": {
"type": "array",
"items": {
"type": "object",
"required": ["page", "svg", "texts", "unmatched_texts"],
"additionalProperties": true,
"properties": {
"page": {"type": "integer", "minimum": 1},
"svg": {"type": "string", "minLength": 1},
"texts": {
"type": "array",
"items": {
"type": "object",
"required": ["text", "source", "status"],
"additionalProperties": true,
"properties": {
"text": {"type": "string", "minLength": 1},
"source": {"enum": ["slide_plan.json", "source/evidence.json", "generator"]},
"source_path": {"type": ["string", "null"]},
"status": {"enum": ["matched", "allowed_generated", "unmatched"]}
}
}
},
"unmatched_texts": {"type": "array", "items": {"type": "string"}}
}
}
},
"summary": {
"type": "object",
"required": ["slide_count", "text_count", "unmatched_text_count"],
"additionalProperties": true,
"properties": {
"slide_count": {"type": "integer", "minimum": 0},
"text_count": {"type": "integer", "minimum": 0},
"unmatched_text_count": {"type": "integer", "minimum": 0}
}
}
}
}

View File

@@ -0,0 +1,158 @@
# SVGlide Validation Checklist
Read this file only after `svglide-svg` route admission. Shared XML validation still lives in `validation-checklist.md`.
Compatibility note: new runner-first check paths are defined in `svglide-checks.checklist.md`. Keep this file for gate semantics; use the staged `02-plan`, `04-svg/prepared`, `05-preview`, `06-check`, `07-create`, and `08-readback` paths for new work.
## Required Flow
1. Validate the SVG plan against `svglide-plan.schema.json` and route admission.
2. Run `svglide_source.py` to produce a fresh source receipt and evidence pack. User runs default to online-first through the runner; CI/golden should use `--network-policy fixture` or `--offline`.
3. Run local source preflight with `svg_preflight.py --plan`.
4. Build or inspect a local preview when practical, then run `svg_preview_lint.py` before live create.
5. Record an aesthetic review following `svg-aesthetic-review.md`; this review cannot replace deterministic lint.
6. Run `svglide_chart_verify.py` and `svglide_runtime_review.py` before `quality_gate`.
7. Run `svglide_semantic_review.py` to block English plans, weak page structure, thin content, missing source refs, and SVG text that does not trace to plan/source.
8. Run `slides +create-svg --dry-run` when command behavior is under review.
9. Run `svglide_ppe_proof.py` before live create.
10. After live create, use `xml_presentations.get` readback and record page count, blank-page, asset, bounds, and text-fit checks.
Treat the gate as a single chain:
```text
route admission
-> loaded_rule_set + art_direction + business_claims
-> source receipt
-> asset manifest / image jobs
-> svg_preflight --plan
-> svg_preview_lint.py
-> aesthetic review record
-> chart_verify
-> semantic_review
-> runtime_review
-> quality_gate
-> dry-run
-> ppe_proof
-> live create
-> readback checks
```
Any P0/error-level result before live create blocks the API call.
## Online-First Flags
Runner flags:
- `--offline`: disables online research and image acquisition.
- `--no-online-research`: keeps source local-only.
- `--no-image-search`: disables web image search/download.
- `--no-ai-image`: disables AI image job planning.
- `--refresh-online`: refreshes source/assets instead of reusing existing artifacts.
- `--network-policy auto|online|offline|fixture`: choose online-first, forced online, local-only, or deterministic fixture behavior.
- `--asset-provider` and `--image-backend`: record acquisition/provider intent in asset receipts.
## Local Preflight
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--plan .lark-slides/plan/<deck-id>/02-plan/slide_plan.json \
--input .lark-slides/plan/<deck-id>/04-svg/prepared/page-001.svg
```
Pass criteria:
- `summary.error_count == 0`; any error blocks live API calls.
- `loaded_rule_set` records the SVG private design and validation files loaded for the run.
- `art_direction` records cover, section-divider/tempo, closing, deck motif, and at least 3 source-backed SVG-native moments.
- `quality_gates` includes `no_text_overflow`, `no_debug_guides`, and `no_xml_like_pages` set to true.
- Visible numeric or business claims have `business_claims` source records; derived or assumed claims include derivation/assumption notes.
- The selected style preset exists in `style-presets.json`.
- The style system contains palette, typography, background strategy, and motif.
- Every page declares the SVG-only planning fields listed in `svglide-planning-layer.md`.
- Declared effects and required primitives match the corresponding source SVG.
- Visible slide text does not leak preset names, source tokens, prompts, tool names, or local file paths.
Common remediation:
| code | Meaning | Action |
|------|---------|--------|
| `plan_style_preset_unknown` | Unknown preset id | Choose a valid id from `style-presets.json` |
| `plan_missing_visual_signature` | No SVG visual memory point | State the distinctive structure on that page |
| `plan_missing_svg_effects` | No declared SVG capability | Declare real source-backed effects |
| `plan_svg_effect_not_found` | Declared effect missing in source | Adjust source SVG or remove inaccurate metadata |
| `plan_style_preset_visible_leak` | Preset/source metadata leaked into visible text | Keep metadata in plan only |
| `plan_missing_loaded_rule_set` | SVG private refs were not recorded | Add the manifest-required SVG rule file list |
| `plan_missing_art_direction` | No deck-level design strategy | Add cover/section/closing treatments, motif, and SVG-native moments |
| `plan_missing_business_claims` | Visible numeric/business claims lack source records | Mark each claim as prompt-provided, derived, assumption, etc. |
## Preview Lint
Run preview lint on local HTML/SVG preview before live create:
```bash
python3 skills/lark-slides/scripts/svg_preview_lint.py \
.lark-slides/plan/<deck-id>/05-preview/preview.html --pretty
```
Pass criteria:
- `summary.error_count == 0`; errors block live create.
- `action == "create_live"`.
- `page_issues` has no `preview_safe_area_debug_rect_visible`, `preview_debug_guide_visible`, `preview_text_overflow_risk`, or `preview_big_number_box_tight`.
- `rendering_mode` may be `static_dom_approximation` until a headless renderer is available; keep `screenshot_paths` in the output shape so rendered checks can be added without changing downstream gates.
Preview lint owns rendered/local-preview risks. Do not duplicate recipe-family or source-primitives rules here; those stay in `svg_preflight.py`.
## Aesthetic Preview Review
After deterministic preflight passes, inspect rendered preview and follow `svg-aesthetic-review.md`.
Pass criteria:
- Every page is checked, not only the cover.
- No obvious overlap or clipping among titles, body text, badges, decorations, image frames, chart labels, and footers.
- Root canvas and main content follow the 960 x 540 canvas and safe area.
- Each page has a clear visual focal point that matches the declared signature.
- Pages do not look like ordinary card/bullet XML pages with SVG wrapped around them.
- Repeated layout problems are fixed in the generator or source, then preflight is rerun.
- Review records include preview path, score, threshold, issue ids, and action.
- If independent review score is below the configured threshold, record `action: repair_and_rerun`; do not treat self-scoring as a gate.
## Semantic Review
Run semantic review before `quality_gate`:
```bash
python3 skills/lark-slides/scripts/svglide_semantic_review.py \
.lark-slides/plan/<deck-id> --profile preview_only --pretty
```
Pass criteria:
- `summary.error_count == 0`.
- `language == zh-CN`, `audience` is non-empty, and `deck_structure` covers the required page types.
- Every slide has `page_type`, `section`, `role`, Chinese `title`, Chinese `key_message`, and sufficient `body_points`.
- Content slides have source refs that resolve to `source/evidence.json`.
- Numeric claims have source refs; blocked online research is not acceptable for production/live profiles.
- `06-check/text-inventory.json` contains no unmatched visible SVG text.
Semantic review owns content-language and plan/source provenance. Do not treat a clean preview or aesthetic score as a substitute for this gate.
## Chart And Runtime Review
- `06-check/chart-verify.json` must be fresh. Pages declaring `chart_contract.verify=required` or exact chart precision must have chart data and chart-like SVG marks.
- `06-check/runtime-review.json` must be fresh. Each page must declare `renderer_id` and `layout_family`; 4+ page decks cannot use a single renderer or layout family throughout; image-led cover/closing assets must match cover/closing renderer families.
- `06-check/visual-distinctness.json` must be fresh. Topic-only decks must have a theme-specific `visual_identity`; different themes cannot reuse the same style preset, palette, cover treatment, and renderer/layout sequence.
- `06-check/aesthetic-review.json` must verify asset placement metadata from `03-assets/asset-manifest.json`; cover/background/closing images require safe editable text zones.
## Readback Checks
Live create is not complete until readback confirms:
- Actual page count matches the plan and user request.
- No page is blank or missing its key message.
- Images are visible or explicitly documented as preview-only risk.
- Converted XML keeps content inside canvas and safe area.
- Text boxes, labels, and footer/source notes remain readable.
- Closing slide is present when required.
- Readback records must be tied to the same plan, quality gate, dry-run, PPE proof, and live-create digests. HTML preview is not a substitute for readback because server conversion can change text boxes, image tokens, and bounds.

View File

@@ -0,0 +1,38 @@
# SVGlide Visual Planning
Read this file only after `svglide-svg` route admission. Shared layout guidance remains in `visual-planning.md`; this file adds SVG-specific rendering constraints.
## Layout Boxes First
Define stable boxes before writing SVG coordinates:
```text
page = 960 x 540
safe = x:48 y:40 w:864 h:460
titleBox = x:54 y:52 w:600 h:96
visualBox = x:516 y:176 w:350 h:260
notesGrid = x:54 y:430 w:760 h:48
```
Generate source from these boxes. Do not paste 1280 x 720 coordinates and only change the root viewBox.
## Text Safety
- Use `foreignObject` text with explicit `font-size`, `font-weight`, `font-family`, `color`, `line-height`, and `text-align`.
- Avoid CSS font shorthand for key text.
- Leave extra height for Chinese and mixed Chinese/English text.
- White or near-white text must sit fully on a dark backing shape.
- Circular and elliptical nodes should contain only short labels; explanations belong in separate callouts.
- Do not rely on browser wrapping or clipping to hide layout mistakes.
## SVG-Safe Geometry
- Geometry attributes must be numbers or `px`.
- Path data should use only `M/L/H/V/C/Q/Z` commands.
- Use explicit shapes, short line segments, and filled dots for important dashed routes.
- Use double shapes for critical rings instead of depending on stroked circle width.
- Bake important image opacity into the image, or use a semi-transparent shape overlay.
## Visual Advantage
Each SVG slide should show a real SVG-native advantage: path composition, dense geometry, explicit annotation layers, dashboard frames, image overlays, texture, or brand-system motifs. If the page is only title plus cards or bullets, route back to XML or redesign the page.

View File

@@ -0,0 +1,69 @@
# SVGlide Workflow Spec
Read this file only after `svglide-svg` route admission. It summarizes the P0 execution graph for runner-first `slides +create-svg` work and points to the private SVG route files for detailed rules.
## Stage Graph
```text
request
-> route admission
-> load SVG private rule set
-> init run directory
-> source
-> plan
-> strategy_review
-> user confirms plan
-> assets
-> generate_svg
-> prepare SVG inputs
-> build local preview
-> preflight
-> preview_lint
-> aesthetic_review
-> chart_verify
-> semantic_review
-> runtime_review
-> visual_distinctness_review
-> quality_gate
-> dry-run create
-> ppe_proof
-> live create
-> readback
-> delivery record
```
## Stage Contract
| Stage | Input | Output | Gate |
|---|---|---|---|
| route admission | user request, source root, plan route | confirmed `svglide-svg` route | if not admitted, stay on XML route |
| load rules | `svglide-svg-private.rules.json` | recorded `loaded_rule_set` | missing required private files blocks preflight |
| init | deck id, title | `.lark-slides/plan/<deck-id>/01-project/` | repeat init of the same deck id is rejected unless explicitly forced |
| source | `source/evidence.json` or `source/source-notes.md`; online research unless disabled | `source/evidence.json`, `source/research_queries.json`, `source/research.md`, `source/source-receipt.json`, `receipts/source.json` | `source_status=thin/blocked`, blocked online research, too few evidence items, or stale source receipt blocks strategy/generation |
| 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 |
| assets | confirmed plan/lock asset contracts | `03-assets/assets.json`, `03-assets/asset-manifest.json`, `03-assets/image-jobs.json`, `receipts/assets.json` | empty image boxes, required HTTP/data assets that cannot be acquired, missing local files, and unsafe image placements block the chain |
| generate_svg | confirmed plan, lock, and assets manifest | ordered `04-svg/page-###.svg` files, per-page receipts, `receipts/generate_svg.json` | each page must use SVGlide roles, 960 x 540 canvas, and safe geometry |
| prepare | `generate_svg` source SVG pages and asset map | ordered `04-svg/prepared/page-###.svg` files, `receipts/prepare.json` | unresolved local image placeholders block the chain; source SVG changes after `generate_svg` require rerun |
| 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` |
| 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 |
| visual_distinctness_review | current plan and recent local project plans | `06-check/visual-distinctness.json` | different themes must not reuse the same style preset, palette, cover treatment, and renderer/layout sequence; default-only renderer sequences fail |
| quality_gate | generator receipt, preflight, preview lint, aesthetic review, chart verify, semantic review, runtime review, visual distinctness, source/assets readiness | `06-check/quality-gate.json` | required checks must all pass and be fresh before dry-run or live create; strict profiles reject blocked research, failed asset manifests, and fallback skeleton generation |
| dry-run create | checked prepared SVG files | `07-create/dry-run.json`, `07-create/create-command.txt` | request order and asset rewrites must match files |
| 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 |
## Route Boundary
The SVG route is private to `slides +create-svg`. XML creation, XML edit, and SXSD work must not load these SVG private strategy files unless route admission later proves that the request is SVG route work.
## Online-First Controls
User generation defaults to `--network-policy auto`: `source` may acquire current web evidence and `assets` may acquire web images or write AI image jobs. Deterministic suites should pass `--network-policy fixture` or `--offline`; resume reuses existing source/assets unless `--refresh-online` is set. `--no-online-research`, `--no-image-search`, and `--no-ai-image` disable the corresponding acquisition path without changing the stage graph.

View File

@@ -44,6 +44,10 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
## SVG Route Validation
SVG route validation is loaded only after route admission. XML validation remains focused on XML schema, page count, blank page, overflow, overlap, media visibility, and readback integrity.
## Page Count And Structure
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。

View File

@@ -20,6 +20,16 @@
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
- Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML.
## Title Zone Guardrails
The title zone is the most common place for subtle overlap. Treat badges, decorative rules, headlines, and the first content row as one unit.
- If a page uses a chapter badge, status pill, or small label above the headline, the headline text top must be at least `8` px below the badge bottom; prefer `12` px when the headline is bold or larger than `28` px.
- If a decorative horizontal line, accent rule, or divider sits above a headline, the line bottom must be at least `16` px above the headline text top; prefer `20-28` px when the headline is larger than `48` px.
- When a headline is moved down to create breathing room, move the subtitle, column headers, and main content start down together. Do not fix one collision by creating a new one below.
- Do not place large headlines directly under a top border or accent stripe. The decoration should frame the title, not press on it.
- Check the same page family across the whole deck. If one section/title page has a badge-headline collision, scan all pages with the same badge pattern before accepting the deck.
## Background And Motif Consistency
Decks can vary page backgrounds, but variation must be intentional and legible:
@@ -47,6 +57,7 @@ Use these as conservative minimums on a 960 x 540 canvas. Increase height when u
Additional rules:
- Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only.
- Text must fit both its own text box and its nearest visible container. A card, pill, footer bar, or table band should provide enough width and height for the visible wording with padding; do not rely on clipping, browser overflow, or renderer default wrapping to hide mistakes.
- Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area.
- Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines.
- Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line.

View File

@@ -0,0 +1,16 @@
{
"language": null,
"audience": null,
"deck_structure": ["content"],
"slides": [
{
"page": 1,
"layout_family": "chart",
"visual_recipe": "bends",
"title": "NVIDIA Supply Chain Inflection",
"key_message": "Three bends define the next phase",
"body_points": ["From graphics leader to AI infrastructure"],
"source_refs": []
}
]
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<text x="80" y="90">From graphics leader to AI infrastructure</text>
<text x="80" y="140">Three bends define the next phase</text>
<text x="80" y="190">Takeaway: execution risk now matters most</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import html
import json
import re
import sys
from pathlib import Path
from typing import Any
DEBUG_GUIDE_RE = re.compile(r"\b(?:safe[-_ ]?area|debug|guide|bbox|layout[-_ ]?guide)\b", re.IGNORECASE)
SVG_RE = re.compile(r"<svg\b[^>]*>.*?</svg>", re.IGNORECASE | re.DOTALL)
FO_RE = re.compile(r"<foreignObject\b([^>]*)>(.*?)</foreignObject>", re.IGNORECASE | re.DOTALL)
TAG_RE = re.compile(r"<[^>]+>")
ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*([\"'])(.*?)\2", re.DOTALL)
FONT_SIZE_RE = re.compile(r"font-size\s*:\s*([0-9.]+)px?", re.IGNORECASE)
LINE_HEIGHT_RE = re.compile(r"line-height\s*:\s*([0-9.]+)", re.IGNORECASE)
BIG_NUMBER_RE = re.compile(r"^[+\-]?\d+(?:\.\d+)?\s*(?:万|亿|%|人)?$")
CLASS_FONT_SIZE = {
"kicker": 15.0,
"title": 40.0,
"title-sm": 29.0,
"body": 16.0,
"body-strong": 18.0,
"small": 13.0,
"metric": 54.0,
"metric-light": 42.0,
}
CLASS_LINE_HEIGHT = {
"title": 1.16,
"title-sm": 1.18,
"body": 1.5,
"body-strong": 1.34,
"small": 1.38,
"metric": 1.0,
"metric-light": 1.0,
}
def parse_attrs(raw: str) -> dict[str, str]:
return {match.group(1): html.unescape(match.group(3)) for match in ATTR_RE.finditer(raw)}
def number(value: str | None, default: float = 0.0) -> float:
if value is None:
return default
try:
return float(value)
except ValueError:
return default
def issue(level: str, code: str, page: int, message: str, hint: str | None = None, box: dict[str, float] | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"level": level, "code": code, "page": page, "message": message}
if hint:
out["hint"] = hint
if box:
out["box"] = box
return out
def is_hidden_element(attrs: dict[str, str]) -> bool:
style = attrs.get("style", "")
if re.search(r"display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:\.0+)?(?:\s|;|$)", style, re.IGNORECASE):
return True
if attrs.get("display", "").lower() == "none":
return True
if attrs.get("visibility", "").lower() == "hidden":
return True
if attrs.get("opacity", "").strip() in {"0", "0.0", "0.00"}:
return True
return False
def css_class_value(raw_html: str) -> str:
match = re.search(r"\bclass\s*=\s*([\"'])(.*?)\1", raw_html, re.IGNORECASE | re.DOTALL)
return html.unescape(match.group(2)) if match else ""
def style_value(raw_html: str) -> str:
match = re.search(r"\bstyle\s*=\s*([\"'])(.*?)\1", raw_html, re.IGNORECASE | re.DOTALL)
return html.unescape(match.group(2)) if match else ""
def font_size_for(raw_html: str) -> float:
style = style_value(raw_html)
match = FONT_SIZE_RE.search(style)
if match:
return number(match.group(1), 13.0)
classes = set(css_class_value(raw_html).split())
for class_name, size in CLASS_FONT_SIZE.items():
if class_name in classes:
return size
return 13.0
def line_height_for(raw_html: str, font_size: float) -> float:
style = style_value(raw_html)
match = LINE_HEIGHT_RE.search(style)
if match:
return number(match.group(1), 1.35)
classes = set(css_class_value(raw_html).split())
for class_name, line_height in CLASS_LINE_HEIGHT.items():
if class_name in classes:
return line_height
return 1.35 if font_size < 18 else 1.25
def visible_text(raw_html: str) -> str:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.IGNORECASE)
text = TAG_RE.sub("", text)
return html.unescape(text).strip()
def estimate_text_box(text: str, width: float, font_size: float, line_height: float) -> tuple[float, float, int]:
if width <= 0:
return 0.0, 0.0, 0
lines = [line.strip() for line in text.splitlines() if line.strip()] or [text.strip()]
estimated_lines = 0
max_line_width = 0.0
for line in lines:
line_width = 0.0
for char in line:
line_width += font_size * (0.92 if "\u4e00" <= char <= "\u9fff" else 0.56)
max_line_width = max(max_line_width, line_width)
estimated_lines += max(1, int((line_width + width - 1) // width))
return estimated_lines * font_size * line_height, max_line_width, estimated_lines
def overlap_ratio(a: dict[str, float], b: dict[str, float]) -> float:
left = max(a["x"], b["x"])
top = max(a["y"], b["y"])
right = min(a["x"] + a["width"], b["x"] + b["width"])
bottom = min(a["y"] + a["height"], b["y"] + b["height"])
if right <= left or bottom <= top:
return 0.0
overlap = (right - left) * (bottom - top)
smaller = min(a["width"] * a["height"], b["width"] * b["height"])
return overlap / smaller if smaller > 0 else 0.0
def lint_svg_block(svg: str, page: int) -> tuple[list[dict[str, Any]], list[dict[str, float]]]:
issues: list[dict[str, Any]] = []
text_boxes: list[dict[str, float]] = []
for match in re.finditer(r"<(rect|line|path|circle|ellipse)\b([^>]*)>", svg, re.IGNORECASE):
element_name = match.group(1).lower()
attrs = parse_attrs(match.group(2))
if is_hidden_element(attrs):
continue
if (
element_name == "rect"
and attrs.get("x") == "48"
and attrs.get("y") == "40"
and attrs.get("width") == "864"
and attrs.get("height") == "460"
and attrs.get("fill", "").lower() == "none"
and "stroke" in attrs
):
issues.append(
issue(
"error",
"preview_safe_area_debug_rect_visible",
page,
"preview must not show the 48/40/864/460 safe-area guide rectangle",
"Keep safe-area constraints in plan/preflight only; remove visible guide rects from delivered preview.",
)
)
text = " ".join(attrs.get(key, "") for key in ["id", "class", "data-role", "data-debug", "aria-label"])
if DEBUG_GUIDE_RE.search(text):
issues.append(
issue(
"error",
"preview_debug_guide_visible",
page,
"preview contains a visible debug/layout guide element",
"Remove elements whose id/class/data-* labels mark safe-area, debug, guide, bbox, or layout-guide.",
)
)
for fo in FO_RE.finditer(svg):
attrs = parse_attrs(fo.group(1))
box = {
"x": number(attrs.get("x")),
"y": number(attrs.get("y")),
"width": number(attrs.get("width")),
"height": number(attrs.get("height")),
}
raw_inner = fo.group(2)
text = visible_text(raw_inner)
if not text:
continue
font_size = font_size_for(raw_inner)
line_height = line_height_for(raw_inner, font_size)
estimated_height, estimated_width, estimated_lines = estimate_text_box(text, box["width"], font_size, line_height)
text_boxes.append(box)
if box["width"] <= 0 or box["height"] <= 0:
issues.append(issue("error", "preview_text_box_non_positive", page, "foreignObject text box has non-positive size", box=box))
continue
if estimated_height + 4 > box["height"]:
issues.append(
issue(
"error",
"preview_text_overflow_risk",
page,
f'text box is too short for estimated {estimated_lines} line(s): "{text[:48]}"',
"Increase foreignObject height or reduce text/font size before delivery.",
box,
)
)
if estimated_width > box["width"] * 1.25 and estimated_lines == 1:
issues.append(
issue(
"warning",
"preview_text_width_tight",
page,
f'text box width is tight and may wrap unexpectedly: "{text[:48]}"',
"Reserve width for Chinese text and fallback fonts.",
box,
)
)
normalized_text = text.replace(" ", "")
if font_size >= 36 or BIG_NUMBER_RE.match(normalized_text):
if estimated_width + 8 > box["width"] or font_size * line_height + 4 > box["height"]:
issues.append(
issue(
"error",
"preview_big_number_box_tight",
page,
f'large number/title is in a tight box: "{text[:32]}"',
"Large numeric focal points need explicit width/height budget and should not rely on wrapping.",
box,
)
)
for index, current in enumerate(text_boxes):
for other in text_boxes[index + 1 :]:
ratio = overlap_ratio(current, other)
if ratio >= 0.35:
issues.append(
issue(
"error",
"preview_text_box_overlap",
page,
"foreignObject text boxes overlap substantially",
"Verify this is intentional; otherwise separate the text boxes or reduce their height.",
current,
)
)
break
return issues, text_boxes
def lint_text(text: str, path: str) -> dict[str, Any]:
issues: list[dict[str, Any]] = []
svg_blocks = SVG_RE.findall(text)
if not svg_blocks:
issues.append(issue("error", "preview_no_svg_pages", 0, "preview contains no SVG pages"))
page_boxes: list[int] = []
for index, svg in enumerate(svg_blocks, 1):
page_issues, boxes = lint_svg_block(svg, index)
issues.extend(page_issues)
page_boxes.append(len(boxes))
return {
"path": path,
"rendering_mode": "static_dom_approximation",
"screenshot_paths": [],
"page_count": len(svg_blocks),
"text_box_count": sum(page_boxes),
"summary": {
"error_count": sum(1 for item in issues if item["level"] == "error"),
"warning_count": sum(1 for item in issues if item["level"] == "warning"),
},
"page_issues": issues,
"action": "repair_and_rerun" if any(item["level"] == "error" for item in issues) else "create_live",
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Lint local SVGlide HTML/SVG previews for delivery-blocking visual risks.")
parser.add_argument("input", help="preview HTML or SVG file")
parser.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
args = parser.parse_args(argv)
path = Path(args.input)
try:
text = path.read_text(encoding="utf-8")
except OSError as error:
print(f"svg_preview_lint: {error}", file=sys.stderr)
return 2
result = lint_text(text, str(path))
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 1 if result["summary"]["error_count"] else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,96 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svg_preview_lint
class SvgPreviewLintTest(unittest.TestCase):
def test_reports_safe_area_debug_rect(self) -> None:
html = """
<html><body>
<svg width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#F5F1EE"/>
<rect x="48" y="40" width="864" height="460" fill="none" stroke="#0E5A3C" stroke-opacity="0.12"/>
</svg>
</body></html>
"""
result = svg_preview_lint.lint_text(html, "preview.html")
codes = [issue["code"] for issue in result["page_issues"]]
self.assertIn("preview_safe_area_debug_rect_visible", codes)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["action"], "repair_and_rerun")
def test_ignores_hidden_debug_guide(self) -> None:
html = """
<svg width="960" height="540" viewBox="0 0 960 540">
<rect id="safe-area-guide" x="48" y="40" width="864" height="460" fill="none" stroke="#0E5A3C" style="display:none"/>
</svg>
"""
result = svg_preview_lint.lint_text(html, "preview.html")
codes = [issue["code"] for issue in result["page_issues"]]
self.assertNotIn("preview_debug_guide_visible", codes)
self.assertNotIn("preview_safe_area_debug_rect_visible", codes)
self.assertEqual(result["summary"]["error_count"], 0)
def test_reports_tight_chinese_text_and_big_number_boxes(self) -> None:
html = """
<svg width="960" height="540" viewBox="0 0 960 540">
<foreignObject x="102" y="358" width="110" height="46">
<div xmlns="http://www.w3.org/1999/xhtml" class="t body-strong center">锁定重点客户清单</div>
</foreignObject>
<foreignObject x="654" y="194" width="90" height="40">
<div xmlns="http://www.w3.org/1999/xhtml" class="t metric-light">+100</div>
</foreignObject>
</svg>
"""
result = svg_preview_lint.lint_text(html, "preview.html")
codes = [issue["code"] for issue in result["page_issues"]]
self.assertIn("preview_text_overflow_risk", codes)
self.assertIn("preview_big_number_box_tight", codes)
self.assertGreaterEqual(result["summary"]["error_count"], 2)
def test_reports_substantial_text_box_overlap_as_error(self) -> None:
html = """
<svg width="960" height="540" viewBox="0 0 960 540">
<foreignObject x="100" y="100" width="220" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px;line-height:1.3;">第一段文本</div>
</foreignObject>
<foreignObject x="120" y="110" width="220" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px;line-height:1.3;">第二段文本</div>
</foreignObject>
</svg>
"""
result = svg_preview_lint.lint_text(html, "preview.html")
codes = [issue["code"] for issue in result["page_issues"]]
self.assertIn("preview_text_box_overlap", codes)
self.assertGreaterEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["action"], "repair_and_rerun")
def test_reports_no_svg_pages(self) -> None:
result = svg_preview_lint.lint_text("<html><body>No slides</body></html>", "preview.html")
codes = [issue["code"] for issue in result["page_issues"]]
self.assertIn("preview_no_svg_pages", codes)
self.assertEqual(result["action"], "repair_and_rerun")
def test_accepts_roomy_text_boxes(self) -> None:
html = """
<svg width="960" height="540" viewBox="0 0 960 540">
<foreignObject x="80" y="80" width="360" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:18px;line-height:1.35;">年度目标拆解清晰</div>
</foreignObject>
</svg>
"""
result = svg_preview_lint.lint_text(html, "preview.html")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["action"], "create_live")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PREVIEW_HTML = Path("05-preview/preview.html")
PREVIEW_MANIFEST = Path("05-preview/preview-manifest.json")
PREVIEW_LINT = Path("06-check/preview-lint.json")
ASSET_MANIFEST = Path("03-assets/asset-manifest.json")
AESTHETIC_REVIEW = Path("06-check/aesthetic-review.json")
PASS_ACTION = "create_live"
FAIL_ACTION = "repair_and_rerun"
class AestheticReviewError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def read_json(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise AestheticReviewError(f"missing required file: {path}") from error
except json.JSONDecodeError as error:
raise AestheticReviewError(f"invalid JSON in {path}: {error}") from error
if not isinstance(payload, dict):
raise AestheticReviewError(f"invalid JSON in {path}: expected object")
return payload
def read_json_optional(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return read_json(path)
def expected_page_count(project: Path) -> int | None:
plan_path = project / "02-plan" / "slide_plan.json"
if not plan_path.exists():
return None
plan = read_json(plan_path)
for key in ["slides", "svg_files", "pages"]:
value = plan.get(key)
if isinstance(value, list):
return len(value)
raw = plan.get("page_count") or plan.get("target_slide_count")
return raw if isinstance(raw, int) and raw > 0 else None
def issue(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message}
def asset_review(project: Path) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, Any]]:
issues: list[dict[str, str]] = []
warnings: list[dict[str, str]] = []
manifest = read_json_optional(project / ASSET_MANIFEST)
acquired = manifest.get("acquired_assets") if isinstance(manifest.get("acquired_assets"), list) else []
real_assets = 0
fallback_assets = 0
for item in acquired:
if not isinstance(item, dict):
continue
role = item.get("placement_role")
status = item.get("status")
kind = item.get("asset_kind")
safe_zones = item.get("safe_text_zones")
if status == "acquired":
real_assets += 1
if status == "fallback_used":
fallback_assets += 1
if role in {"cover", "background", "closing"} and status == "acquired" and not isinstance(safe_zones, list):
issues.append(issue("asset_text_zone_unsafe", f"asset {item.get('asset_id')} needs safe_text_zones for {role} placement"))
if role == "body_visual" and status == "acquired" and kind == "web_image" and item.get("caption_required") is True and not item.get("source_url"):
issues.append(issue("asset_source_missing", f"body visual asset {item.get('asset_id')} must keep source_url"))
if role in {"cover", "closing"} and status == "fallback_used":
warnings.append(issue("asset_fallback_used", f"{role} asset {item.get('asset_id')} fell back to SVG-native rendering"))
if isinstance(item.get("source_url"), str) and "watermark" in str(item.get("source_url")).lower():
issues.append(issue("asset_label_baked_into_image", f"asset {item.get('asset_id')} source suggests watermark/text risk"))
summary = {
"manifest_status": manifest.get("status") if manifest else "missing",
"asset_count": len(acquired),
"real_asset_count": real_assets,
"fallback_asset_count": fallback_assets,
}
return issues, warnings, summary
def run_aesthetic_review(project: Path) -> dict[str, Any]:
project = project.resolve()
issues: list[dict[str, str]] = []
preview = project / PREVIEW_HTML
manifest_path = project / PREVIEW_MANIFEST
lint_path = project / PREVIEW_LINT
if not preview.exists():
issues.append(issue("missing_preview_html", "preview.html is missing"))
manifest = read_json(manifest_path) if manifest_path.exists() else {}
if not manifest:
issues.append(issue("missing_preview_manifest", "preview manifest is missing"))
lint = read_json(lint_path) if lint_path.exists() else {}
lint_errors = lint.get("summary", {}).get("error_count") if isinstance(lint.get("summary"), dict) else None
if lint_errors != 0 or lint.get("action") != PASS_ACTION:
issues.append(issue("preview_lint_not_clean", "preview lint must be clean before aesthetic review can pass"))
expected = expected_page_count(project)
actual = manifest.get("page_count")
if expected is not None and actual != expected:
issues.append(issue("preview_page_count_mismatch", f"expected {expected} preview pages, got {actual!r}"))
for page in manifest.get("pages", []) if isinstance(manifest.get("pages"), list) else []:
if isinstance(page, dict) and page.get("source_bytes") == 0:
issues.append(issue("blank_preview_source", f"preview page {page.get('page')} has an empty SVG source"))
asset_issues, asset_warnings, asset_summary = asset_review(project)
issues.extend(asset_issues)
result = {
"version": "svglide-aesthetic-review/v1",
"review_mode": "automated_preview_record",
"reviewed_at": now_iso(),
"status": "failed" if issues else "passed",
"preview_path": PREVIEW_HTML.as_posix(),
"manifest_path": PREVIEW_MANIFEST.as_posix(),
"page_count": actual,
"asset_review": asset_summary,
"summary": {
"error_count": len(issues),
"warning_count": len(asset_warnings),
},
"issues": issues,
"warnings": asset_warnings,
"action": FAIL_ACTION if issues else PASS_ACTION,
}
output = project / AESTHETIC_REVIEW
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Record automated SVGlide preview aesthetic review gate.")
parser.add_argument("project", help="SVGlide project directory under .lark-slides/plan/<deck-id>")
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_aesthetic_review(Path(args.project))
except (OSError, AestheticReviewError) as error:
print(f"svglide_aesthetic_review: error: {error}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["action"] == PASS_ACTION else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,83 @@
# 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_aesthetic_review
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideAestheticReviewTest(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}]})
(project / "05-preview").mkdir(parents=True, exist_ok=True)
(project / "05-preview/preview.html").write_text("<html></html>", encoding="utf-8")
write_json(project / "05-preview/preview-manifest.json", {"page_count": 1, "pages": [{"page": 1, "source_bytes": 12}]})
write_json(project / "06-check/preview-lint.json", {"summary": {"error_count": 0}, "action": "create_live"})
return project
def test_aesthetic_review_passes_with_clean_preview_artifacts(self) -> None:
project = self.make_project()
result = svglide_aesthetic_review.run_aesthetic_review(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["action"], "create_live")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertTrue((project / "06-check/aesthetic-review.json").exists())
def test_aesthetic_review_blocks_when_preview_lint_blocks(self) -> None:
project = self.make_project()
write_json(project / "06-check/preview-lint.json", {"summary": {"error_count": 0}, "action": "repair_and_rerun"})
result = svglide_aesthetic_review.run_aesthetic_review(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["action"], "repair_and_rerun")
self.assertEqual(result["issues"][0]["code"], "preview_lint_not_clean")
def test_aesthetic_review_blocks_page_count_mismatch(self) -> None:
project = self.make_project()
write_json(project / "05-preview/preview-manifest.json", {"page_count": 2, "pages": [{"page": 1, "source_bytes": 12}]})
result = svglide_aesthetic_review.run_aesthetic_review(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["action"], "repair_and_rerun")
codes = {issue["code"] for issue in result["issues"]}
self.assertIn("preview_page_count_mismatch", codes)
def test_aesthetic_review_blocks_cover_asset_without_safe_text_zone(self) -> None:
project = self.make_project()
write_json(
project / "03-assets/asset-manifest.json",
{
"status": "passed",
"acquired_assets": [
{"asset_id": "hero", "placement_role": "cover", "status": "acquired", "asset_kind": "web_image"}
],
},
)
result = svglide_aesthetic_review.run_aesthetic_review(project)
self.assertEqual(result["status"], "failed")
codes = {issue["code"] for issue in result["issues"]}
self.assertIn("asset_text_zone_unsafe", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import html
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ASSET_MANIFEST = Path("03-assets/asset-manifest.json")
SVG_DIR = Path("04-svg")
INJECTABLE_ROLES = {"cover", "closing", "body_visual", "inline_figure"}
FILE_BACKED_STATUSES = {"acquired", "local_file", "generated"}
FILE_BACKED_KINDS = {"web_image", "user_file", "ai_image"}
SVG_OPEN_RE = re.compile(r"<svg\b[^>]*>", re.IGNORECASE | re.DOTALL)
VIEWBOX_RE = re.compile(r"""viewBox\s*=\s*["']\s*([0-9.-]+)\s+([0-9.-]+)\s+([0-9.-]+)\s+([0-9.-]+)\s*["']""", re.IGNORECASE)
NUMBER_ATTR_RE = re.compile(r"""{name}\s*=\s*["']([0-9.]+)(?:px)?["']""")
PAGE_RE = re.compile(r"page-(\d+)\.svg$")
FULL_SLIDE_RECT_RE = re.compile(r"<rect\b[^>]*>", re.IGNORECASE | re.DOTALL)
class AssetInjectionError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def read_json_object(path: Path, *, required: bool = True) -> dict[str, Any]:
if not path.exists():
if required:
raise AssetInjectionError(f"missing required file: {path}")
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise AssetInjectionError(f"invalid JSON in {path}: {error}") from error
if not isinstance(payload, dict):
raise AssetInjectionError(f"invalid JSON in {path}: expected object")
return payload
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def as_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, str):
try:
parsed = int(value)
except ValueError:
return None
return parsed
return None
def safe_id(value: Any) -> str:
raw = str(value or "asset").strip().lower()
out = "".join(ch if ch.isalnum() else "-" for ch in raw).strip("-")
return out or "asset"
def source_svg_files(project: Path) -> list[Path]:
svg_dir = project / SVG_DIR
if not svg_dir.exists():
return []
return sorted(path for path in svg_dir.glob("*.svg") if path.is_file())
def page_number(path: Path, fallback: int) -> int:
match = PAGE_RE.search(path.name)
if match:
return int(match.group(1))
return fallback
def slide_count(project: Path, fallback: int) -> int:
plan = read_json_object(project / "02-plan" / "slide_plan.json", required=False)
slides = plan.get("slides")
if isinstance(slides, list) and slides:
return len(slides)
count = as_int(plan.get("page_count") or plan.get("target_slide_count"))
return count if count and count > 0 else fallback
def normalize_asset_file(project: Path, raw: Any) -> tuple[Path | None, str | None]:
if not isinstance(raw, str) or not raw.strip():
return None, None
value = raw.strip()
if value.startswith("@./"):
rel = value[3:]
elif value.startswith("@/"):
rel = value[2:]
else:
rel = value
path = (project / rel).resolve()
root = project.resolve()
if path != root and root not in path.parents:
return None, None
return path, f"@./{rel}"
def has_safe_text_zone(asset: dict[str, Any]) -> bool:
zones = asset.get("safe_text_zones")
return isinstance(zones, list) and bool(zones)
def candidate_page(asset: dict[str, Any], *, page_count: int) -> int | None:
page = as_int(asset.get("page") or asset.get("usage_page"))
role = asset.get("placement_role")
if page and page > 0:
return page
if role == "cover":
return 1
if role == "closing":
return page_count if page_count > 0 else None
return None
def merged_assets(manifest: dict[str, Any]) -> list[dict[str, Any]]:
merged: list[dict[str, Any]] = []
seen: set[str] = set()
for key in ["acquired_assets", "contracts"]:
values = manifest.get(key)
if not isinstance(values, list):
continue
for raw in values:
if not isinstance(raw, dict):
continue
item = dict(raw)
asset_id = item.get("asset_id") or item.get("id")
if not isinstance(asset_id, str) or not asset_id.strip():
continue
item["asset_id"] = asset_id
dedupe_key = f"{asset_id}:{item.get('page') or item.get('usage_page')}:{item.get('placement_role')}"
if dedupe_key in seen:
continue
seen.add(dedupe_key)
merged.append(item)
return merged
def geometry(svg_text: str) -> tuple[float, float]:
open_match = SVG_OPEN_RE.search(svg_text)
if not open_match:
return 960.0, 540.0
tag = open_match.group(0)
viewbox = VIEWBOX_RE.search(tag)
if viewbox:
return float(viewbox.group(3)), float(viewbox.group(4))
width_re = re.compile(NUMBER_ATTR_RE.pattern.format(name="width"), re.IGNORECASE)
height_re = re.compile(NUMBER_ATTR_RE.pattern.format(name="height"), re.IGNORECASE)
width = width_re.search(tag)
height = height_re.search(tag)
if width and height:
return float(width.group(1)), float(height.group(1))
return 960.0, 540.0
def has_body_slot(svg_text: str, asset: dict[str, Any], page: int) -> bool:
asset_id = safe_id(asset.get("asset_id"))
tokens = [
"data-svglide-asset-slot",
f'id="asset-slot-{asset_id}"',
f"id='asset-slot-{asset_id}'",
f'id="asset-slot-page-{page:03d}"',
f"id='asset-slot-page-{page:03d}'",
"<!-- svglide:asset-slot",
]
return any(token in svg_text for token in tokens)
def already_injected(svg_text: str, asset_id: str) -> bool:
escaped = html.escape(asset_id, quote=True)
safe = safe_id(asset_id)
return f'data-svglide-asset-id="{escaped}"' in svg_text or f'id="svglide-asset-{safe}"' in svg_text
def cover_or_closing_layer(asset: dict[str, Any], *, href: str, width: float, height: float) -> str:
role = str(asset.get("placement_role") or "")
asset_id = html.escape(str(asset.get("asset_id")), quote=True)
safe = safe_id(asset.get("asset_id"))
scrim_opacity = "0.40" if role == "cover" else "0.30"
return f"""
<g id="svglide-asset-{safe}" data-svglide-asset-layer="true" data-svglide-asset-id="{asset_id}" data-svglide-placement-role="{html.escape(role, quote=True)}">
<image slide:role="image" id="svglide-asset-image-{safe}" href="{html.escape(href, quote=True)}" x="0" y="0" width="{width:g}" height="{height:g}" preserveAspectRatio="xMidYMid slice" />
<rect slide:role="shape" id="svglide-asset-scrim-{safe}" x="0" y="0" width="{width:g}" height="{height:g}" fill="#06111F" opacity="{scrim_opacity}" />
</g>"""
def text_layer(id_: str, *, x: float, y: float, width: float, height: float, text: str, color: str = "#1F2937", size: int = 12) -> str:
safe_id = html.escape(id_, quote=True)
safe_text = html.escape(text, quote=True)
style = (
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;"
f"font-size:{size}px;font-weight:600;line-height:1.2;color:{color};"
"white-space:pre-wrap;overflow:hidden;"
)
return (
f'<foreignObject slide:role="shape" slide:shape-type="text" id="{safe_id}" '
f'x="{x:g}" y="{y:g}" width="{width:g}" height="{height:g}">'
f'<div xmlns="http://www.w3.org/1999/xhtml" style="{html.escape(style, quote=True)}">{safe_text}</div>'
"</foreignObject>"
)
def figure_layer(asset: dict[str, Any], *, href: str, width: float, height: float) -> str:
asset_id = html.escape(str(asset.get("asset_id")), quote=True)
safe = safe_id(asset.get("asset_id"))
source = asset.get("source_url")
caption = ""
if isinstance(source, str) and source:
caption = "\n " + text_layer(
f"svglide-asset-source-{safe}",
x=width - 344,
y=332,
width=280,
height=18,
text=f"Source: {source[:88]}",
color="#596579",
size=10,
)
return f"""
<g id="svglide-asset-{safe}" data-svglide-asset-layer="true" data-svglide-asset-id="{asset_id}" data-svglide-placement-role="{html.escape(str(asset.get("placement_role") or ""), quote=True)}">
<rect slide:role="shape" x="{width - 368:g}" y="78" width="320" height="276" rx="8" fill="#FFFFFF" opacity="0.92" />
<image slide:role="image" id="svglide-asset-image-{safe}" href="{html.escape(href, quote=True)}" x="{width - 352:g}" y="94" width="288" height="216" preserveAspectRatio="xMidYMid slice" />
{text_layer(f"svglide-asset-caption-{safe}", x=width - 344, y=318, width=160, height=18, text="Visual evidence", color="#1F2937", size=12)}{caption}
</g>"""
def inject_layer(svg_text: str, layer: str) -> str:
match = SVG_OPEN_RE.search(svg_text)
if not match:
raise AssetInjectionError("source SVG has no <svg> root")
insert_at = match.end()
tail = svg_text[insert_at:]
rect_match = FULL_SLIDE_RECT_RE.match(tail.lstrip())
if rect_match:
leading_ws = len(tail) - len(tail.lstrip())
rect = rect_match.group(0)
attrs = {
name: value
for name, value in re.findall(r"""([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*["']([^"']+)["']""", rect)
}
if attrs.get("x", "0") == "0" and attrs.get("y", "0") == "0" and attrs.get("width") in {"960", "960.0"} and attrs.get("height") in {"540", "540.0"}:
insert_at += leading_ws + rect_match.end()
return svg_text[:insert_at] + layer + svg_text[insert_at:]
def injection_for_asset(project: Path, svg_text: str, asset: dict[str, Any], *, page: int) -> tuple[str, dict[str, Any]]:
role = asset.get("placement_role")
asset_id = str(asset.get("asset_id"))
result: dict[str, Any] = {
"page": page,
"asset_id": asset_id,
"placement_role": role,
"status": "skipped",
}
if role not in INJECTABLE_ROLES:
result["reason"] = "placement_role_not_injectable"
return svg_text, result
status = asset.get("status")
kind = asset.get("asset_kind")
if status not in FILE_BACKED_STATUSES or kind not in FILE_BACKED_KINDS:
result["reason"] = "asset_has_no_local_file"
return svg_text, result
asset_path, href = normalize_asset_file(project, asset.get("file") or asset.get("href") or asset.get("path"))
if asset_path is None or href is None or not asset_path.exists() or not asset_path.is_file():
result["reason"] = "asset_file_missing"
result["file"] = asset.get("file")
return svg_text, result
if role in {"cover", "closing"} and not has_safe_text_zone(asset):
result["reason"] = "safe_text_zones_missing"
result["file"] = relpath(asset_path, project)
return svg_text, result
if role in {"body_visual", "inline_figure"} and not has_body_slot(svg_text, asset, page):
result["reason"] = "body_asset_slot_missing"
result["file"] = relpath(asset_path, project)
return svg_text, result
if role == "inline_figure" and not asset.get("source_url"):
result["reason"] = "inline_figure_source_missing"
result["file"] = relpath(asset_path, project)
return svg_text, result
if already_injected(svg_text, asset_id):
result.update(
{
"status": "already_present",
"href": href,
"file": relpath(asset_path, project),
"asset_kind": kind,
"source_url": asset.get("source_url"),
"license": asset.get("license"),
}
)
return svg_text, result
width, height = geometry(svg_text)
if role in {"cover", "closing"}:
layer = cover_or_closing_layer(asset, href=href, width=width, height=height)
renderer_id = "editorial_image_cover" if role == "cover" else "image_closing_takeaway"
else:
layer = figure_layer(asset, href=href, width=width, height=height)
renderer_id = "figure_panel_asset"
injected = inject_layer(svg_text, layer)
result.update(
{
"status": "injected",
"href": href,
"file": relpath(asset_path, project),
"asset_kind": kind,
"source_url": asset.get("source_url"),
"license": asset.get("license"),
"renderer_id": renderer_id,
"asset_fit_reason": f"{role} page has local file-backed {kind} asset",
}
)
return injected, result
def inject_project_assets(project: Path) -> dict[str, Any]:
project = project.resolve()
manifest = read_json_object(project / ASSET_MANIFEST, required=False)
svg_files = source_svg_files(project)
pages = {page_number(path, index): path for index, path in enumerate(svg_files, 1)}
page_count = slide_count(project, len(svg_files))
by_page: dict[int, list[dict[str, Any]]] = {}
for asset in merged_assets(manifest):
page = candidate_page(asset, page_count=page_count)
if page is None:
continue
by_page.setdefault(page, []).append(asset)
results: list[dict[str, Any]] = []
for page, assets in sorted(by_page.items()):
svg_path = pages.get(page)
if svg_path is None:
for asset in assets:
results.append(
{
"page": page,
"asset_id": asset.get("asset_id"),
"placement_role": asset.get("placement_role"),
"status": "skipped",
"reason": "page_svg_missing",
}
)
continue
svg_text = svg_path.read_text(encoding="utf-8")
page_results: list[dict[str, Any]] = []
for asset in sorted(assets, key=lambda item: str(item.get("asset_id"))):
updated, result = injection_for_asset(project, svg_text, asset, page=page)
svg_text = updated
page_results.append(result)
if any(item.get("status") == "injected" for item in page_results):
svg_path.write_text(svg_text, encoding="utf-8")
results.extend(page_results)
injected = [item for item in results if item.get("status") == "injected"]
already = [item for item in results if item.get("status") == "already_present"]
skipped = [item for item in results if item.get("status") == "skipped"]
return {
"version": "svglide-asset-injection/v1",
"status": "passed",
"generated_at": now_iso(),
"manifest_path": ASSET_MANIFEST.as_posix() if manifest else None,
"used_count": len(injected) + len(already),
"injected_count": len(injected),
"already_present_count": len(already),
"skipped_count": len(skipped),
"by_page": results,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Inject file-backed SVGlide assets into generated SVG pages.")
parser.add_argument("project", help="SVGlide project directory under .lark-slides/plan/<deck-id>")
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 = inject_project_assets(Path(args.project))
except (OSError, AssetInjectionError) as error:
print(f"svglide_asset_injector: error: {error}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,242 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import base64
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_asset_injector
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideAssetInjectorTest(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",
{
"route": "svglide-svg",
"slides": [
{"page": 1, "page_type": "cover"},
{"page": 2, "page_type": "closing"},
],
},
)
(project / "04-svg").mkdir(parents=True)
return project
def write_svg(self, project: Path, page: int, body: str = "<text>Title</text>") -> None:
(project / f"04-svg/page-{page:03d}.svg").write_text(
f'<svg width="960" height="540" viewBox="0 0 960 540">{body}</svg>',
encoding="utf-8",
)
def write_asset(self, project: Path, name: str = "hero.png") -> str:
asset = project / "03-assets" / "raw" / name
asset.parent.mkdir(parents=True, exist_ok=True)
asset.write_bytes(PNG_1X1)
return asset.relative_to(project).as_posix()
def test_injects_cover_asset_with_scrim_and_editable_overlay_preserved(self) -> None:
project = self.make_project()
self.write_svg(project, 1)
self.write_svg(project, 2)
asset_file = self.write_asset(project)
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"acquired_assets": [
{
"asset_id": "hero",
"page": 1,
"placement_role": "cover",
"asset_kind": "user_file",
"status": "local_file",
"file": asset_file,
"source_url": "https://example.com/hero",
"license": "preview_unverified",
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
],
},
)
result = svglide_asset_injector.inject_project_assets(project)
svg = (project / "04-svg/page-001.svg").read_text(encoding="utf-8")
self.assertEqual(result["used_count"], 1)
self.assertIn('data-svglide-asset-id="hero"', svg)
self.assertIn('href="@./03-assets/raw/hero.png"', svg)
self.assertIn("svglide-asset-scrim-hero", svg)
self.assertIn("<text>Title</text>", svg)
def test_injection_is_idempotent(self) -> None:
project = self.make_project()
self.write_svg(project, 1)
asset_file = self.write_asset(project)
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"acquired_assets": [
{
"asset_id": "hero",
"page": 1,
"placement_role": "cover",
"asset_kind": "user_file",
"status": "local_file",
"file": asset_file,
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
],
},
)
first = svglide_asset_injector.inject_project_assets(project)
second = svglide_asset_injector.inject_project_assets(project)
svg = (project / "04-svg/page-001.svg").read_text(encoding="utf-8")
self.assertEqual(first["injected_count"], 1)
self.assertEqual(second["already_present_count"], 1)
self.assertEqual(svg.count('data-svglide-asset-id="hero"'), 1)
def test_injects_after_full_slide_background_rect(self) -> None:
project = self.make_project()
self.write_svg(project, 1, '<rect x="0" y="0" width="960" height="540" fill="#fff"/><text>Title</text>')
asset_file = self.write_asset(project)
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"acquired_assets": [
{
"asset_id": "hero",
"page": 1,
"placement_role": "cover",
"asset_kind": "user_file",
"status": "local_file",
"file": asset_file,
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
],
},
)
svglide_asset_injector.inject_project_assets(project)
svg = (project / "04-svg/page-001.svg").read_text(encoding="utf-8")
self.assertLess(svg.index("<rect"), svg.index('data-svglide-asset-id="hero"'))
self.assertLess(svg.index('data-svglide-asset-id="hero"'), svg.index("<text>Title</text>"))
def test_missing_file_is_skipped_without_empty_image(self) -> None:
project = self.make_project()
self.write_svg(project, 1)
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"acquired_assets": [
{
"asset_id": "hero",
"page": 1,
"placement_role": "cover",
"asset_kind": "user_file",
"status": "local_file",
"file": "03-assets/raw/missing.png",
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
],
},
)
result = svglide_asset_injector.inject_project_assets(project)
svg = (project / "04-svg/page-001.svg").read_text(encoding="utf-8")
self.assertEqual(result["used_count"], 0)
self.assertEqual(result["by_page"][0]["reason"], "asset_file_missing")
self.assertNotIn("<image", svg)
def test_cover_without_safe_text_zone_is_skipped(self) -> None:
project = self.make_project()
self.write_svg(project, 1)
asset_file = self.write_asset(project)
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"acquired_assets": [
{
"asset_id": "hero",
"page": 1,
"placement_role": "cover",
"asset_kind": "user_file",
"status": "local_file",
"file": asset_file,
}
],
},
)
result = svglide_asset_injector.inject_project_assets(project)
self.assertEqual(result["used_count"], 0)
self.assertEqual(result["by_page"][0]["reason"], "safe_text_zones_missing")
def test_body_visual_asset_uses_svg_text_contract_for_caption(self) -> None:
project = self.make_project()
self.write_svg(project, 1, '<rect x="0" y="0" width="960" height="540" fill="#fff"/><!-- svglide:asset-slot -->')
asset_file = self.write_asset(project)
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"acquired_assets": [
{
"asset_id": "figure",
"page": 1,
"placement_role": "body_visual",
"asset_kind": "user_file",
"status": "local_file",
"file": asset_file,
"source_url": "https://example.com/source",
"license": "preview_unverified",
}
],
},
)
result = svglide_asset_injector.inject_project_assets(project)
svg = (project / "04-svg/page-001.svg").read_text(encoding="utf-8")
self.assertEqual(result["used_count"], 1)
self.assertIn('slide:shape-type="text"', svg)
self.assertIn("Visual evidence", svg)
self.assertIn("Source: https://example.com/source", svg)
self.assertNotIn("<text", svg)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,554 @@
#!/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 mimetypes
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PLAN_PATH = Path("02-plan/slide_plan.json")
LOCK_PATH = Path("02-plan/svglide.lock.json")
SOURCE_RECEIPT_PATH = Path("source/source-receipt.json")
ASSETS_DIR = Path("03-assets")
ASSETS_JSON = ASSETS_DIR / "assets.json"
ASSET_MANIFEST = ASSETS_DIR / "asset-manifest.json"
RAW_ASSETS_DIR = ASSETS_DIR / "raw"
PROCESSED_ASSETS_DIR = ASSETS_DIR / "processed"
IMAGE_JOBS = ASSETS_DIR / "image-jobs.json"
RECEIPT_PATH = Path("receipts/assets.json")
NETWORK_POLICIES = {"auto", "online", "offline", "fixture"}
IMAGE_BACKENDS = {"auto", "openai", "gemini", "qwen", "flux", "stage_command", "none"}
class AssetsError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_network_policy(value: str | None) -> str:
policy = (value or "offline").strip().lower()
if policy not in NETWORK_POLICIES:
raise AssetsError(f"unsupported network policy: {value}")
return policy
def normalize_image_backend(value: str | None) -> str:
backend = (value or "none").strip().lower()
if backend not in IMAGE_BACKENDS:
raise AssetsError(f"unsupported image backend: {value}")
return backend
def online_enabled(policy: str) -> bool:
return policy in {"auto", "online"}
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
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 | None) -> str | None:
return file_sha256(path) if path and path.exists() and path.is_file() else None
def dominant_colors(path: Path, *, limit: int = 5) -> list[str]:
try:
from PIL import Image # type: ignore
except ImportError:
return []
try:
with Image.open(path) as image:
image.thumbnail((96, 96))
converted = image.convert("RGB")
colors = converted.getcolors(maxcolors=96 * 96) or []
except OSError:
return []
ranked = sorted(colors, key=lambda item: item[0], reverse=True)
result: list[str] = []
for _count, color in ranked:
hex_color = "#{:02X}{:02X}{:02X}".format(*color)
if hex_color not in result:
result.append(hex_color)
if len(result) >= limit:
break
return result
def read_json_object(path: Path, *, required: bool = True) -> dict[str, Any]:
if not path.exists():
if required:
raise AssetsError(f"missing required file: {path}")
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise AssetsError(f"invalid JSON in {path}: {error}") from error
if not isinstance(payload, dict):
raise AssetsError(f"invalid JSON in {path}: expected object")
return payload
def normalize_assets_json(project: Path) -> dict[str, str]:
path = project / ASSETS_JSON
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}\n", encoding="utf-8")
return {}
data = read_json_object(path)
normalized: dict[str, str] = {}
for key, value in data.items():
if not isinstance(key, str) or not isinstance(value, str):
raise AssetsError(f"{ASSETS_JSON}: keys and values must be strings")
normalized[key] = value
return normalized
def iter_contract_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, dict):
return list(value.values())
return []
def collect_asset_contracts(plan: dict[str, Any], lock: dict[str, Any]) -> list[dict[str, Any]]:
contracts: list[dict[str, Any]] = []
for source_name, source in [("plan", plan), ("lock", lock)]:
for key in ["asset_contracts", "assets", "images"]:
for index, raw in enumerate(iter_contract_values(source.get(key))):
if isinstance(raw, str):
contracts.append({"source": source_name, "key": key, "id": raw, "href": raw, "required": True})
elif isinstance(raw, dict):
item = dict(raw)
item.setdefault("source", source_name)
item.setdefault("key", key)
item.setdefault("id", item.get("name") or item.get("href") or item.get("path") or f"{key}-{index + 1}")
item.setdefault("required", True)
contracts.append(item)
return contracts
def placement_role(contract: dict[str, Any]) -> str:
raw = contract.get("placement_role") or contract.get("role") or contract.get("usage")
if isinstance(raw, str) and raw:
lowered = raw.lower()
if lowered in {"cover", "background", "section", "closing", "body_visual", "inline_figure"}:
return lowered
page = contract.get("usage_page") or contract.get("page")
if page == 1:
return "cover"
return "body_visual"
def image_query(contract: dict[str, Any]) -> str:
for key in ["query", "suggested_query", "search_query", "purpose", "alt", "id"]:
value = contract.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return "presentation visual evidence"
def safe_asset_id(value: Any, index: int) -> str:
raw = str(value or f"asset-{index:03d}").strip().lower()
safe = "".join(ch if ch.isalnum() else "-" for ch in raw).strip("-")
return safe or f"asset-{index:03d}"
def extension_from_url(url: str, content_type: str | None = None) -> str:
suffix = Path(urllib.parse.urlparse(url).path).suffix.lower()
if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
return suffix
guessed = mimetypes.guess_extension(content_type or "")
if guessed in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
return guessed
return ".jpg"
def http_get(url: str, *, timeout: float = 10.0) -> tuple[bytes, str | None]:
request = urllib.request.Request(url, headers={"User-Agent": "SVGlide/online-first"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read(), response.headers.get("content-type")
def http_json(url: str, *, timeout: float = 10.0) -> Any:
body, _content_type = http_get(url, timeout=timeout)
return json.loads(body.decode("utf-8"))
def openverse_candidate(query: str) -> dict[str, Any] | None:
params = urllib.parse.urlencode({"q": query, "page_size": "5"})
payload = http_json(f"https://api.openverse.org/v1/images/?{params}")
results = payload.get("results") if isinstance(payload, dict) else None
if not isinstance(results, list):
return None
for item in results:
if not isinstance(item, dict):
continue
url = item.get("url") or item.get("thumbnail")
if isinstance(url, str) and url.startswith(("http://", "https://")):
return {
"url": url,
"source_url": item.get("foreign_landing_url") or url,
"license": item.get("license") or "preview_unverified",
"title": item.get("title") or query,
"provider": "openverse",
}
return None
def save_download(project: Path, *, asset_id: str, url: str) -> Path:
body, content_type = http_get(url)
target_dir = project / RAW_ASSETS_DIR
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{asset_id}{extension_from_url(url, content_type)}"
target.write_bytes(body)
return target
def build_image_job(contract: dict[str, Any], *, asset_id: str, backend: str) -> dict[str, Any]:
role = placement_role(contract)
query = image_query(contract)
prompt = contract.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
prompt = (
f"Editorial 16:9 presentation image for {query}, role {role}, professional visual style, "
"large negative space for editable text overlay, no readable text, no watermark, no logos."
)
return {
"id": asset_id,
"page": contract.get("usage_page") or contract.get("page"),
"placement_role": role,
"backend": backend,
"prompt": prompt,
"negative_prompt": "text, watermark, logo, fake numbers, low quality, distorted subject",
"size": "1792x1024",
"requires_image_input": False,
"status": "planned",
}
def acquire_contract_asset(
project: Path,
contract: dict[str, Any],
*,
index: int,
policy: str,
provider: str,
no_image_search: bool,
no_ai_image: bool,
image_backend: str,
) -> tuple[dict[str, Any], dict[str, Any] | None]:
asset_id = safe_asset_id(contract.get("id"), index)
role = placement_role(contract)
query = image_query(contract)
href = contract.get("href") or contract.get("path")
base = {
"asset_id": asset_id,
"page": contract.get("usage_page") or contract.get("page"),
"placement_role": role,
"asset_kind": "unknown",
"query": query,
"provider": provider,
"license": contract.get("license") if isinstance(contract.get("license"), str) else None,
"retrieved_at": None,
"safe_text_zones": contract.get("safe_text_zones") if isinstance(contract.get("safe_text_zones"), list) else [],
"crop_hint": contract.get("crop_hint") if isinstance(contract.get("crop_hint"), str) else None,
"source_url": contract.get("source_url") if isinstance(contract.get("source_url"), str) else None,
"file": None,
"sha256": None,
"fallback_reason": None,
}
if isinstance(href, str):
local = local_asset_path(project, href)
if local is not None and local.exists() and local.is_file():
base.update({"asset_kind": "user_file", "file": relpath(local, project), "sha256": file_sha256(local), "status": "local_file"})
colors = dominant_colors(local)
if colors:
base["dominant_colors"] = colors
return base, None
if href.startswith(("http://", "https://")) and online_enabled(policy) and not no_image_search:
try:
downloaded = save_download(project, asset_id=asset_id, url=href)
base.update(
{
"asset_kind": "web_image",
"file": relpath(downloaded, project),
"sha256": file_sha256(downloaded),
"source_url": href,
"license": base["license"] or "preview_unverified",
"retrieved_at": now_iso(),
"status": "acquired",
"safe_text_zones": base["safe_text_zones"] or [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
)
colors = dominant_colors(downloaded)
if colors:
base["dominant_colors"] = colors
return base, None
except (OSError, urllib.error.URLError, TimeoutError) as error:
base["fallback_reason"] = f"download_failed: {error}"
if policy == "fixture":
base.update({"asset_kind": "svg_fallback", "status": "fallback_used", "fallback_reason": "network_policy=fixture"})
elif not online_enabled(policy) or no_image_search:
base.update({"asset_kind": "svg_fallback", "status": "fallback_used", "fallback_reason": "image_search_disabled_or_offline"})
else:
try:
candidate = openverse_candidate(query)
if candidate:
downloaded = save_download(project, asset_id=asset_id, url=str(candidate["url"]))
base.update(
{
"asset_kind": "web_image",
"file": relpath(downloaded, project),
"sha256": file_sha256(downloaded),
"source_url": candidate.get("source_url"),
"license": candidate.get("license") or "preview_unverified",
"retrieved_at": now_iso(),
"status": "acquired",
"safe_text_zones": base["safe_text_zones"] or [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
)
colors = dominant_colors(downloaded)
if colors:
base["dominant_colors"] = colors
return base, None
except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
base["fallback_reason"] = f"image_search_failed: {error}"
base.update({"asset_kind": "svg_fallback", "status": "fallback_used", "fallback_reason": base["fallback_reason"] or "no_candidate"})
job = None if no_ai_image or image_backend == "none" else build_image_job(contract, asset_id=asset_id, backend=image_backend)
if job:
base["asset_kind"] = "ai_image"
base["status"] = "planned"
return base, job
def write_image_jobs(project: Path, jobs: list[dict[str, Any]], *, backend: str) -> None:
payload = {
"schema_version": "svglide-image-jobs/v1",
"backend": backend,
"generated_at": now_iso(),
"jobs": jobs,
}
path = project / IMAGE_JOBS
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def local_asset_path(project: Path, ref: str) -> Path | None:
if ref.startswith("@./"):
rel = ref[3:]
elif ref.startswith("@/"):
rel = ref[2:]
else:
return None
candidate = (project / rel).resolve()
root = project.resolve()
if candidate != root and root not in candidate.parents:
raise AssetsError(f"asset path escapes project root: {ref}")
return candidate
def evaluate_contract(project: Path, contract: dict[str, Any], assets: dict[str, str]) -> dict[str, Any]:
href = contract.get("href") or contract.get("placeholder") or contract.get("path")
token = contract.get("token") or contract.get("file_token")
required = bool(contract.get("required", True))
status = "declared"
issues: list[dict[str, str]] = []
result = {
"id": str(contract.get("id")),
"source": str(contract.get("source", "unknown")),
"href": href if isinstance(href, str) else None,
"required": required,
"status": status,
"token": token if isinstance(token, str) else None,
"issues": issues,
}
if not isinstance(href, str) or not href:
result["status"] = "metadata_only"
return result
if href in assets:
result["status"] = "mapped_token"
result["token"] = assets[href]
return result
local_path = local_asset_path(project, href)
if local_path is not None:
result["path"] = relpath(local_path, project)
if local_path.exists() and local_path.is_file():
result["status"] = "local_file"
elif required:
result["status"] = "missing"
issues.append({"code": "missing_local_asset", "message": f"asset file is missing: {href}"})
else:
result["status"] = "missing_optional"
return result
if href.startswith("http://") or href.startswith("https://") or href.startswith("data:"):
result["status"] = "invalid_for_create_svg" if required else "preview_only"
if required:
issues.append({"code": "invalid_asset_href", "message": "create-svg inputs require local @ paths or file tokens"})
return result
result["status"] = "external_or_token"
return result
def run_assets(
project: Path,
*,
network_policy: str = "offline",
asset_provider: str = "auto",
image_backend: str = "none",
no_image_search: bool = False,
no_ai_image: bool = False,
refresh_online: bool = False,
) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
policy = normalize_network_policy(network_policy)
backend = normalize_image_backend(image_backend)
plan = read_json_object(project / PLAN_PATH)
lock = read_json_object(project / LOCK_PATH, required=False)
assets = normalize_assets_json(project)
contracts = collect_asset_contracts(plan, lock)
evaluated = [evaluate_contract(project, contract, assets) for contract in contracts]
acquired: list[dict[str, Any]] = []
image_jobs: list[dict[str, Any]] = []
for index, contract in enumerate(contracts, 1):
item, job = acquire_contract_asset(
project,
contract,
index=index,
policy=policy,
provider=asset_provider,
no_image_search=no_image_search,
no_ai_image=no_ai_image,
image_backend=backend,
)
acquired.append(item)
if job:
image_jobs.append(job)
write_image_jobs(project, image_jobs, backend=backend)
palette_candidates: list[str] = []
for item in acquired:
for color in item.get("dominant_colors") if isinstance(item.get("dominant_colors"), list) else []:
if isinstance(color, str) and color not in palette_candidates:
palette_candidates.append(color)
evaluated_by_id = {str(item.get("id")): item for item in evaluated}
for item in acquired:
evaluated_item = evaluated_by_id.get(str(item.get("asset_id")))
if isinstance(evaluated_item, dict):
evaluated_item.update({key: value for key, value in item.items() if value is not None})
if item.get("status") == "acquired":
evaluated_item["status"] = "acquired"
evaluated_item["issues"] = []
issues = [issue for item in evaluated for issue in item["issues"]]
status = "failed" if issues else "passed"
manifest = {
"version": "svglide-assets/v1",
"status": status,
"network_policy": policy,
"asset_provider": asset_provider,
"image_backend": backend,
"plan_path": PLAN_PATH.as_posix(),
"plan_sha256": file_sha256(project / PLAN_PATH),
"lock_path": LOCK_PATH.as_posix() if (project / LOCK_PATH).exists() else None,
"lock_sha256": file_sha256(project / LOCK_PATH) if (project / LOCK_PATH).exists() else None,
"source_receipt_path": SOURCE_RECEIPT_PATH.as_posix() if (project / SOURCE_RECEIPT_PATH).exists() else None,
"source_receipt_sha256": file_sha256(project / SOURCE_RECEIPT_PATH) if (project / SOURCE_RECEIPT_PATH).exists() else None,
"assets_json": ASSETS_JSON.as_posix(),
"assets_json_sha256": file_sha256(project / ASSETS_JSON),
"image_jobs": IMAGE_JOBS.as_posix(),
"image_jobs_sha256": optional_sha256(project / IMAGE_JOBS),
"contracts": evaluated,
"acquired_assets": acquired,
"visual_identity_palette_candidates": palette_candidates[:8],
"summary": {
"contract_count": len(evaluated),
"error_count": len(issues),
"mapped_token_count": sum(1 for item in evaluated if item["status"] == "mapped_token"),
"local_file_count": sum(1 for item in evaluated if item["status"] == "local_file"),
"acquired_count": sum(1 for item in acquired if item.get("status") == "acquired"),
"fallback_count": sum(1 for item in acquired if item.get("status") == "fallback_used"),
"image_job_count": len(image_jobs),
},
"issues": issues,
}
write_json(project / ASSET_MANIFEST, manifest)
receipt = {
"stage": "assets",
"status": status,
"started_at": started_at,
"ended_at": now_iso(),
"network_policy": policy,
"asset_provider": asset_provider,
"image_backend": backend,
"inputs": [PLAN_PATH.as_posix()] + ([LOCK_PATH.as_posix()] if (project / LOCK_PATH).exists() else []),
"outputs": [ASSETS_JSON.as_posix(), ASSET_MANIFEST.as_posix(), IMAGE_JOBS.as_posix()],
"manifest": manifest,
}
write_json(project / RECEIPT_PATH, receipt)
return receipt
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) + "\n", encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate SVGlide asset contracts before SVG generation.")
parser.add_argument("project", help="SVGlide project directory under .lark-slides/plan/<deck-id>")
parser.add_argument("--network-policy", default="offline", choices=sorted(NETWORK_POLICIES))
parser.add_argument("--asset-provider", default="auto")
parser.add_argument("--image-backend", default="none", choices=sorted(IMAGE_BACKENDS))
parser.add_argument("--no-image-search", action="store_true")
parser.add_argument("--no-ai-image", action="store_true")
parser.add_argument("--refresh-online", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
receipt = run_assets(
Path(args.project),
network_policy=args.network_policy,
asset_provider=args.asset_provider,
image_backend=args.image_backend,
no_image_search=args.no_image_search,
no_ai_image=args.no_ai_image,
refresh_online=args.refresh_online,
)
except (OSError, AssetsError) as error:
print(f"svglide_assets: error: {error}", file=sys.stderr)
return 1
print(json.dumps(receipt, ensure_ascii=False, indent=2))
return 0 if receipt["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,79 @@
# 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_assets
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideAssetsTest(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", {"route": "svglide-svg", "slides": [{"page": 1}]})
return project
def test_assets_stage_creates_empty_assets_json_when_no_contracts(self) -> None:
project = self.make_project()
result = svglide_assets.run_assets(project)
self.assertEqual(result["status"], "passed")
self.assertTrue((project / "03-assets/assets.json").exists())
self.assertTrue((project / "03-assets/asset-manifest.json").exists())
self.assertEqual(result["manifest"]["summary"]["contract_count"], 0)
def test_assets_stage_accepts_existing_local_asset(self) -> None:
project = self.make_project()
write_json(
project / "02-plan/svglide.lock.json",
{"asset_contracts": [{"id": "hero", "href": "@./03-assets/hero.png"}]},
)
(project / "03-assets").mkdir(parents=True, exist_ok=True)
(project / "03-assets/hero.png").write_bytes(b"png")
result = svglide_assets.run_assets(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["manifest"]["contracts"][0]["status"], "local_file")
def test_assets_stage_blocks_required_http_asset(self) -> None:
project = self.make_project()
write_json(project / "02-plan/svglide.lock.json", {"asset_contracts": [{"id": "hero", "href": "https://example.com/hero.png"}]})
result = svglide_assets.run_assets(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["manifest"]["summary"]["error_count"], 1)
self.assertEqual(result["manifest"]["issues"][0]["code"], "invalid_asset_href")
def test_fixture_policy_writes_fallback_manifest_and_image_jobs(self) -> None:
project = self.make_project()
write_json(
project / "02-plan/svglide.lock.json",
{"asset_contracts": [{"id": "hero", "query": "rocket launch", "usage_page": 1, "placement_role": "cover"}]},
)
result = svglide_assets.run_assets(project, network_policy="fixture", image_backend="openai")
self.assertEqual(result["status"], "passed")
self.assertEqual(result["manifest"]["summary"]["image_job_count"], 1)
self.assertEqual(result["manifest"]["acquired_assets"][0]["status"], "planned")
self.assertEqual(result["manifest"]["acquired_assets"][0]["asset_kind"], "ai_image")
self.assertTrue((project / "03-assets/image-jobs.json").exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,194 @@
#!/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 re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
PLAN_PATH = Path("02-plan/slide_plan.json")
PREPARED_SVG_DIR = Path("04-svg/prepared")
OUTPUT_PATH = Path("06-check/chart-verify.json")
PASS_ACTION = "create_live"
FAIL_ACTION = "repair_and_rerun"
CHART_MARK_RE = re.compile(r"<(rect|circle|path|line|polyline|polygon)\b", re.IGNORECASE)
class ChartVerifyError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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 read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ChartVerifyError(f"invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise ChartVerifyError(f"invalid JSON in {path}: expected object")
return payload
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def issue(code: str, message: str, *, page: int | None = None, path: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"code": code, "message": message}
if page is not None:
payload["page"] = page
if path:
payload["path"] = path
return payload
def prepared_svg_files(project: Path) -> list[Path]:
root = project / PREPARED_SVG_DIR
return sorted(path for path in root.glob("*.svg") if path.is_file()) if root.exists() else []
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 chart_verify_required(slide: dict[str, Any]) -> bool:
contract = slide.get("chart_contract")
if isinstance(contract, dict):
verify = contract.get("verify")
if verify == "required" or contract.get("precision") == "exact":
return True
for key in ["role", "renderer_id", "layout_family", "visual_recipe"]:
value = slide.get(key)
if isinstance(value, str) and any(token in value.lower() for token in ["chart", "graph", "plot"]):
if isinstance(contract, dict) and contract.get("verify") == "optional":
return False
return False
def chart_data_present(slide: dict[str, Any]) -> bool:
contract = slide.get("chart_contract")
if not isinstance(contract, dict):
return False
for key in ["data", "series", "values", "source_data", "labels"]:
value = contract.get(key)
if isinstance(value, list) and value:
return True
if isinstance(value, dict) and value:
return True
return False
def run_chart_verify(project: Path) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
plan_file = project / PLAN_PATH
if not plan_file.exists():
raise ChartVerifyError(f"missing required plan file: {PLAN_PATH.as_posix()}")
plan = read_json_object(plan_file)
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
svgs = prepared_svg_files(project)
issues: list[dict[str, Any]] = []
verified_pages: list[dict[str, Any]] = []
for index, raw_slide in enumerate(slides, 1):
if not isinstance(raw_slide, dict) or not chart_verify_required(raw_slide):
continue
page = raw_slide.get("page") if isinstance(raw_slide.get("page"), int) else index
svg_path = svgs[index - 1] if index - 1 < len(svgs) else None
page_issues: list[dict[str, Any]] = []
if not chart_data_present(raw_slide):
page_issues.append(issue("chart_contract_data_missing", "required chart verification needs chart_contract data/series/labels", page=page))
if svg_path is None:
page_issues.append(issue("chart_svg_missing", "required chart page has no prepared SVG", page=page))
else:
raw_svg = svg_path.read_text(encoding="utf-8")
if not CHART_MARK_RE.search(raw_svg):
page_issues.append(issue("chart_marks_missing", "prepared SVG does not contain chart-like marks", page=page, path=relpath(svg_path, project)))
issues.extend(page_issues)
verified_pages.append(
{
"page": page,
"status": "failed" if page_issues else "passed",
"svg": relpath(svg_path, project) if svg_path else None,
"issue_count": len(page_issues),
}
)
status = "failed" if issues else "passed"
result: dict[str, Any] = {
"schema_version": "svglide-chart-verify/v1",
"status": status,
"action": PASS_ACTION if status == "passed" else FAIL_ACTION,
"project": str(project),
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"slide_plan": PLAN_PATH.as_posix(),
"plan_sha256": file_sha256(plan_file),
"svg_dir": PREPARED_SVG_DIR.as_posix(),
},
"prepared_files": prepared_file_hashes(project),
"pages": verified_pages,
"summary": {
"error_count": len(issues),
"warning_count": 0,
"required_chart_count": len(verified_pages),
},
"issues": issues,
}
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-chart-verify.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["status"] = "failed"
result["action"] = FAIL_ACTION
result["issues"].extend(issue(item["code"], item["message"], path=item["path"]) for item in schema_issues)
result["summary"]["error_count"] = len(result["issues"])
output = project / OUTPUT_PATH
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Verify page-level chart contracts for SVGlide.")
parser.add_argument("project")
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_chart_verify(Path(args.project))
except (OSError, ChartVerifyError) as error:
print(f"svglide_chart_verify: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,53 @@
# 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_chart_verify
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideChartVerifyTest(unittest.TestCase):
def test_chart_verify_passes_when_no_required_chart(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1, "title": "普通页"}]})
(project / "04-svg/prepared").mkdir(parents=True)
(project / "04-svg/prepared/page-001.svg").write_text("<svg><text>普通页</text></svg>", encoding="utf-8")
result = svglide_chart_verify.run_chart_verify(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["summary"]["required_chart_count"], 0)
def test_chart_verify_blocks_required_chart_without_data(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "02-plan/slide_plan.json",
{"slides": [{"page": 1, "title": "图表页", "chart_contract": {"verify": "required"}}]},
)
(project / "04-svg/prepared").mkdir(parents=True)
(project / "04-svg/prepared/page-001.svg").write_text("<svg><text>图表页</text></svg>", encoding="utf-8")
result = svglide_chart_verify.run_chart_verify(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("chart_contract_data_missing", codes)
self.assertIn("chart_marks_missing", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import html
import json
import sys
from pathlib import Path
PREPARED_DIR = Path("04-svg/prepared")
OUTPUT_PATH = Path("05-preview/contact-sheet.html")
MANIFEST_PATH = Path("05-preview/contact-sheet.json")
def prepared_svgs(project: Path) -> list[Path]:
root = project / PREPARED_DIR
return sorted(root.glob("*.svg")) if root.exists() else []
def run_contact_sheet(project: Path) -> dict[str, object]:
project = project.resolve()
svgs = prepared_svgs(project)
cards = []
manifest_pages = []
for index, svg in enumerate(svgs, 1):
rel = svg.relative_to(project).as_posix()
manifest_pages.append({"page": index, "svg": rel})
cards.append(
f'<section class="page"><div class="label">Page {index}</div>'
f'<iframe src="../{html.escape(rel)}" title="Page {index}"></iframe></section>'
)
html_doc = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>SVGlide Contact Sheet</title>
<style>
body { margin: 24px; font-family: system-ui, sans-serif; background: #f7f7f5; color: #202124; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.page { border: 1px solid #d7d9dc; background: white; padding: 8px; }
.label { font-size: 12px; margin-bottom: 6px; color: #5f6368; }
iframe { width: 100%; aspect-ratio: 16 / 9; border: 0; background: white; }
</style>
</head>
<body>
<div class="grid">
__SVGLIDE_CONTACT_SHEET_CARDS__
</div>
</body>
</html>
""".replace("__SVGLIDE_CONTACT_SHEET_CARDS__", "\n".join(cards))
output = project / OUTPUT_PATH
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(html_doc, encoding="utf-8")
result = {
"schema_version": "svglide-contact-sheet/v1",
"status": "passed" if svgs else "failed",
"output": OUTPUT_PATH.as_posix(),
"summary": {"page_count": len(svgs)},
"pages": manifest_pages,
}
(project / MANIFEST_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate a local SVGlide contact sheet HTML.")
parser.add_argument("project")
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_contact_sheet(Path(args.project))
except OSError as error:
print(f"svglide_contact_sheet: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_contact_sheet
class SVGlideContactSheetTest(unittest.TestCase):
def test_contact_sheet_writes_html_and_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
(project / "04-svg/prepared").mkdir(parents=True)
(project / "04-svg/prepared/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
result = svglide_contact_sheet.run_contact_sheet(project)
self.assertEqual(result["status"], "passed")
self.assertTrue((project / "05-preview/contact-sheet.html").exists())
self.assertTrue((project / "05-preview/contact-sheet.json").exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
import svglide_chart_verify
import svglide_runtime_review
import svglide_semantic_advisory
import svglide_semantic_review
import svglide_source
import svglide_strategy_review
CASE_TYPES = ["data_news", "real_estate_planning", "data_dense_report"]
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) + "\n", encoding="utf-8")
def write_svg(path: Path, texts: list[str], *, chart: bool = False) -> None:
body = []
if chart:
body.append('<rect x="80" y="200" width="120" height="180" fill="#3366cc" />')
body.append('<rect x="240" y="150" width="120" height="230" fill="#66aa99" />')
for index, text in enumerate(texts):
body.append(f'<text x="80" y="{80 + index * 34}">{text}</text>')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f'<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540">{"".join(body)}</svg>', encoding="utf-8")
def evidence_items(case_type: str) -> list[dict[str, str]]:
topics = {
"data_news": "市场结构变化",
"real_estate_planning": "片区空间规划",
"data_dense_report": "经营指标复盘",
"negative_english": "薄弱英文样例",
}
topic = topics[case_type]
return [
{"id": "item-001", "text": f"{topic}的第一条证据覆盖背景、时间、对象和关键变化,文本长度足以支撑图表页和内容页判断。"},
{"id": "item-002", "text": f"{topic}的第二条证据覆盖对比样本、业务含义和主要风险,避免页面只剩口号或装饰性图表。"},
{"id": "item-003", "text": f"{topic}的第三条证据覆盖后续动作、约束条件和可验证指标,能够支撑结尾页的行动建议。"},
]
def positive_plan(case_type: str) -> dict[str, Any]:
labels = {
"data_news": ("数据新闻", "市场变化"),
"real_estate_planning": ("空间规划", "片区更新"),
"data_dense_report": ("经营报告", "指标复盘"),
}
identities = {
"data_news": ("market_signal", "market_signal", "market", "市场信号图", "新闻证据流", "风险提示牌"),
"real_estate_planning": ("travel_destination", "destination_atlas", "atlas", "片区地图", "空间路径", "地块分层"),
"data_dense_report": ("company_ecosystem", "ecosystem_wall", "ecosystem", "指标生态墙", "组织网络", "复盘看板"),
}
title_prefix, section = labels[case_type]
archetype, evidence_renderer, evidence_family, anchor_a, anchor_b, anchor_c = identities[case_type]
return {
"route": "svglide-svg",
"language": "zh-CN",
"audience": "业务决策团队",
"deck_structure": ["cover", "content", "content", "closing"],
"style_preset": "safe-native-v1",
"visual_identity": {
"theme_archetype": archetype,
"design_dna": {
"palette": "fixture stable palette",
"layout_motif": anchor_a,
"shape_language": "结构化信息块",
"image_treatment": "素材只作为证据或背景信号",
"component_bias": "图表、路径、总结条",
"theme_visual_anchors": [anchor_a, anchor_b, anchor_c],
},
"forbidden_reuse": {"recent_decks": 5, "avoid_default_skeleton": True},
"distinctness_target": {"palette_overlap_max": 0.67, "renderer_sequence_similarity_max": 0.75},
},
"slides": [
{
"page": 1,
"page_type": "cover",
"section": "开场",
"role": "thesis",
"title": f"{title_prefix}洞察简报",
"key_message": f"{section}需要从结构证据进入判断",
"body_points": ["明确本次判断对象", "锁定后续验证口径"],
"renderer_id": "cover",
"layout_family": "cover",
"visual_recipe": "hero_typography",
"source_refs": ["source:item-001"],
},
{
"page": 2,
"page_type": "content",
"section": section,
"role": "evidence",
"title": f"{section}的第一组证据",
"key_message": "核心变化已经具备连续验证信号",
"body_points": ["证据一显示结构变化正在形成", "证据二说明变化不是单点扰动", "证据三给出后续观测口径"],
"source_refs": ["source:item-001", "source:item-002"],
"renderer_id": evidence_renderer,
"layout_family": evidence_family,
"visual_recipe": "micro_chart",
"chart_contract": {"verify": "required", "data": [12, 18, 25], "labels": ["", "", ""]},
},
{
"page": 3,
"page_type": "content",
"section": section,
"role": "implication",
"title": f"{section}的业务含义",
"key_message": "行动排序应围绕高确定性证据展开",
"body_points": ["优先处理确定性最高的场景", "保留对不确定变量的监控", "把复盘口径前置到执行计划"],
"source_refs": ["source:item-002", "source:item-003"],
"renderer_id": "org_network" if case_type == "data_dense_report" else "timeline",
"layout_family": "network" if case_type == "data_dense_report" else "timeline",
"visual_recipe": "path_flow",
},
{
"page": 4,
"page_type": "closing",
"section": "结论",
"role": "takeaway",
"title": f"{title_prefix}后续动作",
"key_message": "下一步应把证据链转成可复跑行动",
"body_points": ["保留当前证据链", "补齐缺口数据", "按周复盘关键变化"],
"source_refs": ["source:item-003"],
"renderer_id": "closing",
"layout_family": "closing",
"visual_recipe": "closing_cta",
},
],
}
def negative_plan() -> dict[str, Any]:
return {
"route": "svglide-svg",
"language": "en-US",
"audience": "",
"deck_structure": ["content"],
"slides": [
{
"page": 1,
"title": "English Thin Chart",
"key_message": "Thin",
"body_points": ["Fast", "Cheap"],
"renderer_id": "unknown",
"layout_family": "chart",
"visual_recipe": "unknown",
"chart_contract": {"verify": "required"},
}
],
}
def build_project(root: Path, case_type: str, *, negative: bool = False) -> Path:
project = root / case_type
plan = negative_plan() if negative else positive_plan(case_type)
write_json(project / "02-plan/slide_plan.json", plan)
write_json(project / "source/evidence.json", {"schema_version": "svglide-evidence/v1", "source_status": "ready", "items": evidence_items(case_type)})
slides = plan["slides"]
for index, slide in enumerate(slides, 1):
texts = [value for value in [slide.get("title"), slide.get("key_message"), *slide.get("body_points", [])] if isinstance(value, str)]
write_svg(project / f"04-svg/prepared/page-{index:03d}.svg", texts, chart=bool(slide.get("chart_contract")))
return project
def run_case(project: Path, *, expected_status: str, expected_codes: set[str] | None = None) -> dict[str, Any]:
checks = [
("source", svglide_source.run_source),
("strategy_review", svglide_strategy_review.run_strategy_review),
("chart_verify", svglide_chart_verify.run_chart_verify),
("semantic_review", svglide_semantic_review.run_semantic_review),
("runtime_review", svglide_runtime_review.run_runtime_review),
("semantic_advisory", svglide_semantic_advisory.run_advisory),
]
results: dict[str, Any] = {}
issue_codes: set[str] = set()
warning_count = 0
for name, fn in checks:
result = fn(project)
results[name] = {"status": result.get("status"), "summary": result.get("summary", {})}
for item in result.get("issues", []) if isinstance(result.get("issues"), list) else []:
if isinstance(item, dict) and isinstance(item.get("code"), str):
issue_codes.add(item["code"])
summary = result.get("summary")
if name == "semantic_advisory" and isinstance(summary, dict) and isinstance(summary.get("warning_count"), int):
warning_count += summary["warning_count"]
actual_status = "passed" if all(item["status"] == "passed" for item in results.values()) else "failed"
expected_codes = expected_codes or set()
missing_expected_codes = sorted(expected_codes - issue_codes)
status = "passed" if actual_status == expected_status and not missing_expected_codes and (expected_status == "failed" or warning_count == 0) else "failed"
return {
"status": status,
"expected_status": expected_status,
"actual_status": actual_status,
"warning_count": warning_count,
"issue_codes": sorted(issue_codes),
"missing_expected_codes": missing_expected_codes,
"checks": results,
}
def run_suite(output_dir: Path | None = None) -> dict[str, Any]:
temp_dir = Path(tempfile.mkdtemp(prefix="svglide-golden-"))
try:
cases = []
for case_type in CASE_TYPES:
project = build_project(temp_dir, case_type)
cases.append({"name": case_type, **run_case(project, expected_status="passed")})
negative_project = build_project(temp_dir, "negative_english", negative=True)
cases.append(
{
"name": "negative_english",
**run_case(
negative_project,
expected_status="failed",
expected_codes={"language_not_zh_cn", "audience_missing", "renderer_unknown", "chart_contract_data_missing"},
),
}
)
failed = [case for case in cases if case["status"] != "passed"]
result = {
"schema_version": "svglide-golden-suite/v1",
"status": "failed" if failed else "passed",
"summary": {
"case_count": len(cases),
"failed_case_count": len(failed),
"positive_case_count": len(CASE_TYPES),
"warning_budget": 0,
},
"cases": cases,
}
if output_dir is not None:
output_dir.mkdir(parents=True, exist_ok=True)
write_json(output_dir / "golden-suite.json", result)
return result
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the local SVGlide golden suite profile.")
parser.add_argument("--output-dir", type=Path)
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_suite(args.output_dir)
except Exception as error:
print(f"svglide_golden_suite: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_golden_suite
class SVGlideGoldenSuiteTest(unittest.TestCase):
def test_golden_suite_passes_positive_and_negative_cases(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
result = svglide_golden_suite.run_suite(Path(tmpdir))
self.assertEqual(result["status"], "passed")
self.assertEqual(result["summary"]["positive_case_count"], 3)
self.assertEqual(result["summary"]["failed_case_count"], 0)
self.assertTrue((Path(tmpdir) / "golden-suite.json").exists())
negative = next(case for case in result["cases"] if case["name"] == "negative_english")
self.assertEqual(negative["expected_status"], "failed")
self.assertIn("language_not_zh_cn", negative["issue_codes"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,99 @@
#!/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
from pathlib import Path
from typing import Any
import svglide_schema
PREPARED_DIR = Path("04-svg/prepared")
QUALITY_GATE = Path("06-check/quality-gate.json")
OUTPUT_PATH = Path("receipts/page-rerun.json")
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 current_prepared(project: Path) -> list[dict[str, Any]]:
root = project / PREPARED_DIR
files = sorted(root.glob("*.svg")) if root.exists() else []
return [{"page": index, "path": path.relative_to(project).as_posix(), "sha256": file_sha256(path)} for index, path in enumerate(files, 1)]
def previous_hashes(project: Path) -> dict[str, str]:
path = project / QUALITY_GATE
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
files = payload.get("prepared_files") if isinstance(payload, dict) else None
if not isinstance(files, list):
return {}
return {item.get("path"): item.get("sha256") for item in files if isinstance(item, dict) and isinstance(item.get("path"), str) and isinstance(item.get("sha256"), str)}
def run_page_rerun(project: Path) -> dict[str, Any]:
project = project.resolve()
current = current_prepared(project)
previous = previous_hashes(project)
pages = []
for item in current:
old_hash = previous.get(item["path"])
status = "dirty" if old_hash != item["sha256"] else "clean"
pages.append({**item, "previous_sha256": old_hash, "status": status})
result: dict[str, Any] = {
"schema_version": "svglide-page-rerun/v1",
"status": "passed",
"pages": pages,
"summary": {
"page_count": len(pages),
"dirty_page_count": sum(1 for item in pages if item["status"] == "dirty"),
},
}
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-page-rerun.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["status"] = "failed"
result["issues"] = schema_issues
output = project / OUTPUT_PATH
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Summarize page-level dirty set for SVGlide reruns.")
parser.add_argument("project")
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_page_rerun(Path(args.project))
except OSError as error:
print(f"svglide_page_rerun: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,40 @@
# 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_page_rerun
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlidePageRerunTest(unittest.TestCase):
def test_page_rerun_marks_dirty_pages_against_quality_gate(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
(project / "04-svg/prepared").mkdir(parents=True)
page = project / "04-svg/prepared/page-001.svg"
page.write_text("<svg></svg>", encoding="utf-8")
current = svglide_page_rerun.current_prepared(project)
write_json(project / "06-check/quality-gate.json", {"prepared_files": current})
page.write_text("<svg><rect /></svg>", encoding="utf-8")
result = svglide_page_rerun.run_page_rerun(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["summary"]["dirty_page_count"], 1)
self.assertEqual(result["pages"][0]["status"], "dirty")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,138 @@
#!/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
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
QUALITY_GATE = Path("06-check/quality-gate.json")
DRY_RUN = Path("07-create/dry-run.json")
PROOF_INPUT = Path("07-create/ppe-proof.input.json")
PROOF_OUTPUT = Path("07-create/ppe-proof.json")
class PPEProofError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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 read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise PPEProofError(f"invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise PPEProofError(f"invalid JSON in {path}: expected object")
return payload
def issue(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message}
def proof_issues(proof: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
if proof.get("status") != "passed":
issues.append(issue("ppe_status_not_passed", "ppe proof input status must be passed"))
for key in ["environment", "auth", "proxy", "headers", "route"]:
value = proof.get(key)
if not isinstance(value, dict) or not value:
issues.append(issue(f"ppe_{key}_missing", f"ppe proof input requires non-empty {key} object"))
environment = proof.get("environment")
if isinstance(environment, dict) and environment.get("name") != "Pre_release":
issues.append(issue("ppe_environment_not_pre_release", "ppe proof environment.name must be Pre_release"))
headers = proof.get("headers")
if isinstance(headers, dict) and headers.get("x-tt-env") != "ppe_pure_svg":
issues.append(issue("ppe_header_missing_x_tt_env", "ppe proof headers.x-tt-env must be ppe_pure_svg"))
return issues
def run_ppe_proof(project: Path) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
issues: list[dict[str, str]] = []
quality_gate = project / QUALITY_GATE
dry_run = project / DRY_RUN
proof_file = project / PROOF_INPUT
if not quality_gate.exists():
issues.append(issue("quality_gate_missing", "quality gate must exist before PPE proof"))
if not dry_run.exists():
issues.append(issue("dry_run_missing", "dry-run receipt must exist before PPE proof"))
proof: dict[str, Any] = {}
if proof_file.exists():
proof = read_json_object(proof_file)
issues.extend(proof_issues(proof))
else:
issues.append(issue("ppe_proof_input_missing", "07-create/ppe-proof.input.json is required before live create"))
status = "failed" if issues else "passed"
result: dict[str, Any] = {
"schema_version": "svglide-ppe-proof/v1",
"status": status,
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"quality_gate": QUALITY_GATE.as_posix() if quality_gate.exists() else None,
"quality_gate_sha256": file_sha256(quality_gate) if quality_gate.exists() else None,
"dry_run": DRY_RUN.as_posix() if dry_run.exists() else None,
"dry_run_sha256": file_sha256(dry_run) if dry_run.exists() else None,
"proof_input": PROOF_INPUT.as_posix() if proof_file.exists() else None,
"proof_input_sha256": file_sha256(proof_file) if proof_file.exists() else None,
},
"proof": proof,
"summary": {"error_count": len(issues)},
"issues": issues,
}
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-ppe-proof.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["status"] = "failed"
result["issues"].extend(issue(item["code"], f"{item['path']}: {item['message']}") for item in schema_issues)
result["summary"]["error_count"] = len(result["issues"])
output = project / PROOF_OUTPUT
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate PPE/auth/proxy/header proof before SVGlide live create.")
parser.add_argument("project")
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_ppe_proof(Path(args.project))
except (OSError, PPEProofError) as error:
print(f"svglide_ppe_proof: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,59 @@
# 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_ppe_proof
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlidePPEProofTest(unittest.TestCase):
def write_inputs(self, project: Path) -> None:
write_json(project / "06-check/quality-gate.json", {"status": "passed"})
write_json(project / "07-create/dry-run.json", {"status": "passed"})
def test_ppe_proof_requires_input(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
self.write_inputs(project)
result = svglide_ppe_proof.run_ppe_proof(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["issues"][0]["code"], "ppe_proof_input_missing")
def test_ppe_proof_passes_complete_input(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
self.write_inputs(project)
write_json(
project / "07-create/ppe-proof.input.json",
{
"status": "passed",
"environment": {"name": "Pre_release", "x-tt-env": "ppe_pure_svg"},
"auth": {"identity": "user"},
"proxy": {"mode": "whistle"},
"headers": {"x-tt-env": "ppe_pure_svg"},
"route": {"name": "slides +create-svg"},
},
)
result = svglide_ppe_proof.run_ppe_proof(project)
self.assertEqual(result["status"], "passed")
self.assertTrue((project / "07-create/ppe-proof.json").exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
INPUT_PATH = Path("source/ppt-master-asset-map.json")
OUTPUT_PATH = Path("06-check/ppt-master-inventory.json")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def issue(code: str, message: str, *, index: int | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"code": code, "message": message}
if index is not None:
payload["index"] = index
return payload
def read_json_object(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"invalid JSON in {path}: expected object")
return payload
def run_inventory(project: Path) -> dict[str, Any]:
project = project.resolve()
path = project / INPUT_PATH
warnings: list[dict[str, Any]] = []
issues: list[dict[str, Any]] = []
payload: dict[str, Any] = {"schema_version": "svglide-ppt-master-asset-map/v1", "items": []}
if path.exists():
payload = read_json_object(path)
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-ppt-master-asset-map.schema.json"))
issues.extend(issue(item["code"], f"{item['path']}: {item['message']}") for item in svglide_schema.validate_json_schema(payload, schema))
else:
warnings.append(issue("asset_map_missing", "source/ppt-master-asset-map.json is not present; inventory is empty"))
items = payload.get("items") if isinstance(payload.get("items"), list) else []
for index, item in enumerate(items, 1):
if not isinstance(item, dict):
continue
if item.get("activation_status") == "active":
if item.get("copy_policy") != "svglide_native":
issues.append(issue("active_asset_copy_policy_invalid", "active migrated assets must use svglide_native copy_policy", index=index))
if item.get("license_status") != "cleared":
issues.append(issue("active_asset_license_not_cleared", "active migrated assets require cleared license_status", index=index))
if item.get("copy_policy") == "blocked_raw_runtime" and item.get("activation_status") == "active":
issues.append(issue("raw_runtime_asset_active", "raw ppt-master runtime assets cannot be active CLI runtime dependencies", index=index))
status = "failed" if issues else "passed"
result = {
"schema_version": "svglide-ppt-master-inventory/v1",
"status": status,
"generated_at": now_iso(),
"input": INPUT_PATH.as_posix() if path.exists() else None,
"summary": {
"error_count": len(issues),
"warning_count": len(warnings),
"asset_count": len(items),
"active_count": sum(1 for item in items if isinstance(item, dict) and item.get("activation_status") == "active"),
},
"issues": issues,
"warnings": warnings,
"items": items,
}
output = project / OUTPUT_PATH
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate ppt-master asset inventory for SVGlide migration.")
parser.add_argument("project")
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_inventory(Path(args.project))
except (OSError, ValueError) as error:
print(f"svglide_ppt_master_inventory: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,79 @@
# 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_ppt_master_inventory
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlidePPTMasterInventoryTest(unittest.TestCase):
def test_inventory_passes_active_native_cleared_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "source/ppt-master-asset-map.json",
{
"schema_version": "svglide-ppt-master-asset-map/v1",
"items": [
{
"source": "ppt-master/layouts/title.json",
"kind": "layout",
"svglide_target": "svg-seeds:title",
"activation_status": "active",
"copy_policy": "svglide_native",
"license_status": "cleared",
"unsupported_features": [],
}
],
},
)
result = svglide_ppt_master_inventory.run_inventory(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["summary"]["active_count"], 1)
def test_inventory_blocks_active_raw_runtime_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "source/ppt-master-asset-map.json",
{
"schema_version": "svglide-ppt-master-asset-map/v1",
"items": [
{
"source": "ppt-master/raw/template.pptx",
"kind": "pptx",
"svglide_target": "runtime",
"activation_status": "active",
"copy_policy": "blocked_raw_runtime",
"license_status": "unknown",
"unsupported_features": ["pptx-runtime"],
}
],
},
)
result = svglide_ppt_master_inventory.run_inventory(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("active_asset_copy_policy_invalid", codes)
self.assertIn("active_asset_license_not_cleared", codes)
self.assertIn("raw_runtime_asset_active", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,166 @@
#!/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 re
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SVG_IMAGE_TAG_RE = re.compile(r"<image\b[^>]*>", re.IGNORECASE | re.DOTALL)
SVG_IMAGE_HREF_RE = re.compile(r"""(?:^|\s)(?:xlink:href|href)\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
class PrepareError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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 load_assets(project: Path) -> dict[str, str]:
assets_path = project / "03-assets" / "assets.json"
if not assets_path.exists():
return {}
try:
data = json.loads(assets_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise PrepareError(f"invalid assets json: {assets_path}: {exc}") from exc
if not isinstance(data, dict):
raise PrepareError(f"invalid assets json: {assets_path}: expected object")
out: dict[str, str] = {}
for key, value in data.items():
if not isinstance(key, str) or not isinstance(value, str):
raise PrepareError(f"invalid assets json: {assets_path}: keys and values must be strings")
out[key] = value
return out
def source_svg_files(project: Path) -> list[Path]:
svg_dir = project / "04-svg"
if not svg_dir.exists():
raise PrepareError(f"missing svg directory: {svg_dir}")
files = sorted(path for path in svg_dir.glob("*.svg") if path.is_file())
if not files:
raise PrepareError(f"no source SVG files found in {svg_dir}")
return files
def image_hrefs(svg_text: str) -> list[str]:
hrefs: list[str] = []
for tag in SVG_IMAGE_TAG_RE.findall(svg_text):
match = SVG_IMAGE_HREF_RE.search(tag)
if match:
hrefs.append(match.group(1))
return hrefs
def local_asset_path(project: Path, href: str) -> Path:
if href.startswith("@./"):
rel = href[3:]
elif href.startswith("@/"):
rel = href[2:]
else:
raise PrepareError(f"not a local SVGlide asset placeholder: {href}")
candidate = (project / rel).resolve()
project_root = project.resolve()
if candidate != project_root and project_root not in candidate.parents:
raise PrepareError(f"asset path escapes project root: {href}")
return candidate
def validate_asset_refs(project: Path, svg_file: Path, svg_text: str, assets: dict[str, str]) -> list[dict[str, str]]:
refs: list[dict[str, str]] = []
for href in image_hrefs(svg_text):
if not href.startswith("@"):
continue
if href in assets:
refs.append({"href": href, "status": "mapped", "token": assets[href]})
continue
path = local_asset_path(project, href)
if not path.exists() or not path.is_file():
raise PrepareError(f"{svg_file}: unresolved image placeholder {href}; add file or map it in 03-assets/assets.json")
refs.append({"href": href, "status": "local", "path": str(path.relative_to(project))})
return refs
def prepare_project(project: Path) -> dict[str, Any]:
project = project.resolve()
assets = load_assets(project)
sources = source_svg_files(project)
prepared_dir = project / "04-svg" / "prepared"
prepared_dir.mkdir(parents=True, exist_ok=True)
receipts_dir = project / "receipts"
receipts_dir.mkdir(parents=True, exist_ok=True)
started_at = now_iso()
prepared_files: list[dict[str, Any]] = []
asset_refs: list[dict[str, Any]] = []
for source in sources:
svg_text = source.read_text(encoding="utf-8")
refs = validate_asset_refs(project, source, svg_text, assets)
target = prepared_dir / source.name
shutil.copyfile(source, target)
prepared_files.append(
{
"source": str(source.relative_to(project)),
"prepared": str(target.relative_to(project)),
"sha256": file_sha256(target),
}
)
if refs:
asset_refs.append({"source": str(source.relative_to(project)), "refs": refs})
receipt: dict[str, Any] = {
"stage": "prepare",
"status": "passed",
"started_at": started_at,
"ended_at": now_iso(),
"source_files": [item["source"] for item in prepared_files],
"prepared_files": prepared_files,
"assets_json": "03-assets/assets.json" if (project / "03-assets" / "assets.json").exists() else None,
"asset_refs": asset_refs,
"normalizations": [],
}
receipt_path = receipts_dir / "prepare.json"
receipt_path.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return receipt
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Prepare SVGlide SVG files for CLI create-svg consumption.")
parser.add_argument("project", help="SVGlide project directory under .lark-slides/plan/<deck-id>")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
receipt = prepare_project(Path(args.project))
except PrepareError as exc:
print(f"svglide_prepare: error: {exc}", file=sys.stderr)
return 1
print(json.dumps(receipt, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,111 @@
# 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_prepare
SIMPLE_SVG = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
slide:contract-version="svglide-authoring-contract/v1"
width="960" height="540" viewBox="0 0 960 540">
<rect slide:role="shape" x="0" y="0" width="960" height="540" fill="#fff" />
</svg>
"""
class SVGlidePrepareTest(unittest.TestCase):
def make_project(self) -> Path:
root = Path(tempfile.mkdtemp())
project = root / ".lark-slides" / "plan" / "demo"
(project / "04-svg").mkdir(parents=True)
(project / "03-assets").mkdir(parents=True)
return project
def test_prepare_copies_source_to_prepared_and_writes_receipt(self) -> None:
project = self.make_project()
(project / "04-svg" / "page-001.svg").write_text(SIMPLE_SVG, encoding="utf-8")
receipt = svglide_prepare.prepare_project(project)
prepared = project / "04-svg" / "prepared" / "page-001.svg"
self.assertTrue(prepared.exists())
self.assertEqual(receipt["status"], "passed")
self.assertEqual(receipt["source_files"], ["04-svg/page-001.svg"])
self.assertEqual(receipt["prepared_files"][0]["prepared"], "04-svg/prepared/page-001.svg")
self.assertTrue((project / "receipts" / "prepare.json").exists())
def test_prepare_fails_when_no_source_svg_exists(self) -> None:
project = self.make_project()
with self.assertRaisesRegex(svglide_prepare.PrepareError, "no source SVG files"):
svglide_prepare.prepare_project(project)
def test_prepare_fails_on_unresolved_local_asset(self) -> None:
project = self.make_project()
svg = SIMPLE_SVG.replace(
"</svg>",
'<image slide:role="image" href="@./assets/missing.png" x="0" y="0" width="100" height="80" /></svg>',
)
(project / "04-svg" / "page-001.svg").write_text(svg, encoding="utf-8")
with self.assertRaisesRegex(svglide_prepare.PrepareError, "unresolved image placeholder"):
svglide_prepare.prepare_project(project)
def test_prepare_accepts_asset_mapping(self) -> None:
project = self.make_project()
svg = SIMPLE_SVG.replace(
"</svg>",
'<image slide:role="image" href="@./assets/hero.png" x="0" y="0" width="100" height="80" /></svg>',
)
(project / "04-svg" / "page-001.svg").write_text(svg, encoding="utf-8")
(project / "03-assets" / "assets.json").write_text(json.dumps({"@./assets/hero.png": "boxcn_hero"}), encoding="utf-8")
receipt = svglide_prepare.prepare_project(project)
self.assertEqual(receipt["asset_refs"][0]["refs"][0]["status"], "mapped")
self.assertEqual(receipt["asset_refs"][0]["refs"][0]["token"], "boxcn_hero")
def test_prepare_accepts_existing_local_asset(self) -> None:
project = self.make_project()
(project / "assets").mkdir()
(project / "assets" / "hero.png").write_bytes(b"fake")
svg = SIMPLE_SVG.replace(
"</svg>",
'<image slide:role="image" href="@./assets/hero.png" x="0" y="0" width="100" height="80" /></svg>',
)
(project / "04-svg" / "page-001.svg").write_text(svg, encoding="utf-8")
receipt = svglide_prepare.prepare_project(project)
self.assertEqual(receipt["asset_refs"][0]["refs"][0]["status"], "local")
self.assertEqual(receipt["asset_refs"][0]["refs"][0]["path"], "assets/hero.png")
def test_prepare_accepts_raw_asset_placeholder(self) -> None:
project = self.make_project()
(project / "03-assets" / "raw").mkdir(parents=True, exist_ok=True)
(project / "03-assets" / "raw" / "hero.png").write_bytes(b"fake")
svg = SIMPLE_SVG.replace(
"</svg>",
'<image href="@./03-assets/raw/hero.png" x="0" y="0" width="960" height="540" /></svg>',
)
(project / "04-svg" / "page-001.svg").write_text(svg, encoding="utf-8")
receipt = svglide_prepare.prepare_project(project)
self.assertEqual(receipt["asset_refs"][0]["refs"][0]["status"], "local")
self.assertEqual(receipt["asset_refs"][0]["refs"][0]["path"], "03-assets/raw/hero.png")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import base64
import html
import json
import mimetypes
import os
import re
import sys
from pathlib import Path
from typing import Any
SVG_INPUT_DIR = Path("04-svg/prepared")
PREVIEW_OUTPUT_DIR = Path("05-preview")
PREVIEW_HTML_NAME = "preview.html"
PREVIEW_MANIFEST_NAME = "preview-manifest.json"
XML_DECL_RE = re.compile(r"^\s*<\?xml\b[^>]*\?>", re.IGNORECASE)
DOCTYPE_RE = re.compile(r"^\s*<!DOCTYPE\b[^>]*>", re.IGNORECASE)
LOCAL_ASSET_HREF_RE = re.compile(r"""(\b(?:xlink:href|href)\s*=\s*["'])(@\.\/[^"']+|@\/[^"']+)(["'])""", re.IGNORECASE)
class SVGlidePreviewError(ValueError):
pass
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def normalize_inline_svg(text: str) -> str:
out = XML_DECL_RE.sub("", text.strip())
out = DOCTYPE_RE.sub("", out.strip())
return out.strip()
def local_asset_path(project: Path, href: str) -> Path | None:
if href.startswith("@./"):
rel = href[3:]
elif href.startswith("@/"):
rel = href[2:]
else:
return None
candidate = (project / rel).resolve()
root = project.resolve()
if candidate != root and root not in candidate.parents:
return None
return candidate
def browser_asset_href(project: Path, href: str) -> str | None:
local = local_asset_path(project, href)
if local is None or not local.exists() or not local.is_file():
return None
mime_type = mimetypes.guess_type(local.name)[0] or "application/octet-stream"
encoded = base64.b64encode(local.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def rewrite_preview_asset_hrefs(project: Path, svg_text: str) -> tuple[str, list[dict[str, str]]]:
rewrites: list[dict[str, str]] = []
def replace(match: re.Match[str]) -> str:
original = match.group(2)
rewritten = browser_asset_href(project, original)
if rewritten is None:
return match.group(0)
rewrites.append({"from": original, "to": rewritten})
return f"{match.group(1)}{rewritten}{match.group(3)}"
return LOCAL_ASSET_HREF_RE.sub(replace, svg_text), rewrites
def collect_svg_paths(project: Path) -> list[Path]:
return sorted((project / SVG_INPUT_DIR).glob("*.svg"))
def build_html(project: Path, pages: list[dict[str, Any]]) -> str:
nav_links = "\n".join(
f' <a href="#page-{page["page"]}">{page["page"]}</a>' for page in pages
)
sections: list[str] = []
total = len(pages)
for page in pages:
page_no = page["page"]
previous_link = f'<a href="#page-{page_no - 1}">Previous</a>' if page_no > 1 else "<span>Previous</span>"
next_link = f'<a href="#page-{page_no + 1}">Next</a>' if page_no < total else "<span>Next</span>"
source = html.escape(page["source_path"])
sections.append(
f""" <section class="slide-page" id="page-{page_no}">
<header class="slide-header">
<div>
<strong>Page {page_no} of {total}</strong>
<span>Source path: {source}</span>
</div>
<div class="pager">{previous_link}{next_link}</div>
</header>
<div class="slide-frame">
{page["svg"]}
</div>
</section>"""
)
body = "\n".join(sections)
project_label = html.escape(relpath(project, project.parent))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVGlide Preview</title>
<style>
:root {{
color-scheme: light;
--bg: #f3f5f7;
--panel: #ffffff;
--text: #17202a;
--muted: #627183;
--line: #d7dde5;
--accent: #2364aa;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}}
.topbar {{
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid var(--line);
}}
.topbar h1 {{
margin: 0;
font-size: 16px;
font-weight: 700;
}}
.topbar nav {{
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
a {{
color: var(--accent);
text-decoration: none;
}}
a:hover {{ text-decoration: underline; }}
main {{
width: min(1120px, calc(100vw - 32px));
margin: 24px auto 48px;
}}
.slide-page {{
margin: 0 0 28px;
}}
.slide-header {{
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin: 0 0 10px;
}}
.slide-header strong {{
display: block;
font-size: 15px;
}}
.slide-header span {{
color: var(--muted);
word-break: break-word;
}}
.pager {{
display: flex;
gap: 12px;
white-space: nowrap;
}}
.slide-frame {{
overflow: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 16px;
box-shadow: 0 8px 22px rgba(23, 32, 42, 0.08);
}}
.slide-frame svg {{
display: block;
width: min(100%, 960px);
height: auto;
margin: 0 auto;
}}
</style>
</head>
<body>
<header class="topbar">
<h1>SVGlide Preview: {project_label}</h1>
<nav aria-label="Pages">
{nav_links}
</nav>
</header>
<main>
{body}
</main>
</body>
</html>
"""
def build_preview(project: Path) -> dict[str, Any]:
project = project.resolve()
svg_paths = collect_svg_paths(project)
if not svg_paths:
raise SVGlidePreviewError(f"no SVG files found under {project / SVG_INPUT_DIR}")
pages: list[dict[str, Any]] = []
asset_href_rewrites: list[dict[str, Any]] = []
for index, path in enumerate(svg_paths, 1):
svg_text = normalize_inline_svg(path.read_text(encoding="utf-8"))
svg_text, rewrites = rewrite_preview_asset_hrefs(project, svg_text)
if rewrites:
asset_href_rewrites.append({"page": index, "source_path": relpath(path, project), "rewrites": rewrites})
pages.append(
{
"page": index,
"source_path": relpath(path, project),
"source_bytes": path.stat().st_size,
"svg": svg_text,
}
)
output_dir = project / PREVIEW_OUTPUT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
html_path = output_dir / PREVIEW_HTML_NAME
manifest_path = output_dir / PREVIEW_MANIFEST_NAME
html_path.write_text(build_html(project, pages), encoding="utf-8")
manifest = {
"project": str(project),
"source_dir": relpath(project / SVG_INPUT_DIR, project),
"html_path": relpath(html_path, project),
"manifest_path": relpath(manifest_path, project),
"page_count": len(pages),
"asset_href_rewrites": asset_href_rewrites,
"pages": [
{
"page": page["page"],
"source_path": page["source_path"],
"source_bytes": page["source_bytes"],
}
for page in pages
],
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return manifest
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Build a static SVGlide HTML preview from prepared SVG pages.")
parser.add_argument("project", help="SVGlide project directory containing 04-svg/prepared/*.svg")
parser.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
args = parser.parse_args(argv)
try:
result = build_preview(Path(args.project))
except (OSError, SVGlidePreviewError) as error:
print(f"svglide_preview: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import svglide_schema
INPUT_PATH = Path("05-preview/preview-annotations.json")
OUTPUT_PATH = Path("06-check/preview-annotations-review.json")
REPAIR_LIST_PATH = Path("06-check/preview-repair-list.json")
def read_json_object(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"invalid JSON in {path}: expected object")
return payload
def issue(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message}
def run_preview_annotations(project: Path) -> dict[str, Any]:
project = project.resolve()
path = project / INPUT_PATH
issues: list[dict[str, str]] = []
payload: dict[str, Any] = {"schema_version": "svglide-preview-annotations/v1", "annotations": []}
if path.exists():
payload = read_json_object(path)
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-preview-annotations.schema.json"))
issues.extend(issue(item["code"], f"{item['path']}: {item['message']}") for item in svglide_schema.validate_json_schema(payload, schema))
annotations = payload.get("annotations") if isinstance(payload.get("annotations"), list) else []
open_items = [
item for item in annotations
if isinstance(item, dict) and item.get("status", "open") == "open" and item.get("severity") in {"warning", "error"}
]
result = {
"schema_version": "svglide-preview-annotations-review/v1",
"status": "failed" if issues else "passed",
"input": INPUT_PATH.as_posix() if path.exists() else None,
"repair_list": REPAIR_LIST_PATH.as_posix(),
"summary": {
"error_count": len(issues),
"annotation_count": len(annotations),
"open_repair_count": len(open_items),
},
"issues": issues,
}
repair = {"schema_version": "svglide-preview-repair-list/v1", "items": open_items}
for output, data in [(project / OUTPUT_PATH, result), (project / REPAIR_LIST_PATH, repair)]:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate preview annotations and write a repair list.")
parser.add_argument("project")
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_preview_annotations(Path(args.project))
except (OSError, ValueError) as error:
print(f"svglide_preview_annotations: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,46 @@
# 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_preview_annotations
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlidePreviewAnnotationsTest(unittest.TestCase):
def test_preview_annotations_write_repair_list(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "05-preview/preview-annotations.json",
{
"schema_version": "svglide-preview-annotations/v1",
"annotations": [
{"page": 1, "severity": "warning", "message": "标题太挤", "status": "open"},
{"page": 2, "severity": "info", "message": "仅备注", "status": "open"},
{"page": 3, "severity": "error", "message": "文本重叠", "status": "resolved"},
],
},
)
result = svglide_preview_annotations.run_preview_annotations(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["summary"]["open_repair_count"], 1)
repair = json.loads((project / "06-check/preview-repair-list.json").read_text(encoding="utf-8"))
self.assertEqual(len(repair["items"]), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,84 @@
#!/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_preview
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
class SVGlidePreviewTest(unittest.TestCase):
def test_build_preview_inlines_prepared_svgs(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write(
project / "04-svg/prepared/002.svg",
'<svg width="960" height="540" viewBox="0 0 960 540"><text>Second</text></svg>',
)
write(
project / "04-svg/prepared/001.svg",
'<?xml version="1.0" encoding="UTF-8"?><svg width="960" height="540" viewBox="0 0 960 540"><text>First</text></svg>',
)
manifest = svglide_preview.build_preview(project)
html_path = project / "05-preview/preview.html"
manifest_path = project / "05-preview/preview-manifest.json"
self.assertTrue(html_path.exists())
self.assertTrue(manifest_path.exists())
self.assertEqual(manifest["page_count"], 2)
self.assertEqual(
[page["source_path"] for page in manifest["pages"]],
["04-svg/prepared/001.svg", "04-svg/prepared/002.svg"],
)
html = html_path.read_text(encoding="utf-8")
self.assertIn("<svg", html)
self.assertIn("Page 1 of 2", html)
self.assertIn("Source path: 04-svg/prepared/001.svg", html)
self.assertIn('href="#page-2"', html)
self.assertNotIn("<?xml", html)
saved_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(saved_manifest["page_count"], 2)
def test_build_preview_fails_without_prepared_svgs(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with self.assertRaises(svglide_preview.SVGlidePreviewError):
svglide_preview.build_preview(Path(tmpdir))
def test_build_preview_rewrites_local_asset_placeholders_for_browser(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write(project / "03-assets/raw/hero.svg", '<svg width="1" height="1"></svg>')
write(
project / "04-svg/prepared/001.svg",
'<svg width="960" height="540" viewBox="0 0 960 540"><image href="@./03-assets/raw/hero.svg" x="0" y="0" width="960" height="540" /></svg>',
)
manifest = svglide_preview.build_preview(project)
html = (project / "05-preview/preview.html").read_text(encoding="utf-8")
prepared = (project / "04-svg/prepared/001.svg").read_text(encoding="utf-8")
self.assertIn('href="data:image/svg+xml;base64,', html)
self.assertNotIn('href="@./03-assets/raw/hero.svg"', html)
self.assertIn('href="@./03-assets/raw/hero.svg"', prepared)
self.assertEqual(manifest["asset_href_rewrites"][0]["rewrites"][0]["from"], "@./03-assets/raw/hero.svg")
self.assertTrue(manifest["asset_href_rewrites"][0]["rewrites"][0]["to"].startswith("data:image/svg+xml;base64,"))
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,807 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_project_runner as runner
class SVGlideProjectRunnerTest(unittest.TestCase):
def write_plan(self, project_root: Path) -> None:
(project_root / "02-plan").mkdir(parents=True, exist_ok=True)
(project_root / "02-plan/slide_plan.json").write_text(
json.dumps(
{
"route": "svglide-svg",
"language": "zh-CN",
"audience": "企业管理层",
"deck_structure": ["cover", "content", "closing"],
"style_preset": "safe-native-v1",
"style_selection_reason": "用于稳定测试",
"style_system": {
"palette": ["#111111", "#ffffff"],
"typography": "system",
"background_strategy": "solid",
"motif": "test",
},
"loaded_rule_set": ["skills/lark-slides/references/svglide-svg-private.rules.json"],
"plan_path": "02-plan/slide_plan.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": ["结构化开场", "信息图正文", "总结强调"],
},
"visual_identity": {
"theme_archetype": "company_ecosystem",
"design_dna": {
"palette": "light neutral test palette",
"layout_motif": "测试生态墙",
"shape_language": "低圆角信息块",
"image_treatment": "图片只做背景信号",
"component_bias": "生态墙、结构卡片、总结条",
"theme_visual_anchors": ["测试产品墙", "测试组织网络", "测试结论条"],
},
"forbidden_reuse": {"recent_decks": 5, "avoid_default_skeleton": True},
"distinctness_target": {"palette_overlap_max": 0.67, "renderer_sequence_similarity_max": 0.75},
},
"slides": [
{
"page": 1,
"page_type": "cover",
"section": "开场",
"role": "thesis",
"title": "测试标题",
"key_message": "测试主结论",
"body_points": ["测试要点一", "测试要点二"],
"source_refs": ["source:item-001"],
"renderer_id": "cover_full_bleed",
"layout_family": "cover",
"visual_recipe": "test-recipe",
"visual_intent": "验证 runner",
"visual_focal_point": "标题",
"visual_signature": "稳定结构",
"svg_effects": ["text"],
"required_primitives": ["text"],
"svg_primitives": ["text"],
"xml_like_risk": "low",
"content_density_contract": "medium",
"risk_flags": [],
"source_policy": "source-backed",
},
{
"page": 2,
"page_type": "content",
"section": "正文",
"role": "evidence",
"title": "测试正文",
"key_message": "正文主结论",
"body_points": ["测试证据一", "测试证据二"],
"source_refs": ["source:item-001"],
"renderer_id": "ecosystem_wall",
"layout_family": "ecosystem",
"visual_recipe": "test-recipe",
"visual_intent": "验证 runner",
"visual_focal_point": "正文",
"visual_signature": "稳定结构",
"svg_effects": ["text"],
"required_primitives": ["text"],
"svg_primitives": ["text"],
"xml_like_risk": "low",
"content_density_contract": "medium",
"risk_flags": [],
"source_policy": "source-backed",
},
{
"page": 3,
"page_type": "closing",
"section": "结尾",
"role": "takeaway",
"title": "测试结论",
"key_message": "结论主线",
"body_points": ["后续动作一", "后续动作二"],
"source_refs": ["source:item-001"],
"renderer_id": "closing_cta",
"layout_family": "closing",
"visual_recipe": "test-recipe",
"visual_intent": "验证 runner",
"visual_focal_point": "结论",
"visual_signature": "稳定结构",
"svg_effects": ["text"],
"required_primitives": ["text"],
"svg_primitives": ["text"],
"xml_like_risk": "low",
"content_density_contract": "medium",
"risk_flags": [],
"source_policy": "source-backed",
},
],
}
),
encoding="utf-8",
)
def write_plan_confirmation(self, project_root: Path) -> None:
plan = project_root / "02-plan/slide_plan.json"
payload = {
"version": "svglide-plan-confirmation/v1",
"status": "confirmed",
"confirmed_by": "user",
"confirmed_at": "2026-06-18T00:00:00+08:00",
"plan_path": "02-plan/slide_plan.json",
"plan_sha256": runner.file_sha256(plan),
}
lock = project_root / "02-plan/svglide.lock.json"
if lock.exists():
payload["lock_path"] = "02-plan/svglide.lock.json"
payload["lock_sha256"] = runner.file_sha256(lock)
(project_root / "02-plan/plan-confirmation.json").write_text(
json.dumps(payload),
encoding="utf-8",
)
def write_evidence(self, project_root: Path) -> None:
(project_root / "source").mkdir(parents=True, exist_ok=True)
(project_root / "source/evidence.json").write_text(
json.dumps(
{
"schema_version": "svglide-evidence/v1",
"source_status": "ready",
"items": [
{"id": "item-001", "text": "第一条中文证据内容足够长,用于支撑测试页面。"},
{"id": "item-002", "text": "第二条中文证据内容足够长,用于验证来源闭环。"},
{"id": "item-003", "text": "第三条中文证据内容足够长,用于避免资料过薄。"},
],
}
),
encoding="utf-8",
)
def run_source(self, project_root: Path) -> None:
self.write_evidence(project_root)
runner.run_stage(project_root, "source")
def make_project(self, tmpdir: str) -> Path:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("smoke", "Smoke", plan_root=plan_root)
project_root = Path(result["project_root"])
self.write_plan(project_root)
self.run_source(project_root)
self.write_plan_confirmation(project_root)
runner.run_confirm_plan_stage(project_root, runner.load_state(project_root))
(project_root / "04-svg/prepared/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
(project_root / "06-check/visual-distinctness.json").write_text(json.dumps({"status": "passed"}), encoding="utf-8")
(project_root / "06-check/quality-gate.json").write_text(
json.dumps(
{
"status": "passed",
"inputs": {"visual_distinctness": "06-check/visual-distinctness.json"},
"checks": [{"name": "visual-distinctness", "status": "passed"}],
}
),
encoding="utf-8",
)
return project_root
def completed(self, command: list[str], payload: dict[str, object] | None = None, returncode: int = 0) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(command, returncode, stdout=json.dumps(payload or {"ok": True}), stderr="")
def write_ppe_input(self, project_root: Path) -> None:
(project_root / "07-create/ppe-proof.input.json").write_text(
json.dumps(
{
"status": "passed",
"environment": {"name": "Pre_release", "x-tt-env": "ppe_pure_svg"},
"auth": {"identity": "user"},
"proxy": {"mode": "whistle", "rewrite_host": "ppe"},
"headers": {"x-tt-env": "ppe_pure_svg"},
"route": {"name": "slides +create-svg", "lane": "pure-svg"},
}
),
encoding="utf-8",
)
def test_normalize_stage_accepts_aliases(self) -> None:
self.assertEqual(runner.normalize_stage("confirm-plan"), "confirm_plan")
self.assertEqual(runner.normalize_stage("source-review"), "source")
self.assertEqual(runner.normalize_stage("strategy-review"), "strategy_review")
self.assertEqual(runner.normalize_stage("aesthetic-review"), "aesthetic_review")
self.assertEqual(runner.normalize_stage("chart-verify"), "chart_verify")
self.assertEqual(runner.normalize_stage("semantic-review"), "semantic_review")
self.assertEqual(runner.normalize_stage("runtime-review"), "runtime_review")
self.assertEqual(runner.normalize_stage("visual-distinctness"), "visual_distinctness_review")
self.assertEqual(runner.normalize_stage("visual-distinctness-review"), "visual_distinctness_review")
self.assertEqual(runner.normalize_stage("generate"), "generate_svg")
self.assertEqual(runner.normalize_stage("generate-svg"), "generate_svg")
self.assertEqual(runner.normalize_stage("quality-gate"), "quality_gate")
self.assertEqual(runner.normalize_stage("preview-lint"), "preview_lint")
self.assertEqual(runner.normalize_stage("dry-run"), "dry_run")
self.assertEqual(runner.normalize_stage("ppe-proof"), "ppe_proof")
self.assertEqual(runner.normalize_stage("live-create"), "live_create")
def test_stages_until_uses_normalized_stage_graph(self) -> None:
dry_run = runner.stages_until("dry_run")
self.assertIn("source", dry_run)
self.assertIn("confirm_plan", dry_run)
self.assertIn("strategy_review", dry_run)
self.assertIn("assets", dry_run)
self.assertIn("generate_svg", dry_run)
self.assertIn("aesthetic_review", dry_run)
self.assertIn("chart_verify", dry_run)
self.assertIn("semantic_review", dry_run)
self.assertIn("runtime_review", dry_run)
self.assertIn("visual_distinctness_review", dry_run)
self.assertIn("quality_gate", dry_run)
self.assertIn("dry_run", dry_run)
self.assertNotIn("ppe_proof", dry_run)
self.assertNotIn("live_create", dry_run)
self.assertNotIn("readback", dry_run)
self.assertNotIn("export", dry_run)
readback = runner.stages_until("readback")
self.assertIn("ppe_proof", readback)
self.assertIn("live_create", readback)
self.assertIn("readback", readback)
def test_resolve_run_target_accepts_preview_only_profile(self) -> None:
self.assertEqual(runner.resolve_run_target(None, "preview_only"), "quality_gate")
self.assertEqual(runner.resolve_run_target("preview_lint", "preview_only"), "preview_lint")
with self.assertRaisesRegex(runner.RunnerError, "preview_only"):
runner.resolve_run_target("dry_run", "preview_only")
def test_preview_only_profile_runs_to_quality_gate_without_create_stages(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"])
calls: list[tuple[str, str]] = []
original_run_implemented_stage = runner.run_implemented_stage
original_implemented_stages = runner.IMPLEMENTED_STAGES
def fake_run_implemented_stage(project_root: Path, stage: str, state: dict[str, object], *, profile: str = "production") -> dict[str, object]:
calls.append((stage, profile))
return runner.complete_stage(project_root, state, stage, "passed", started_at=runner.now_iso())
try:
runner.IMPLEMENTED_STAGES = set(runner.IMPLEMENTED_STAGES) | set(runner.stages_until("quality_gate"))
runner.run_implemented_stage = fake_run_implemented_stage
runner.run_until(project_root, runner.resolve_run_target(None, "preview_only"), profile="preview_only")
finally:
runner.run_implemented_stage = original_run_implemented_stage
runner.IMPLEMENTED_STAGES = original_implemented_stages
called_stages = [stage for stage, _ in calls]
self.assertIn("source", called_stages)
self.assertIn("chart_verify", called_stages)
self.assertIn("semantic_review", called_stages)
self.assertIn("runtime_review", called_stages)
self.assertIn("visual_distinctness_review", called_stages)
self.assertLess(called_stages.index("chart_verify"), called_stages.index("semantic_review"))
self.assertLess(called_stages.index("semantic_review"), called_stages.index("quality_gate"))
self.assertLess(called_stages.index("runtime_review"), called_stages.index("visual_distinctness_review"))
self.assertLess(called_stages.index("visual_distinctness_review"), called_stages.index("quality_gate"))
self.assertNotIn("dry_run", called_stages)
self.assertNotIn("ppe_proof", called_stages)
self.assertNotIn("live_create", called_stages)
self.assertNotIn("readback", called_stages)
self.assertTrue(all(profile == "preview_only" for _, profile in calls))
def test_production_live_profile_runs_ppe_before_live_create(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"])
calls: list[tuple[str, str]] = []
original_run_implemented_stage = runner.run_implemented_stage
original_implemented_stages = runner.IMPLEMENTED_STAGES
def fake_run_implemented_stage(project_root: Path, stage: str, state: dict[str, object], *, profile: str = "production") -> dict[str, object]:
calls.append((stage, profile))
return runner.complete_stage(project_root, state, stage, "passed", started_at=runner.now_iso())
try:
runner.IMPLEMENTED_STAGES = set(runner.IMPLEMENTED_STAGES) | set(runner.stages_until("readback"))
runner.run_implemented_stage = fake_run_implemented_stage
runner.run_until(project_root, runner.resolve_run_target(None, "production_live"), profile="production_live")
finally:
runner.run_implemented_stage = original_run_implemented_stage
runner.IMPLEMENTED_STAGES = original_implemented_stages
called_stages = [stage for stage, _ in calls]
self.assertIn("ppe_proof", called_stages)
self.assertLess(called_stages.index("dry_run"), called_stages.index("ppe_proof"))
self.assertLess(called_stages.index("ppe_proof"), called_stages.index("live_create"))
self.assertTrue(all(profile == "production_live" for _, profile in calls))
def test_init_creates_project_directories_manifest_and_state(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"])
for directory in runner.PROJECT_DIRS:
self.assertTrue((project_root / directory).is_dir(), directory)
manifest = json.loads((project_root / "01-project/project_manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], runner.PROJECT_VERSION)
self.assertEqual(manifest["deck_id"], "smoke")
self.assertEqual(manifest["title"], "Smoke")
self.assertEqual(manifest["route"], runner.ROUTE)
self.assertEqual(manifest["stage_graph"], runner.STAGE_GRAPH)
self.assertEqual(manifest["artifact_root"], project_root.as_posix())
state = json.loads((project_root / "01-project/state.json").read_text(encoding="utf-8"))
self.assertEqual(state["version"], runner.STATE_VERSION)
self.assertEqual(state["current_stage"], "init")
self.assertEqual(state["stages"]["init"]["status"], "passed")
self.assertEqual(state["stages"]["init"]["receipt"], "receipts/init.json")
init_receipt = json.loads((project_root / "receipts/init.json").read_text(encoding="utf-8"))
self.assertEqual(init_receipt["stage"], "init")
self.assertEqual(init_receipt["status"], "passed")
def test_repeated_init_rejects_existing_project(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
runner.init_project("smoke", "Smoke", plan_root=plan_root)
with self.assertRaises(runner.RunnerError) as err:
runner.init_project("smoke", "Smoke", plan_root=plan_root)
self.assertEqual(err.exception.exit_code, 2)
self.assertIn("already exists", str(err.exception))
def test_run_until_fails_on_skipped_required_stage(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"])
self.write_plan(project_root)
self.run_source(project_root)
state_path = project_root / "01-project/state.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["stages"]["plan"] = {"status": "skipped", "receipt": "receipts/plan.json"}
state_path.write_text(json.dumps(state), encoding="utf-8")
with self.assertRaises(runner.RunnerError) as err:
runner.run_until(project_root, "dry_run")
self.assertIn("required stage 'plan' is skipped", str(err.exception))
def test_plan_stage_validates_existing_plan(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"])
self.write_plan(project_root)
result = runner.run_stage(project_root, "plan")
receipt = json.loads((project_root / "receipts/plan.json").read_text(encoding="utf-8"))
self.assertEqual(receipt["stage"], "plan")
self.assertEqual(receipt["status"], "passed")
self.assertEqual(result["status"], "passed")
state = json.loads((project_root / "01-project/state.json").read_text(encoding="utf-8"))
self.assertEqual(state["stages"]["plan"]["status"], "passed")
def test_plan_stage_adds_visual_identity_before_confirmation(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("bytedance", "字节跳动", plan_root=plan_root)
project_root = Path(result["project_root"])
self.write_plan(project_root)
plan_path = project_root / "02-plan/slide_plan.json"
plan = json.loads(plan_path.read_text(encoding="utf-8"))
plan.pop("visual_identity")
plan["title"] = "字节跳动"
plan_path.write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8")
result = runner.run_stage(project_root, "plan")
updated = json.loads(plan_path.read_text(encoding="utf-8"))
receipt = json.loads((project_root / "receipts/plan.json").read_text(encoding="utf-8"))
self.assertEqual(result["status"], "passed")
self.assertTrue(receipt["visual_identity_added"])
self.assertEqual(updated["visual_identity"]["theme_archetype"], "company_ecosystem")
def test_strategy_review_stage_validates_plan_semantics(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"])
self.write_plan(project_root)
self.run_source(project_root)
runner.run_stage(project_root, "plan")
result = runner.run_stage(project_root, "strategy-review")
self.assertEqual(result["status"], "passed")
review = json.loads((project_root / "02-plan/strategy-review.json").read_text(encoding="utf-8"))
self.assertEqual(review["status"], "passed")
def test_confirm_plan_writes_request_and_does_not_block_state_when_missing(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"])
self.write_plan(project_root)
with self.assertRaisesRegex(runner.RunnerError, "plan confirmation required"):
runner.run_stage(project_root, "confirm-plan")
request = json.loads((project_root / "02-plan/plan-confirmation.request.json").read_text(encoding="utf-8"))
self.assertEqual(request["status"], "pending")
self.assertEqual(request["plan_path"], "02-plan/slide_plan.json")
state = json.loads((project_root / "01-project/state.json").read_text(encoding="utf-8"))
self.assertNotIn("confirm_plan", state["stages"])
def test_confirm_plan_passes_with_matching_user_confirmation(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
result = runner.run_stage(project_root, "confirm-plan")
self.assertEqual(result["status"], "passed")
receipt = json.loads((project_root / "receipts/confirm_plan.json").read_text(encoding="utf-8"))
self.assertEqual(receipt["confirmation"]["confirmed_by"], "user")
def test_generate_svg_requires_confirm_plan_stage(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
with self.assertRaisesRegex(runner.RunnerError, "confirm_plan"):
runner.run_stage(project_root, "generate-svg")
def test_assets_requires_confirm_plan_stage(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
with self.assertRaisesRegex(runner.RunnerError, "confirm_plan"):
runner.run_stage(project_root, "assets")
def test_generate_svg_requires_assets_stage(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
runner.run_stage(project_root, "confirm-plan")
self.run_source(project_root)
(project_root / "04-svg/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "assets"):
runner.run_stage(project_root, "generate-svg")
def test_generate_svg_adopts_existing_source_files(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
self.run_source(project_root)
runner.run_stage(project_root, "confirm-plan")
runner.run_stage(project_root, "assets")
for page in range(1, 4):
(project_root / f"04-svg/page-{page:03d}.svg").write_text("<svg></svg>", encoding="utf-8")
result = runner.run_stage(project_root, "generate-svg")
self.assertEqual(result["status"], "passed")
receipt = json.loads((project_root / "receipts/generate_svg.json").read_text(encoding="utf-8"))
self.assertEqual(receipt["generator_mode"], "external")
self.assertEqual(receipt["generated_files"][0]["path"], "04-svg/page-001.svg")
self.assertEqual(receipt["page_receipts"][0], "04-svg/page-001.receipt.json")
self.assertTrue((project_root / "04-svg/page-001.receipt.json").exists())
def test_generate_svg_injects_file_backed_cover_asset(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"])
self.write_plan(project_root)
(project_root / "03-assets/raw").mkdir(parents=True, exist_ok=True)
(project_root / "03-assets/raw/hero.png").write_bytes(b"png")
(project_root / "02-plan/svglide.lock.json").write_text(
json.dumps(
{
"asset_contracts": [
{
"id": "hero",
"href": "@./03-assets/raw/hero.png",
"usage_page": 1,
"placement_role": "cover",
"safe_text_zones": [{"x": 0.05, "y": 0.12, "w": 0.42, "h": 0.72}],
}
]
}
),
encoding="utf-8",
)
self.write_plan_confirmation(project_root)
self.run_source(project_root)
runner.run_stage(project_root, "confirm-plan")
runner.run_stage(project_root, "assets")
for page in range(1, 4):
(project_root / f"04-svg/page-{page:03d}.svg").write_text(
'<svg width="960" height="540" viewBox="0 0 960 540"><text>测试标题</text></svg>',
encoding="utf-8",
)
result = runner.run_stage(project_root, "generate-svg")
self.assertEqual(result["status"], "passed")
svg = (project_root / "04-svg/page-001.svg").read_text(encoding="utf-8")
self.assertIn('href="@./03-assets/raw/hero.png"', svg)
receipt = json.loads((project_root / "receipts/generate_svg.json").read_text(encoding="utf-8"))
self.assertEqual(receipt["asset_injection_summary"]["used_count"], 1)
self.assertEqual(receipt["generated_files"][0]["sha256"], runner.file_sha256(project_root / "04-svg/page-001.svg"))
page_receipt = json.loads((project_root / "04-svg/page-001.receipt.json").read_text(encoding="utf-8"))
self.assertEqual(page_receipt["asset_refs"][0]["asset_id"], "hero")
self.assertEqual(page_receipt["asset_injection"][0]["status"], "injected")
runner.run_stage(project_root, "prepare")
runner.run_stage(project_root, "preview")
preview_html = (project_root / "05-preview/preview.html").read_text(encoding="utf-8")
self.assertIn('href="data:image/png;base64,', preview_html)
def test_generate_svg_runs_local_generator_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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
self.run_source(project_root)
runner.run_stage(project_root, "confirm-plan")
runner.run_stage(project_root, "assets")
generator = project_root / "logs/generate_svgs.py"
generator.write_text(
"\n".join(
[
"from pathlib import Path",
"project = Path(__file__).resolve().parents[1]",
"for page in range(1, 4):",
" (project / f'04-svg/page-{page:03d}.svg').write_text('<svg></svg>', encoding='utf-8')",
]
),
encoding="utf-8",
)
result = runner.run_stage(project_root, "generate-svg")
self.assertEqual(result["status"], "passed")
self.assertTrue((project_root / "04-svg/page-001.svg").exists())
receipt = json.loads((project_root / "receipts/generate_svg.json").read_text(encoding="utf-8"))
self.assertEqual(receipt["generator_mode"], "script")
self.assertIn("logs/generate_svgs.py", receipt["command"][1])
def test_prepare_requires_confirm_plan_stage(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
(project_root / "04-svg/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "confirm_plan"):
runner.run_stage(project_root, "prepare")
def test_prepare_requires_generate_svg_stage(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
self.run_source(project_root)
runner.run_stage(project_root, "confirm-plan")
runner.run_stage(project_root, "assets")
(project_root / "04-svg/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "generate_svg"):
runner.run_stage(project_root, "prepare")
def test_prepare_requires_assets_stage(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
runner.run_stage(project_root, "confirm-plan")
(project_root / "04-svg/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "assets"):
runner.run_stage(project_root, "prepare")
def test_prepare_refuses_changed_sources_after_generate_svg(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"])
self.write_plan(project_root)
self.write_plan_confirmation(project_root)
self.run_source(project_root)
runner.run_stage(project_root, "confirm-plan")
runner.run_stage(project_root, "assets")
source = project_root / "04-svg/page-001.svg"
for page in range(1, 4):
(project_root / f"04-svg/page-{page:03d}.svg").write_text("<svg></svg>", encoding="utf-8")
runner.run_stage(project_root, "generate-svg")
source.write_text("<svg><rect /></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "changed after generate_svg"):
runner.run_stage(project_root, "prepare")
def test_dry_run_refuses_failed_quality_gate(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
(project_root / "06-check/quality-gate.json").write_text(json.dumps({"status": "failed"}), encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "quality gate"):
runner.run_create_stage(project_root, runner.load_state(project_root), "dry_run", dry_run=True, command_runner=lambda *a, **k: self.completed(a[0]))
def test_dry_run_refuses_changed_prepared_hashes_after_quality_gate(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
hashes = runner.prepared_file_hashes(project_root)
(project_root / "06-check/quality-gate.json").write_text(json.dumps({"status": "passed", "prepared_files": hashes}), encoding="utf-8")
(project_root / "04-svg/prepared/page-001.svg").write_text("<svg><rect /></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "changed after quality gate"):
runner.run_create_stage(project_root, runner.load_state(project_root), "dry_run", dry_run=True, command_runner=lambda *a, **k: self.completed(a[0]))
def test_existing_quality_gate_without_visual_distinctness_is_stale(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
old_gate = {
"status": "passed",
"inputs": {},
"checks": [],
"prepared_files": runner.prepared_file_hashes(project_root),
}
(project_root / "06-check/quality-gate.json").write_text(json.dumps(old_gate), encoding="utf-8")
state = runner.load_state(project_root)
receipt = project_root / "receipts/quality_gate.json"
receipt.write_text(json.dumps({"stage": "quality_gate", "status": "passed"}), encoding="utf-8")
runner.record_stage(state, "quality_gate", "passed", receipt)
runner.write_state(project_root, state)
with self.assertRaisesRegex(runner.RunnerError, "visual_distinctness"):
runner.run_stage(project_root, "quality_gate")
def test_dry_run_command_includes_assets_when_present(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
(project_root / "03-assets/assets.json").write_text(json.dumps({"@./hero.png": "boxcn_hero"}), encoding="utf-8")
captured: list[list[str]] = []
def fake(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
captured.append(command)
return self.completed(command)
runner.run_create_stage(project_root, runner.load_state(project_root), "dry_run", dry_run=True, command_runner=fake)
self.assertIn("--assets", captured[0])
self.assertIn("--dry-run", captured[0])
dry_run = json.loads((project_root / "07-create/dry-run.json").read_text(encoding="utf-8"))
self.assertEqual(dry_run["prepared_files"][0]["path"], "04-svg/prepared/page-001.svg")
def test_dry_run_command_omits_assets_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
captured: list[list[str]] = []
def fake(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
captured.append(command)
return self.completed(command)
runner.run_create_stage(project_root, runner.load_state(project_root), "dry_run", dry_run=True, command_runner=fake)
self.assertNotIn("--assets", captured[0])
def test_create_command_allows_local_cli_override(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
previous = os.environ.get(runner.LARK_CLI_COMMAND_ENV)
os.environ[runner.LARK_CLI_COMMAND_ENV] = "env GOCACHE=/private/tmp/svglide-gocache go run ."
try:
command = runner.create_command(project_root, dry_run=True)
finally:
if previous is None:
os.environ.pop(runner.LARK_CLI_COMMAND_ENV, None)
else:
os.environ[runner.LARK_CLI_COMMAND_ENV] = previous
self.assertEqual(command[:5], ["env", "GOCACHE=/private/tmp/svglide-gocache", "go", "run", "."])
self.assertIn("+create-svg", command)
self.assertIn("--dry-run", command)
def test_cli_arg_path_uses_repo_relative_paths(self) -> None:
repo_file = runner.repo_root() / "skills/lark-slides/scripts/svglide_project_runner.py"
self.assertEqual(runner.cli_arg_path(repo_file), "skills/lark-slides/scripts/svglide_project_runner.py")
def test_live_create_refuses_changed_prepared_hashes_after_dry_run(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
runner.run_create_stage(
project_root,
runner.load_state(project_root),
"dry_run",
dry_run=True,
command_runner=lambda command, **_: self.completed(command),
)
self.write_ppe_input(project_root)
runner.run_stage(project_root, "ppe-proof")
(project_root / "04-svg/prepared/page-001.svg").write_text("<svg><rect /></svg>", encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "changed after dry-run"):
runner.run_create_stage(
project_root,
runner.load_state(project_root),
"live_create",
dry_run=False,
command_runner=lambda command, **_: self.completed(command, {"xml_presentation_id": "xml_1", "slide_ids": ["s1"]}),
)
receipt = json.loads((project_root / "receipts/live_create.json").read_text(encoding="utf-8"))
self.assertEqual(receipt["status"], "failed")
self.assertEqual(receipt["error"]["code"], "prepared_hash_mismatch")
def test_live_create_requires_ppe_proof(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
runner.run_create_stage(
project_root,
runner.load_state(project_root),
"dry_run",
dry_run=True,
command_runner=lambda command, **_: self.completed(command),
)
with self.assertRaisesRegex(runner.RunnerError, "ppe_proof"):
runner.run_create_stage(
project_root,
runner.load_state(project_root),
"live_create",
dry_run=False,
command_runner=lambda command, **_: self.completed(command, {"xml_presentation_id": "xml_1", "slide_ids": ["s1"]}),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,476 @@
#!/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
from pathlib import Path
from typing import Any
import svglide_schema
CHECK_DIR = Path("06-check")
QUALITY_GATE_NAME = "quality-gate.json"
PREPARED_SVG_DIR = Path("04-svg/prepared")
SOURCE_SVG_DIR = Path("04-svg")
PLAN_PATH = Path("02-plan/slide_plan.json")
EVIDENCE_PATH = Path("source/evidence.json")
SOURCE_RECEIPT_PATH = Path("source/source-receipt.json")
ASSET_MANIFEST_PATH = Path("03-assets/asset-manifest.json")
GENERATOR_RECEIPT_PATH = Path("receipts/generate_svg.json")
REQUIRED_CHECKS = [
("preflight", CHECK_DIR / "preflight.json"),
("preview-lint", CHECK_DIR / "preview-lint.json"),
("aesthetic-review", CHECK_DIR / "aesthetic-review.json"),
("runtime-review", CHECK_DIR / "runtime-review.json"),
("semantic-review", CHECK_DIR / "semantic-review.json"),
("visual-distinctness", CHECK_DIR / "visual-distinctness.json"),
]
CHART_VERIFY_CHECK = ("chart-verify", CHECK_DIR / "chart-verify.json")
OPTIONAL_CHECKS = []
PASS_ACTION = "create_live"
FAIL_ACTIONS = {"repair_and_rerun", "failed", "fail"}
PRODUCTION_PROFILE = "production"
STRICT_PROFILES = {PRODUCTION_PROFILE, "production_live"}
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def issue(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message}
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 prepared_file_hashes(project: Path) -> list[dict[str, str]]:
svg_dir = project / PREPARED_SVG_DIR
if not svg_dir.exists():
return []
return [
{
"path": path.relative_to(project).as_posix(),
"sha256": file_sha256(path),
}
for path in sorted(svg_dir.glob("*.svg"))
if path.is_file()
]
def source_file_hashes(project: Path) -> list[dict[str, str]]:
svg_dir = project / SOURCE_SVG_DIR
if not svg_dir.exists():
return []
return [
{
"path": path.relative_to(project).as_posix(),
"sha256": file_sha256(path),
}
for path in sorted(svg_dir.glob("*.svg"))
if path.is_file()
]
def optional_file_sha256(project: Path, rel: Path) -> str | None:
path = project / rel
return file_sha256(path) if path.exists() else None
def error_count_from_payload(payload: Any) -> int | None:
if not isinstance(payload, dict):
return None
summary = payload.get("summary")
if not isinstance(summary, dict):
return None
raw = summary.get("error_count")
if isinstance(raw, bool) or not isinstance(raw, int):
return None
return raw
def list_waivers(payload: Any) -> list[Any]:
if not isinstance(payload, dict):
return []
raw = payload.get("waivers")
return raw if isinstance(raw, list) else []
def action_from_payload(payload: Any) -> str | None:
if not isinstance(payload, dict):
return None
raw = payload.get("action") or payload.get("status")
return raw if isinstance(raw, str) else None
def read_json_optional(project: Path, rel: Path) -> dict[str, Any]:
path = project / rel
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def load_online_readiness(project: Path, *, profile: str) -> dict[str, Any]:
source_receipt = read_json_optional(project, SOURCE_RECEIPT_PATH)
asset_manifest = read_json_optional(project, ASSET_MANIFEST_PATH)
research = source_receipt.get("research") if isinstance(source_receipt.get("research"), dict) else {}
asset_summary = asset_manifest.get("summary") if isinstance(asset_manifest.get("summary"), dict) else {}
research_status = research.get("status") if isinstance(research, dict) and isinstance(research.get("status"), str) else "legacy"
asset_status = asset_manifest.get("status") if isinstance(asset_manifest.get("status"), str) else "legacy"
issues: list[dict[str, str]] = []
if profile in STRICT_PROFILES and research_status in {"blocked_by_network", "skipped_by_user"}:
issues.append(issue("research_missing_for_current_topic", f"research status is {research_status}"))
if asset_status == "failed":
issues.append(issue("asset_manifest_failed", "asset manifest status is failed"))
status = "failed" if issues else "skipped" if not source_receipt and not asset_manifest else "passed"
return {
"name": "online-readiness",
"path": "source/source-receipt.json + 03-assets/asset-manifest.json",
"required": False,
"status": status,
"error_count": len(issues),
"action": PASS_ACTION if not issues else "repair_and_rerun",
"waivers": [],
"issues": issues,
"research_status": research_status,
"asset_status": asset_status,
"asset_real_coverage": asset_summary.get("acquired_count"),
"asset_fallback_count": asset_summary.get("fallback_count"),
"image_job_count": asset_summary.get("image_job_count"),
}
def plan_requires_chart_verify(project: Path) -> bool | None:
path = project / PLAN_PATH
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
slides = payload.get("slides") if isinstance(payload, dict) else None
if not isinstance(slides, list):
return None
for slide in slides:
if not isinstance(slide, dict):
continue
contract = slide.get("chart_contract")
if isinstance(contract, dict) and (contract.get("verify") == "required" or contract.get("precision") == "exact"):
return True
return False
def semantic_review_freshness_issues(project: Path, payload: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
if payload.get("status") != "passed":
issues.append(issue("semantic_review_not_passed", "semantic review status must be passed"))
inputs = payload.get("inputs")
if not isinstance(inputs, dict):
issues.append(issue("semantic_review_inputs_missing", "semantic review must include inputs"))
return issues
if inputs.get("plan_sha256") != optional_file_sha256(project, PLAN_PATH):
issues.append(issue("semantic_review_plan_stale", "semantic review plan_sha256 does not match current slide_plan.json"))
evidence_hash = optional_file_sha256(project, EVIDENCE_PATH)
if inputs.get("evidence_sha256") != evidence_hash:
issues.append(issue("semantic_review_evidence_stale", "semantic review evidence_sha256 does not match current source/evidence.json"))
if payload.get("prepared_files") != prepared_file_hashes(project):
issues.append(issue("semantic_review_prepared_stale", "semantic review prepared_files do not match current prepared SVG files"))
inventory = payload.get("text_inventory")
if not isinstance(inventory, str) or not (project / inventory).exists():
issues.append(issue("semantic_review_text_inventory_missing", "semantic review must point to an existing text inventory"))
return issues
def plan_bound_check_freshness_issues(project: Path, payload: dict[str, Any], name: str, *, prepared: bool) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
if payload.get("status") != "passed":
issues.append(issue(f"{name}_not_passed", f"{name} status must be passed"))
inputs = payload.get("inputs")
if not isinstance(inputs, dict):
issues.append(issue(f"{name}_inputs_missing", f"{name} must include inputs"))
return issues
if inputs.get("plan_sha256") != optional_file_sha256(project, PLAN_PATH):
issues.append(issue(f"{name}_plan_stale", f"{name} plan_sha256 does not match current slide_plan.json"))
if prepared and payload.get("prepared_files") != prepared_file_hashes(project):
issues.append(issue(f"{name}_prepared_stale", f"{name} prepared_files do not match current prepared SVG files"))
return issues
def load_generator_receipt(project: Path, *, profile: str) -> dict[str, Any]:
rel = GENERATOR_RECEIPT_PATH
path = project / rel
check: dict[str, Any] = {
"name": "generator-receipt",
"path": rel.as_posix(),
"required": True,
"status": "missing" if not path.exists() else "failed",
"error_count": None,
"action": None,
"waivers": [],
"issues": [],
}
if not path.exists():
check["issues"].append(issue("missing_generator_receipt", "generator receipt is required"))
return check
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
check["issues"].append(issue("invalid_generator_receipt_json", f"could not read generator receipt JSON: {error}"))
return check
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-generator-receipt.schema.json"))
schema_issues = svglide_schema.validate_json_schema(payload, schema)
check["issues"].extend(issue(item["code"], f"{item['path']}: {item['message']}") for item in schema_issues)
if payload.get("status") != "passed":
check["issues"].append(issue("generator_receipt_not_passed", "generator receipt status must be passed"))
page_identity_summary = payload.get("page_identity_summary")
if not isinstance(page_identity_summary, list) or not page_identity_summary:
check["issues"].append(issue("generator_page_identity_summary_missing", "generator receipt must include page_identity_summary"))
if profile in STRICT_PROFILES and payload.get("fallback_skeleton_used") is True:
check["issues"].append(issue("fallback_skeleton_used", "production profiles cannot use the generic fallback SVG skeleton"))
if payload.get("generated_files") != source_file_hashes(project):
check["issues"].append(issue("generator_source_stale", "generator receipt generated_files do not match current source SVG files"))
expected = {
"plan_sha256": optional_file_sha256(project, PLAN_PATH),
"evidence_sha256": optional_file_sha256(project, EVIDENCE_PATH),
"asset_manifest_sha256": optional_file_sha256(project, ASSET_MANIFEST_PATH),
"source_receipt_sha256": optional_file_sha256(project, SOURCE_RECEIPT_PATH),
}
for key, current in expected.items():
if payload.get(key) != current:
check["issues"].append(issue(f"generator_{key}_stale", f"generator receipt {key} does not match current project files"))
generated = payload.get("generated_files")
page_receipts = payload.get("page_receipts")
if not isinstance(generated, list) or not generated:
check["issues"].append(issue("generator_generated_files_missing", "generator receipt must include generated_files"))
if not isinstance(page_receipts, list) or not page_receipts:
check["issues"].append(issue("generator_page_receipts_missing", "generator receipt must include page_receipts"))
elif isinstance(generated, list) and len(page_receipts) != len(generated):
check["issues"].append(issue("generator_page_receipt_count_mismatch", "page_receipts count must match generated_files"))
if isinstance(page_receipts, list):
for item in page_receipts:
if not isinstance(item, str):
check["issues"].append(issue("generator_page_receipt_invalid", "page_receipts must be string paths"))
continue
page_receipt = project / item
if not page_receipt.exists():
check["issues"].append(issue("generator_page_receipt_missing", f"page receipt is missing: {item}"))
check["error_count"] = len(check["issues"])
check["status"] = "failed" if check["issues"] else "passed"
return check
def load_check(project: Path, name: str, rel: Path, *, required: bool, profile: str) -> dict[str, Any]:
path = project / rel
check: dict[str, Any] = {
"name": name,
"path": relpath(path, project),
"required": required,
"status": "missing" if not path.exists() else "failed",
"error_count": None,
"action": None,
"waivers": [],
"issues": [],
}
if not path.exists():
if required:
check["issues"].append(issue("missing_check_file", f"required check file is missing: {rel.as_posix()}"))
else:
check["status"] = "skipped"
return check
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
check["issues"].append(issue("invalid_check_json", f"could not read check JSON: {error}"))
return check
schema_names = {
"semantic-review": "svglide-semantic-review.schema.json",
"chart-verify": "svglide-chart-verify.schema.json",
"runtime-review": "svglide-runtime-review.schema.json",
}
if name in schema_names:
schema = svglide_schema.read_json(svglide_schema.schema_path(schema_names[name]))
schema_issues = svglide_schema.validate_json_schema(payload, schema)
if schema_issues:
check["issues"].extend(issue(item["code"], f"{item['path']}: {item['message']}") for item in schema_issues)
return check
error_count = error_count_from_payload(payload)
if error_count is None:
check["issues"].append(issue("missing_error_count", "check JSON must contain integer summary.error_count"))
return check
waivers = list_waivers(payload)
action = action_from_payload(payload)
check["error_count"] = error_count
check["action"] = action
check["waivers"] = waivers
if error_count > 0:
check["issues"].append(issue("check_has_errors", f"summary.error_count is {error_count}"))
return check
if name == "preview-lint" and action != PASS_ACTION:
check["issues"].append(issue("preview_lint_action_not_create_live", f"preview lint action is {action!r}; expected {PASS_ACTION!r}"))
return check
if name == "aesthetic-review":
if action in FAIL_ACTIONS:
check["issues"].append(issue("aesthetic_review_blocks_create", f"aesthetic review action is {action!r}"))
return check
if action is not None and action != PASS_ACTION:
check["issues"].append(issue("aesthetic_review_action_unknown", f"aesthetic review action is {action!r}; expected {PASS_ACTION!r} or repair action"))
return check
if name == "semantic-review" and isinstance(payload, dict):
freshness = semantic_review_freshness_issues(project, payload)
if freshness:
check["issues"].extend(freshness)
return check
if name == "runtime-review" and isinstance(payload, dict):
freshness = plan_bound_check_freshness_issues(project, payload, "runtime_review", prepared=False)
if freshness:
check["issues"].extend(freshness)
return check
if name == "chart-verify" and isinstance(payload, dict):
freshness = plan_bound_check_freshness_issues(project, payload, "chart_verify", prepared=True)
if freshness:
check["issues"].extend(freshness)
return check
if name in {"chart-verify", "runtime-review", "semantic-review"} and action not in {PASS_ACTION, "passed"}:
check["issues"].append(issue(f"{name.replace('-', '_')}_action_not_create_live", f"{name} action is {action!r}; expected {PASS_ACTION!r}"))
return check
if waivers:
if name == "preflight":
check["issues"].append(issue("preflight_waiver_not_allowed", "preflight waivers are not allowed"))
return check
if profile in STRICT_PROFILES:
check["issues"].append(issue("production_waiver_not_allowed", "production profile does not accept waivers"))
return check
check["status"] = "passed_with_waiver"
return check
check["status"] = "passed"
return check
def run_quality_gate(project: Path, *, profile: str = PRODUCTION_PROFILE) -> dict[str, Any]:
project = project.resolve()
checks = [load_generator_receipt(project, profile=profile)]
checks.append(load_online_readiness(project, profile=profile))
checks.extend(load_check(project, name, rel, required=True, profile=profile) for name, rel in REQUIRED_CHECKS)
chart_required = plan_requires_chart_verify(project)
if chart_required is None:
checks.append(
{
"name": "chart-verify-admission",
"path": PLAN_PATH.as_posix(),
"required": True,
"status": "failed",
"error_count": 1,
"action": None,
"waivers": [],
"issues": [issue("chart_verify_requirement_unknown", "could not determine whether chart verification is required")],
}
)
else:
checks.append(load_check(project, *CHART_VERIFY_CHECK, required=chart_required, profile=profile))
checks.extend(load_check(project, name, rel, required=False, profile=profile) for name, rel in OPTIONAL_CHECKS)
failed_checks = [check for check in checks if check["status"] not in {"passed", "passed_with_waiver", "skipped"}]
waiver_checks = [check for check in checks if check["status"] == "passed_with_waiver"]
source_error_count = sum(check["error_count"] or 0 for check in checks)
status = "failed" if failed_checks else "passed_with_waiver" if waiver_checks else "passed"
output_path = project / CHECK_DIR / QUALITY_GATE_NAME
result = {
"version": "svglide-quality-gate/v1",
"project": str(project),
"profile": profile,
"status": status,
"inputs": {
name.replace("-", "_"): rel.as_posix()
for name, rel in REQUIRED_CHECKS + ([CHART_VERIFY_CHECK] if chart_required else []) + OPTIONAL_CHECKS
if (project / rel).exists() or name in {item[0] for item in REQUIRED_CHECKS}
},
"prepared_files": prepared_file_hashes(project),
"waivers": [
{"check": check["name"], "waivers": check["waivers"]}
for check in checks
if check["waivers"]
],
"summary": {
"check_count": len(checks),
"failed_check_count": len(failed_checks),
"waiver_check_count": len(waiver_checks),
"source_error_count": source_error_count,
"research_status": next((check.get("research_status") for check in checks if check.get("name") == "online-readiness"), None),
"asset_status": next((check.get("asset_status") for check in checks if check.get("name") == "online-readiness"), None),
"asset_real_coverage": next((check.get("asset_real_coverage") for check in checks if check.get("name") == "online-readiness"), None),
"asset_fallback_count": next((check.get("asset_fallback_count") for check in checks if check.get("name") == "online-readiness"), None),
"image_job_count": next((check.get("image_job_count") for check in checks if check.get("name") == "online-readiness"), None),
},
"checks": checks,
"output_path": relpath(output_path, project),
}
result["inputs"]["generator_receipt"] = GENERATOR_RECEIPT_PATH.as_posix()
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-quality-gate.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["status"] = "failed"
result["summary"]["failed_check_count"] += 1
result["checks"].append(
{
"name": "quality-gate-schema",
"path": "06-check/quality-gate.json",
"required": True,
"status": "failed",
"error_count": len(schema_issues),
"action": None,
"waivers": [],
"issues": [issue(item["code"], f"{item['path']}: {item['message']}") for item in schema_issues],
}
)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Evaluate SVGlide preflight and preview lint outputs.")
parser.add_argument("project", help="SVGlide project directory containing 06-check/preflight.json and preview-lint.json")
parser.add_argument("--profile", default=PRODUCTION_PROFILE, choices=["production", "debug", "preview_only", "production_live"])
parser.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
args = parser.parse_args(argv)
try:
result = run_quality_gate(Path(args.project), profile=args.profile)
except OSError as error:
print(f"svglide_quality_gate: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,522 @@
#!/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_quality_gate
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def write_passing_semantic_review(project: Path) -> None:
(project / "02-plan").mkdir(parents=True, exist_ok=True)
(project / "source").mkdir(parents=True, exist_ok=True)
(project / "03-assets").mkdir(parents=True, exist_ok=True)
(project / "04-svg").mkdir(parents=True, exist_ok=True)
(project / "04-svg/prepared").mkdir(parents=True, exist_ok=True)
if not (project / "02-plan/slide_plan.json").exists():
write_json(project / "02-plan/slide_plan.json", {"language": "zh-CN", "slides": []})
if not (project / "source/evidence.json").exists():
write_json(project / "source/evidence.json", {"schema_version": "svglide-evidence/v1", "source_status": "ready", "items": [{"id": "item-001", "text": "这是一条足够长的中文证据内容,用于质量门禁测试。"}]})
if not (project / "source/source-receipt.json").exists():
write_json(
project / "source/source-receipt.json",
{
"schema_version": "svglide-source-receipt/v1",
"stage": "source",
"status": "passed",
"inputs": {"evidence_sha256": svglide_quality_gate.file_sha256(project / "source/evidence.json"), "source_notes_sha256": None},
"outputs": {"evidence": "source/evidence.json", "source_receipt": "source/source-receipt.json"},
"summary": {"error_count": 0, "evidence_item_count": 1},
"issues": [],
},
)
if not (project / "03-assets/asset-manifest.json").exists():
write_json(
project / "03-assets/asset-manifest.json",
{
"version": "svglide-assets/v1",
"status": "passed",
"source_receipt_sha256": svglide_quality_gate.file_sha256(project / "source/source-receipt.json"),
"summary": {"error_count": 0},
},
)
if not (project / "04-svg/page-001.svg").exists():
(project / "04-svg/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
if not any((project / "04-svg/prepared").glob("*.svg")):
(project / "04-svg/prepared/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
source_files = svglide_quality_gate.source_file_hashes(project)
page_receipt = project / "04-svg/page-001.receipt.json"
write_json(
page_receipt,
{
"version": "svglide-page-generation/v1",
"stage": "generate_svg",
"page": 1,
"source_svg": source_files[0]["path"],
"source_sha256": source_files[0]["sha256"],
"plan_sha256": svglide_quality_gate.file_sha256(project / "02-plan/slide_plan.json"),
"evidence_sha256": svglide_quality_gate.file_sha256(project / "source/evidence.json"),
},
)
write_json(
project / "receipts/generate_svg.json",
{
"stage": "generate_svg",
"status": "passed",
"generator_mode": "external",
"generated_files": source_files,
"page_receipts": ["04-svg/page-001.receipt.json"],
"plan_sha256": svglide_quality_gate.file_sha256(project / "02-plan/slide_plan.json"),
"evidence_sha256": svglide_quality_gate.file_sha256(project / "source/evidence.json"),
"asset_manifest_sha256": svglide_quality_gate.file_sha256(project / "03-assets/asset-manifest.json"),
"source_receipt_sha256": svglide_quality_gate.file_sha256(project / "source/source-receipt.json"),
"lock_sha256": None,
"generator_script_sha256": None,
"fallback_skeleton_used": False,
"page_identity_summary": [
{
"page": 1,
"theme_archetype": "company_ecosystem",
"identity_fit_reason": "测试页符合视觉身份",
"reuse_risk_score": 0,
"fallback_skeleton_used": False,
}
],
},
)
write_json(project / "06-check/text-inventory.json", {"schema_version": "svglide-text-inventory/v1", "slides": []})
write_json(
project / "06-check/runtime-review.json",
{
"schema_version": "svglide-runtime-review/v1",
"status": "passed",
"action": "create_live",
"inputs": {
"slide_plan": "02-plan/slide_plan.json",
"plan_sha256": svglide_quality_gate.file_sha256(project / "02-plan/slide_plan.json"),
},
"registry": {
"path": "skills/lark-slides/references/svglide-renderer-registry.json",
"sha256": svglide_quality_gate.file_sha256(Path(__file__).resolve().parent.parent / "references" / "svglide-renderer-registry.json"),
},
"pages": [],
"summary": {"error_count": 0, "warning_count": 0, "slide_count": 0, "renderer_count": 0, "layout_family_count": 0},
"issues": [],
},
)
write_json(
project / "06-check/visual-distinctness.json",
{
"schema_version": "svglide-visual-distinctness/v1",
"status": "passed",
"action": "create_live",
"inputs": {"slide_plan": "02-plan/slide_plan.json"},
"signature": {"theme_archetype": "company_ecosystem"},
"comparisons": [],
"summary": {"error_count": 0, "warning_count": 0, "comparison_count": 0},
"issues": [],
},
)
write_json(
project / "06-check/chart-verify.json",
{
"schema_version": "svglide-chart-verify/v1",
"status": "passed",
"action": "create_live",
"inputs": {
"slide_plan": "02-plan/slide_plan.json",
"plan_sha256": svglide_quality_gate.file_sha256(project / "02-plan/slide_plan.json"),
"svg_dir": "04-svg/prepared",
},
"prepared_files": svglide_quality_gate.prepared_file_hashes(project),
"summary": {"error_count": 0, "warning_count": 0, "required_chart_count": 0},
"issues": [],
},
)
write_json(
project / "06-check/semantic-review.json",
{
"schema_version": "svglide-semantic-review/v1",
"status": "passed",
"action": "create_live",
"profile": "preview_only",
"inputs": {
"slide_plan": "02-plan/slide_plan.json",
"plan_sha256": svglide_quality_gate.file_sha256(project / "02-plan/slide_plan.json"),
"evidence": "source/evidence.json",
"evidence_sha256": svglide_quality_gate.file_sha256(project / "source/evidence.json"),
"svg_dir": "04-svg/prepared",
},
"prepared_files": svglide_quality_gate.prepared_file_hashes(project),
"text_inventory": "06-check/text-inventory.json",
"summary": {"error_count": 0, "warning_count": 0, "slide_count": 1, "prepared_svg_count": 1, "unmatched_text_count": 0},
"issues": [],
},
)
class SVGlideQualityGateTest(unittest.TestCase):
def test_quality_gate_passes_when_required_checks_have_zero_errors(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "06-check/preflight.json", {"summary": {"error_count": 0, "warning_count": 1}})
write_json(project / "06-check/preview-lint.json", {"summary": {"error_count": 0, "warning_count": 0}, "action": "create_live"})
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0, "warning_count": 0}, "action": "create_live"})
(project / "04-svg/prepared").mkdir(parents=True)
(project / "04-svg/prepared/page-001.svg").write_text("<svg></svg>", encoding="utf-8")
write_passing_semantic_review(project)
result = svglide_quality_gate.run_quality_gate(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["version"], "svglide-quality-gate/v1")
self.assertEqual(result["inputs"]["preflight"], "06-check/preflight.json")
self.assertEqual(result["inputs"]["preview_lint"], "06-check/preview-lint.json")
self.assertEqual(result["inputs"]["aesthetic_review"], "06-check/aesthetic-review.json")
self.assertEqual(result["inputs"]["semantic_review"], "06-check/semantic-review.json")
self.assertEqual(result["inputs"]["visual_distinctness"], "06-check/visual-distinctness.json")
self.assertEqual(result["prepared_files"][0]["path"], "04-svg/prepared/page-001.svg")
self.assertEqual(result["summary"]["failed_check_count"], 0)
self.assertTrue((project / "06-check/quality-gate.json").exists())
def test_quality_gate_fails_when_required_check_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "06-check/preflight.json", {"summary": {"error_count": 0}})
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("missing_check_file", failed_codes)
def test_quality_gate_fails_when_any_check_has_errors(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": 2}, "action": "repair_and_rerun"})
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_passing_semantic_review(project)
result = svglide_quality_gate.run_quality_gate(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["summary"]["source_error_count"], 2)
failed_codes = {
issue["code"]
for check in result["checks"]
for issue in check["issues"]
}
self.assertIn("check_has_errors", failed_codes)
def test_quality_gate_fails_when_preview_lint_action_blocks_create(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": "repair_and_rerun"})
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_passing_semantic_review(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("preview_lint_action_not_create_live", failed_codes)
def test_quality_gate_fails_when_aesthetic_review_blocks_create(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": "repair_and_rerun"})
write_passing_semantic_review(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("aesthetic_review_blocks_create", failed_codes)
def test_quality_gate_rejects_production_waivers(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", "waivers": [{"id": "w1"}]})
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_passing_semantic_review(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("production_waiver_not_allowed", failed_codes)
def test_quality_gate_rejects_production_live_waivers(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", "waivers": [{"id": "w1"}]})
write_json(project / "06-check/aesthetic-review.json", {"summary": {"error_count": 0}, "action": "create_live"})
write_passing_semantic_review(project)
result = svglide_quality_gate.run_quality_gate(project, profile="production_live")
self.assertEqual(result["status"], "failed")
failed_codes = {
issue["code"]
for check in result["checks"]
for issue in check["issues"]
}
self.assertIn("production_waiver_not_allowed", failed_codes)
def test_quality_gate_fails_when_semantic_review_is_stale(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)
(project / "04-svg/prepared/page-001.svg").write_text("<svg><rect /></svg>", encoding="utf-8")
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("semantic_review_prepared_stale", failed_codes)
def test_quality_gate_fails_when_semantic_review_plan_is_stale(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)
write_json(project / "02-plan/slide_plan.json", {"language": "zh-CN", "slides": [{"page": 1}]})
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("semantic_review_plan_stale", failed_codes)
def test_quality_gate_fails_when_semantic_review_evidence_is_stale(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)
write_json(project / "source/evidence.json", {"schema_version": "svglide-evidence/v1", "source_status": "ready", "items": [{"id": "item-001", "text": "新的证据内容足够长,应该让旧 semantic receipt 失效"}]})
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("semantic_review_evidence_stale", failed_codes)
def test_quality_gate_fails_when_semantic_review_text_inventory_is_missing(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)
(project / "06-check/text-inventory.json").unlink()
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("semantic_review_text_inventory_missing", failed_codes)
def test_quality_gate_fails_when_semantic_review_status_is_failed(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)
semantic = json.loads((project / "06-check/semantic-review.json").read_text(encoding="utf-8"))
semantic["status"] = "failed"
write_json(project / "06-check/semantic-review.json", semantic)
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("semantic_review_not_passed", failed_codes)
def test_quality_gate_fails_when_generator_receipt_is_missing(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)
(project / "receipts/generate_svg.json").unlink()
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("missing_generator_receipt", failed_codes)
def test_quality_gate_fails_when_generator_receipt_is_stale(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)
(project / "04-svg/page-001.svg").write_text("<svg><rect /></svg>", encoding="utf-8")
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_source_stale", failed_codes)
def test_quality_gate_requires_chart_verify_when_plan_requires_it(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "02-plan/slide_plan.json",
{"language": "zh-CN", "slides": [{"page": 1, "chart_contract": {"verify": "required", "data": [1, 2]}}]},
)
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)
(project / "06-check/chart-verify.json").unlink()
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("missing_check_file", failed_codes)
def test_quality_gate_fails_when_runtime_review_is_stale(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)
write_json(project / "02-plan/slide_plan.json", {"language": "zh-CN", "slides": [{"page": 1, "title": "新计划"}]})
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("runtime_review_plan_stale", failed_codes)
def test_quality_gate_blocks_strict_profile_when_research_is_blocked(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)
receipt = json.loads((project / "source/source-receipt.json").read_text(encoding="utf-8"))
receipt["research"] = {"status": "blocked_by_network"}
write_json(project / "source/source-receipt.json", receipt)
result = svglide_quality_gate.run_quality_gate(project, profile="production")
self.assertEqual(result["status"], "failed")
failed_codes = {
issue["code"]
for check in result["checks"]
for issue in check["issues"]
}
self.assertIn("research_missing_for_current_topic", failed_codes)
def test_quality_gate_blocks_strict_profile_when_fallback_skeleton_used(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)
receipt = json.loads((project / "receipts/generate_svg.json").read_text(encoding="utf-8"))
receipt["fallback_skeleton_used"] = True
write_json(project / "receipts/generate_svg.json", receipt)
result = svglide_quality_gate.run_quality_gate(project, profile="production")
self.assertEqual(result["status"], "failed")
failed_codes = {
issue["code"]
for check in result["checks"]
for issue in check["issues"]
}
self.assertIn("fallback_skeleton_used", failed_codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,381 @@
#!/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 subprocess
import sys
from pathlib import Path
from typing import Any, Callable
CREATE_DIR = Path("07-create")
READBACK_DIR = Path("08-readback")
LIVE_CREATE_NAME = "live-create.json"
RAW_READBACK_NAME = "xml-presentations-get.json"
READBACK_CHECK_NAME = "readback-check.json"
class ReadbackError(Exception):
pass
CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise ReadbackError(f"missing required file: {path}") from error
except json.JSONDecodeError as error:
raise ReadbackError(f"invalid JSON in {path}: {error}") from error
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) + "\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 find_first_key(value: Any, keys: set[str]) -> Any:
if isinstance(value, dict):
for key, child in value.items():
if key in keys:
return child
for child in value.values():
found = find_first_key(child, keys)
if found is not None:
return found
elif isinstance(value, list):
for child in value:
found = find_first_key(child, keys)
if found is not None:
return found
return None
def extract_presentation_id(live_create: Any) -> str | None:
raw = find_first_key(live_create, {"xml_presentation_id", "presentation_id"})
return raw if isinstance(raw, str) and raw.strip() else None
def extract_slide_ids(live_create: Any) -> list[str]:
raw = find_first_key(live_create, {"slide_ids", "created_slide_ids"})
if isinstance(raw, list):
return [item for item in raw if isinstance(item, str) and item.strip()]
return []
def build_input_binding(project: Path, live_create: Any) -> dict[str, Any]:
revision = find_first_key(live_create, {"revision_id", "revision"})
slide_ids = extract_slide_ids(live_create)
return {
"plan_sha256": optional_sha256(project / "02-plan" / "slide_plan.json"),
"quality_gate_sha256": optional_sha256(project / "06-check" / "quality-gate.json"),
"dry_run_sha256": optional_sha256(project / CREATE_DIR / "dry-run.json"),
"ppe_proof_sha256": optional_sha256(project / CREATE_DIR / "ppe-proof.json"),
"live_create_sha256": optional_sha256(project / CREATE_DIR / LIVE_CREATE_NAME),
"revision_id": revision if isinstance(revision, str) else None,
"expected_slide_count": expected_page_count(project),
"created_slide_count": len(slide_ids),
}
def expected_page_count(project: Path) -> int | None:
plan_path = project / "02-plan" / "slide_plan.json"
if not plan_path.exists():
return None
plan = read_json(plan_path)
if not isinstance(plan, dict):
return None
for key in ["slides", "svg_files", "pages"]:
value = plan.get(key)
if isinstance(value, list):
return len(value)
raw = plan.get("page_count") or plan.get("target_slide_count")
if isinstance(raw, int) and raw > 0:
return raw
return None
def find_slide_list(value: Any) -> list[Any] | None:
if isinstance(value, dict):
for key, child in value.items():
if key in {"slides", "slide_list", "slide_metas", "items"} and isinstance(child, list):
return child
for child in value.values():
found = find_slide_list(child)
if found is not None:
return found
elif isinstance(value, list):
for child in value:
found = find_slide_list(child)
if found is not None:
return found
return None
def actual_page_count(readback: Any) -> int | None:
raw = find_first_key(readback, {"page_count", "slide_count"})
if isinstance(raw, int) and raw >= 0:
return raw
slides = find_slide_list(readback)
return len(slides) if slides is not None else None
def expected_asset_tokens(project: Path) -> list[str]:
assets_path = project / "03-assets" / "assets.json"
if not assets_path.exists():
return []
data = read_json(assets_path)
if not isinstance(data, dict):
return []
return [value for value in data.values() if isinstance(value, str) and value.strip()]
def iter_strings(value: Any) -> list[str]:
strings: list[str] = []
if isinstance(value, str):
strings.append(value)
elif isinstance(value, dict):
for child in value.values():
strings.extend(iter_strings(child))
elif isinstance(value, list):
for child in value:
strings.extend(iter_strings(child))
return strings
def expected_business_claim_fragments(project: Path) -> list[str]:
plan_path = project / "02-plan" / "slide_plan.json"
if not plan_path.exists():
return []
plan = read_json(plan_path)
if not isinstance(plan, dict):
return []
fragments: list[str] = []
raw_claims = plan.get("business_claims")
if isinstance(raw_claims, list):
for item in raw_claims:
if isinstance(item, str):
fragments.append(item)
elif isinstance(item, dict):
for key in ["fragment", "claim", "text", "visible_text"]:
value = item.get(key)
if isinstance(value, str) and value.strip():
fragments.append(value)
break
for slide in plan.get("slides", []) if isinstance(plan.get("slides"), list) else []:
if isinstance(slide, dict):
for item in slide.get("business_claims", []) if isinstance(slide.get("business_claims"), list) else []:
if isinstance(item, str):
fragments.append(item)
elif isinstance(item, dict):
value = item.get("fragment") or item.get("claim") or item.get("text")
if isinstance(value, str):
fragments.append(value)
return [fragment.strip() for fragment in fragments if fragment.strip()]
def expected_chart_marker_count(project: Path) -> int:
count = 0
svg_dirs = [project / "04-svg" / "prepared", project / "04-svg"]
seen: set[Path] = set()
for svg_dir in svg_dirs:
if not svg_dir.exists():
continue
for path in sorted(svg_dir.glob("*.svg")):
if path in seen:
continue
seen.add(path)
text = path.read_text(encoding="utf-8", errors="ignore")
if 'slide:role="chart"' in text or "data-svglide-chart" in text:
count += 1
return count
def has_blank_marker(value: Any) -> bool:
if isinstance(value, dict):
for key, child in value.items():
if "blank" in key.lower() and child:
return True
if has_blank_marker(child):
return True
elif isinstance(value, list):
return any(has_blank_marker(item) for item in value)
elif isinstance(value, str):
return value.lower() in {"blank", "blank_page", "empty_slide"}
return False
def has_any_marker(readback_text: str, markers: list[str]) -> bool:
lower = readback_text.lower()
return any(marker.lower() in lower for marker in markers)
def check_status(status: str, **extra: Any) -> dict[str, Any]:
return {"status": status, **extra}
def build_checks(project: Path, live_create: Any, readback: Any, xml_presentation_id: str) -> dict[str, Any]:
expected_count = expected_page_count(project)
actual_count = actual_page_count(readback)
slide_ids = extract_slide_ids(live_create)
tokens = expected_asset_tokens(project)
readback_text = json.dumps(readback, ensure_ascii=False)
checks: dict[str, Any] = {}
if expected_count is None or actual_count is None:
checks["page_count"] = check_status("skipped", reason="plan or readback page count is unavailable")
elif expected_count == actual_count:
checks["page_count"] = check_status("passed", expected=expected_count, actual=actual_count)
else:
checks["page_count"] = check_status("failed", expected=expected_count, actual=actual_count)
if not slide_ids:
checks["slide_ids"] = check_status("failed", reason="live-create output does not include created slide ids")
elif expected_count is not None and len(slide_ids) != expected_count:
checks["slide_ids"] = check_status("failed", expected=expected_count, actual=len(slide_ids))
else:
checks["slide_ids"] = check_status("passed", actual=len(slide_ids))
checks["blank_page"] = check_status("failed" if has_blank_marker(readback) else "passed")
if not tokens:
checks["asset_tokens"] = check_status("skipped", reason="no expected assets")
else:
missing = [token for token in tokens if token not in readback_text]
checks["asset_tokens"] = check_status("failed" if missing else "passed", missing=missing)
overflow_markers = ["text_overflow", "text-overflow", "overflow_text", "text out of bounds", "text_fit_failed"]
checks["text_fit"] = check_status(
"failed" if has_any_marker(readback_text, overflow_markers) else "passed",
mode="readback_marker_scan",
)
bounds_markers = ["out_of_bounds", "out-of-bounds", "clip_error", "clipped_element", "bounds_failed"]
checks["bounds"] = check_status(
"failed" if has_any_marker(readback_text, bounds_markers) else "passed",
mode="readback_marker_scan",
)
expected_chart_markers = expected_chart_marker_count(project)
if expected_chart_markers == 0:
checks["chart_markers"] = check_status("skipped", reason="no source chart markers")
else:
chart_present = has_any_marker(readback_text, ["svglide-chart", "slide:role=\"chart\"", "chart-ref", "chart"])
checks["chart_markers"] = check_status("passed" if chart_present else "failed", expected=expected_chart_markers)
claims = expected_business_claim_fragments(project)
if not claims:
checks["business_claims"] = check_status("skipped", reason="no business claims")
else:
visible_text = "\n".join(iter_strings(readback))
missing_claims = [claim for claim in claims if claim not in visible_text]
checks["business_claims"] = check_status("failed" if missing_claims else "passed", missing=missing_claims)
failed = [name for name, item in checks.items() if item["status"] == "failed"]
return {
"version": "svglide-readback/v1",
"status": "failed" if failed else "passed",
"xml_presentation_id": xml_presentation_id,
"input_binding": build_input_binding(project, live_create),
"checks": checks,
"failed_checks": failed,
}
def run_readback(project: Path, *, command_runner: CommandRunner = subprocess.run) -> dict[str, Any]:
project = project.resolve()
live_create = read_json(project / CREATE_DIR / LIVE_CREATE_NAME)
xml_presentation_id = extract_presentation_id(live_create)
output_dir = project / READBACK_DIR
output_dir.mkdir(parents=True, exist_ok=True)
if not xml_presentation_id:
result = {
"version": "svglide-readback/v1",
"status": "failed",
"xml_presentation_id": None,
"input_binding": build_input_binding(project, live_create),
"checks": {"presentation_id": check_status("failed", reason="live-create output is missing xml_presentation_id")},
"failed_checks": ["presentation_id"],
}
write_json(output_dir / READBACK_CHECK_NAME, result)
return result
params = json.dumps({"xml_presentation_id": xml_presentation_id}, separators=(",", ":"))
command = ["lark-cli", "slides", "xml_presentations", "get", "--as", "user", "--params", params]
completed = command_runner(command, check=False, capture_output=True, text=True)
raw_record = {
"command": command,
"returncode": completed.returncode,
"stdout": completed.stdout,
"stderr": completed.stderr,
}
try:
readback_payload = json.loads(completed.stdout) if completed.stdout.strip() else {}
except json.JSONDecodeError as error:
readback_payload = {"error": f"invalid JSON from readback command: {error}", "raw_stdout": completed.stdout}
raw_record["json"] = readback_payload
write_json(output_dir / RAW_READBACK_NAME, raw_record)
if completed.returncode != 0:
result = {
"version": "svglide-readback/v1",
"status": "failed",
"xml_presentation_id": xml_presentation_id,
"input_binding": build_input_binding(project, live_create),
"checks": {"readback_command": check_status("failed", returncode=completed.returncode, stderr=completed.stderr)},
"failed_checks": ["readback_command"],
}
else:
result = build_checks(project, live_create, readback_payload, xml_presentation_id)
write_json(output_dir / READBACK_CHECK_NAME, result)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Read back a created SVGlide presentation and validate key delivery checks.")
parser.add_argument("project", help="SVGlide project directory containing 07-create/live-create.json")
parser.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
result = run_readback(Path(args.project))
except (OSError, ReadbackError) as error:
print(f"svglide_readback: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,137 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_readback
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideReadbackTest(unittest.TestCase):
def make_project(self) -> Path:
root = Path(tempfile.mkdtemp())
project = root / ".lark-slides" / "plan" / "demo"
(project / "07-create").mkdir(parents=True)
return project
def completed(self, payload: dict[str, object], returncode: int = 0) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(["lark-cli"], returncode, stdout=json.dumps(payload), stderr="")
def test_readback_passes_when_page_count_and_slide_ids_match(self) -> None:
project = self.make_project()
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1}, {"page": 2}]})
write_json(project / "06-check/quality-gate.json", {"status": "passed"})
write_json(project / "07-create/dry-run.json", {"status": "passed"})
write_json(project / "07-create/ppe-proof.json", {"status": "passed"})
write_json(project / "07-create/live-create.json", {"xml_presentation_id": "xml_1", "revision_id": "rev_1", "slide_ids": ["s1", "s2"]})
result = svglide_readback.run_readback(
project,
command_runner=lambda *args, **kwargs: self.completed({"data": {"slides": [{"id": "s1"}, {"id": "s2"}]}}),
)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["checks"]["page_count"]["status"], "passed")
self.assertEqual(result["checks"]["asset_tokens"]["status"], "skipped")
self.assertEqual(result["input_binding"]["revision_id"], "rev_1")
self.assertEqual(result["input_binding"]["expected_slide_count"], 2)
self.assertEqual(result["input_binding"]["created_slide_count"], 2)
self.assertIsNotNone(result["input_binding"]["plan_sha256"])
self.assertIsNotNone(result["input_binding"]["quality_gate_sha256"])
self.assertIsNotNone(result["input_binding"]["dry_run_sha256"])
self.assertIsNotNone(result["input_binding"]["ppe_proof_sha256"])
self.assertIsNotNone(result["input_binding"]["live_create_sha256"])
self.assertTrue((project / "08-readback/readback-check.json").exists())
def test_readback_fails_without_presentation_id(self) -> None:
project = self.make_project()
write_json(project / "07-create/live-create.json", {"slide_ids": ["s1"]})
result = svglide_readback.run_readback(project)
self.assertEqual(result["status"], "failed")
self.assertIn("presentation_id", result["failed_checks"])
def test_readback_fails_on_page_count_mismatch(self) -> None:
project = self.make_project()
write_json(project / "02-plan/slide_plan.json", {"svg_files": [{"page": 1}, {"page": 2}]})
write_json(project / "07-create/live-create.json", {"xml_presentation_id": "xml_1", "slide_ids": ["s1", "s2"]})
result = svglide_readback.run_readback(
project,
command_runner=lambda *args, **kwargs: self.completed({"data": {"slides": [{"id": "s1"}]}}),
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["checks"]["page_count"]["status"], "failed")
def test_readback_checks_expected_asset_tokens(self) -> None:
project = self.make_project()
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1}]})
write_json(project / "03-assets/assets.json", {"@./hero.png": "boxcn_hero"})
write_json(project / "07-create/live-create.json", {"xml_presentation_id": "xml_1", "slide_ids": ["s1"]})
result = svglide_readback.run_readback(
project,
command_runner=lambda *args, **kwargs: self.completed({"data": {"slides": [{"id": "s1", "image": "boxcn_hero"}]}}),
)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["checks"]["asset_tokens"]["status"], "passed")
def test_readback_fails_when_business_claim_is_missing(self) -> None:
project = self.make_project()
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1}], "business_claims": [{"fragment": "Revenue 130.5B"}]})
write_json(project / "07-create/live-create.json", {"xml_presentation_id": "xml_1", "slide_ids": ["s1"]})
result = svglide_readback.run_readback(
project,
command_runner=lambda *args, **kwargs: self.completed({"data": {"slides": [{"id": "s1", "text": "Revenue"}]}}),
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["checks"]["business_claims"]["status"], "failed")
def test_readback_fails_when_text_overflow_marker_is_present(self) -> None:
project = self.make_project()
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1}]})
write_json(project / "07-create/live-create.json", {"xml_presentation_id": "xml_1", "slide_ids": ["s1"]})
result = svglide_readback.run_readback(
project,
command_runner=lambda *args, **kwargs: self.completed({"data": {"slides": [{"id": "s1", "text_overflow": True}]}}),
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["checks"]["text_fit"]["status"], "failed")
def test_readback_fails_when_expected_chart_marker_is_missing(self) -> None:
project = self.make_project()
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1}]})
write_json(project / "07-create/live-create.json", {"xml_presentation_id": "xml_1", "slide_ids": ["s1"]})
(project / "04-svg/prepared").mkdir(parents=True, exist_ok=True)
(project / "04-svg/prepared/page-001.svg").write_text('<svg><g slide:role="chart"></g></svg>', encoding="utf-8")
result = svglide_readback.run_readback(
project,
command_runner=lambda *args, **kwargs: self.completed({"data": {"slides": [{"id": "s1"}]}}),
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["checks"]["chart_markers"]["status"], "failed")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,407 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
TOP_LEVEL_ALLOWED_INDEXES = {
"slides +create-svg",
"svglide-route-admission.md",
"svg-private-manifest.json",
}
TOP_LEVEL_PRIVATE_TOKENS = [
"style-presets",
"visual_recipe",
"visual_signature",
"svg_effects",
"svg_effect",
"safe-native-v1",
"required_primitives",
"svg_primitives",
"xml_like_risk",
]
ROUTE_ADMISSION_BODY_TOKENS = [
"style_preset",
"visual_recipe",
"visual_signature",
"svg_effects",
"required_primitives",
"svg_primitives",
"xml_like_risk",
"content_density_contract",
]
XML_DOC_PRIVATE_TOKENS = TOP_LEVEL_PRIVATE_TOKENS + [
"svg_preflight.py",
"svg-aesthetic-review.md",
"svglide-plan.schema.json",
"safe-native-v1.profile.json",
]
SVG_ONLY_PLAN_FIELDS = {
"style_preset",
"style_selection_reason",
"style_system",
"svg_constraints",
"svg_files",
"visual_recipe",
"visual_intent",
"visual_focal_point",
"visual_signature",
"svg_effects",
"required_primitives",
"svg_primitives",
"xml_like_risk",
"recipe_fallback",
"content_density_contract",
"risk_flags",
"source_status",
"source_policy",
"asset_contract",
"fallback_policy",
"requires_fallback",
}
SVG_REQUIRED_TOP_FIELDS = {
"style_preset",
"style_selection_reason",
"style_system",
"slides",
}
SVG_REQUIRED_SLIDE_FIELDS = {
"renderer_id",
"layout_family",
"visual_recipe",
"visual_intent",
"visual_focal_point",
"visual_signature",
"svg_effects",
"required_primitives",
"svg_primitives",
"xml_like_risk",
"content_density_contract",
"risk_flags",
"source_policy",
}
XML_SHARED_DOCS = [
"references/planning-layer.md",
"references/validation-checklist.md",
"references/visual-planning.md",
"references/asset-planning.md",
]
XML_CREATE_PRIVATE_SYMBOL_RE = re.compile(
r"\bSVGlide\b|"
r"\bsvgFallback[A-Za-z0-9_]*\b|"
r"\bclassifySVGlide[A-Za-z0-9_]*\b|"
r"\bsvgPlan[A-Za-z0-9_]*\b|"
r"safe-native-v1|"
r"internal/svglide|"
r"svg_preflight|"
r"style-presets|"
r"visual_recipe|"
r"visual_signature|"
r"svg_effects|"
r"required_primitives|"
r"svg_primitives|"
r"xml_like_risk"
)
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def issue(code: str, path: str, message: str, detail: str | None = None) -> dict[str, str]:
item = {"code": code, "path": path, "message": message}
if detail:
item["detail"] = detail
return item
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def token_occurrences(text: str, tokens: list[str]) -> list[str]:
lower = text.lower()
return sorted({token for token in tokens if token.lower() in lower})
def private_reference_names(manifest: dict[str, Any]) -> set[str]:
names: set[str] = set()
for raw in manifest.get("private_strategy_files", []):
if not isinstance(raw, str):
continue
if any(char in raw for char in "*?[]"):
continue
names.add(raw)
names.add(Path(raw).name)
return {name for name in names if name}
def lint_top_skill(repo_root: Path, root: Path) -> list[dict[str, str]]:
path = root / "SKILL.md"
issues: list[dict[str, str]] = []
if not path.exists():
return [issue("missing_top_skill", relpath(path, repo_root), "SKILL.md is missing")]
text = read_text(path)
for token in token_occurrences(text, TOP_LEVEL_PRIVATE_TOKENS):
issues.append(
issue(
"top_skill_private_token",
relpath(path, repo_root),
"Top-level skill contains SVG private strategy token",
token,
)
)
for token in TOP_LEVEL_ALLOWED_INDEXES:
if token not in text:
issues.append(
issue(
"top_skill_missing_route_index",
relpath(path, repo_root),
"Top-level skill must keep route admission indexes",
token,
)
)
return issues
def lint_route_admission(repo_root: Path, manifest: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
for raw in manifest.get("route_admission_files", []):
if not isinstance(raw, str):
issues.append(issue("route_admission_path_invalid", "<manifest>", "Route admission path must be a string", repr(raw)))
continue
path = repo_root / raw
if not path.exists():
issues.append(issue("route_admission_missing", raw, "Route admission file is missing"))
continue
text = read_text(path)
for token in token_occurrences(text, ROUTE_ADMISSION_BODY_TOKENS):
issues.append(
issue(
"route_admission_private_body_token",
raw,
"Route admission file must not contain SVG strategy field body",
token,
)
)
return issues
def lint_xml_docs(repo_root: Path, root: Path, manifest: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
private_names = private_reference_names(manifest)
for rel in XML_SHARED_DOCS:
path = root / rel
if not path.exists():
continue
text = read_text(path)
for token in token_occurrences(text, XML_DOC_PRIVATE_TOKENS):
issues.append(
issue(
"xml_doc_private_token",
relpath(path, repo_root),
"XML/shared doc contains SVG private strategy token",
token,
)
)
for name in sorted(private_names):
if name in text:
issues.append(
issue(
"xml_doc_private_reference",
relpath(path, repo_root),
"XML/shared doc references an SVG private strategy file",
name,
)
)
return issues
def is_svg_plan(plan: dict[str, Any]) -> bool:
return plan.get("route") == "svglide-svg" or plan.get("output_mode") == "svglide-svg"
def walk_dict_keys(value: Any, path: str = "$") -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}.{key}"
out.append((key, child_path))
out.extend(walk_dict_keys(child, child_path))
elif isinstance(value, list):
for index, child in enumerate(value):
out.extend(walk_dict_keys(child, f"{path}[{index}]"))
return out
def lint_plan(plan: dict[str, Any], path: str = "<plan>") -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
if not is_svg_plan(plan):
for key, key_path in walk_dict_keys(plan):
if key in SVG_ONLY_PLAN_FIELDS:
issues.append(
issue(
"xml_plan_svg_only_field",
path,
"XML route plan contains SVG-only field",
key_path,
)
)
return issues
for key in sorted(SVG_REQUIRED_TOP_FIELDS):
if key not in plan:
issues.append(issue("svg_plan_missing_required_top_field", path, "SVG route plan missing required top-level field", key))
if plan.get("route") != "svglide-svg" and plan.get("output_mode") != "svglide-svg":
issues.append(issue("svg_plan_missing_route", path, "SVG route plan must declare route or output_mode as svglide-svg"))
slides = plan.get("slides")
if not isinstance(slides, list) or not slides:
issues.append(issue("svg_plan_missing_slides", path, "SVG route plan must contain a non-empty slides array"))
return issues
for index, slide in enumerate(slides):
if not isinstance(slide, dict):
issues.append(issue("svg_plan_slide_invalid", path, "SVG route slide must be an object", f"slides[{index}]"))
continue
for key in sorted(SVG_REQUIRED_SLIDE_FIELDS):
if key not in slide:
issues.append(
issue(
"svg_plan_missing_required_slide_field",
path,
"SVG route slide missing required field",
f"slides[{index}].{key}",
)
)
return issues
def lint_plan_file(path: Path) -> list[dict[str, str]]:
try:
data = load_json(path)
except (OSError, json.JSONDecodeError) as error:
return [issue("plan_json_invalid", path.as_posix(), "Plan JSON cannot be loaded", str(error))]
if not isinstance(data, dict):
return [issue("plan_root_invalid", path.as_posix(), "Plan root must be an object")]
return lint_plan(data, path.as_posix())
def lint_code_imports(repo_root: Path) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
slides_dir = repo_root / "shortcuts" / "slides"
if slides_dir.exists():
for path in sorted(slides_dir.glob("*create*.go")):
name = path.name
if name.endswith("_test.go") or name == "slides_create_svg.go":
continue
text = read_text(path)
match = XML_CREATE_PRIVATE_SYMBOL_RE.search(text)
if match:
issues.append(
issue(
"xml_create_private_symbol",
relpath(path, repo_root),
"XML create path must not reference SVG private symbols",
match.group(0),
)
)
for rel in ["apps/server/tasks", "apps/server/controller"]:
directory = repo_root / rel
if not directory.exists():
continue
for path in sorted(directory.glob("**/*.ts")):
text = read_text(path)
if "modules/svg-parser-module/src/" in text or "safe-native-v1" in text:
issues.append(
issue(
"server_private_svg_import",
relpath(path, repo_root),
"Shared server route must not import/read SVG private strategy internals",
)
)
return issues
def lint_manifest_shape(repo_root: Path, manifest_path: Path, manifest: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
if manifest.get("route") != "svglide-svg":
issues.append(issue("manifest_route_invalid", relpath(manifest_path, repo_root), "Manifest route must be svglide-svg"))
for key in ["route_admission_files", "private_strategy_files", "allowed_route_entrypoints"]:
if not isinstance(manifest.get(key), list) or not manifest.get(key):
issues.append(issue("manifest_list_missing", relpath(manifest_path, repo_root), "Manifest must contain a non-empty list", key))
return issues
def lint_repository(repo_root: Path, root_rel: str, manifest_rel: str, plan_paths: list[str] | None = None) -> list[dict[str, str]]:
repo_root = repo_root.resolve()
root = (repo_root / root_rel).resolve()
manifest_path = (repo_root / manifest_rel).resolve()
try:
manifest = load_json(manifest_path)
except (OSError, json.JSONDecodeError) as error:
return [issue("manifest_json_invalid", relpath(manifest_path, repo_root), "Manifest JSON cannot be loaded", str(error))]
if not isinstance(manifest, dict):
return [issue("manifest_root_invalid", relpath(manifest_path, repo_root), "Manifest root must be an object")]
issues: list[dict[str, str]] = []
issues.extend(lint_manifest_shape(repo_root, manifest_path, manifest))
issues.extend(lint_top_skill(repo_root, root))
issues.extend(lint_route_admission(repo_root, manifest))
issues.extend(lint_xml_docs(repo_root, root, manifest))
issues.extend(lint_code_imports(repo_root))
for raw in plan_paths or []:
issues.extend(lint_plan_file((repo_root / raw).resolve()))
return issues
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Lint SVGlide route isolation boundaries")
parser.add_argument("--manifest", default="skills/lark-slides/references/svg-private-manifest.json")
parser.add_argument("--root", default="skills/lark-slides")
parser.add_argument("--repo-root", default=".")
parser.add_argument("--plan", action="append", default=[], help="Optional slide_plan.json to validate")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
options = parse_args(argv)
repo_root = Path(options.repo_root)
issues = lint_repository(repo_root, options.root, options.manifest, options.plan)
result = {
"summary": {
"error_count": len(issues),
},
"issues": issues,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 1 if issues else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
import svglide_route_contamination_lint as lint
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def valid_svg_slide() -> dict[str, object]:
return {
"page": 1,
"title": "Title",
"key_message": "Message",
"renderer_id": "hero_path_cover",
"layout_family": "hero",
"visual_recipe": "hero_typography",
"visual_intent": "Use SVG geometry for a distinctive cover.",
"visual_focal_point": "Large title",
"visual_signature": "Layered title and path frame",
"svg_effects": ["path", "typography"],
"required_primitives": ["typography", "geometric_shape"],
"svg_primitives": ["typography", "geometric_shape", "path"],
"xml_like_risk": "Would become a plain title slide in XML.",
"content_density_contract": "hero >= 1 focal title",
"risk_flags": [],
"source_policy": "No invented metrics.",
}
def valid_svg_plan() -> dict[str, object]:
return {
"route": "svglide-svg",
"style_preset": "raw_grid",
"style_selection_reason": "Matches the topic.",
"style_system": {
"palette": {"background": "#fff", "text": "#111", "accent": "#f00"},
"typography": "bold titles",
"background_strategy": "stable background",
"motif": "path frame",
},
"slides": [valid_svg_slide()],
}
class SVGlideRouteContaminationLintTest(unittest.TestCase):
def test_lint_plan_rejects_svg_fields_on_xml_route(self) -> None:
issues = lint.lint_plan(
{
"presentation_goal": "Create XML deck",
"slides": [
{
"page": 1,
"title": "Title",
"visual_recipe": "path_flow",
}
],
},
"xml_plan.json",
)
self.assertIn("xml_plan_svg_only_field", {item["code"] for item in issues})
def test_lint_plan_accepts_valid_svg_route(self) -> None:
self.assertEqual(lint.lint_plan(valid_svg_plan(), "svg_plan.json"), [])
def test_lint_plan_reports_missing_svg_fields(self) -> None:
plan = valid_svg_plan()
plan["slides"] = [{"page": 1, "title": "Title"}]
issues = lint.lint_plan(plan, "svg_plan.json")
self.assertIn("svg_plan_missing_required_slide_field", {item["code"] for item in issues})
def test_repository_lint_rejects_top_skill_private_token(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo = Path(tmpdir)
self._write_minimal_repo(repo)
write(repo / "skills/lark-slides/SKILL.md", "slides +create-svg\nsvglide-route-admission.md\nsvg-private-manifest.json\nvisual_recipe\n")
issues = lint.lint_repository(repo, "skills/lark-slides", "skills/lark-slides/references/svg-private-manifest.json")
self.assertIn("top_skill_private_token", {item["code"] for item in issues})
def test_repository_lint_rejects_xml_doc_private_reference(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo = Path(tmpdir)
self._write_minimal_repo(repo)
write(repo / "skills/lark-slides/references/planning-layer.md", "XML docs must not read style-presets.md\n")
issues = lint.lint_repository(repo, "skills/lark-slides", "skills/lark-slides/references/svg-private-manifest.json")
self.assertIn("xml_doc_private_reference", {item["code"] for item in issues})
def test_repository_lint_rejects_route_admission_strategy_body(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo = Path(tmpdir)
self._write_minimal_repo(repo)
write(repo / "skills/lark-slides/references/svglide-route-admission.md", "After activation set visual_recipe in each page.\n")
issues = lint.lint_repository(repo, "skills/lark-slides", "skills/lark-slides/references/svg-private-manifest.json")
self.assertIn("route_admission_private_body_token", {item["code"] for item in issues})
def test_repository_lint_rejects_xml_create_private_symbol(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo = Path(tmpdir)
self._write_minimal_repo(repo)
write(repo / "shortcuts/slides/slides_create.go", "package slides\nfunc f(){ _ = \"SVGlide\" }\n")
issues = lint.lint_repository(repo, "skills/lark-slides", "skills/lark-slides/references/svg-private-manifest.json")
self.assertIn("xml_create_private_symbol", {item["code"] for item in issues})
def test_current_repository_passes_lint(self) -> None:
repo = Path(__file__).resolve().parents[3]
issues = lint.lint_repository(repo, "skills/lark-slides", "skills/lark-slides/references/svg-private-manifest.json")
self.assertEqual([], issues)
def _write_minimal_repo(self, repo: Path) -> None:
manifest = {
"route": "svglide-svg",
"route_admission_files": ["skills/lark-slides/references/svglide-route-admission.md"],
"private_strategy_files": [
"skills/lark-slides/references/style-presets.md",
"skills/lark-slides/references/svglide-planning-layer.md",
],
"allowed_route_entrypoints": ["skills/lark-slides/references/lark-slides-create-svg.md"],
}
write(repo / "skills/lark-slides/references/svg-private-manifest.json", json.dumps(manifest))
write(repo / "skills/lark-slides/SKILL.md", "slides +create-svg\nsvglide-route-admission.md\nsvg-private-manifest.json\n")
write(repo / "skills/lark-slides/references/svglide-route-admission.md", "Activate by user request, then load private file index.\n")
for name in ["planning-layer.md", "validation-checklist.md", "visual-planning.md", "asset-planning.md"]:
write(repo / f"skills/lark-slides/references/{name}", "XML route shared doc.\n")
write(repo / "shortcuts/slides/slides_create.go", "package slides\nfunc f(){}\n")
write(repo / "shortcuts/slides/slides_create_svg.go", "package slides\nfunc f(){ _ = \"SVGlide\" }\n")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,251 @@
#!/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
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
PLAN_PATH = Path("02-plan/slide_plan.json")
PROJECT_REGISTRY_PATH = Path("02-plan/renderer-registry.json")
DEFAULT_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "references" / "svglide-renderer-registry.json"
ASSET_MANIFEST_PATH = Path("03-assets/asset-manifest.json")
OUTPUT_PATH = Path("06-check/runtime-review.json")
PASS_ACTION = "create_live"
FAIL_ACTION = "repair_and_rerun"
class RuntimeReviewError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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 read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeReviewError(f"invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise RuntimeReviewError(f"invalid JSON in {path}: expected object")
return payload
def read_json_object_optional(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return read_json_object(path)
def issue(code: str, message: str, *, page: int | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"code": code, "message": message}
if page is not None:
payload["page"] = page
return payload
def assets_by_page(project: Path) -> dict[int, list[dict[str, Any]]]:
manifest = read_json_object_optional(project / ASSET_MANIFEST_PATH)
raw_items = manifest.get("acquired_assets")
if not isinstance(raw_items, list):
return {}
by_page: dict[int, list[dict[str, Any]]] = {}
for item in raw_items:
if not isinstance(item, dict):
continue
raw_page = item.get("page") or item.get("usage_page")
if isinstance(raw_page, int):
by_page.setdefault(raw_page, []).append(item)
return by_page
def registry_path_for(project: Path) -> Path:
project_registry = project / PROJECT_REGISTRY_PATH
return project_registry if project_registry.exists() else DEFAULT_REGISTRY_PATH
def load_registry(project: Path) -> tuple[Path, dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
path = registry_path_for(project)
registry = read_json_object(path)
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-renderer-registry.schema.json"))
issues = [issue(item["code"], f"{item['path']}: {item['message']}") for item in svglide_schema.validate_json_schema(registry, schema)]
by_id: dict[str, dict[str, Any]] = {}
renderers = registry.get("renderers")
if isinstance(renderers, list):
for item in renderers:
if not isinstance(item, dict):
continue
renderer_id = item.get("id")
if isinstance(renderer_id, str):
if renderer_id in by_id:
issues.append(issue("renderer_registry_duplicate_id", f"duplicate renderer id: {renderer_id}"))
by_id[renderer_id] = item
return path, registry, by_id, issues
def style_preset_allowed(style_preset: Any, renderer: dict[str, Any]) -> bool:
allowed = renderer.get("allowed_style_presets")
if not isinstance(allowed, list) or "*" in allowed:
return True
if not isinstance(style_preset, str) or not style_preset:
return True
return style_preset in allowed
def run_runtime_review(project: Path) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
plan_file = project / PLAN_PATH
if not plan_file.exists():
raise RuntimeReviewError(f"missing required plan file: {PLAN_PATH.as_posix()}")
plan = read_json_object(plan_file)
registry_path, _registry, registry, issues = load_registry(project)
page_assets = assets_by_page(project)
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
renderers: list[str] = []
families: list[str] = []
pages: list[dict[str, Any]] = []
style_preset = plan.get("style_preset")
for index, raw_slide in enumerate(slides, 1):
if not isinstance(raw_slide, dict):
continue
page = raw_slide.get("page") if isinstance(raw_slide.get("page"), int) else index
renderer = raw_slide.get("renderer_id")
family = raw_slide.get("layout_family")
current_assets = page_assets.get(page, [])
renderer_record = registry.get(renderer) if isinstance(renderer, str) else None
page_status = "passed"
if not isinstance(renderer, str) or not renderer.strip():
issues.append(issue("renderer_id_missing", "each slide must declare renderer_id", page=page))
page_status = "failed"
else:
renderers.append(renderer)
if renderer_record is None:
issues.append(issue("renderer_unknown", f"renderer_id is not present in registry: {renderer}", page=page))
page_status = "failed"
else:
status = renderer_record.get("status")
if status != "active":
issues.append(issue("renderer_not_active", f"renderer_id {renderer} status is {status}", page=page))
page_status = "failed"
if not style_preset_allowed(style_preset, renderer_record):
issues.append(issue("renderer_style_preset_not_allowed", f"renderer_id {renderer} does not allow style_preset {style_preset}", page=page))
page_status = "failed"
if not isinstance(family, str) or not family.strip():
issues.append(issue("layout_family_missing", "each slide must declare layout_family", page=page))
page_status = "failed"
else:
families.append(family)
if isinstance(renderer_record, dict) and isinstance(renderer_record.get("family"), str) and family != renderer_record.get("family"):
issues.append(issue("renderer_family_mismatch", f"layout_family {family} does not match registry family {renderer_record.get('family')}", page=page))
page_status = "failed"
for asset in current_assets:
role = asset.get("placement_role")
status = asset.get("status")
if role == "cover" and status in {"acquired", "planned"} and isinstance(renderer, str) and "cover" not in renderer:
issues.append(issue("asset_renderer_mismatch", "cover asset should use a cover renderer", page=page))
page_status = "failed"
if role == "closing" and status in {"acquired", "planned"} and isinstance(family, str) and family != "closing":
issues.append(issue("asset_renderer_mismatch", "closing asset should use a closing layout family", page=page))
page_status = "failed"
pages.append(
{
"page": page,
"renderer_id": renderer,
"registry_status": renderer_record.get("status") if isinstance(renderer_record, dict) else "unknown",
"layout_family": family,
"registry_family": renderer_record.get("family") if isinstance(renderer_record, dict) else None,
"asset_count": len(current_assets),
"asset_roles": [item.get("placement_role") for item in current_assets if isinstance(item, dict)],
"status": page_status,
}
)
renderer_count = len(set(renderers))
family_count = len(set(families))
if len(slides) >= 4 and renderer_count <= 1:
issues.append(issue("renderer_monoculture", "decks with at least 4 slides need more than one renderer_id"))
if len(slides) >= 4 and family_count <= 1:
issues.append(issue("layout_family_monoculture", "decks with at least 4 slides need more than one layout_family"))
status = "failed" if issues else "passed"
result: dict[str, Any] = {
"schema_version": "svglide-runtime-review/v1",
"status": status,
"action": PASS_ACTION if status == "passed" else FAIL_ACTION,
"project": str(project),
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"slide_plan": PLAN_PATH.as_posix(),
"plan_sha256": file_sha256(plan_file),
"style_preset": style_preset,
},
"registry": {
"path": str(registry_path if registry_path.is_absolute() else registry_path.as_posix()),
"sha256": file_sha256(registry_path),
},
"pages": pages,
"renderers": sorted(set(renderers)),
"layout_families": sorted(set(families)),
"summary": {
"error_count": len(issues),
"warning_count": 0,
"slide_count": len(slides),
"renderer_count": renderer_count,
"layout_family_count": family_count,
"asset_page_count": len(page_assets),
},
"issues": issues,
}
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-runtime-review.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["status"] = "failed"
result["action"] = FAIL_ACTION
result["issues"].extend(issue(item["code"], item["message"]) for item in schema_issues)
result["summary"]["error_count"] = len(result["issues"])
output = project / OUTPUT_PATH
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Review SVGlide runtime renderer and visual diversity contracts.")
parser.add_argument("project")
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_runtime_review(Path(args.project))
except (OSError, RuntimeReviewError) as error:
print(f"svglide_runtime_review: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,123 @@
# 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_runtime_review
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideRuntimeReviewTest(unittest.TestCase):
def test_runtime_review_blocks_renderer_monoculture(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
slides = [
{"page": index, "renderer_id": "same", "layout_family": "same"}
for index in range(1, 5)
]
write_json(project / "02-plan/slide_plan.json", {"slides": slides})
result = svglide_runtime_review.run_runtime_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("renderer_monoculture", codes)
self.assertIn("layout_family_monoculture", codes)
def test_runtime_review_passes_diverse_renderers(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
slides = [
{"page": 1, "renderer_id": "cover", "layout_family": "cover"},
{"page": 2, "renderer_id": "chart", "layout_family": "chart"},
{"page": 3, "renderer_id": "timeline", "layout_family": "timeline"},
{"page": 4, "renderer_id": "closing", "layout_family": "closing"},
]
write_json(project / "02-plan/slide_plan.json", {"slides": slides})
result = svglide_runtime_review.run_runtime_review(project)
self.assertEqual(result["status"], "passed")
self.assertIn("registry", result)
self.assertEqual(result["pages"][0]["registry_status"], "active")
def test_runtime_review_blocks_unknown_renderer(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1, "renderer_id": "unknown", "layout_family": "cover"}]})
result = svglide_runtime_review.run_runtime_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("renderer_unknown", codes)
def test_runtime_review_blocks_inactive_renderer(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "02-plan/renderer-registry.json",
{
"schema_version": "svglide-renderer-registry/v1",
"renderers": [
{"id": "candidate_renderer", "status": "candidate", "family": "cover"},
{"id": "blocked_renderer", "status": "blocked", "family": "content"},
],
},
)
write_json(
project / "02-plan/slide_plan.json",
{"slides": [{"page": 1, "renderer_id": "candidate_renderer", "layout_family": "cover"}]},
)
result = svglide_runtime_review.run_runtime_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("renderer_not_active", codes)
def test_runtime_review_blocks_family_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1, "renderer_id": "cover", "layout_family": "chart"}]})
result = svglide_runtime_review.run_runtime_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("renderer_family_mismatch", codes)
def test_runtime_review_blocks_cover_asset_with_non_cover_renderer(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(project / "02-plan/slide_plan.json", {"slides": [{"page": 1, "renderer_id": "chart", "layout_family": "chart"}]})
write_json(
project / "03-assets/asset-manifest.json",
{
"status": "passed",
"acquired_assets": [
{"asset_id": "hero", "page": 1, "placement_role": "cover", "status": "acquired"}
],
},
)
result = svglide_runtime_review.run_runtime_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("asset_renderer_mismatch", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
REFERENCE_DIR = SCRIPT_DIR.parent / "references"
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def schema_path(name: str) -> Path:
return REFERENCE_DIR / name
def validate_json_schema(payload: Any, schema: dict[str, Any], *, path: str = "$") -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
expected_type = schema.get("type")
if expected_type is not None and not _matches_type(payload, expected_type):
issues.append(
{
"code": "schema_type_mismatch",
"path": path,
"message": f"expected {expected_type}, got {_json_type(payload)}",
}
)
return issues
if "const" in schema and payload != schema["const"]:
issues.append({"code": "schema_const_mismatch", "path": path, "message": f"expected constant {schema['const']!r}"})
if "enum" in schema and payload not in schema["enum"]:
issues.append({"code": "schema_enum_mismatch", "path": path, "message": f"value {payload!r} is not allowed"})
any_of = schema.get("anyOf")
if isinstance(any_of, list) and any_of:
if not any(isinstance(option, dict) and not validate_json_schema(payload, option, path=path) for option in any_of):
issues.append({"code": "schema_any_of_mismatch", "path": path, "message": "value does not match any allowed schema"})
if isinstance(payload, dict):
for required in schema.get("required", []):
if required not in payload:
issues.append({"code": "schema_required_missing", "path": f"{path}.{required}", "message": "required property is missing"})
properties = schema.get("properties")
if isinstance(properties, dict):
for key, value in payload.items():
child_schema = properties.get(key)
if isinstance(child_schema, dict):
issues.extend(validate_json_schema(value, child_schema, path=f"{path}.{key}"))
additional = schema.get("additionalProperties", True)
if additional is False and isinstance(properties, dict):
for key in payload:
if key not in properties:
issues.append({"code": "schema_additional_property", "path": f"{path}.{key}", "message": "additional property is not allowed"})
if isinstance(payload, list):
min_items = schema.get("minItems")
if isinstance(min_items, int) and len(payload) < min_items:
issues.append({"code": "schema_min_items", "path": path, "message": f"expected at least {min_items} items"})
max_items = schema.get("maxItems")
if isinstance(max_items, int) and len(payload) > max_items:
issues.append({"code": "schema_max_items", "path": path, "message": f"expected at most {max_items} items"})
item_schema = schema.get("items")
if isinstance(item_schema, dict):
for index, item in enumerate(payload):
issues.extend(validate_json_schema(item, item_schema, path=f"{path}[{index}]"))
if isinstance(payload, str):
min_length = schema.get("minLength")
if isinstance(min_length, int) and len(payload) < min_length:
issues.append({"code": "schema_min_length", "path": path, "message": f"expected at least {min_length} characters"})
if isinstance(payload, (int, float)) and not isinstance(payload, bool):
minimum = schema.get("minimum")
if isinstance(minimum, (int, float)) and payload < minimum:
issues.append({"code": "schema_minimum", "path": path, "message": f"expected value >= {minimum}"})
return issues
def _matches_type(value: Any, expected: Any) -> bool:
if isinstance(expected, list):
return any(_matches_type(value, item) for item in expected)
if expected == "object":
return isinstance(value, dict)
if expected == "array":
return isinstance(value, list)
if expected == "string":
return isinstance(value, str)
if expected == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "boolean":
return isinstance(value, bool)
if expected == "null":
return value is None
return True
def _json_type(value: Any) -> str:
if isinstance(value, dict):
return "object"
if isinstance(value, list):
return "array"
if isinstance(value, str):
return "string"
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, float):
return "number"
if value is None:
return "null"
return type(value).__name__

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
PLAN_PATH = Path("02-plan/slide_plan.json")
OUTPUT_PATH = Path("06-check/semantic-advisory.json")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def warning(code: str, message: str, *, page: int | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"code": code, "message": message}
if page is not None:
payload["page"] = page
return payload
def read_json_object(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"invalid JSON in {path}: expected object")
return payload
def list_of_strings(value: Any) -> list[str]:
return [item for item in value if isinstance(item, str) and item.strip()] if isinstance(value, list) else []
def run_advisory(project: Path) -> dict[str, Any]:
project = project.resolve()
plan = read_json_object(project / PLAN_PATH)
warnings: list[dict[str, Any]] = []
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
for index, slide in enumerate(slides, 1):
if not isinstance(slide, dict):
continue
page = slide.get("page") if isinstance(slide.get("page"), int) else index
title = slide.get("title")
key_message = slide.get("key_message")
body_points = list_of_strings(slide.get("body_points") or slide.get("bullets"))
if isinstance(key_message, str) and len(key_message.strip()) < 8:
warnings.append(warning("key_message_may_be_weak", "key_message is very short; review insight strength", page=page))
if title == key_message:
warnings.append(warning("title_repeats_key_message", "title and key_message are identical; review narrative hierarchy", page=page))
if slide.get("page_type") == "content" and body_points and all(len(point.strip()) < 10 for point in body_points):
warnings.append(warning("body_points_may_lack_detail", "content body_points are short; review evidence thickness", page=page))
result: dict[str, Any] = {
"schema_version": "svglide-semantic-advisory/v1",
"status": "passed",
"generated_at": now_iso(),
"summary": {"warning_count": len(warnings), "slide_count": len(slides)},
"warnings": warnings,
}
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-semantic-advisory.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["warnings"].extend(warning(item["code"], f"{item['path']}: {item['message']}") for item in schema_issues)
result["summary"]["warning_count"] = len(result["warnings"])
output = project / OUTPUT_PATH
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate non-blocking semantic advisory warnings for SVGlide.")
parser.add_argument("project")
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_advisory(Path(args.project))
except (OSError, ValueError) as error:
print(f"svglide_semantic_advisory: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,47 @@
# 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_semantic_advisory
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
class SVGlideSemanticAdvisoryTest(unittest.TestCase):
def test_advisory_warns_but_passes(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
write_json(
project / "02-plan/slide_plan.json",
{
"slides": [
{
"page": 1,
"page_type": "content",
"title": "增长",
"key_message": "增长",
"body_points": ["", ""],
}
]
},
)
result = svglide_semantic_advisory.run_advisory(project)
self.assertEqual(result["status"], "passed")
self.assertGreater(result["summary"]["warning_count"], 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,565 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import hashlib
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
PLAN_PATH = Path("02-plan/slide_plan.json")
EVIDENCE_PATH = Path("source/evidence.json")
SOURCE_RECEIPT_PATH = Path("source/source-receipt.json")
PREPARED_SVG_DIR = Path("04-svg/prepared")
CHECK_DIR = Path("06-check")
SEMANTIC_REVIEW = CHECK_DIR / "semantic-review.json"
TEXT_INVENTORY = CHECK_DIR / "text-inventory.json"
ALLOWED_PAGE_TYPES = {"cover", "section", "content", "closing"}
PASS_ACTION = "create_live"
FAIL_ACTION = "repair_and_rerun"
TEXT_LIKE_TAG_RE = re.compile(
r"<(?:[A-Za-z_][\w.-]*:)?(?:text|tspan|foreignObject)\b[^>]*>(.*?)</(?:[A-Za-z_][\w.-]*:)?(?:text|tspan|foreignObject)>",
re.IGNORECASE | re.DOTALL,
)
TAG_RE = re.compile(r"<[^>]+>")
CJK_RE = re.compile(r"[\u3400-\u9fff]")
LATIN_WORD_RE = re.compile(r"[A-Za-z]{3,}")
GENERATED_SAFE_RE = re.compile(r"^[\d\s.,:%+\-/()#]+$")
NUMERIC_CLAIM_RE = re.compile(r"(?<![\w.])\d+(?:[.,]\d+)*(?:\s?[%万亿千百]|[KMBTkmbt])?")
MIN_EVIDENCE_TEXT_CHARS = 20
MIN_CHART_EVIDENCE_CHARS = 80
class SemanticReviewError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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 relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise SemanticReviewError(f"invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise SemanticReviewError(f"invalid JSON in {path}: expected object")
return payload
def read_json_object_optional(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return read_json_object(path)
def issue(code: str, message: str, *, page: int | None = None, path: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"code": code, "message": message}
if page is not None:
payload["page"] = page
if path is not None:
payload["path"] = path
return payload
def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", html.unescape(value)).strip()
def has_cjk(value: str) -> bool:
return bool(CJK_RE.search(value))
def mostly_generated_safe(value: str) -> bool:
text = normalize_text(value)
return len(text) <= 2 or bool(GENERATED_SAFE_RE.fullmatch(text))
def collect_strings(value: Any) -> list[str]:
strings: list[str] = []
if isinstance(value, str):
text = normalize_text(value)
if text:
strings.append(text)
elif isinstance(value, list):
for item in value:
strings.extend(collect_strings(item))
elif isinstance(value, dict):
for item in value.values():
strings.extend(collect_strings(item))
return strings
def text_matches_allowed(text: str, allowed: list[str]) -> bool:
normalized = normalize_text(text)
if not normalized:
return True
for candidate in allowed:
normalized_candidate = normalize_text(candidate)
if not normalized_candidate:
continue
if normalized == normalized_candidate:
return True
if normalized in normalized_candidate or normalized_candidate in normalized:
return True
return False
def load_evidence(project: Path) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
path = project / EVIDENCE_PATH
if not path.exists():
return None, [issue("missing_evidence_json", f"missing required evidence file: {EVIDENCE_PATH.as_posix()}")]
evidence = read_json_object(path)
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-evidence.schema.json"))
schema_issues = [
issue(item["code"], item["message"], path=item["path"])
for item in svglide_schema.validate_json_schema(evidence, schema)
]
if evidence.get("source_status") != "ready":
schema_issues.append(issue("source_status_not_ready", "evidence source_status must be ready"))
items = evidence.get("items")
if isinstance(items, list):
for index, item in enumerate(items, 1):
if not isinstance(item, dict):
continue
text = item.get("text")
if not isinstance(text, str) or len(normalize_text(text)) < MIN_EVIDENCE_TEXT_CHARS:
schema_issues.append(issue("source_item_text_too_short", f"evidence item {index} text is too short"))
return evidence, schema_issues
def evidence_ids(evidence: dict[str, Any] | None) -> set[str]:
ids: set[str] = set()
if not isinstance(evidence, dict):
return ids
items = evidence.get("items")
if not isinstance(items, list):
return ids
for item in items:
if not isinstance(item, dict):
continue
raw = item.get("id")
if isinstance(raw, str) and raw:
ids.add(raw)
ids.add(f"source:{raw}")
return ids
def evidence_items_by_ref(evidence: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
items_by_ref: dict[str, dict[str, Any]] = {}
if not isinstance(evidence, dict):
return items_by_ref
items = evidence.get("items")
if not isinstance(items, list):
return items_by_ref
for item in items:
if not isinstance(item, dict):
continue
raw = item.get("id")
if isinstance(raw, str) and raw:
items_by_ref[raw] = item
items_by_ref[f"source:{raw}"] = item
return items_by_ref
def evidence_strings_for_refs(evidence: dict[str, Any] | None, refs: list[str]) -> list[tuple[str, str]]:
by_ref = evidence_items_by_ref(evidence)
strings: list[tuple[str, str]] = []
for ref in refs:
item = by_ref.get(ref)
if not item:
continue
for key, value in item.items():
if isinstance(value, str):
text = normalize_text(value)
if text:
strings.append((text, f"source/evidence.json:{ref}.{key}"))
return strings
def evidence_text_length_for_refs(evidence: dict[str, Any] | None, refs: list[str]) -> int:
by_ref = evidence_items_by_ref(evidence)
total = 0
seen: set[str] = set()
for ref in refs:
item = by_ref.get(ref)
if not item:
continue
raw_id = item.get("id")
dedupe_key = raw_id if isinstance(raw_id, str) else ref
if dedupe_key in seen:
continue
seen.add(dedupe_key)
text = item.get("text")
if isinstance(text, str):
total += len(normalize_text(text))
return total
def prepared_svg_files(project: Path) -> list[Path]:
root = project / PREPARED_SVG_DIR
if not root.exists():
return []
return sorted(path for path in root.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 extract_visible_texts(svg_path: Path) -> list[str]:
raw = svg_path.read_text(encoding="utf-8")
texts: list[str] = []
try:
root = ET.fromstring(raw)
for element in root.iter():
local_name = element.tag.rsplit("}", 1)[-1]
if local_name in {"text", "foreignObject"}:
text = normalize_text("".join(element.itertext()))
if text:
texts.append(text)
except ET.ParseError:
for match in TEXT_LIKE_TAG_RE.finditer(raw):
text = normalize_text(TAG_RE.sub("", match.group(1)))
if text:
texts.append(text)
return texts
def source_refs_for_slide(slide: dict[str, Any]) -> list[str]:
raw = slide.get("source_refs") or slide.get("sources") or []
if not isinstance(raw, list):
return []
return [item for item in raw if isinstance(item, str) and item]
def list_of_strings(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, str) and item.strip()]
def has_numeric_claim(values: list[str]) -> bool:
return any(NUMERIC_CLAIM_RE.search(value) for value in values)
def slide_has_chart_signal(slide: dict[str, Any]) -> bool:
for key in ["layout_family", "visual_recipe", "renderer_id", "role"]:
value = slide.get(key)
if isinstance(value, str) and any(token in value.lower() for token in ["chart", "graph", "plot", "bar", "line", "donut"]):
return True
for key in ["chart_contract", "chart_data", "charts"]:
if slide.get(key):
return True
primitives = slide.get("svg_primitives") or slide.get("primitives")
return isinstance(primitives, list) and any(
isinstance(item, str) and any(token in item.lower() for token in ["chart", "graph", "plot", "bar", "line", "donut"])
for item in primitives
)
def check_plan_structure(plan: dict[str, Any], evidence: dict[str, Any] | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
issues: list[dict[str, Any]] = []
slide_results: list[dict[str, Any]] = []
if plan.get("language") != "zh-CN":
issues.append(issue("language_not_zh_cn", "slide_plan.language must be zh-CN"))
audience = plan.get("audience")
if not isinstance(audience, str) or not audience.strip():
issues.append(issue("audience_missing", "slide_plan.audience must be a non-empty string"))
deck_structure = plan.get("deck_structure")
if not isinstance(deck_structure, list) or not all(isinstance(item, str) for item in deck_structure):
issues.append(issue("deck_structure_missing", "slide_plan.deck_structure must be a string array"))
deck_structure = []
else:
missing_types = {"cover", "content", "closing"} - set(deck_structure)
for page_type in sorted(missing_types):
issues.append(issue("deck_structure_missing_page_type", f"deck_structure must include {page_type}"))
slides = plan.get("slides")
if not isinstance(slides, list) or not slides:
issues.append(issue("slides_missing", "slide_plan.slides must be a non-empty array"))
return issues, slide_results
page_types = [slide.get("page_type") if isinstance(slide, dict) else None for slide in slides]
if len(deck_structure) == len(slides) and list(deck_structure) != page_types:
issues.append(issue("deck_structure_mismatch", "deck_structure must match slides[].page_type when lengths are equal"))
known_source_refs = evidence_ids(evidence)
for index, raw_slide in enumerate(slides, 1):
if not isinstance(raw_slide, dict):
issues.append(issue("slide_not_object", "slide item must be an object", page=index))
continue
page = raw_slide.get("page") if isinstance(raw_slide.get("page"), int) else index
page_issues: list[dict[str, Any]] = []
page_type = raw_slide.get("page_type")
section = raw_slide.get("section")
role = raw_slide.get("role")
title = raw_slide.get("title")
key_message = raw_slide.get("key_message")
body_points = list_of_strings(raw_slide.get("body_points") or raw_slide.get("bullets"))
source_refs = source_refs_for_slide(raw_slide)
if page_type not in ALLOWED_PAGE_TYPES:
page_issues.append(issue("slide_page_type_missing", "slide.page_type must be cover, section, content, or closing", page=page))
if not isinstance(section, str) or not section.strip():
page_issues.append(issue("slide_section_missing", "slide.section must be a non-empty string", page=page))
if not isinstance(role, str) or not role.strip():
page_issues.append(issue("slide_role_missing", "slide.role must be a non-empty string", page=page))
if not isinstance(title, str) or not title.strip() or not has_cjk(title):
page_issues.append(issue("slide_title_not_chinese", "slide.title must be non-empty Chinese text", page=page))
if not isinstance(key_message, str) or not key_message.strip() or not has_cjk(key_message):
page_issues.append(issue("slide_key_message_not_chinese", "slide.key_message must be non-empty Chinese text", page=page))
if page_type == "content":
if len(body_points) < 2:
page_issues.append(issue("content_body_points_too_few", "content slides require at least 2 body_points", page=page))
if len(body_points) > 4:
page_issues.append(issue("content_body_points_too_many", "content slides should keep body_points within 2-4 items", page=page))
if not source_refs:
page_issues.append(issue("content_source_refs_missing", "content slides require at least one source_ref", page=page))
if slide_has_chart_signal(raw_slide) and (len(body_points) < 3 or evidence_text_length_for_refs(evidence, source_refs) < MIN_CHART_EVIDENCE_CHARS):
page_issues.append(
issue(
"chart_rich_content_too_thin",
"chart-rich content slides require at least 3 body_points and stronger evidence coverage",
page=page,
)
)
if page_type == "closing" and len(body_points) < 2:
page_issues.append(issue("closing_takeaways_too_few", "closing slides require at least 2 takeaways/body_points", page=page))
for body_index, point in enumerate(body_points, 1):
if not has_cjk(point):
page_issues.append(issue("body_point_not_chinese", f"body_points[{body_index}] must contain Chinese text", page=page))
for ref in source_refs:
if known_source_refs and ref not in known_source_refs:
page_issues.append(issue("source_ref_not_found", f"source_ref is not present in evidence.json: {ref}", page=page))
numeric_values = [value for value in [title, key_message, *body_points] if isinstance(value, str)]
if has_numeric_claim(numeric_values) and not source_refs:
page_issues.append(issue("numeric_claim_uncited", "numeric claims require at least one source_ref", page=page))
issues.extend(page_issues)
slide_results.append(
{
"page": page,
"page_type": page_type,
"section": section,
"role": role,
"body_point_count": len(body_points),
"source_ref_count": len(source_refs),
"error_count": len(page_issues),
}
)
return issues, slide_results
def research_quality_issues(project: Path, evidence: dict[str, Any] | None, *, profile: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
issues: list[dict[str, Any]] = []
receipt = read_json_object_optional(project / SOURCE_RECEIPT_PATH)
research = receipt.get("research") if isinstance(receipt.get("research"), dict) else {}
status = None
if isinstance(research, dict):
raw_status = research.get("status")
status = raw_status if isinstance(raw_status, str) else None
if status is None and isinstance(evidence, dict):
raw_status = evidence.get("research_status")
status = raw_status if isinstance(raw_status, str) else None
sources = research.get("sources") if isinstance(research, dict) and isinstance(research.get("sources"), list) else []
if status in {"blocked_by_network", "skipped_by_user"}:
issues.append(issue("research_missing_for_current_topic", f"source research status is {status}"))
if profile in {"production", "production_live"} and status == "partial":
issues.append(issue("research_partial_for_production", "production profiles require ready/researched source status"))
if status == "researched" and not sources:
issues.append(issue("source_credibility_missing", "researched source receipt must include sources"))
summary = {
"status": status or "legacy",
"source_count": len(sources),
"retrieved_at": receipt.get("ended_at") if isinstance(receipt, dict) else None,
}
return issues, summary
def build_text_inventory(project: Path, plan: dict[str, Any], evidence: dict[str, Any] | None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
issues: list[dict[str, Any]] = []
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
inventory_slides: list[dict[str, Any]] = []
for index, svg_path in enumerate(prepared_svg_files(project), 1):
page = index
if index <= len(slides) and isinstance(slides[index - 1], dict):
current_slide = slides[index - 1]
raw_page = slides[index - 1].get("page")
if isinstance(raw_page, int):
page = raw_page
else:
current_slide = {}
page_plan_strings = [(text, f"slide_plan.json:slides[{index - 1}]") for text in collect_strings(current_slide)]
page_evidence_strings = evidence_strings_for_refs(evidence, source_refs_for_slide(current_slide))
texts = []
unmatched: list[str] = []
for text in extract_visible_texts(svg_path):
if text_matches_allowed(text, [item[0] for item in page_plan_strings]):
source = "slide_plan.json"
source_path = next((item[1] for item in page_plan_strings if text_matches_allowed(text, [item[0]])), None)
status = "matched"
elif text_matches_allowed(text, [item[0] for item in page_evidence_strings]):
source = "source/evidence.json"
source_path = next((item[1] for item in page_evidence_strings if text_matches_allowed(text, [item[0]])), None)
status = "matched"
elif mostly_generated_safe(text):
source = "generator"
source_path = None
status = "allowed_generated"
else:
source = "generator"
source_path = None
status = "unmatched"
unmatched.append(text)
issues.append(issue("visible_text_not_in_plan_or_source", f"visible SVG text is not traceable to plan or source: {text!r}", page=page, path=relpath(svg_path, project)))
texts.append({"text": text, "source": source, "source_path": source_path, "status": status})
inventory_slides.append({"page": page, "svg": relpath(svg_path, project), "texts": texts, "unmatched_texts": unmatched})
inventory = {
"schema_version": "svglide-text-inventory/v1",
"generated_at": now_iso(),
"slides": inventory_slides,
"summary": {
"slide_count": len(inventory_slides),
"text_count": sum(len(slide["texts"]) for slide in inventory_slides),
"unmatched_text_count": sum(len(slide["unmatched_texts"]) for slide in inventory_slides),
},
}
return inventory, issues
def run_semantic_review(project: Path, *, profile: str = "preview_only") -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
plan_file = project / PLAN_PATH
if not plan_file.exists():
raise SemanticReviewError(f"missing required plan file: {PLAN_PATH.as_posix()}")
plan = read_json_object(plan_file)
evidence, evidence_issues = load_evidence(project)
issues, slide_results = check_plan_structure(plan, evidence)
issues.extend(evidence_issues)
research_issues, research_summary = research_quality_issues(project, evidence, profile=profile)
issues.extend(research_issues)
svgs = prepared_svg_files(project)
if not svgs:
issues.append(issue("prepared_svg_missing", f"no prepared SVG files found under {PREPARED_SVG_DIR.as_posix()}"))
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
if svgs and slides and len(svgs) != len(slides):
issues.append(issue("prepared_svg_count_mismatch", "prepared SVG file count must match slide_plan.slides length"))
text_inventory, text_issues = build_text_inventory(project, plan, evidence)
issues.extend(text_issues)
text_inventory_schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-text-inventory.schema.json"))
for item in svglide_schema.validate_json_schema(text_inventory, text_inventory_schema):
issues.append(issue(item["code"], item["message"], path=item["path"]))
output_dir = project / CHECK_DIR
output_dir.mkdir(parents=True, exist_ok=True)
text_inventory_path = project / TEXT_INVENTORY
text_inventory_path.write_text(json.dumps(text_inventory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
status = "failed" if issues else "passed"
result: dict[str, Any] = {
"schema_version": "svglide-semantic-review/v1",
"status": status,
"action": PASS_ACTION if status == "passed" else FAIL_ACTION,
"project": str(project),
"profile": profile,
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"slide_plan": PLAN_PATH.as_posix(),
"plan_sha256": file_sha256(plan_file),
"evidence": EVIDENCE_PATH.as_posix() if (project / EVIDENCE_PATH).exists() else None,
"evidence_sha256": file_sha256(project / EVIDENCE_PATH) if (project / EVIDENCE_PATH).exists() else None,
"svg_dir": PREPARED_SVG_DIR.as_posix(),
},
"prepared_files": prepared_file_hashes(project),
"text_inventory": TEXT_INVENTORY.as_posix(),
"checks": {
"language": "passed" if not any(item["code"] == "language_not_zh_cn" for item in issues) else "failed",
"deck_structure": "passed" if not any(item["code"].startswith("deck_structure") for item in issues) else "failed",
"content_density": "passed" if not any("body_points" in item["code"] or "takeaways" in item["code"] or item["code"] == "content_source_refs_missing" for item in issues) else "failed",
"plan_svg_text_alignment": "passed" if not text_issues else "failed",
"research_freshness": "passed" if not any(item["code"].startswith("research_") for item in issues) else "failed",
"source_credibility": "passed" if not any(item["code"] == "source_credibility_missing" for item in issues) else "failed",
"numeric_claims": "passed" if not any(item["code"] == "numeric_claim_uncited" for item in issues) else "failed",
},
"research": research_summary,
"slides": slide_results,
"summary": {
"error_count": len(issues),
"warning_count": 0,
"slide_count": len(slide_results),
"prepared_svg_count": len(svgs),
"unmatched_text_count": text_inventory["summary"]["unmatched_text_count"],
},
"issues": issues,
"output_path": SEMANTIC_REVIEW.as_posix(),
}
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-semantic-review.schema.json"))
schema_issues = svglide_schema.validate_json_schema(result, schema)
if schema_issues:
result["status"] = "failed"
result["action"] = FAIL_ACTION
result["issues"].extend(issue(item["code"], item["message"], path=item["path"]) for item in schema_issues)
result["summary"]["error_count"] = len(result["issues"])
output_path = project / SEMANTIC_REVIEW
output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run deterministic semantic and content checks for a SVGlide project.")
parser.add_argument("project", help="SVGlide project directory under .lark-slides/plan/<deck-id>")
parser.add_argument("--profile", default="preview_only", choices=["preview_only", "production_live", "production", "debug"])
parser.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
result = run_semantic_review(Path(args.project), profile=args.profile)
except (OSError, SemanticReviewError) as error:
print(f"svglide_semantic_review: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,231 @@
#!/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_semantic_review
FIXTURE_ROOT = Path(__file__).resolve().parent / "fixtures" / "svglide_semantic_review"
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, indent=2), encoding="utf-8")
class SVGlideSemanticReviewTest(unittest.TestCase):
def make_valid_project(self, root: Path) -> Path:
project = root / "valid"
write_json(
project / "02-plan/slide_plan.json",
{
"language": "zh-CN",
"audience": "企业管理层",
"deck_structure": ["cover", "content", "closing"],
"slides": [
{
"page": 1,
"page_type": "cover",
"section": "开场",
"role": "thesis",
"title": "英伟达供应链拐点",
"key_message": "供应链正在从图形芯片转向 AI 基础设施",
"body_points": ["面向管理层的中文汇报", "聚焦产能与交付风险"],
"source_refs": ["source:item-001"],
},
{
"page": 2,
"page_type": "content",
"section": "供需判断",
"role": "evidence",
"title": "需求增长压缩交付窗口",
"key_message": "关键瓶颈来自先进封装与高带宽内存协同",
"body_points": ["订单节奏提前暴露产能缺口", "交付稳定性决定客户扩容节奏"],
"source_refs": ["source:item-001"],
},
{
"page": 3,
"page_type": "closing",
"section": "结论",
"role": "takeaway",
"title": "结论与行动",
"key_message": "下一步应优先跟踪产能、交付和客户集中度",
"body_points": ["跟踪先进封装产能变化", "复核主要客户交付节奏"],
"source_refs": ["source:item-001"],
},
],
},
)
write_json(
project / "source/evidence.json",
{
"schema_version": "svglide-evidence/v1",
"source_status": "ready",
"items": [
{
"id": "item-001",
"text": "先进封装与高带宽内存协同影响 AI 基础设施交付稳定性",
"source": "source-notes.md",
}
],
},
)
prepared = project / "04-svg/prepared"
prepared.mkdir(parents=True)
(prepared / "page-001.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><text>英伟达供应链拐点</text><text>供应链正在从图形芯片转向 AI 基础设施</text></svg>',
encoding="utf-8",
)
(prepared / "page-002.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><text>需求增长压缩交付窗口</text><text>订单节奏提前暴露产能缺口</text></svg>',
encoding="utf-8",
)
(prepared / "page-003.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><text>结论与行动</text><text>跟踪先进封装产能变化</text></svg>',
encoding="utf-8",
)
return project
def test_semantic_review_passes_valid_chinese_project(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "passed")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertTrue((project / "06-check/semantic-review.json").exists())
inventory = json.loads((project / "06-check/text-inventory.json").read_text(encoding="utf-8"))
self.assertEqual(inventory["summary"]["unmatched_text_count"], 0)
def test_nvidia_negative_fixture_is_blocked(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir) / "nvidia-negative"
shutil.copytree(FIXTURE_ROOT / "nvidia-negative", project)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("language_not_zh_cn", codes)
self.assertIn("audience_missing", codes)
self.assertIn("slide_page_type_missing", codes)
self.assertIn("slide_section_missing", codes)
self.assertIn("slide_role_missing", codes)
self.assertIn("slide_title_not_chinese", codes)
self.assertIn("visible_text_not_in_plan_or_source", codes)
self.assertIn("missing_evidence_json", codes)
def test_semantic_review_blocks_stale_or_unknown_source_ref(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
plan = json.loads((project / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["slides"][1]["source_refs"] = ["source:missing"]
write_json(project / "02-plan/slide_plan.json", plan)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("source_ref_not_found", codes)
def test_semantic_review_extracts_foreign_object_text(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
(project / "04-svg/prepared/page-001.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div xmlns="http://www.w3.org/1999/xhtml">Hardcoded English Takeaway</div></foreignObject></svg>',
encoding="utf-8",
)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("visible_text_not_in_plan_or_source", codes)
inventory = json.loads((project / "06-check/text-inventory.json").read_text(encoding="utf-8"))
self.assertEqual(inventory["summary"]["unmatched_text_count"], 1)
def test_semantic_review_limits_text_provenance_to_current_page_refs(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
evidence = json.loads((project / "source/evidence.json").read_text(encoding="utf-8"))
evidence["items"].append(
{
"id": "item-002",
"text": "这是一条只属于第二页引用的证据内容,不应该替第一页的可见文本背书",
"source": "source-notes.md",
}
)
write_json(project / "source/evidence.json", evidence)
plan = json.loads((project / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["slides"][1]["source_refs"] = ["source:item-002"]
write_json(project / "02-plan/slide_plan.json", plan)
(project / "04-svg/prepared/page-001.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><text>这是一条只属于第二页引用的证据内容</text></svg>',
encoding="utf-8",
)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("visible_text_not_in_plan_or_source", codes)
def test_semantic_review_blocks_thin_source_status(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
evidence = json.loads((project / "source/evidence.json").read_text(encoding="utf-8"))
evidence["source_status"] = "thin"
write_json(project / "source/evidence.json", evidence)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("source_status_not_ready", codes)
def test_semantic_review_blocks_chart_rich_thin_content(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
plan = json.loads((project / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["slides"][1]["layout_family"] = "chart"
write_json(project / "02-plan/slide_plan.json", plan)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("chart_rich_content_too_thin", codes)
def test_semantic_review_blocks_numeric_claim_without_source_ref(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = self.make_valid_project(Path(tmpdir))
plan = json.loads((project / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["slides"][0]["key_message"] = "市场规模增长 30%"
plan["slides"][0]["source_refs"] = []
write_json(project / "02-plan/slide_plan.json", plan)
(project / "04-svg/prepared/page-001.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><text>英伟达供应链拐点</text><text>市场规模增长 30%</text></svg>',
encoding="utf-8",
)
result = svglide_semantic_review.run_semantic_review(project)
self.assertEqual(result["status"], "failed")
codes = {item["code"] for item in result["issues"]}
self.assertIn("numeric_claim_uncited", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,490 @@
#!/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 re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import svglide_schema
SOURCE_DIR = Path("source")
SOURCE_NOTES = SOURCE_DIR / "source-notes.md"
EVIDENCE_PATH = SOURCE_DIR / "evidence.json"
RESEARCH_QUERIES = SOURCE_DIR / "research_queries.json"
RESEARCH_MD = SOURCE_DIR / "research.md"
SOURCE_RECEIPT = SOURCE_DIR / "source-receipt.json"
RECEIPT_PATH = Path("receipts/source.json")
PROJECT_MANIFEST = Path("01-project/project_manifest.json")
MIN_READY_ITEMS = 3
MIN_EVIDENCE_TEXT_CHARS = 20
NETWORK_POLICIES = {"auto", "online", "offline", "fixture"}
class SourceError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
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 normalize_network_policy(value: str | None) -> str:
policy = (value or "offline").strip().lower()
if policy not in NETWORK_POLICIES:
raise SourceError(f"unsupported network policy: {value}")
return policy
def network_allows_research(policy: str, no_online_research: bool) -> bool:
return policy in {"auto", "online"} and not no_online_research
def read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise SourceError(f"invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise SourceError(f"invalid JSON in {path}: expected object")
return payload
def read_json_object_optional(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return read_json_object(path)
def normalize_note_line(line: str) -> str:
line = re.sub(r"^\s{0,3}[-*+]\s+", "", line.strip())
line = re.sub(r"^\s*\d+[.)]\s+", "", line)
return line.strip()
def evidence_from_notes(notes: str) -> dict[str, Any]:
lines = []
for raw in notes.splitlines():
line = normalize_note_line(raw)
if not line or line.startswith("#"):
continue
lines.append(line)
items = [
{
"id": f"item-{index:03d}",
"text": line,
"source": SOURCE_NOTES.as_posix(),
}
for index, line in enumerate(lines, 1)
]
ready_items = sum(1 for item in items if len(item["text"]) >= MIN_EVIDENCE_TEXT_CHARS)
source_status = "ready" if ready_items >= MIN_READY_ITEMS and ready_items == len(items) else "thin"
return {
"schema_version": "svglide-evidence/v1",
"source_status": source_status,
"items": items,
"generated_from": SOURCE_NOTES.as_posix(),
}
def project_title(project: Path) -> str:
manifest = read_json_object_optional(project / PROJECT_MANIFEST)
raw = manifest.get("title") or manifest.get("deck_id") or project.name
return raw if isinstance(raw, str) and raw.strip() else project.name
def research_queries_for(project: Path) -> list[str]:
title = project_title(project).strip()
return [
title,
f"{title} 最新数据",
f"{title} 背景 分析",
]
def write_research_queries(project: Path, queries: list[str], *, policy: str) -> None:
payload = {
"schema_version": "svglide-research-queries/v1",
"network_policy": policy,
"queries": queries,
"generated_at": now_iso(),
}
path = project / RESEARCH_QUERIES
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def fixture_evidence(project: Path) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
title = project_title(project)
source = {
"id": "fixture-src-001",
"title": f"{title} fixture source",
"url": "fixture://svglide/source",
"source_type": "fixture",
"credibility": "fixture",
"retrieved_at": now_iso(),
}
items = [
{
"id": f"item-{index:03d}",
"text": text,
"source": source["title"],
"url": source["url"],
}
for index, text in enumerate(
[
f"{title} 的演示需要先说明背景、核心矛盾和受众最关心的问题。",
f"{title} 的正文应使用结构化证据支撑,不直接把单薄主题扩写成泛泛介绍。",
f"{title} 的视觉表达应优先使用可编辑组件,并用素材增强封面、证据和记忆点。",
],
1,
)
]
evidence = {
"schema_version": "svglide-evidence/v1",
"source_status": "ready",
"items": items,
"generated_from": "fixture",
"research_status": "fixture",
"sources": [source],
}
claims = [
{"claim": item["text"], "source_ids": [source["id"]], "confidence": "fixture", "used_for_pages": []}
for item in items
]
return evidence, [source], claims
def http_json(url: str, *, timeout: float = 8.0) -> Any:
request = urllib.request.Request(url, headers={"User-Agent": "SVGlide/online-first"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def acquire_wikipedia_sources(queries: list[str]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
sources: list[dict[str, Any]] = []
claims: list[dict[str, Any]] = []
seen_urls: set[str] = set()
for query in queries[:2]:
params = urllib.parse.urlencode(
{
"action": "opensearch",
"search": query,
"limit": "3",
"namespace": "0",
"format": "json",
}
)
payload = http_json(f"https://en.wikipedia.org/w/api.php?{params}")
if not isinstance(payload, list) or len(payload) < 4:
continue
titles = payload[1] if isinstance(payload[1], list) else []
descriptions = payload[2] if isinstance(payload[2], list) else []
urls = payload[3] if isinstance(payload[3], list) else []
for index, raw_url in enumerate(urls):
if not isinstance(raw_url, str) or raw_url in seen_urls:
continue
title = titles[index] if index < len(titles) and isinstance(titles[index], str) else raw_url
description = descriptions[index] if index < len(descriptions) and isinstance(descriptions[index], str) else ""
text = description.strip() or f"{title} 是与 {query} 相关的公开百科来源,可作为初步背景资料。"
if len(text) < MIN_EVIDENCE_TEXT_CHARS:
text = f"{title} 是与 {query} 相关的公开资料来源,用于补充背景与上下文。"
source_id = f"web-src-{len(sources) + 1:03d}"
source = {
"id": source_id,
"url": raw_url,
"title": title,
"published_at": None,
"retrieved_at": now_iso(),
"source_type": "web",
"credibility": "secondary",
}
sources.append(source)
claims.append({"claim": text, "source_ids": [source_id], "confidence": "medium", "used_for_pages": []})
seen_urls.add(raw_url)
if len(sources) >= MIN_READY_ITEMS:
return sources, claims
return sources, claims
def evidence_from_sources(sources: list[dict[str, Any]], claims: list[dict[str, Any]]) -> dict[str, Any]:
items = []
for index, claim in enumerate(claims, 1):
source_id = claim.get("source_ids", [None])[0]
source = next((item for item in sources if item.get("id") == source_id), {})
items.append(
{
"id": f"item-{index:03d}",
"text": str(claim.get("claim") or ""),
"source": str(source.get("title") or source_id or "online source"),
"url": str(source.get("url") or ""),
"date": str(source.get("retrieved_at") or ""),
"source_ids": claim.get("source_ids", []),
}
)
return {
"schema_version": "svglide-evidence/v1",
"source_status": "ready" if len(items) >= MIN_READY_ITEMS else "thin",
"items": items,
"generated_from": "online_research",
"research_status": "researched" if len(items) >= MIN_READY_ITEMS else "partial",
"sources": sources,
"claims": claims,
}
def blocked_evidence(error: str) -> dict[str, Any]:
return {
"schema_version": "svglide-evidence/v1",
"source_status": "blocked",
"items": [
{
"id": "item-001",
"text": f"在线资料获取失败,当前 evidence 仅记录阻断原因,不能用于正式生成:{error}",
"source": "svglide_source",
}
],
"generated_from": "online_research",
"research_status": "blocked_by_network",
}
def write_research_markdown(project: Path, *, queries: list[str], sources: list[dict[str, Any]], claims: list[dict[str, Any]], status: str) -> None:
lines = [
"# Research",
"",
f"- status: {status}",
f"- retrieved_at: {now_iso()}",
"",
"## Queries",
]
lines.extend(f"- {query}" for query in queries)
lines.extend(["", "## Sources"])
if sources:
for item in sources:
lines.append(f"- [{item.get('title')}]({item.get('url')}) - {item.get('credibility')} - {item.get('retrieved_at')}")
else:
lines.append("- none")
lines.extend(["", "## Claims"])
if claims:
for item in claims:
lines.append(f"- {item.get('claim')} (sources: {', '.join(item.get('source_ids') or [])})")
else:
lines.append("- none")
path = project / RESEARCH_MD
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def load_or_build_evidence(
project: Path,
*,
network_policy: str,
no_online_research: bool,
refresh_online: bool,
) -> tuple[dict[str, Any] | None, list[dict[str, str]], bool, dict[str, Any]]:
evidence_file = project / EVIDENCE_PATH
notes_file = project / SOURCE_NOTES
queries = research_queries_for(project)
write_research_queries(project, queries, policy=network_policy)
acquisition: dict[str, Any] = {
"status": "reused_existing",
"network_policy": network_policy,
"queries": queries,
"sources": [],
"claims": [],
}
if evidence_file.exists() and not refresh_online:
evidence = read_json_object(evidence_file)
acquisition["status"] = evidence.get("research_status") or "reused_existing"
acquisition["sources"] = evidence.get("sources") if isinstance(evidence.get("sources"), list) else []
acquisition["claims"] = evidence.get("claims") if isinstance(evidence.get("claims"), list) else []
return evidence, [], False, acquisition
if notes_file.exists() and not refresh_online:
evidence = evidence_from_notes(notes_file.read_text(encoding="utf-8"))
evidence_file.parent.mkdir(parents=True, exist_ok=True)
evidence_file.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
acquisition["status"] = "notes"
return evidence, [], True, acquisition
if network_policy == "fixture":
evidence, sources, claims = fixture_evidence(project)
evidence_file.parent.mkdir(parents=True, exist_ok=True)
evidence_file.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_research_markdown(project, queries=queries, sources=sources, claims=claims, status="fixture")
acquisition.update({"status": "fixture", "sources": sources, "claims": claims})
return evidence, [], True, acquisition
if not network_allows_research(network_policy, no_online_research):
acquisition["status"] = "skipped_by_user" if network_policy == "offline" or no_online_research else "skipped"
return None, [issue("source_input_missing", "source/evidence.json or source/source-notes.md is required")], False, acquisition
try:
sources, claims = acquire_wikipedia_sources(queries)
evidence = evidence_from_sources(sources, claims)
evidence_file.parent.mkdir(parents=True, exist_ok=True)
evidence_file.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
status = evidence.get("research_status", "partial")
write_research_markdown(project, queries=queries, sources=sources, claims=claims, status=str(status))
acquisition.update({"status": status, "sources": sources, "claims": claims})
return evidence, [], True, acquisition
except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
evidence = blocked_evidence(str(error))
evidence_file.parent.mkdir(parents=True, exist_ok=True)
evidence_file.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_research_markdown(project, queries=queries, sources=[], claims=[], status="blocked_by_network")
acquisition.update({"status": "blocked_by_network", "error": str(error)})
return evidence, [], True, acquisition
def evidence_issues(evidence: dict[str, Any] | None) -> list[dict[str, str]]:
if evidence is None:
return []
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-evidence.schema.json"))
issues = [
issue(item["code"], item["message"], path=item["path"])
for item in svglide_schema.validate_json_schema(evidence, schema)
]
if evidence.get("source_status") != "ready":
issues.append(issue("source_status_not_ready", "source_status must be ready before planning/generation"))
items = evidence.get("items")
if not isinstance(items, list):
return issues
if len(items) < MIN_READY_ITEMS:
issues.append(issue("source_item_count_too_low", f"evidence requires at least {MIN_READY_ITEMS} items"))
seen: set[str] = set()
for index, item in enumerate(items, 1):
if not isinstance(item, dict):
continue
raw_id = item.get("id")
if isinstance(raw_id, str):
if raw_id in seen:
issues.append(issue("source_item_id_duplicate", f"duplicate evidence id: {raw_id}"))
seen.add(raw_id)
text = item.get("text")
if not isinstance(text, str) or len(text.strip()) < MIN_EVIDENCE_TEXT_CHARS:
issues.append(issue("source_item_text_too_short", f"evidence item {index} text is too short"))
return issues
def validate_source_receipt(receipt: dict[str, Any]) -> list[dict[str, str]]:
schema = svglide_schema.read_json(svglide_schema.schema_path("svglide-source-receipt.schema.json"))
return [
issue(item["code"], item["message"], path=item["path"])
for item in svglide_schema.validate_json_schema(receipt, schema)
]
def run_source(
project: Path,
*,
network_policy: str = "offline",
no_online_research: bool = False,
refresh_online: bool = False,
) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
policy = normalize_network_policy(network_policy)
evidence, issues, generated, acquisition = load_or_build_evidence(
project,
network_policy=policy,
no_online_research=no_online_research,
refresh_online=refresh_online,
)
issues.extend(evidence_issues(evidence))
item_count = len(evidence.get("items", [])) if isinstance(evidence, dict) and isinstance(evidence.get("items"), list) else 0
status = "failed" if issues else "passed"
receipt: dict[str, Any] = {
"schema_version": "svglide-source-receipt/v1",
"stage": "source",
"status": status,
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"source_notes": SOURCE_NOTES.as_posix() if (project / SOURCE_NOTES).exists() else None,
"source_notes_sha256": optional_sha256(project / SOURCE_NOTES),
"evidence": EVIDENCE_PATH.as_posix() if (project / EVIDENCE_PATH).exists() else None,
"evidence_sha256": optional_sha256(project / EVIDENCE_PATH),
"network_policy": policy,
"no_online_research": no_online_research,
"refresh_online": refresh_online,
},
"outputs": {
"evidence": EVIDENCE_PATH.as_posix() if (project / EVIDENCE_PATH).exists() else None,
"research_queries": RESEARCH_QUERIES.as_posix() if (project / RESEARCH_QUERIES).exists() else None,
"research": RESEARCH_MD.as_posix() if (project / RESEARCH_MD).exists() else None,
"source_receipt": SOURCE_RECEIPT.as_posix(),
},
"generated_evidence_from_notes": generated,
"source_status": evidence.get("source_status") if isinstance(evidence, dict) else None,
"research": acquisition,
"summary": {"error_count": len(issues), "evidence_item_count": item_count},
"issues": issues,
}
schema_issues = validate_source_receipt(receipt)
if schema_issues:
receipt["status"] = "failed"
receipt["issues"].extend(schema_issues)
receipt["summary"]["error_count"] = len(receipt["issues"])
for path in [project / SOURCE_RECEIPT, project / RECEIPT_PATH]:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return receipt
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Normalize and validate SVGlide source evidence.")
parser.add_argument("project", help="SVGlide project directory under .lark-slides/plan/<deck-id>")
parser.add_argument("--network-policy", default="offline", choices=sorted(NETWORK_POLICIES))
parser.add_argument("--no-online-research", action="store_true")
parser.add_argument("--refresh-online", action="store_true")
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_source(
Path(args.project),
network_policy=args.network_policy,
no_online_research=args.no_online_research,
refresh_online=args.refresh_online,
)
except (OSError, SourceError) as error:
print(f"svglide_source: error: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

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