mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
16 Commits
feat/laten
...
feat/svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4335ffaafd | ||
|
|
9aa38a3597 | ||
|
|
9c79fe1ca2 | ||
|
|
27062ee254 | ||
|
|
9e5f94b92b | ||
|
|
d70a01b6a8 | ||
|
|
b7519b4ce3 | ||
|
|
db8781f7d6 | ||
|
|
07e57d7857 | ||
|
|
566f6cfd47 | ||
|
|
cd0854e931 | ||
|
|
be8a67b894 | ||
|
|
f5502416c8 | ||
|
|
182c541ea6 | ||
|
|
d980e54ab8 | ||
|
|
85965e41e4 |
@@ -55,9 +55,8 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
// SetEmbeddedSkillContent. nil in builds that embed no skills; the `skills`
|
||||
// commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
|
||||
@@ -26,7 +26,7 @@ build_target() {
|
||||
|
||||
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
|
||||
echo "Building ${goos}/${goarch} -> ${output}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" .
|
||||
}
|
||||
|
||||
build_target darwin arm64
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVG,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
197
shortcuts/slides/slides_create_svg.go
Normal file
197
shortcuts/slides/slides_create_svg.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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"},
|
||||
{
|
||||
Name: "svg-rasterize-effects",
|
||||
Default: "off",
|
||||
Enum: []string{"off", "auto", "strict", "force-page"},
|
||||
Desc: "Rasterize unsupported rich SVG effects before upload: off|auto|strict|force-page",
|
||||
},
|
||||
{
|
||||
Name: "svg-rasterize-scale",
|
||||
Type: "int",
|
||||
Default: "2",
|
||||
Desc: "PNG raster scale; default 2",
|
||||
},
|
||||
{Name: "svg-rasterize-report", Desc: "optional raster report output path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSVGRasterizeFlags(runtime); 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"))
|
||||
dry := common.NewDryRunAPI()
|
||||
svgs, prepareReport, err := prepareSVGFilesForCreate(
|
||||
runtime,
|
||||
runtime.StrArray("file"),
|
||||
svgPrepareOptionsFromRuntime(runtime, true),
|
||||
)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
if err := validateSVGRasterAssetConflicts(assets, prepareReport); err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
pages, uploadPaths := dryRunRewriteSVGImagePlaceholders(svgs, assets)
|
||||
if prepareReport != nil {
|
||||
dry.Set("svg_rasterize_report", prepareReport)
|
||||
}
|
||||
|
||||
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, prepareReport, err := prepareSVGFilesForCreate(
|
||||
runtime,
|
||||
runtime.StrArray("file"),
|
||||
svgPrepareOptionsFromRuntime(runtime, false),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSVGRasterAssetConflicts(assets, prepareReport); 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 prepareReport != nil {
|
||||
result["svg_rasterize_report"] = prepareReport
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
502
shortcuts/slides/slides_create_svg_test.go
Normal file
502
shortcuts/slides/slides_create_svg_test.go
Normal 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.
|
||||
}
|
||||
1305
shortcuts/slides/svg_helpers.go
Normal file
1305
shortcuts/slides/svg_helpers.go
Normal file
File diff suppressed because it is too large
Load Diff
430
shortcuts/slides/svg_helpers_test.go
Normal file
430
shortcuts/slides/svg_helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
366
shortcuts/slides/svg_rasterize.go
Normal file
366
shortcuts/slides/svg_rasterize.go
Normal file
@@ -0,0 +1,366 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
svgRasterizerSkillPath = "lark-slides/scripts/svg_rasterize_effects.py"
|
||||
svgRasterizerSourcePath = "skills/" + svgRasterizerSkillPath
|
||||
svgRasterizedOutputRoot = ".lark-slides/rasterized"
|
||||
maxSVGRasterPNGBytes int64 = 20 * 1024 * 1024
|
||||
)
|
||||
|
||||
var svgRasterizerEmbeddedSkillPaths = []string{
|
||||
"lark-slides/scripts/svg_rasterize_effects.py",
|
||||
"lark-slides/scripts/svg_effect_classifier.py",
|
||||
"lark-slides/scripts/svg_safe_rewrite.py",
|
||||
"lark-slides/scripts/svg_raster_renderer.py",
|
||||
}
|
||||
|
||||
type svgRasterRuntime struct {
|
||||
PythonPath string
|
||||
}
|
||||
|
||||
type svgRasterizerInvocation struct {
|
||||
PythonPath string
|
||||
ScriptPath string
|
||||
Args []string
|
||||
}
|
||||
|
||||
var (
|
||||
svgRasterizeResolveRuntime = resolveSVGRasterRuntime
|
||||
svgRasterizeRunScript = runSVGRasterizerScript
|
||||
)
|
||||
|
||||
func rasterizeRichSVGEffects(
|
||||
runtime *common.RuntimeContext,
|
||||
svgs []string,
|
||||
paths []string,
|
||||
opts svgPrepareOptions,
|
||||
) ([]string, *svgPrepareReport, error) {
|
||||
if len(svgs) != len(paths) {
|
||||
return nil, nil, output.ErrValidation("internal svg rasterization error: SVG count %d does not match path count %d", len(svgs), len(paths))
|
||||
}
|
||||
if opts.RasterizeScale == 0 {
|
||||
opts.RasterizeScale = 2
|
||||
}
|
||||
|
||||
scriptPath, err := resolveSVGRasterizerScript(runtime)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rasterRuntime, err := svgRasterizeResolveRuntime(contextFromRuntime(runtime))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
baseDir, err := runtime.ResolveSavePath(".")
|
||||
if err != nil {
|
||||
return nil, nil, output.ErrValidation("resolve current working directory for SVG rasterization: %v", err)
|
||||
}
|
||||
runID := newSVGRasterRunID()
|
||||
runDir := filepath.ToSlash(filepath.Join(svgRasterizedOutputRoot, runID))
|
||||
if err := ensureSVGRasterOutputDir(runtime, runDir); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
report := &svgPrepareReport{
|
||||
Version: "1",
|
||||
Mode: string(opts.RasterizeMode),
|
||||
RunID: runID,
|
||||
BaseDir: baseDir,
|
||||
Quality: svgPrepareQuality{
|
||||
GatePassed: true,
|
||||
},
|
||||
Pages: make([]svgPreparePageReport, 0, len(svgs)),
|
||||
}
|
||||
prepared := make([]string, 0, len(svgs))
|
||||
for i, svg := range svgs {
|
||||
pageNo := i + 1
|
||||
inputPath := filepath.ToSlash(filepath.Join(runDir, fmt.Sprintf("page-%03d.rich.svg", pageNo)))
|
||||
outputPath := filepath.ToSlash(filepath.Join(runDir, fmt.Sprintf("page-%03d.safe.svg", pageNo)))
|
||||
pageReportPath := filepath.ToSlash(filepath.Join(runDir, fmt.Sprintf("page-%03d-raster-report.json", pageNo)))
|
||||
if _, err := runtime.FileIO().Save(inputPath, fileio.SaveOptions{ContentType: "image/svg+xml", ContentLength: int64(len(svg))}, strings.NewReader(svg)); err != nil {
|
||||
return nil, report, common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
|
||||
invocation := svgRasterizerInvocation{
|
||||
PythonPath: rasterRuntime.PythonPath,
|
||||
ScriptPath: scriptPath,
|
||||
Args: []string{
|
||||
"--mode", string(opts.RasterizeMode),
|
||||
"--scale", strconv.Itoa(opts.RasterizeScale),
|
||||
"--input", inputPath,
|
||||
"--output", outputPath,
|
||||
"--asset-dir", runDir,
|
||||
"--base-dir", baseDir,
|
||||
"--report", pageReportPath,
|
||||
},
|
||||
}
|
||||
start := time.Now()
|
||||
if err := svgRasterizeRunScript(contextFromRuntime(runtime), invocation); err != nil {
|
||||
return nil, report, err
|
||||
}
|
||||
renderMS := time.Since(start).Milliseconds()
|
||||
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), outputPath)
|
||||
if err != nil {
|
||||
return nil, report, common.WrapInputStatError(err, fmt.Sprintf("raster safe SVG %s", outputPath))
|
||||
}
|
||||
safeSVG := string(data)
|
||||
pageReport := readSVGRasterPageReport(runtime, pageReportPath)
|
||||
if pageReport.SourcePath == "" {
|
||||
pageReport.SourcePath = paths[i]
|
||||
}
|
||||
pageReport.SafePath = outputPath
|
||||
if pageReport.Mode == "" {
|
||||
pageReport.Mode = string(opts.RasterizeMode)
|
||||
}
|
||||
if opts.RasterizeMode == svgRasterizeForcePage && pageReport.FallbackReason == "" {
|
||||
pageReport.FallbackReason = "force-page"
|
||||
}
|
||||
pngs := extractSVGImagePlaceholderPaths([]string{safeSVG}, nil)
|
||||
if len(pngs) == 0 {
|
||||
pngs = pageReport.PNGs
|
||||
}
|
||||
pngs = dedupeStrings(pngs)
|
||||
pageReport.PNGs = pngs
|
||||
if len(pageReport.Islands) == 0 {
|
||||
pageReport.Islands = islandsFromRasterPNGs(pngs, opts.RasterizeScale, renderMS)
|
||||
}
|
||||
if err := validateSVGRasterPNGs(runtime, pngs); err != nil {
|
||||
return nil, report, err
|
||||
}
|
||||
for _, pngPath := range pngs {
|
||||
report.GeneratedAssets = append(report.GeneratedAssets, pngPath)
|
||||
}
|
||||
report.RasterImageCount += len(pngs)
|
||||
report.RasterTotalMS += renderMS
|
||||
if pageReport.FallbackReason != "" {
|
||||
report.FullPageFallbackCount++
|
||||
}
|
||||
for _, island := range pageReport.Islands {
|
||||
report.RasterTotalBytes += island.Bytes
|
||||
}
|
||||
report.Pages = append(report.Pages, pageReport)
|
||||
prepared = append(prepared, safeSVG)
|
||||
}
|
||||
report.GeneratedAssets = dedupeStrings(report.GeneratedAssets)
|
||||
if err := writeSVGRasterDeckReport(runtime, report, runDir, opts.ReportPath); err != nil {
|
||||
return nil, report, err
|
||||
}
|
||||
return prepared, report, nil
|
||||
}
|
||||
|
||||
func resolveSVGRasterizerScript(runtime *common.RuntimeContext) (string, error) {
|
||||
if _, err := runtime.FileIO().Stat(svgRasterizerSourcePath); err == nil {
|
||||
return svgRasterizerSourcePath, nil
|
||||
}
|
||||
if runtime.Factory == nil || runtime.Factory.SkillContent == nil {
|
||||
return "", output.ErrValidation("svg rasterization requires bundled lark-slides raster scripts; rebuild CLI with scripts embedded")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "lark-cli-svg-rasterizer-*") //nolint:forbidigo // extracting embedded runtime script to process-local temp dir for execution.
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("extract SVG rasterizer script: %v", err)
|
||||
}
|
||||
for _, skillPath := range svgRasterizerEmbeddedSkillPaths {
|
||||
data, err := fs.ReadFile(runtime.Factory.SkillContent, skillPath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("svg rasterization requires bundled lark-slides raster script %s; rebuild CLI with scripts embedded", skillPath)
|
||||
}
|
||||
target := filepath.Join(dir, filepath.Base(skillPath))
|
||||
if err := os.WriteFile(target, data, 0o600); err != nil { //nolint:forbidigo // writes embedded scripts into the temp dir created above.
|
||||
return "", output.ErrValidation("extract SVG rasterizer script %s: %v", skillPath, err)
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, "svg_rasterize_effects.py"), nil
|
||||
}
|
||||
|
||||
func resolveSVGRasterRuntime(ctx context.Context) (svgRasterRuntime, error) {
|
||||
pythonPath, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
return svgRasterRuntime{}, output.ErrValidation("svg rasterization requires python3 on PATH")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, pythonPath, "-c", "import playwright") //nolint:gosec // fixed interpreter probe, no user-controlled code.
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return svgRasterRuntime{}, output.ErrValidation("svg rasterization requires Python package 'playwright' and installed Chromium; run `python3 -m pip install playwright && python3 -m playwright install chromium` (%s)", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return svgRasterRuntime{PythonPath: pythonPath}, nil
|
||||
}
|
||||
|
||||
func runSVGRasterizerScript(ctx context.Context, invocation svgRasterizerInvocation) error {
|
||||
args := append([]string{invocation.ScriptPath}, invocation.Args...)
|
||||
cmd := exec.CommandContext(ctx, invocation.PythonPath, args...) //nolint:gosec // script path is resolved from source or embedded skill content; args are fixed CLI flags.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return output.ErrValidation("svg rasterization failed: %s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureSVGRasterOutputDir(runtime *common.RuntimeContext, runDir string) error {
|
||||
keep := filepath.ToSlash(filepath.Join(runDir, ".keep"))
|
||||
if _, err := runtime.FileIO().Save(keep, fileio.SaveOptions{ContentType: "text/plain", ContentLength: 0}, strings.NewReader("")); err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSVGRasterPageReport(runtime *common.RuntimeContext, path string) svgPreparePageReport {
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
|
||||
if err != nil || len(bytes.TrimSpace(data)) == 0 {
|
||||
return svgPreparePageReport{}
|
||||
}
|
||||
var page svgPreparePageReport
|
||||
if json.Unmarshal(data, &page) == nil && (page.SafePath != "" || len(page.PNGs) > 0 || len(page.Islands) > 0) {
|
||||
return page
|
||||
}
|
||||
var deck svgPrepareReport
|
||||
if json.Unmarshal(data, &deck) == nil && len(deck.Pages) > 0 {
|
||||
return deck.Pages[0]
|
||||
}
|
||||
return svgPreparePageReport{}
|
||||
}
|
||||
|
||||
func islandsFromRasterPNGs(pngs []string, scale int, renderMS int64) []svgPrepareIslandReport {
|
||||
islands := make([]svgPrepareIslandReport, 0, len(pngs))
|
||||
for i, pngPath := range pngs {
|
||||
islands = append(islands, svgPrepareIslandReport{
|
||||
ID: fmt.Sprintf("page-island-%03d", i+1),
|
||||
Reason: "script-generated",
|
||||
OutputPNG: pngPath,
|
||||
Scale: scale,
|
||||
RenderMS: renderMS,
|
||||
})
|
||||
}
|
||||
return islands
|
||||
}
|
||||
|
||||
func validateSVGRasterPNGs(runtime *common.RuntimeContext, paths []string) error {
|
||||
for _, path := range paths {
|
||||
if err := validateSVGRasterPNGPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("raster PNG %s", path))
|
||||
}
|
||||
if stat.Size() <= 0 {
|
||||
return output.ErrValidation("raster PNG %s is empty", path)
|
||||
}
|
||||
if stat.Size() > maxSVGRasterPNGBytes {
|
||||
return output.ErrValidation("raster PNG %s size %s exceeds %s limit", path, common.FormatSize(stat.Size()), common.FormatSize(maxSVGRasterPNGBytes))
|
||||
}
|
||||
if err := validateSVGRasterPNGContent(runtime, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSVGRasterPNGPath(path string) error {
|
||||
clean := filepath.ToSlash(filepath.Clean(path))
|
||||
if strings.HasPrefix(path, "/private/tmp/") {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return output.ErrValidation("raster PNG %s must use a cwd-relative @./ path for upload", path)
|
||||
}
|
||||
if !strings.HasPrefix(clean, ".lark-slides/rasterized/") {
|
||||
return output.ErrValidation("raster PNG %s must be generated under .lark-slides/rasterized", path)
|
||||
}
|
||||
if strings.Contains(clean, "../") || clean == ".." {
|
||||
return output.ErrValidation("raster PNG %s cannot escape the raster output directory", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSVGRasterPNGContent(runtime *common.RuntimeContext, path string) error {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("raster PNG %s", path))
|
||||
}
|
||||
defer f.Close()
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
if err == io.ErrUnexpectedEOF {
|
||||
return output.ErrValidation("raster PNG %s is truncated", path)
|
||||
}
|
||||
return output.ErrValidation("raster PNG %s is not a valid PNG: %v", path, err)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
|
||||
return output.ErrValidation("raster PNG %s has invalid dimensions %dx%d", path, bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
allTransparent := true
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y && allTransparent; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
if a != 0 {
|
||||
allTransparent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if allTransparent {
|
||||
return output.ErrValidation("raster PNG %s is fully transparent", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSVGRasterDeckReport(runtime *common.RuntimeContext, report *svgPrepareReport, runDir, requestedPath string) error {
|
||||
data, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return output.ErrValidation("marshal SVG raster report: %v", err)
|
||||
}
|
||||
defaultPath := filepath.ToSlash(filepath.Join(runDir, "raster-report.json"))
|
||||
if _, err := runtime.FileIO().Save(defaultPath, fileio.SaveOptions{ContentType: "application/json", ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
if strings.TrimSpace(requestedPath) == "" || filepath.Clean(requestedPath) == filepath.Clean(defaultPath) {
|
||||
return nil
|
||||
}
|
||||
if _, err := runtime.FileIO().Save(requestedPath, fileio.SaveOptions{ContentType: "application/json", ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSVGRasterRunID() string {
|
||||
id := strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
return time.Now().UTC().Format("20060102-150405") + "-" + id[:8]
|
||||
}
|
||||
|
||||
func contextFromRuntime(runtime *common.RuntimeContext) context.Context {
|
||||
if runtime == nil || runtime.Ctx() == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return runtime.Ctx()
|
||||
}
|
||||
275
shortcuts/slides/svg_rasterize_test.go
Normal file
275
shortcuts/slides/svg_rasterize_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGFlagsExposeRasterOptions(t *testing.T) {
|
||||
byName := map[string]common.Flag{}
|
||||
for _, fl := range SlidesCreateSVG.Flags {
|
||||
byName[fl.Name] = fl
|
||||
}
|
||||
if got := byName["svg-rasterize-effects"]; got.Default != "off" || strings.Join(got.Enum, ",") != "off,auto,strict,force-page" {
|
||||
t.Fatalf("svg-rasterize-effects flag = %+v", got)
|
||||
}
|
||||
if got := byName["svg-rasterize-scale"]; got.Type != "int" || got.Default != "2" {
|
||||
t.Fatalf("svg-rasterize-scale flag = %+v", got)
|
||||
}
|
||||
if _, ok := byName["svg-rasterize-report"]; !ok {
|
||||
t.Fatal("missing svg-rasterize-report flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSVGFilesForCreateOffKeepsNativeReadPath(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)
|
||||
}
|
||||
runtime := newSVGRasterTestRuntime(t, nil)
|
||||
got, report, err := prepareSVGFilesForCreate(runtime, []string{"page.svg"}, svgPrepareOptions{RasterizeMode: svgRasterizeOff})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare off: %v", err)
|
||||
}
|
||||
if report != nil {
|
||||
t.Fatalf("report = %+v, want nil in off mode", report)
|
||||
}
|
||||
if len(got) != 1 || !strings.Contains(got[0], `slide:contract-version="svglide-authoring-contract/v1"`) {
|
||||
t.Fatalf("prepared SVG = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSVGFilesForCreateForcePageRunsScriptAndGatesSafeSVG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><defs><filter id="glow"/></defs><rect filter="url(#glow)" x="0" y="0" width="100" height="60"/></svg>`), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
|
||||
restore := stubSVGRasterizer(t)
|
||||
defer restore()
|
||||
runtime := newSVGRasterTestRuntime(t, embeddedSVGRasterizerTestFS())
|
||||
got, report, err := prepareSVGFilesForCreate(runtime, []string{"page.svg"}, svgPrepareOptions{
|
||||
RasterizeMode: svgRasterizeForcePage,
|
||||
RasterizeScale: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare force-page: %v", err)
|
||||
}
|
||||
if len(got) != 1 || strings.Contains(got[0], "<filter") || !strings.Contains(got[0], `href="@./.lark-slides/rasterized/`) {
|
||||
t.Fatalf("safe SVG did not pass through rasterizer: %s", got[0])
|
||||
}
|
||||
if report == nil || report.Mode != "force-page" || len(report.Pages) != 1 || !report.Pages[0].RuntimeGateOK {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if len(report.GeneratedAssets) != 1 {
|
||||
t.Fatalf("GeneratedAssets = %v, want one PNG", report.GeneratedAssets)
|
||||
}
|
||||
if gotPaths := extractSVGImagePlaceholderPaths(got, nil); len(gotPaths) != 1 || gotPaths[0] != report.GeneratedAssets[0] {
|
||||
t.Fatalf("placeholder paths = %v, generated = %v", gotPaths, report.GeneratedAssets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGForcePageDryRunIncludesRasterReport(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)
|
||||
}
|
||||
restore := stubSVGRasterizer(t)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
f.SkillContent = embeddedSVGRasterizerTestFS()
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--title", "raster dry",
|
||||
"--svg-rasterize-effects", "force-page",
|
||||
"--as", "user",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run force-page: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"svg_rasterize_report", ".lark-slides/rasterized/", "uploaded_file_token:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSVGRasterizeFlagsRejectsLowScale(t *testing.T) {
|
||||
runtime := newSVGRasterTestRuntime(t, nil)
|
||||
runtime.Cmd.Flags().Set("svg-rasterize-effects", "force-page")
|
||||
runtime.Cmd.Flags().Set("svg-rasterize-scale", "1")
|
||||
err := validateSVGRasterizeFlags(runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "svg-rasterize-scale") {
|
||||
t.Fatalf("err = %v, want scale validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSafeSVGNoResidualRichEffectsRejectsHardTags(t *testing.T) {
|
||||
err := validateSafeSVGNoResidualRichEffects(`<svg><defs><filter id="f"/></defs><rect filter="url(#f)"/></svg>`, "safe.svg")
|
||||
if err == nil || !strings.Contains(err.Error(), "safe SVG") {
|
||||
t.Fatalf("err = %v, want safe SVG hard-tag rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSVGRasterizerScriptUsesSourceThenEmbedded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
sourcePath := filepath.Join("skills", "lark-slides", "scripts")
|
||||
if err := os.MkdirAll(sourcePath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir source: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourcePath, "svg_rasterize_effects.py"), []byte("# source"), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
runtime := newSVGRasterTestRuntime(t, nil)
|
||||
got, err := resolveSVGRasterizerScript(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve source: %v", err)
|
||||
}
|
||||
if got != svgRasterizerSourcePath {
|
||||
t.Fatalf("script path = %s, want source path", got)
|
||||
}
|
||||
|
||||
dir = t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
runtime = newSVGRasterTestRuntime(t, embeddedSVGRasterizerTestFS())
|
||||
got, err = resolveSVGRasterizerScript(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve embedded: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(got)
|
||||
if err != nil {
|
||||
t.Fatalf("read extracted script: %v", err)
|
||||
}
|
||||
if string(data) != "# embedded" {
|
||||
t.Fatalf("extracted script = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func embeddedSVGRasterizerTestFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-slides/scripts/svg_rasterize_effects.py": &fstest.MapFile{Data: []byte("# embedded")},
|
||||
"lark-slides/scripts/svg_effect_classifier.py": &fstest.MapFile{Data: []byte("# classifier")},
|
||||
"lark-slides/scripts/svg_safe_rewrite.py": &fstest.MapFile{Data: []byte("# rewrite")},
|
||||
"lark-slides/scripts/svg_raster_renderer.py": &fstest.MapFile{Data: []byte("# renderer")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSVGRasterAssetConflicts(t *testing.T) {
|
||||
report := &svgPrepareReport{GeneratedAssets: []string{".lark-slides/rasterized/run/page.png"}}
|
||||
err := validateSVGRasterAssetConflicts(map[string]string{"@.lark-slides/rasterized/run/page.png": "boxcn_existing"}, report)
|
||||
if err == nil || !strings.Contains(err.Error(), "--assets conflicts") {
|
||||
t.Fatalf("err = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newSVGRasterTestRuntime(t *testing.T, skills fs.FS) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
f.SkillContent = skills
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, fl := range SlidesCreateSVG.Flags {
|
||||
switch fl.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(fl.Name, 0, "")
|
||||
if fl.Default != "" {
|
||||
cmd.Flags().Set(fl.Name, fl.Default)
|
||||
}
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, "")
|
||||
default:
|
||||
cmd.Flags().String(fl.Name, fl.Default, "")
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{
|
||||
Config: slidesTestConfig(t, ""),
|
||||
Cmd: cmd,
|
||||
Factory: f,
|
||||
}
|
||||
}
|
||||
|
||||
func stubSVGRasterizer(t *testing.T) func() {
|
||||
t.Helper()
|
||||
origResolve := svgRasterizeResolveRuntime
|
||||
origRun := svgRasterizeRunScript
|
||||
svgRasterizeResolveRuntime = func(context.Context) (svgRasterRuntime, error) {
|
||||
return svgRasterRuntime{PythonPath: "python3"}, nil
|
||||
}
|
||||
svgRasterizeRunScript = func(_ context.Context, invocation svgRasterizerInvocation) error {
|
||||
args := map[string]string{}
|
||||
for i := 0; i+1 < len(invocation.Args); i += 2 {
|
||||
args[invocation.Args[i]] = invocation.Args[i+1]
|
||||
}
|
||||
out := args["--output"]
|
||||
assetDir := args["--asset-dir"]
|
||||
reportPath := args["--report"]
|
||||
pngPath := "./" + filepath.ToSlash(filepath.Join(assetDir, "page-001-island-001.png"))
|
||||
if err := writeTestRasterPNG(pngPath); err != nil {
|
||||
return err
|
||||
}
|
||||
safe := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="@` + pngPath + `" x="0" y="0" width="960" height="540"/></svg>`
|
||||
if err := os.WriteFile(out, []byte(safe), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
report := svgPreparePageReport{
|
||||
Mode: "force-page",
|
||||
FallbackReason: "force-page",
|
||||
PNGs: []string{pngPath},
|
||||
Islands: []svgPrepareIslandReport{{
|
||||
ID: "page-001-island-001",
|
||||
Reason: "force-page",
|
||||
OutputPNG: pngPath,
|
||||
Scale: 2,
|
||||
Bytes: 1,
|
||||
RenderMS: 1,
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(reportPath, data, 0o644)
|
||||
}
|
||||
return func() {
|
||||
svgRasterizeResolveRuntime = origResolve
|
||||
svgRasterizeRunScript = origRun
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestRasterPNG(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
@@ -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,40 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md`、`svg-protocol.md`、`style-presets.md`、`svg-seeds.json`、`svg-recipes.json`、`svg-visual-recipes.md`、`svglide-craft.md`、`svg-aesthetic-review.md` |
|
||||
| 大幅改写页面 | 先回读现有 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) |
|
||||
| 上传或使用图片 | Preview 阶段优先多用真实图片增强视觉冲击;可先用公开可访问 http(s)/data 图片或本地 `@./path`,来源/授权只 warning 不阻断;正式交付再替换为授权清晰的 file token / 本地资产 | `slides +media-upload`,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线等 MVP 支持的数据图表 | XML 路径使用原生 `<chart>`;SVG 路径如需原生 chart,使用 `slide:role="chart"` spec marker | `xml-schema-quick-ref.md`、`svg-protocol.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | XML 路径必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素;`slides +create-svg` 明确不支持 whiteboard marker | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)、`svg-protocol.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 — 走 XML 创建/编辑路径时,生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径(`slides +create-svg`)时,MUST 改读 [svg-protocol.md](references/svg-protocol.md),不要求读取 XML schema。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVG:root `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。**
|
||||
|
||||
**CRITICAL — `slides +create-svg` 只允许 chart spec marker,不允许 whiteboard marker。chart marker 必须是 root `<svg>` 的直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">`,且只包含一个 `data-svglide-chart="svglide-chart-inline/v1"` metadata;metadata 必须使用 `data-format="svglide-chart-spec-v1"`、`data-encoding="base64url-json"`,payload 是 canonical JSON chart spec,不是 SXSD `<chart>` XML,也不是 chart snapshot/staticData。CLI 校验 marker 外壳、base64url/hash、JSON 基础结构、bar/line chartType、categories/series/values 长度和数值合法性,不会为 chart 调任何额外 API。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 必须改走 XML/whiteboard 路径。**
|
||||
|
||||
**CRITICAL — SVGlide deck 页数默认值:当用户要求生成 SVG/SVGlide 幻灯片但未说明页数,或使用“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时,默认生成 `10` 页,不要仅因页数缺失而停下来追问。只有用户明确说“一页 / 单页 / onepage / one slide / 只要封面”等单页意图时,才生成 `1` 页;用户给出明确页数时始终服从用户要求。默认 10 页时必须在 `slide_plan.json` 写入 `page_count` 或 `target_slide_count=10`,并包含明确 closing slide。**
|
||||
|
||||
**CRITICAL — 高质量 SVG deck 生成时,MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md)、[style-presets.md](references/style-presets.md)、[svg-visual-recipes.md](references/svg-visual-recipes.md) 和 [svglide-craft.md](references/svglide-craft.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan,再选择 `style_preset` / `style_system`,然后为每页从 [svg-seeds.json](references/svg-seeds.json) 选择 `seed_id`,按 seed 填写 `visual_recipe`、`layout_boxes`、`content_budget`、`reserved_bands`、`one_idea`、`svg_primitives` / `visual_focal_point` / `visual_signature` / `xml_like_risk`,给 `foreignObject` 文本留足安全高度。Public runtime recipe 机器真源是 [svg-recipes.json](references/svg-recipes.json),public seed 机器真源是 [svg-seeds.json](references/svg-seeds.json),研究文档里的 dotted recipe 名称不得直接写入 `slide_plan.json`。SVG 私有 recipe 只属于 `slides +create-svg` route:共享 `slide_plan.json` 不得写 exact private recipe id,只能写抽象 `visual_recipe=route_private` 并由 route-private sidecar 解析;XML/SXSD 路径不得读取或调用这些私有 recipe。生成器必须在写 SVG 前做 preflight-aware 自检:由实际组件 manifest 反推出 primitives,按 `content_density_contract` 计数,检查主体元素 safe area / text bbox,不要只靠最终 `svg_preflight.py` 兜底。Preview 阶段默认必须使用丰富真实图片资产,并 SHOULD 优先根据用户 query / deck 主题 / 章节标题去网络检索和拉取强相关图片;公开图、场景图、产品图、截图、纹理/材质、图鉴图均可作为占位视觉。版权/授权不作为 preview 阻断,但要在 `asset_contract` 里标记 `retrieval_query`、`source_url` 和 `preview_unverified`;正式交付再替换为授权清晰的本地 `@./path` / file token。若目标 live lane 尚未证明支持 image token,必须先用纯 SVG 页和图片页 smoke;图片上传成功但 `/slide` 失败时,可生成单独 `online-pure` 发布版,用 SVG-native 几何/渐变替代图片区域,但不得删除 authoring preview 中的真实图片,且交付时必须说明降级和后续 image-token 修复项。相邻页面要显著换版式且 8 页以上至少使用 5 种 visual recipe family;如果 agent 支持本地浏览器预览,SHOULD 生成并打开 `preview.html`,并按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 检查明显视觉问题;调用 API 前必须跑本地 preflight(优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py),create-svg route 使用 `--route-manifest references/routes/create-svg/route.manifest.json --report-scope public`),live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
|
||||
|
||||
**CRITICAL — OD 本地化硬门禁:`slides +create-svg` 不得从空白 SVG 自由摆组件。每页必须先从 [svg-seeds.json](references/svg-seeds.json) 选择 seed skeleton,并在 `slide_plan.json` 显式继承 `layout_skeleton_id`、`layout_boxes`、`content_budget` / `text_capacity`、`text_budget_by_role`、`reserved_bands.footer`、`footer_safe_zone` 和 `vertical_text_policy`。内容放不下时,只能删减、拆页或换 seed;不得放宽 seed budget、删除 required layout role、把 footer band 改成正文区,或用缩小字号/竖排正文/writing-mode 解决溢出。默认禁止竖排正文、`writing-mode`、`text-orientation` 和 90° 旋转长文本;只有 seed 明确允许的短装饰标签可使用,并必须通过本地 preview review。**
|
||||
|
||||
**CRITICAL — SVGlide 高质量生成必须读取 [style-presets.md](references/style-presets.md),并从 [style-presets.json](references/style-presets.json) 选择一个 deck-level `style_preset`。`style_preset` 只表达视觉语言,不替代 `visual_recipe`;`visual_recipe` 的选择和安全效果边界以 [svg-visual-recipes.md](references/svg-visual-recipes.md) 为准。生成顺序是 semantic plan -> style_preset/style_system -> deck arc -> page visual_recipe -> layout boxes -> SVG;`visual_recipe` 可以反向校验 preset 是否适合当前页。每页必须声明 `visual_signature` 和 `svg_effects`,说明这一页相对普通 XML/PPT 模板的 SVG 视觉优势。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**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 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py),SVG 本地预览后按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 做审美和重复问题复核。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
@@ -80,7 +93,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 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md)、[`style-presets.md`](references/style-presets.md)、[`svg-seeds.json`](references/svg-seeds.json)、[`svg-recipes.json`](references/svg-recipes.json)、[`svg-visual-recipes.md`](references/svg-visual-recipes.md)、[`svglide-craft.md`](references/svglide-craft.md)、[`svg-aesthetic-review.md`](references/svg-aesthetic-review.md)
|
||||
- 编辑:[`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 +117,7 @@ lark-cli auth login --domain slides
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
@@ -128,7 +141,10 @@ lark-cli auth login --domain slides
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- Preview 阶段不要因为版权/授权缺失而退回纯矢量;推荐先把用户 query、deck 标题和每页章节主题拆成图片检索词,去网络拉取强相关真实图片、网页截图、产品截图或图库图做视觉占位。必须记录 `retrieval_query`、来源 URL,或标记 `license=preview_unverified`,并避免误导性商标背书、敏感肖像和明显不适当素材。正式交付时再替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。
|
||||
- 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。
|
||||
- Preview/MVP 阶段图片来源/授权/外链问题不作为 `svg_preflight.py` 的 hard blocker,但必须保留 warning 并在 live readback 后检查图片是否可见;正式交付仍优先用本地 `@./path` 自动上传或 file token。
|
||||
- live lane 图片 token 不稳定时,不要把 authoring preview 改成无图版本;应另建 online-pure 发布目录,并在最终回复中说明图片 token 兼容性边界。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
@@ -137,6 +153,7 @@ lark-cli auth login --domain slides
|
||||
|------|----------|
|
||||
| 简单 XML(1-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 +174,20 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 澄清主题、受众、页数、风格;SVGlide 模糊页数按默认 10 页处理,不因页数缺失单独阻塞;模板需求按“模板与脚本优先流程”处理
|
||||
- 纯主题输入不能直接跳到页面绘制;先形成结构化资料层,至少记录 `input_profile`、`source_brief`、事实/数字状态和缺来源处理策略
|
||||
- SVGlide 生成前锁定核心决策:`canvas`、`page_count`、`audience`、`narrative_mode`、`visual_style`、`asset_strategy.mode`、`chart_policy`、`icon_policy`
|
||||
- 读取 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`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
- plan 字段、路径命名、模板边界、`strategy_locks`、`source_pack`、`asset_need` 和 chart/icon policy 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
|
||||
- 逐页消费 plan:key_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 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML,仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
@@ -264,6 +283,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/>`,不改变页序 |
|
||||
|
||||
@@ -279,12 +299,34 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:简单短 XML(1-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. **Preview 阶段图片要优先丰富,不要纯矢量兜底**:XML 路径使用 `<img src="...">`;SVG 路径使用 `<image slide:role="image" href="...">`。推荐流程是「从用户 query / 页面主题生成图片检索词 → 网络拉取主题强相关图片 → 存成本地资产 → 用 `slides +media-upload` 上传,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。Preview/MVP 阶段 `svg_preflight.py` 对 http(s) / data 图片、来源/授权不完整只 warning,不阻断;如果时间紧,可先保留公开可访问图片 URL 做视觉验证,并在 `asset_contract` 标记 `retrieval_query`、`source_url` 和 `preview_unverified`。正式交付再统一替换为本地 `@./path` 或 file token。**图片最大 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 路径以 [svg-protocol.md](references/svg-protocol.md) 为准。
|
||||
|
||||
## SVG 排障
|
||||
|
||||
`slides +create-svg` 失败时,优先查看错误中是否包含 `svglide_error` 或服务端 `SVGLIDE_ERROR_JSON:` marker。常见修复:
|
||||
|
||||
- `svg_validation_error`:按 [svg-protocol.md](references/svg-protocol.md) 修正 root `<svg>`、`xmlns:slide`、`slide:role` 或不支持元素。
|
||||
- 图片不显示:确认 `<image>` 使用 canonical `href="file_token"`,不要保留 `xlink:href`;本地图片用 `href="@./image.png"` 让 CLI 上传,或用 `--assets assets.json` 提供 token 映射。
|
||||
- 有 file token 仍失败:确认 SVG 内存在 transport metadata:`<metadata data-svglide-assets="true"><img src="同一个 file_token" /></metadata>`;`+create-svg` 会自动注入,手写 SVG 时不要删除。
|
||||
|
||||
@@ -4,15 +4,53 @@
|
||||
|
||||
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
|
||||
|
||||
## SVGlide Asset Modes
|
||||
|
||||
`slides +create-svg` 的资产模式只写在现有 `asset_strategy.mode` 和 page-level
|
||||
`visual_plan.asset_contract.mode` 里。不要新增平行的顶层 `asset_mode` 或
|
||||
`image_policy`。
|
||||
|
||||
允许的 mode:
|
||||
|
||||
- `authoring_preview_rich`:默认 authoring 模式。只要有助于页面表达,就使用丰富 preview 资产;同时记录来源元数据,并把未知授权标为 preview-only。
|
||||
- `online_pure_fallback`:live lane 尚未证明 image token 可用时的发布 fallback。保留丰富 authoring preview,另行准备 pure-SVG 发布输出。
|
||||
- `production_asset_strict`:正式交付模式。每张图片、logo 和资产都必须有可审计来源、授权、本地路径或 file token,以及使用页。
|
||||
|
||||
迁移期兼容映射:
|
||||
|
||||
- `preview` 映射到 `authoring_preview_rich`。
|
||||
- `production` 映射到 `production_asset_strict`。
|
||||
|
||||
P0 preflight 可以 warning 并映射旧值。等示例和文档迁完后,旧值应升级为 error。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- SVG decks must keep deck-level image and icon strategy consistent. Use existing `asset_strategy.mode` for image lane; do not add a parallel `image_policy`.
|
||||
- `asset_strategy.icon_policy` or top-level `icon_policy` should define one semantic icon family, line/fill style, and mapping rule. Icons should label concepts, roles, steps, or status; they are not filler decoration.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
|
||||
|
||||
Recommended SVGlide policy shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_strategy": {
|
||||
"mode": "authoring_preview_rich",
|
||||
"image_strategy": "svg",
|
||||
"fallback": "prefer SVGlide-safe vector primitives when source or license is unavailable"
|
||||
},
|
||||
"icon_policy": {
|
||||
"style": "single_consistent_family",
|
||||
"semantic_mapping_required": true,
|
||||
"consistency_rule": "one deck uses one icon stroke/fill language"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JSON Shape
|
||||
|
||||
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
|
||||
|
||||
967
skills/lark-slides/references/lark-slides-create-svg.md
Normal file
967
skills/lark-slides/references/lark-slides-create-svg.md
Normal file
@@ -0,0 +1,967 @@
|
||||
# slides +create-svg
|
||||
|
||||
从一个或多个 SVGlide SVG 文件创建飞书幻灯片:
|
||||
|
||||
```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>`。
|
||||
|
||||
注意:图片上传成功只证明 Drive media upload 成功,不证明目标 live lane 已能解析
|
||||
SVGlide image token。若某个 lane 中纯 SVG 页可创建、但带 `@./assets/...`
|
||||
的页面在上传后进入 `/slide` 阶段报 `nodeServer internal error`,应按
|
||||
image-token 兼容性问题处理,而不是继续修改图片文件本身。`ppe_pure_svg`
|
||||
的内部路由和已知边界记录在
|
||||
[routes/create-svg/private/ppe-pure-svg-live.md](routes/create-svg/private/ppe-pure-svg-live.md),
|
||||
这份知识只属于 `slides +create-svg` route,XML/SXSD 路径不得调用。
|
||||
|
||||
预上传资源可用 `--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 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
|
||||
|
||||
### Open Design 方法在 SVGlide 中的边界
|
||||
|
||||
SVGlide 借鉴 Open Design 的“受约束创作系统”,不是照搬 HTML deck runtime。
|
||||
|
||||
| Open Design 机制 | SVGlide 对应做法 |
|
||||
|-|-|
|
||||
| deck mode / skill routing | 只有 `slides +create-svg` route 加载 SVG 私有规则;XML/SXSD 路径不得读取 SVG private recipe。 |
|
||||
| template seed / layout catalog | 使用 `svg-seeds.json` 中的 `seed_id + layout_skeleton + visual_recipe + layout_boxes + content_budget + text_budget_by_role + footer_safe_zone + vertical_text_policy` 作为 SVG seed,不复制 HTML section。 |
|
||||
| design system / tokens | 使用 `style-presets.json` 和 `style_system`,翻译为显式 SVG `fill` / `stroke` / 字号 / 文本承载面。 |
|
||||
| craft | 读取 `svglide-craft.md`,约束排版、SVG advantage、反 AI 味和资产 lane。 |
|
||||
| artifact lint / publication guard | 使用 `svg_preflight.py`、preview review、dry-run、live readback 和截图/native oracle。 |
|
||||
|
||||
不要照搬 HTML deck 的 `1920x1080` stage、`runtime.js`、keyboard navigation、localStorage、CSS animation、Chart.js 或 canvas FX。SVGlide 只复制 Open Design 的生成控制体系:先选 seed/layout,再替换内容,保留结构和预算,不从空白画布开始。SVGlide 的硬边界仍是 `960x540` 协议化 SVG,并由 Slides server 转成真实线上文档快照。
|
||||
|
||||
### Seed Skeleton Contract
|
||||
|
||||
seed skeleton 不是灵感参考,而是初始版式骨架。生成器必须保留 seed 的 `required_layout_box_roles`、关键 `layout_boxes`、`reserved_bands.footer`、`footer_safe_zone` 和文本容量上限;允许在 seed 容忍范围内微调坐标,但不得删除角色、把 footer 改成正文区,或用 freestyle 坐标绕过 seed。
|
||||
|
||||
每页必须先选 `seed_id`,再填内容。内容放不下时,处理顺序是删减、拆页、换 seed;不得放宽 seed 的 `content_budget`、`text_budget_by_role`、`footer_safe_zone` 或 `vertical_text_policy`。新增页面结构时,先新增/更新 `svg-seeds.json`,再生成 SVG。
|
||||
|
||||
### 与现有规划层对齐
|
||||
|
||||
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
|
||||
|
||||
当需要把 source、strategy、generate、prepare、preview、preflight、preview_lint、chart_verify、quality_gate、dry-run、PPE proof、live create 和 readback 串成可恢复流水线时,项目级 `project_manifest.json`、`state.json`、`prepared/` 和 `receipts/` 规则见 [svglide-project-pipeline.md](svglide-project-pipeline.md)。该文档只管本地执行状态,不替代 `slide_plan.json`、SVG 协议或 route-private 规则。
|
||||
|
||||
在通用 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},
|
||||
"input_profile": {"input_type": "topic", "source_status": "user_prompt_only"},
|
||||
"source_pack": {
|
||||
"schema_version": "svglide-source-pack/v1",
|
||||
"source_status": "user_prompt_only",
|
||||
"numeric_claim_policy": "cite_or_remove",
|
||||
"items": [{"id": "brief", "type": "user_prompt", "status": "available", "source_ref": "source/brief.md"}]
|
||||
},
|
||||
"narrative_mode": "briefing",
|
||||
"visual_style": "data_journalism",
|
||||
"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"
|
||||
},
|
||||
"strategy_locks": [
|
||||
{"id": "canvas", "decision": {"width": 960, "height": 540}, "evidence_ref": "plan.canvas"},
|
||||
{"id": "page_count", "decision": 10, "evidence_ref": "plan.page_count"},
|
||||
{"id": "audience", "decision": "inferred_from_brief", "evidence_ref": "plan.audience"},
|
||||
{"id": "narrative_mode", "decision": "briefing", "evidence_ref": "plan.narrative_mode"},
|
||||
{"id": "visual_style", "decision": "data_journalism", "evidence_ref": "plan.visual_style"},
|
||||
{"id": "style_preset", "decision": "raw_grid", "evidence_ref": "plan.style_preset"},
|
||||
{"id": "asset_strategy", "decision": "svg", "evidence_ref": "plan.asset_strategy.mode"},
|
||||
{"id": "chart_policy", "decision": "data_relationship_first", "evidence_ref": "plan.chart_policy"}
|
||||
],
|
||||
"chart_policy": {
|
||||
"selection_rule": "data_relationship_first",
|
||||
"requires_data_coordinate_check": true,
|
||||
"receipt": "receipts/chart-verify.json"
|
||||
},
|
||||
"icon_policy": {
|
||||
"style": "single_consistent_family",
|
||||
"semantic_mapping_required": true
|
||||
},
|
||||
"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>/pages/page-001.svg"}
|
||||
],
|
||||
"preflight": {
|
||||
"command": "python3 skills/lark-slides/scripts/svg_preflight.py --route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json --report-scope public --plan .lark-slides/plan/<deck-id>/slide_plan.json --input .lark-slides/plan/<deck-id>/pages/page-001.svg",
|
||||
"status": "pending"
|
||||
},
|
||||
"readback_verification": {
|
||||
"status": "pending",
|
||||
"checks": ["page_count", "blank_page", "canvas_bounds", "text_overlap", "asset_tokens", "closing_slide"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
每页还必须有 `visual_design_contract`。它不是新的一套平行 plan,而是把现有
|
||||
`seed_id`、`visual_recipe`、`renderer_id`、`design_pattern_selection` 和页面
|
||||
视觉目标锁成可验收字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"visual_design_contract": {
|
||||
"schema_version": "svglide-visual-design-contract/v1",
|
||||
"page_kind": "chart_takeaway",
|
||||
"visual_thesis": "这一页视觉上要让用户记住的结论",
|
||||
"composition_archetype": "data_stage",
|
||||
"pattern_bundle": ["chart.bar_chart"],
|
||||
"density": "dense",
|
||||
"primary_motif": "takeaway_chart",
|
||||
"required_visual_evidence": ["chart_geometry", "insight_strip", "full_page_archetype"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`required_visual_evidence` 必须由 renderer 写入
|
||||
`receipts/emitted_components.json` 的 page-level component `effects`、
|
||||
`primitives`、`renderer_id` 或 component id。`quality_gate` 会核对这些证据;
|
||||
只在 plan 里声明但 SVG/component report 没有落地时不得进入 dry-run/live。
|
||||
|
||||
Renderer 的可执行覆盖面由 [svglide-renderer-registry.json](svglide-renderer-registry.json) 声明。只有 `status=active` 且能映射到现有 `svg-seeds.json` 与 `svg-recipes.json` 的 renderer 才能被自动选择;candidate renderer 只能用于实验或手工样张。
|
||||
|
||||
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes,不要照搬模板 XML,也不要读取完整模板 XML。
|
||||
|
||||
SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 3,
|
||||
"renderer_id": "dashboard_scorecard",
|
||||
"page_rhythm": "dense",
|
||||
"page_type": "kpi_overview",
|
||||
"chart_type": "kpi_cards",
|
||||
"main_visual_anchor": "2x2 KPI scorecard with hero numbers and micro bars",
|
||||
"annotation_zone": {"role": "right_observation", "x": 690, "y": 126, "width": 206, "height": 246},
|
||||
"seed_id": "dashboard_kpi_grid",
|
||||
"layout_skeleton_id": "dashboard_kpi_grid_skeleton",
|
||||
"layout_family": "dashboard",
|
||||
"visual_recipe": "fake_ui_dashboard",
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 48, "y": 34, "width": 864, "height": 48},
|
||||
{"id": "primary-kpi", "role": "metric", "x": 64, "y": 106, "width": 260, "height": 128},
|
||||
{"id": "secondary-grid", "role": "grid", "x": 348, "y": 106, "width": 548, "height": 128},
|
||||
{"id": "chart-row", "role": "chart", "x": 64, "y": 258, "width": 832, "height": 150},
|
||||
{"id": "body", "role": "body", "x": 64, "y": 426, "width": 832, "height": 56},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24}
|
||||
],
|
||||
"content_budget": {"max_visible_chars": 360, "title": 34, "body": 230, "footer": 36, "max_text_boxes": 14},
|
||||
"text_capacity": {"max_visible_chars": 360, "title": 34, "body": 230, "footer": 36, "max_text_boxes": 14},
|
||||
"text_budget_by_role": {
|
||||
"title": {"max_chars": 34, "max_lines": 2, "max_boxes": 1, "min_font_px": 22},
|
||||
"body": {"max_chars": 230, "max_lines": 5, "max_boxes": 1, "min_font_px": 12},
|
||||
"metric": {"max_chars": 96, "max_lines": 3, "max_boxes": 4, "min_font_px": 12},
|
||||
"footer": {"max_chars": 36, "max_lines": 1, "max_boxes": 1, "min_font_px": 9}
|
||||
},
|
||||
"reserved_bands": {"footer": {"x": 48, "y": 496, "width": 864, "height": 32}},
|
||||
"footer_safe_zone": {"x": 48, "y": 496, "width": 864, "height": 32, "allowed_roles": ["footer"], "min_gap_above_px": 8},
|
||||
"vertical_text_policy": {"mode": "deny", "allowed_roles": [], "max_chars": 0, "max_lines": 0},
|
||||
"one_idea": "operating status is readable through dashboard structure",
|
||||
"key_message": "dashboard structure turns scattered metrics into one operating view",
|
||||
"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",
|
||||
"reference_asset": {"source": "svglide_design_pattern", "asset_id": "chart.kpi_cards", "usage": "page-type geometry only; do not copy raw SVG paths"},
|
||||
"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: authoring_preview_rich|online_pure_fallback|production_asset_strict, 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",
|
||||
"source_refs": ["brief"],
|
||||
"asset_selection_reason": "chart.kpi_cards is selected because it matches page_type, density, and geometry needs",
|
||||
"rejected_asset_alternatives": [
|
||||
{"asset_id": "chart.timeline", "reason": "rejected because this page is metric-led rather than phase-led"}
|
||||
],
|
||||
"chart_decision": {
|
||||
"chart_type": "bar_chart",
|
||||
"reason": "bar chart fits category comparison and supports one takeaway",
|
||||
"data_ref": "brief",
|
||||
"anchor_role": "chart",
|
||||
"bbox_tolerance_px": 12
|
||||
},
|
||||
"chart_verification": {
|
||||
"status": "required",
|
||||
"receipt": "receipts/chart-verify.json",
|
||||
"checks": ["plot_area", "mark_count", "label_alignment", "scale_mapping"]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### SVGlide Design Pattern Lessons
|
||||
|
||||
SVGlide design pattern 的可借鉴点是生成流程和页型合同,不是 PPTX/DrawingML 导出链路,也不是复制现成 SVG。SVGlide 生成时必须把经验收敛成 protocol-safe 字段:
|
||||
|
||||
- `page_rhythm`: `anchor` / `breathing` / `dense`。8 页以上 deck 不能全是 dense 或全是同一张卡片结构;封面、目录、章节、收尾应形成节奏。
|
||||
- `page_type`: 页面叙事类型,例如 `cover`、`editor_note`、`contents`、`chart_takeaway`、`chapter`、`closing`。
|
||||
- `chart_type`: 当页面主视觉是图表时必须声明,例如 `kpi_cards`、`bar_chart`、`dumbbell_chart`、`bubble_chart`、`donut_chart`、`horizontal_bar_chart`、`comparison_table`、`sankey_chart`、`pareto_chart`、`hub_spoke`、`dual_axis_line_chart`、`quadrant_text_bullets`。
|
||||
- `main_visual_anchor`: 一句话说明这页的主视觉锚点。不能写成“高级图表”或“信息可视化”;必须能被截图肉眼确认。
|
||||
- `annotation_zone`: 右侧观察、底部 source、图例或 callout 的固定区域。密集页靠图表承载信息,文字只做解释。
|
||||
- `reference_asset`: 记录借鉴的 SVGlide design pattern layout/chart/image/style 资产,只表示结构和选择理由,不表示复制 raw SVG。
|
||||
|
||||
经验规则:
|
||||
|
||||
- 先选页型和图表,再写 SVG;不要从空白画布临场拼元素。
|
||||
- 图表页必须有真实 chart geometry。声明了 `sankey_chart` 就要有 source/node/sink/flow;声明了 `bar_chart` 就要有 axis/bar/value/callout。
|
||||
- 密集页的复杂度来自图表、表格、flow、hub、quadrant 等结构,不来自增加装饰线或文字盒。
|
||||
- 封面和章节页可以使用图片/纹理作为 atmosphere,但图片必须预留负空间、无可见文字,并由 `asset_contract` 记录来源和替代策略。
|
||||
- 红色/高饱和强调色只打关键数字、风险信号或章节标记;不要让所有图形同等抢眼。
|
||||
|
||||
经验归属:
|
||||
|
||||
- 本文件只保留 create-svg 的入口流程、plan 字段边界和 CLI 发布约束。
|
||||
- 页型、图表几何、视觉节奏和 SVGlide design pattern 借鉴规则归 [svglide-craft.md](svglide-craft.md)。
|
||||
- 项目目录、stage、timing、prepare resume、asset usage receipt 和 live guard 归 [svglide-project-pipeline.md](svglide-project-pipeline.md)。
|
||||
- 自动门禁和 archetype drift 归 [validation-checklist.md](validation-checklist.md)。
|
||||
- 截图/人工审美判断和 label/backing 遮挡经验归 [svg-aesthetic-review.md](svg-aesthetic-review.md)。
|
||||
|
||||
### Runtime Recipe Registry
|
||||
|
||||
Public `visual_recipe` 的机器真源是 [svg-recipes.json](svg-recipes.json),`svg_preflight.py` 会直接读取它。[svg-visual-recipes.md](svg-visual-recipes.md) 是人类可读的选择指南,不是第二份 runtime catalog。
|
||||
|
||||
研究文档可以使用内部 taxonomy 讨论设计,但写入 `slide_plan.json` 前必须映射到 `svg-recipes.json` 中的 public underscore id,例如 `infographic_scorecard`、`geometric_composition`、`path_flow`、`fake_ui_dashboard`。公开文档、公开 fixtures 和 public preflight report 不应暴露 route-private 或 research dotted id。
|
||||
|
||||
### Runtime Seed Registry
|
||||
|
||||
SVGlide page seed 的机器真源是 [svg-seeds.json](svg-seeds.json),`svg_preflight.py` 会直接读取它。seed 是 Open Design 经验的本地化控制层,不是可复制的 HTML 模板。
|
||||
|
||||
每个 seed 至少包含:
|
||||
|
||||
- `page_use`: 适用页型。
|
||||
- `visual_recipe` / `layout_family`: seed 对应的结构类型。
|
||||
- `layout_boxes`: 可复用的 title/body/visual/chart/footer 等盒子坐标。
|
||||
- `content_budget` / `default_text_capacity`: 全页、title、body、footer 和 text box 数量预算。
|
||||
- `reserved_bands`: footer/source/legal/page mark 等保留区域。
|
||||
- `required_primitives`: seed 需要 SVG source 实现的 primitives。
|
||||
- `quality_rules`: seed 的本地审查规则。
|
||||
|
||||
生成器必须先选 `seed_id`,再替换内容。允许微调 layout boxes,但必须保留 seed 的关键角色和 reserved band;如果需要完全不同的信息结构,换 seed,不要在原 seed 上硬塞内容。
|
||||
|
||||
### 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
|
||||
-> style_preset + style_system
|
||||
-> deck arc
|
||||
-> page seed_id from svg-seeds.json
|
||||
-> page visual_recipe
|
||||
-> layout boxes
|
||||
-> SVG source
|
||||
-> svg_preflight.py --route-manifest ... --report-scope public --plan
|
||||
```
|
||||
|
||||
`visual_recipe` 可以反向校验 `style_preset`:如果页面结构需要强数据密度、路径流动或真实图片叙事,而 preset 的留白、色彩或 text surface 无法承载,应调整 preset 或重写该页视觉计划。
|
||||
|
||||
`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 runtime recipe summary
|
||||
|
||||
SVG 不是普通矢量图文件的传输外壳。每页都必须选择一个 `visual_recipe`,并在 `svg_primitives` 中声明真实会绘制的 SVGlide-safe primitives。`renderer_id` 负责几何布局命名;`visual_recipe` 负责说明这页为什么值得走 SVG。
|
||||
|
||||
本节只是 `svg-recipes.json` 的人工摘要,帮助快速选型;不要手工维护为第二份 runtime catalog。实际生成和校验以 [svg-recipes.json](svg-recipes.json) 为准,生成前再读 [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 式卡片。
|
||||
|
||||
### Create-SVG Route-Private Recipes
|
||||
|
||||
SVG 私有 recipe 只属于 `slides +create-svg` route,用于鼓励更强的 SVG-native 艺术处理。它们不是 XML/SXSD 的共享知识,也不能写进普通共享 plan。
|
||||
|
||||
- Public/shared `slide_plan.json` 只能使用 public recipe id,或在 create-svg route 下使用抽象值 `visual_recipe: "route_private"`。
|
||||
- Exact private recipe id 只允许出现在 route-private manifest、route-private selection sidecar、private fixture 或 internal report。
|
||||
- 当使用 `route_private` 时,必须传入 create-svg route manifest;如果需要解析到具体 private recipe,还必须传入 route-private selection sidecar。没有 manifest/sidecar 时 preflight fail-closed,不猜测、不回退。
|
||||
- Public preflight report 使用 `--report-scope public`,不得输出 exact private recipe id、manifest path、selection path 或 private enum list。
|
||||
- XML 创建、普通 `+create`、共享 planning docs 和公开 fixtures 不得读取 route-private manifest,也不得调用 SVG 私有 recipe。
|
||||
|
||||
### 生成阶段 Fail-Fast Gate
|
||||
|
||||
`slide_plan.json` 不是说明文档,而是生成阶段的硬契约。生成器必须先通过 plan gate,再渲染 SVG;本地 `svg_preflight.py --plan` 失败时禁止调用 live API。
|
||||
|
||||
每页 SVG plan 必填:
|
||||
|
||||
| Field | 作用 | 失败后处理 |
|
||||
|---|---|---|
|
||||
| `renderer_id` | 标识具体渲染器/几何结构 | 换真实 renderer,不用 `two_column_1` 这类假命名 |
|
||||
| `seed_id` | 绑定 `svg-seeds.json` 中的 page seed | 先选 seed,再写内容;不要从空白页直接开始 |
|
||||
| `layout_skeleton_id` | 绑定 seed 的机器化版式骨架 | 不匹配或缺失时从 seed 复制;大改结构必须换 seed |
|
||||
| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 |
|
||||
| `visual_recipe` | 说明这页为什么值得走 SVG | 从 `svg-recipes.json` 选择,不能自造枚举 |
|
||||
| `visual_design_contract` | 锁定视觉 thesis、composition archetype、motif 和必须落地的 evidence | 补齐合同,或改 renderer/component report 证明 evidence |
|
||||
| `layout_boxes` | seed 派生的标题、正文、视觉、chart、footer 坐标 | 缺角色或 bbox 非正数时先修布局 |
|
||||
| `content_budget` / `text_capacity` | seed 派生的文本容量预算 | 超量时删内容、拆页或换 seed |
|
||||
| `text_budget_by_role` | seed 派生的 role 级文本预算 | 局部超量时删内容、拆页或换 seed,不用缩字/竖排硬塞 |
|
||||
| `one_idea` / `key_message` | 单页只承载一个核心观点 | 先收敛消息,再替换 seed 内容 |
|
||||
| `reserved_bands` | footer/source/legal/page mark 等保留区域 | body/callout 不得侵入 footer band |
|
||||
| `footer_safe_zone` | footer band 的 allowed roles 和上方净距 | 只有 footer/source/legal/page mark 可以进入;正文、图表标签和图例必须离开 |
|
||||
| `vertical_text_policy` | 竖排/旋转文本策略 | 默认 deny;只有 seed 明确允许的短装饰标签可使用 |
|
||||
| `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[]` 必须记录 `seed_id`、`layout_skeleton_id`、`layout_family`、`layout_boxes`、`content_budget` 或 `text_capacity`、`text_budget_by_role`、`reserved_bands.footer`、`footer_safe_zone`、`vertical_text_policy`、`one_idea` 或 `key_message`、`visual_recipe`、`visual_intent`、`visual_focal_point`、`visual_design_contract`、`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: `seed_id` 必须来自 `svg-seeds.json`,seed 的 `visual_recipe` / `layout_family` 必须和 plan 一致。`route_private` 只隐藏具体 private recipe,不跳过 seed/layout/budget 控制。
|
||||
- MUST: seed skeleton 是版式合同,不是灵感参考。生成器必须继承 seed 的 `required_layout_box_roles`、`layout_skeleton_id`、`layout_boxes`、`text_budget_by_role`、`footer_safe_zone` 和 `vertical_text_policy`;内容放不下时删减、拆页或换 seed,不得放宽 seed budget 或自由重画骨架。
|
||||
- MUST: 任何 footer/source/note/legal/page mark 文本必须落在 `reserved_bands.footer` 内;正文、callout、chart labels、图例、标签和解释文字不得压进 footer band,并应与 `footer_safe_zone` 顶部保持至少 `min_gap_above_px` 净距。背景和装饰可穿过 footer band,但不得承载关键文字。
|
||||
- MUST: 默认禁止 `writing-mode`、`text-orientation`、竖排正文和 90° 旋转长文本;不要用竖排解决窄列或溢出。只有 seed 的 `vertical_text_policy` 明确允许时,才可用短标题、章节号或装饰性标签,并且必须通过 preview review。
|
||||
- MUST: 标签、chip、badge、装饰块不得覆盖标题、正文、竖排说明或图表标签;如果需要强调,用独立 layout box 或扩大文本承载面,不要把标记压在可读文本上。
|
||||
- MUST: 不要把可见内容写进 `display:none`、`visibility:hidden`、近零 opacity、`overflow:hidden`、`clip-path` 或 `mask` 裁切的 text box;`svg_preflight.py` 会报 `hidden_visible_text` 或 `clipped_visible_text`。
|
||||
- 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,但应只承担背景/承载作用,不承载关键文本。
|
||||
- MUST: 承载可见文字的卡片、callout、badge、panel 和 insight box 必须有 `text_surface_contract`,不能默认裸白底黑字。至少使用一种 style-preset 派生处理:tinted fill、accent rail、visible stroke、glass overlay、number/icon marker、深色背板或与背景共用的承载色。
|
||||
- MUST: `titleBox` 是不可侵入区域。callout、badge、panel、connector、装饰线和图片标签与标题框底部至少保持 24px 视觉间距;如果标题是两行或大号字,优先扩大间距或移动卡片,而不是压缩标题。
|
||||
- MUST: connector line/path 只连接到卡片、节点或图表边缘,不能穿过 title、中心文字、callout 文案或图例。若线条只是背景纹理,降低 opacity 并在 plan 中标为 decorative,不要让它承担 connector_flow。
|
||||
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px,曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
|
||||
- SHOULD: 把每页的 `safe`、`titleBox`、`visualBox`、`textBox`、`calloutBox`、`connectorPath` 等布局盒保存为可检查数据,便于自动计算越界、重叠、标题压力和 connector 穿字。
|
||||
|
||||
推荐生成顺序:
|
||||
|
||||
```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` 前,生成一个本地 `preview.html`,把每页 SVG 按 16:9 画布嵌入,并展示页码、标题、`renderer_id` / `visual_recipe`、图片资产状态、preview-only 图片来源和明显 warning。
|
||||
- SHOULD: 如果当前 agent、IDE 或浏览器工具支持打开本地文件,打开 `preview.html` 进行人工或截图式预览,优先检查:
|
||||
- 页面是否空白、明显裁切或整体偏大。
|
||||
- 标题、正文、图片和装饰元素是否重叠。
|
||||
- 白色/浅色文字是否压到浅色背景或图片亮部。
|
||||
- 相邻页面是否版式过度重复。
|
||||
- 信息密度是否明显不足,尤其是高密度页是否真的有 matrix/table/timeline/dashboard/flow/risk grid。
|
||||
- 结尾页是否存在。
|
||||
- 图片是否显示,是否有破图、空图片框、图片过少或 preview-only 来源未记录。
|
||||
- SHOULD: 在最终产物目录记录 `preview.html` 路径;如果未生成或无法打开,说明原因,并继续执行 preflight / dry-run / readback。
|
||||
- 在 project runner quality lane 中,`preview_lint` 是 hard gate:缺少
|
||||
`preview.html` 或出现高置信 objective lint error 时,必须在 `dry_run`
|
||||
前失败。手工排障路径仍可在无 preview 时继续 preflight、dry-run 和 readback,
|
||||
但不得进入 guarded live creation,也不得标记为 production/golden 交付。
|
||||
- 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 --route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json --report-scope public --plan .lark-slides/plan/<deck-id>/slide_plan.json --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
|
||||
- 生成脚本和 preflight 不得并行读写同一个 output 目录;必须等 SVG 文件全部写完后再跑 preflight,避免读到中间态导致误判。
|
||||
- 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 primitive,preflight 必须失败。
|
||||
- 禁止零尺寸元素;文本框、图片、卡片和圆/椭圆必须有正向宽高,不能生成 `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。
|
||||
- 对尚未证明支持 image token 的 live lane,先用一页纯 SVG 和一页含 `@./assets/...` 的图片 SVG 分别 smoke;如果图片页上传成功但 `/slide` 失败,可以为线上发布生成单独的 `online-pure` SVG 目录,用 SVG-native 几何和渐变替代图片区域,但必须保留原图片预览版本并在交付说明里标注降级。
|
||||
- 禁止 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 -> generate assets -> generate SVG files
|
||||
-> optional preview.html and browser preview when supported
|
||||
-> local preflight with --plan -> 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 内。
|
||||
- 同组元素使用同一个父盒推导坐标。
|
||||
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
|
||||
- 图表页必须检查 `chart_decision.anchor_role` 是否对应图表 layout box;有数据源时还要核对柱高、折线点、堆叠比例、雷达顶点或 flow 宽度是否与源数据映射一致。
|
||||
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `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`。
|
||||
|
||||
### 文本承载面美学
|
||||
|
||||
文本框不是视觉主元素,但承载文字的 surface 是页面层级的一部分。生成器必须先决定 surface 类型,再放置文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"text_surface_contract": {
|
||||
"surface_kind": "accent_rail_card | tinted_panel | glass_overlay | dark_backing | label_chip | metric_tile",
|
||||
"min_gap_to_title": 24,
|
||||
"padding": {"x": 14, "y": 12},
|
||||
"allow_plain_white_panel": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `accent_rail_card`: 卡片左侧或顶部有 6-10px 强调条,适合 callout、洞察点和团队分工。
|
||||
- `tinted_panel`: 使用 preset 派生浅底和细描边,适合普通信息组;不要用纯白裸框。
|
||||
- `glass_overlay`: 图片上方使用半透明浅底或深底,并与图片遮罩同色系。
|
||||
- `dark_backing`: 白字必须完整落在深色承载底内。
|
||||
- `label_chip`: 仅承载短标签,避免承载解释句。
|
||||
- `metric_tile`: KPI 数字可用高对比底色,但仍要有角色色、分隔线或图形关系。
|
||||
|
||||
`svg_preflight.py` 会把以下问题作为 error:`plain_white_text_panel`、`title_surface_pressure`、`connector_crosses_text`。这些错误必须修 source SVG / layout boxes,不能只在 plan 里改字段。
|
||||
|
||||
### 几何与 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 检索;记录图片页 URL;license 可先写 `preview_unverified`,正式交付再确认 |
|
||||
| Openverse / Wikimedia Commons | 百科、历史、技术、公共领域素材 | 记录单图 URL 和作者/页面;preview 可先用,正式交付补 license / attribution |
|
||||
| The Met / Smithsonian / NASA Open Access | 艺术、科学、历史、航天视觉 | 记录条目 URL;preview 可先用,正式交付确认 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` 留足高度,不要按浏览器本地字体做极限排版。
|
||||
- image token 支持可能随 live lane 漂移:如果 readback 前创建失败,先区分“图片上传失败”和“上传后 `/slide` 失败”。后者是目标 lane 的 token 解析/权限问题,短期发布可改用独立 online-pure 版本;不要把 authoring preview 里的真实图片直接删掉。
|
||||
|
||||
### 生成后检查
|
||||
|
||||
生成脚本或人工复核必须检查:
|
||||
|
||||
- 是否已执行本地 preflight,且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
|
||||
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
|
||||
- 如果使用 `@./assets/...`,dry-run 是否展示预期的 `medias/upload_all` 和 transport metadata;live 失败时是否已经用纯 SVG 页和图片页隔离出 lane 问题。
|
||||
- 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;高密度页的视觉结构是否承载信息,而不只是装饰。
|
||||
- 图表页是否已记录 `chart_decision` 和 `chart_verification`;有数据源时是否写入 `receipts/chart-verify.json` 并核对数据到坐标映射。
|
||||
- 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。
|
||||
- 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
|
||||
- 图片是否足够丰富并可见;如果 Preview/MVP 阶段暂时保留 http(s) / data URL 或 `preview_unverified` 来源,要记录 warning、确认 live/readback 可见,并在正式交付前列出替换项。
|
||||
|
||||
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 的 `readback_verification` 字段,并在最终回复中简述:
|
||||
|
||||
```text
|
||||
验证记录:
|
||||
- Preflight:N/N SVG 通过 root/role/geometry/path/image/bbox 检查。
|
||||
- Dry-run:已确认 create presentation + N 次 /slide。
|
||||
- Readback:实际页数 N / 预期 N;未发现空白页、破图或缺失 closing slide。
|
||||
- Chart data:checked N/N chart pages;failed M;missing data K。
|
||||
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
|
||||
- 资产:Preview 阶段优先丰富图片和 readback 可见性;若保留 http(s)/data URL 或 `preview_unverified` 来源,必须记录 warning。正式交付再替换为本地 @path 自动上传或 file token,并补齐授权。
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
任一页失败时,错误会包含:
|
||||
|
||||
- `xml_presentation_id`
|
||||
- 失败页序号
|
||||
- 已成功页数
|
||||
- 已创建的 `slide_ids`
|
||||
|
||||
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` marker,CLI 会提取并在错误中展示 `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>/pages/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>/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Required Flow
|
||||
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。SVGlide 新建 deck 如果用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”等模糊表达,默认按 10 页写入 `page_count` / `target_slide_count`,不要仅因页数缺失而停下来追问;只有明确“一页 / 单页 / onepage / one slide / 只要封面”才按 1 页。
|
||||
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>`。
|
||||
@@ -55,8 +55,19 @@ Exception:
|
||||
|
||||
```json
|
||||
{
|
||||
"input_profile": {
|
||||
"input_type": "topic",
|
||||
"source_status": "research_required"
|
||||
},
|
||||
"source_brief": {
|
||||
"path": "source/brief.md",
|
||||
"evidence_index": "source/evidence.json",
|
||||
"numeric_claim_policy": "cite_or_remove"
|
||||
},
|
||||
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
|
||||
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
|
||||
"narrative_mode": "briefing",
|
||||
"visual_style": "data_journalism",
|
||||
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
|
||||
"visual_system": {
|
||||
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
|
||||
@@ -67,12 +78,50 @@ Exception:
|
||||
"accent": "Used only for key numbers, conclusions, or focus markers."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"strategy_locks": [
|
||||
{"id": "canvas", "decision": {"width": 960, "height": 540}, "evidence_ref": "plan.canvas"},
|
||||
{"id": "page_count", "decision": 10, "evidence_ref": "plan.page_count"},
|
||||
{"id": "audience", "decision": "Product and engineering leaders", "evidence_ref": "plan.audience"},
|
||||
{"id": "narrative_mode", "decision": "briefing", "evidence_ref": "plan.narrative_mode"},
|
||||
{"id": "visual_style", "decision": "data_journalism", "evidence_ref": "plan.visual_style"},
|
||||
{"id": "style_preset", "decision": "raw_grid", "evidence_ref": "plan.style_preset"},
|
||||
{"id": "asset_strategy", "decision": "authoring_preview_rich", "evidence_ref": "plan.asset_strategy.mode"},
|
||||
{"id": "chart_policy", "decision": "data_relationship_first", "evidence_ref": "plan.chart_policy"}
|
||||
],
|
||||
"chart_policy": {
|
||||
"selection_rule": "data_relationship_first",
|
||||
"requires_data_coordinate_check": true,
|
||||
"receipt": "receipts/chart-verify.json"
|
||||
},
|
||||
"icon_policy": {
|
||||
"style": "single_consistent_family",
|
||||
"semantic_mapping_required": true
|
||||
},
|
||||
"typography_constraints": {
|
||||
"title_max_lines": 2,
|
||||
"body_max_lines_per_box": 2,
|
||||
"footer_max_lines": 1,
|
||||
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
|
||||
},
|
||||
"text_surface_contract": {
|
||||
"allowed_surface_kinds": ["accent_rail_card", "tinted_panel", "glass_overlay", "dark_backing", "label_chip", "metric_tile"],
|
||||
"allow_plain_white_panel": false,
|
||||
"min_gap_to_title": 24,
|
||||
"padding": {"x": 14, "y": 12},
|
||||
"connector_policy": "Connectors terminate at card/node/chart edges and must not cross visible text boxes."
|
||||
},
|
||||
"verification_plan": {
|
||||
"check_background_consistency": true,
|
||||
"check_text_fit": true,
|
||||
@@ -103,11 +152,22 @@ Exception:
|
||||
|
||||
Top-level fields:
|
||||
|
||||
- `input_profile`: input type and source status. For pure topic prompts, mark `source_status` as `research_required` or `user_prompt_only` rather than pretending a source document exists.
|
||||
- `source_brief`: path and evidence index for structured source material. Numeric claims must be cited or removed according to `numeric_claim_policy`.
|
||||
- `presentation_goal`: what the whole deck is trying to achieve.
|
||||
- `audience`: target readers or listeners and their assumed background.
|
||||
- `narrative_mode`: the story mode, such as `briefing`, `instructional`, `narrative`, `pyramid`, or `showcase`. Do not put visual style names here.
|
||||
- `visual_style`: the visual language target, separate from `narrative_mode` and separate from the executable `style_preset`.
|
||||
- `theme_style`: visual tone, palette direction, and professional style.
|
||||
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
|
||||
- `style_preset`: required for SVGlide SVG decks. Choose one id from `references/style-presets.json`; omit only for non-SVG XML/SXSD plans.
|
||||
- `style_selection_reason`: required for SVGlide SVG decks. Explain why the preset fits the audience, topic, density, and expected tone.
|
||||
- `style_system`: required for SVGlide SVG decks. Translate the selected preset into concrete palette, typography, background strategy, and motif rules. This is separate from `visual_system`: `visual_system` describes the deck identity, while `style_system` records the executable style preset translation.
|
||||
- `strategy_locks`: required for SVGlide SVG decks. Record exactly eight locked decisions: `canvas`, `page_count`, `audience`, `narrative_mode`, `visual_style`, `style_preset`, `asset_strategy`, and `chart_policy`. Each lock must have `id`, `decision`, and `evidence_ref`.
|
||||
- `chart_policy`: deck-level chart rule. Select chart type from data relationship and page purpose first; chart pages must have a page-level `chart_decision`.
|
||||
- `icon_policy`: deck-level icon discipline. Use one consistent semantic family and map icons to concepts; do not mix unrelated icon styles as decoration.
|
||||
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
|
||||
- `text_surface_contract`: required for SVGlide SVG decks. Defines allowed text-bearing surface types, title exclusion gap, padding, and connector avoidance. Do not generate plain white text panels unless the user explicitly asks for bare wireframes or tables.
|
||||
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
|
||||
- `slides`: ordered page plans.
|
||||
|
||||
@@ -122,6 +182,18 @@ Each slide must include:
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
SVGlide SVG slides must also include:
|
||||
|
||||
- `visual_recipe`: the SVG-native page recipe, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`.
|
||||
- `route_private` is only an abstract create-svg marker. Shared plans must not contain exact SVG private recipe ids; route-private selection belongs in the create-svg sidecar.
|
||||
- `visual_signature`: the page's distinctive SVG visual memory point compared with a normal XML/PPT template.
|
||||
- `svg_effects`: canonical effect names actually used or planned, such as `path`, `connector_flow`, `gradient`, `texture`, `chart_geometry`, or `image_overlay`.
|
||||
- `required_primitives` and `svg_primitives`: the planned SVGlide-safe primitives that must be present in the SVG source.
|
||||
- `xml_like_risk`, `content_density_contract`, `risk_flags`, and `source_policy`: quality and source-safety fields consumed by `svg_preflight.py --plan`.
|
||||
- `source_refs`: stable ids from top-level `source_pack.items` used by this page. If a page cites data, charts, or numeric claims, those refs must resolve.
|
||||
- `chart_decision`: required when `chart_type` is present. Include `chart_type`, `reason`, `data_ref`, `anchor_role`, and `bbox_tolerance_px`; the reason must explain the data relationship and page purpose, not just name a template.
|
||||
- `chart_verification`: required when `chart_type` is present. Point to the receipt that checks visible marks against source data, such as bar heights, line points, stacked proportions, or radar vertices.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
@@ -216,4 +288,5 @@ After creating the PPT, fetch the presentation and verify:
|
||||
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
|
||||
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
|
||||
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
|
||||
- Text-bearing cards, callouts, badges, labels and metric tiles respect `text_surface_contract`; they are not naked white rectangles, do not press into `titleBox`, and connector lines do not pass through visible text.
|
||||
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"route_id": "create-svg",
|
||||
"visibility": "route-private",
|
||||
"selection_sidecar_schema": "references/routes/create-svg/private/recipe-selection.schema.json",
|
||||
"blocked_absolute_paths": [
|
||||
"/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/visual_recipe_catalog.md"
|
||||
],
|
||||
"blocked_research_dotted_recipe_ids": [
|
||||
"cover.hero",
|
||||
"section.divider",
|
||||
"agenda.structured",
|
||||
"kpi.big-number",
|
||||
"comparison.two-column",
|
||||
"timeline.roadmap",
|
||||
"process.flow",
|
||||
"architecture.layered",
|
||||
"data.single-chart",
|
||||
"dashboard.kpi-grid",
|
||||
"table.visual-summary",
|
||||
"image.story",
|
||||
"quote.insight"
|
||||
],
|
||||
"recipes": {
|
||||
"solar_gallery_hero": {
|
||||
"base_recipe": "hero_typography",
|
||||
"required_primitives": [
|
||||
"typography",
|
||||
"image_overlay",
|
||||
"geometric_shape"
|
||||
],
|
||||
"required_effects": [
|
||||
"gradient",
|
||||
"image_overlay",
|
||||
"spotlight"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.62,
|
||||
"source_truth_requirements": [
|
||||
"Primary image or product evidence must be traceable to the deck query, local asset reference, or cited preview source.",
|
||||
"Hero title and visual focal point must remain editable or explicitly represented in the SVG source manifest.",
|
||||
"Decorative solar rays or gallery frames must not be the only evidence for the selected recipe."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"spectral_wave_strata": {
|
||||
"base_recipe": "gradient_depth",
|
||||
"required_primitives": [
|
||||
"path",
|
||||
"gradient",
|
||||
"geometric_shape"
|
||||
],
|
||||
"required_effects": [
|
||||
"path",
|
||||
"gradient",
|
||||
"texture"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.58,
|
||||
"source_truth_requirements": [
|
||||
"Wave strata must encode an explicit concept, timeline, segment, or hierarchy from the page plan.",
|
||||
"At least three visible strata must be present in the SVG source manifest.",
|
||||
"Labels must map strata to source-backed page content instead of acting as generic decoration."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"modernist_grid_gallery": {
|
||||
"base_recipe": "geometric_composition",
|
||||
"required_primitives": [
|
||||
"texture",
|
||||
"image_overlay",
|
||||
"typography"
|
||||
],
|
||||
"required_effects": [
|
||||
"grid_geometry",
|
||||
"image_overlay",
|
||||
"gradient"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.6,
|
||||
"source_truth_requirements": [
|
||||
"Every gallery tile must correspond to a source-backed item, image, milestone, or evidence unit.",
|
||||
"The grid must use stable alignment and must not collapse into ordinary card bullets.",
|
||||
"Any clipping or mask-like treatment must include a safe rewrite in the SVG source manifest."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"aurora_ribbon_landscape": {
|
||||
"base_recipe": "gradient_depth",
|
||||
"required_primitives": [
|
||||
"path",
|
||||
"gradient",
|
||||
"geometric_shape"
|
||||
],
|
||||
"required_effects": [
|
||||
"gradient",
|
||||
"path",
|
||||
"connector_flow"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.57,
|
||||
"source_truth_requirements": [
|
||||
"Ribbon paths must carry the page narrative, flow, or segmentation rather than only forming a background.",
|
||||
"The focal ribbon must occupy a meaningful visible area and remain behind readable text.",
|
||||
"Color bands must map to source-backed categories, stages, or emphasis states when labels are present."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"prism_spectrum_split": {
|
||||
"base_recipe": "geometric_composition",
|
||||
"required_primitives": [
|
||||
"geometric_shape",
|
||||
"gradient",
|
||||
"typography"
|
||||
],
|
||||
"required_effects": [
|
||||
"gradient",
|
||||
"spotlight",
|
||||
"connector_flow"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.55,
|
||||
"source_truth_requirements": [
|
||||
"The split spectrum must represent a source-backed comparison, segmentation, funnel, or transformation.",
|
||||
"Prism geometry must expose explicit source primitives instead of being a raster-only decoration.",
|
||||
"Each split region must preserve text contrast and safe-area constraints."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"layered_hill_dawn": {
|
||||
"base_recipe": "gradient_depth",
|
||||
"required_primitives": [
|
||||
"geometric_shape",
|
||||
"gradient",
|
||||
"typography"
|
||||
],
|
||||
"required_effects": [
|
||||
"gradient",
|
||||
"texture",
|
||||
"spotlight"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.56,
|
||||
"source_truth_requirements": [
|
||||
"Layered hills must express a staged build-up, maturity path, or depth hierarchy from the page plan.",
|
||||
"At least three source-visible depth layers must be represented in the SVG source manifest.",
|
||||
"Dawn lighting must not reduce title or body text contrast below the route safety threshold."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"curtain_call_orbit": {
|
||||
"base_recipe": "metaphor_loop",
|
||||
"required_primitives": [
|
||||
"path",
|
||||
"flow",
|
||||
"geometric_shape"
|
||||
],
|
||||
"required_effects": [
|
||||
"path",
|
||||
"connector_flow",
|
||||
"spotlight"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.59,
|
||||
"source_truth_requirements": [
|
||||
"Orbiting elements must map to source-backed actors, milestones, capabilities, or closing takeaways.",
|
||||
"The orbit path must remain visible and must be represented by SVG path or line primitives.",
|
||||
"The curtain or spotlight motif must support the closing narrative instead of hiding required evidence."
|
||||
],
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# PPE Pure SVG Live Create Notes
|
||||
|
||||
This note is private to the `slides +create-svg` route. XML/SXSD generation must not read or rely on it.
|
||||
|
||||
## Route Setup
|
||||
|
||||
When validating `ppe_pure_svg` with the local worktree CLI, use the worktree-local binary, not a global `lark-cli`:
|
||||
|
||||
```bash
|
||||
/path/to/worktree/./lark-cli slides +create-svg ...
|
||||
```
|
||||
|
||||
Whistle routing needs the pre OpenAPI host plus both headers:
|
||||
|
||||
```text
|
||||
/^https:\/\/open\.feishu\.cn\/(.*)$/ https://open.feishu-pre.cn/$1
|
||||
https://open.feishu.cn/ reqHeaders://Env=Pre_release
|
||||
https://open.feishu.cn/ reqHeaders://x-tt-env=ppe_pure_svg
|
||||
https://open.feishu-pre.cn/ reqHeaders://Env=Pre_release
|
||||
https://open.feishu-pre.cn/ reqHeaders://x-tt-env=ppe_pure_svg
|
||||
/^https:\/\/accounts\.feishu\.cn\/(.*)$/ https://accounts.feishu-pre.cn/$1
|
||||
```
|
||||
|
||||
`w2 start` / `w2 add` may require sandbox escalation because Whistle writes user-level runtime files.
|
||||
|
||||
## Image Token Boundary
|
||||
|
||||
`slides +create-svg` image transport is:
|
||||
|
||||
```text
|
||||
create xml_presentation
|
||||
-> scan SVG href="@./assets/..."
|
||||
-> upload local images through /open-apis/drive/v1/medias/upload_all
|
||||
-> inject <metadata data-svglide-assets="true"><img src="file_token" /></metadata>
|
||||
-> replace image href with file_token
|
||||
-> POST the SVG to /slides_ai/v1/xml_presentations/<id>/slide
|
||||
```
|
||||
|
||||
Upload success does not prove the live lane can parse the image token. In the 2026-06-12 `ppe_pure_svg` smoke, pure SVG pages succeeded, but pages with uploaded image tokens failed after upload with `nodeServer internal error [5090000]`. Treat that as a slide/nodeServer image-token compatibility issue, not a local image upload failure.
|
||||
|
||||
## Publishing Fallback
|
||||
|
||||
If a live deck must be published before the image-token issue is fixed:
|
||||
|
||||
1. Keep the rich local HTML/image preview intact.
|
||||
2. Generate a separate online-pure SVG directory.
|
||||
3. Remove `<image>` and `@./assets/...` references only in that online-pure directory.
|
||||
4. Replace photo regions with SVG-native gradients, paths, ribbons, overlays, and texture geometry.
|
||||
5. Verify no `@./assets`, `<image>`, `uploaded_file_token`, or missing `url(#id)` refs remain.
|
||||
6. Run preflight, dry-run, live create, and readback page-count verification.
|
||||
|
||||
Do not silently remove real images from the authoring preview. State the fallback in the final delivery and keep a follow-up item to repair the image-token lane.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"route_id": "create-svg",
|
||||
"manifest_ref": "references/routes/create-svg/private-recipes.manifest.json",
|
||||
"manifest_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"deck_id": "example-deck",
|
||||
"selections": [
|
||||
{
|
||||
"page_index": 1,
|
||||
"slide_id": "slide-1",
|
||||
"private_recipe_id": "solar_gallery_hero",
|
||||
"base_recipe": "hero_typography",
|
||||
"required_primitives": [
|
||||
"typography",
|
||||
"image_overlay",
|
||||
"geometric_shape"
|
||||
],
|
||||
"required_effects": [
|
||||
"gradient",
|
||||
"image_overlay",
|
||||
"spotlight"
|
||||
],
|
||||
"minimum_visible_area_ratio": 0.62,
|
||||
"source_truth_evidence": [
|
||||
{
|
||||
"requirement": "Manifest source truth requirement copied into the sidecar.",
|
||||
"evidence": "Concrete page evidence or asset reference that satisfies the requirement.",
|
||||
"source_ref": "slide_plan.json#/slides/0"
|
||||
}
|
||||
],
|
||||
"selection_reason": "The private recipe is selected only after matching the page plan to manifest requirements.",
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://lark-cli.local/schemas/lark-slides/create-svg/private/recipe-selection.schema.json",
|
||||
"title": "SVGlide create-svg private recipe selection sidecar",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"route_id",
|
||||
"manifest_ref",
|
||||
"selections"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"route_id": {
|
||||
"type": "string",
|
||||
"const": "create-svg"
|
||||
},
|
||||
"manifest_ref": {
|
||||
"type": "string",
|
||||
"const": "references/routes/create-svg/private-recipes.manifest.json"
|
||||
},
|
||||
"manifest_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"deck_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"selections": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/selection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"selection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"page_index",
|
||||
"private_recipe_id",
|
||||
"base_recipe",
|
||||
"required_primitives",
|
||||
"required_effects",
|
||||
"minimum_visible_area_ratio",
|
||||
"source_truth_evidence",
|
||||
"selection_reason",
|
||||
"fallback_policy",
|
||||
"exemption_policy"
|
||||
],
|
||||
"properties": {
|
||||
"page_index": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"slide_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"private_recipe_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"base_recipe": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"required_primitives": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"required_effects": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"minimum_visible_area_ratio": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"source_truth_evidence": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"requirement",
|
||||
"evidence"
|
||||
],
|
||||
"properties": {
|
||||
"requirement": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selection_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"fallback_policy": {
|
||||
"type": "string",
|
||||
"const": "deny"
|
||||
},
|
||||
"exemption_policy": {
|
||||
"type": "string",
|
||||
"const": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"route_id": "create-svg",
|
||||
"route_name": "slides +create-svg",
|
||||
"owner_skill": "lark-slides",
|
||||
"visibility": "public",
|
||||
"private_recipe_manifest": "private-recipes.manifest.json",
|
||||
"allowed_private_recipe_source": "private_recipe_manifest_keys",
|
||||
"public_entrypoints": [
|
||||
"SKILL.md",
|
||||
"references/lark-slides-create-svg.md",
|
||||
"references/svg-protocol.md",
|
||||
"references/svglide-project-pipeline.md",
|
||||
"references/asset-planning.md",
|
||||
"references/svg-visual-recipes.md",
|
||||
"references/svg-aesthetic-review.md",
|
||||
"references/svglide-absorption-matrix.md",
|
||||
"references/svglide-renderer-registry.json"
|
||||
],
|
||||
"private_policy": {
|
||||
"private_recipe_manifest": "references/routes/create-svg/private-recipes.manifest.json",
|
||||
"recipe_selection_schema": "references/routes/create-svg/private/recipe-selection.schema.json",
|
||||
"recipe_selection_example": "references/routes/create-svg/private/recipe-selection.example.json",
|
||||
"runtime_surface": "route-private",
|
||||
"fallback_policy": "deny",
|
||||
"exemption_policy": "deny"
|
||||
},
|
||||
"docs_leak_lint": {
|
||||
"script": "scripts/svg_private_docs_lint.py",
|
||||
"denylist_source": "references/routes/create-svg/private-recipes.manifest.json",
|
||||
"allowed_exact_id_paths": [
|
||||
"references/routes/create-svg/private-recipes.manifest.json",
|
||||
"references/routes/create-svg/private/**",
|
||||
"tests/fixtures/routes/create-svg/private/**",
|
||||
"tests/fixtures/routes/create-svg/internal-reports/**"
|
||||
],
|
||||
"allowed_private_doc_links": [
|
||||
{
|
||||
"path": "references/lark-slides-create-svg.md",
|
||||
"target": "references/routes/create-svg/private/ppe-pure-svg-live.md",
|
||||
"reason": "public workflow may link to PPE troubleshooting, but must not inline private environment details"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
564
skills/lark-slides/references/style-presets.json
Normal file
564
skills/lark-slides/references/style-presets.json
Normal file
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"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."
|
||||
],
|
||||
"text_surface_contract": {
|
||||
"allowed_surface_kinds": ["accent_rail_card", "tinted_panel", "glass_overlay", "dark_backing", "label_chip", "metric_tile"],
|
||||
"allow_plain_white_panel": false,
|
||||
"min_gap_to_title": 24,
|
||||
"min_card_padding": {"x": 14, "y": 12},
|
||||
"connector_policy": "connector line/path must terminate at card, node, or chart edges and must not cross visible text boxes"
|
||||
},
|
||||
"groups": {
|
||||
"Restrained": {"expected_count": 10, "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": ["geometric_composition", "path_flow", "spotlight_annotation"],
|
||||
"avoid_for": ["hero_typography"],
|
||||
"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": ["geometric_composition", "hero_typography", "path_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": ["path_flow", "geometric_composition", "geometric_composition"],
|
||||
"avoid_for": ["hero_typography"],
|
||||
"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": ["geometric_composition", "path_flow", "path_flow"],
|
||||
"avoid_for": ["hero_typography"],
|
||||
"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": ["spotlight_annotation", "hero_typography", "geometric_composition"],
|
||||
"avoid_for": ["fake_ui_dashboard"],
|
||||
"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": ["geometric_composition", "spotlight_annotation", "geometric_composition"],
|
||||
"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": "data_journalism_editorial",
|
||||
"display_name": "Data Journalism Editorial",
|
||||
"group": "Restrained",
|
||||
"source_token": "svglide/editorial_ai_capital_2026",
|
||||
"formality": "high",
|
||||
"vibe": ["editorial", "data_journalism", "financial", "cinematic"],
|
||||
"best_for": ["hero_typography", "infographic_scorecard", "path_flow", "geometric_composition", "icon_capability_map"],
|
||||
"avoid_for": ["mask_clip_showcase"],
|
||||
"palette": {"background": "#0E1116", "text": "#E8E6E1", "muted": "#8A857E", "accent": "#E63946", "support": ["#1A1F26", "#F4A261", "#52B788", "#2A2F36"]},
|
||||
"shape_language": {"panel_treatment": "dark editorial chart surfaces with thin rules, sparse grid texture, and one red risk accent", "corner_radius": "low", "border_weight": "hairline", "texture": "newspaper grid, paper grain, restrained infrastructure particles"},
|
||||
"density": {"text_density": "high", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 72, "source_text": 34, "source_shapes": 28, "source_connectors": 10}},
|
||||
"slide_translation": {"recommended_layouts": ["cover", "editor_note", "contents", "chart_takeaway", "chapter", "closing"], "svglide_primitives": ["typography", "geometric_shape", "micro_chart", "path", "annotation", "texture"], "fallback_policy": "Keep the main chart geometry first; if atmosphere assets are unavailable, preserve negative space, thin rules, and red numeric emphasis with SVG-native shapes."},
|
||||
"quality_oracle": {"expected_style_signals": ["dark graphite ground", "large serif or editorial title hierarchy", "red numeric emphasis", "thin chart rules", "small source/footer text"], "warning_thresholds": {"text_boxes_max": 32, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "papier_bleu",
|
||||
"display_name": "Papier Bleu",
|
||||
"group": "Restrained",
|
||||
"source_token": "HWi5woaS8h1D4EbKutnulYWdsWc",
|
||||
"formality": "medium",
|
||||
"vibe": ["blueprint", "clear", "knowledge"],
|
||||
"best_for": ["path_flow", "path_flow", "mask_clip_showcase"],
|
||||
"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": ["spotlight_annotation", "hero_typography", "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": ["hero_typography", "infographic_scorecard", "geometric_composition"],
|
||||
"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": ["path_flow", "path_flow", "hero_typography"],
|
||||
"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": ["geometric_composition", "mask_clip_showcase", "fake_ui_dashboard"],
|
||||
"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": ["hero_typography", "spotlight_annotation", "hero_typography"],
|
||||
"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": ["fake_ui_dashboard", "icon_capability_map", "geometric_composition"],
|
||||
"avoid_for": ["spotlight_annotation"],
|
||||
"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": ["geometric_composition", "fake_ui_dashboard", "hero_typography"],
|
||||
"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": ["geometric_composition", "geometric_composition", "infographic_scorecard"],
|
||||
"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", "geometric_composition", "hero_typography"],
|
||||
"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": ["spotlight_annotation", "geometric_composition", "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": ["hero_typography", "geometric_composition", "path_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": ["geometric_composition", "geometric_composition", "geometric_composition"],
|
||||
"avoid_for": ["hero_typography"],
|
||||
"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": ["geometric_composition", "path_flow", "spotlight_annotation"],
|
||||
"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": ["fake_ui_dashboard", "geometric_composition", "geometric_composition"],
|
||||
"avoid_for": ["hero_typography"],
|
||||
"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", "geometric_composition", "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": ["spotlight_annotation", "geometric_composition", "geometric_composition"],
|
||||
"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": ["hero_typography", "path_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": ["hero_typography", "brand_system", "mask_clip_showcase"],
|
||||
"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": ["hero_typography", "fake_ui_dashboard", "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": ["hero_typography", "mask_clip_showcase", "spotlight_annotation"],
|
||||
"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": ["path_flow", "path_flow", "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": ["hero_typography", "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": ["infographic_scorecard", "metaphor_loop", "path_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": ["geometric_composition", "path_flow", "hero_typography"],
|
||||
"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", "geometric_composition", "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", "hero_typography", "mask_clip_showcase"],
|
||||
"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", "geometric_composition", "hero_typography"],
|
||||
"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": ["spotlight_annotation", "path_flow", "geometric_composition"],
|
||||
"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}}
|
||||
}
|
||||
]
|
||||
}
|
||||
114
skills/lark-slides/references/style-presets.md
Normal file
114
skills/lark-slides/references/style-presets.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# SVGlide Style Presets
|
||||
|
||||
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets plus the SVGlide `data_journalism_editorial` preset. 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 Editorial Preset
|
||||
|
||||
Use `data_journalism_editorial` when a deck needs a dark data-journalism feel. Translate the preset into SVGlide-safe parts: dark graphite ground, large editorial title hierarchy, thin chart rules, small source/footer text, restrained red numeric emphasis, and real chart geometry. Do not copy raw SVG paths, images, or PPTX export assumptions.
|
||||
|
||||
## SVGlide-Safe Translation
|
||||
|
||||
Translate style into supported SVG primitives:
|
||||
|
||||
- Palette -> explicit `fill`, `stroke`, and text colors.
|
||||
- Panel treatment -> `rect`, `path`, and grouped layout boxes. Text-bearing panels must be translated into a concrete surface kind, not a naked white rectangle.
|
||||
- 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.
|
||||
|
||||
## Text Surface Translation
|
||||
|
||||
Every style preset has a `shape_language.panel_treatment`. Translate it into one of these SVG-safe text surfaces:
|
||||
|
||||
- `accent_rail_card`: tinted card with a 6-10px left/top rail in the preset accent color.
|
||||
- `tinted_panel`: non-white preset support fill plus visible stroke.
|
||||
- `glass_overlay`: semi-transparent panel on an image with matching overlay color.
|
||||
- `dark_backing`: dark rect/card/overlay for light text.
|
||||
- `label_chip`: short label only; no explanatory sentence.
|
||||
- `metric_tile`: KPI tile with a role color, separator, rail, or small chart cue.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not use bare `fill="#ffffff"` rectangles for user-visible text unless the page is an intentional wireframe/table and the panel has visible stroke or grid structure.
|
||||
- Keep text surfaces at least 24px away from the title box.
|
||||
- Connector lines must terminate at card/node/chart edges; they must not run through visible text.
|
||||
- If a preset uses low contrast or editorial whitespace, improve the text surface with spacing, stroke, role color, and alignment rather than adding more plain boxes.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Before calling `slides +create-svg`, run:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/svg_preflight.py \
|
||||
--route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json \
|
||||
--report-scope public \
|
||||
--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.
|
||||
- text surfaces avoid `plain_white_text_panel`, `title_surface_pressure`, and `connector_crosses_text` issues.
|
||||
127
skills/lark-slides/references/svg-aesthetic-review.md
Normal file
127
skills/lark-slides/references/svg-aesthetic-review.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 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 问题;这份清单负责需要人工或截图判断的渲染后视觉质量问题。
|
||||
Project runner 中的 `svg_preview_lint.py` 负责缺 preview、破损 SVG、明显重叠、
|
||||
越界、空图和高置信浅字无底等 objective lint;本文件负责人工/截图视角的
|
||||
审美判断。二者都不能替代 live readback。
|
||||
|
||||
## 必须执行的 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 垂直间距 |
|
||||
| 装饰线或色带压迫标题 | 把线移到标题区上方,或下移标题,保留呼吸感 |
|
||||
| callout / 卡片 / insight panel 侵入标题区 | 重算 `titleBox` 和 `calloutBox`;标题底部到任何文本承载面至少 24px |
|
||||
| connector line/path 穿过标题、中心文字或卡片文案 | 改成折线、短 leader line 或连接到卡片边缘;背景线降低 opacity 并声明为 decorative |
|
||||
| 多页出现裸白底黑字文本框 | 按 style preset 改成 tinted panel、accent rail card、glass overlay、dark backing、metric tile 或 label chip |
|
||||
| 主体内容超出 `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 | 小幅打磨项或残余风险 | 记录下来,有时间再修 |
|
||||
|
||||
默认映射:
|
||||
|
||||
- P0:preflight 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 保持一致?
|
||||
|
||||
## SVGlide Archetype Review
|
||||
|
||||
从 SVGlide archetype 样张和本地多轮 preview 得到的经验要落在渲染后审查,而不是只写进 prompt。
|
||||
|
||||
- 第一眼必须看出页型:cover、contents、section、bubble chart、donut chart、
|
||||
sankey/flow、hub-spoke、table、closing 等不能互相退化成同一种卡片页。
|
||||
- 图表页必须让几何承载信息:bubble 用圆形节点大小/位置表达关系,donut 用环形
|
||||
分段和中心 KPI 表达构成,flow/sankey 用流线宽度和方向表达转移。
|
||||
- 白字或浅字必须有足够暗的 backing;name-plate、label-back、badge、pill 不能
|
||||
压住 note、source、正文或图表标签。
|
||||
- connector、趋势线、轨道线、坐标轴不能穿过标题、中心数字、label 或说明文字;
|
||||
必要时改成折线、短 leader line 或降低为 decorative background。
|
||||
- 红色和高饱和强调色只打关键数字、风险或章节标记。不要让每个图形都同等抢眼。
|
||||
- 如果 preview 分数低,优先修 renderer/source SVG 和资产选择,而不是只改
|
||||
`slide_plan.json` 的文案描述。
|
||||
|
||||
## 修复优先级
|
||||
|
||||
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
|
||||
```
|
||||
207
skills/lark-slides/references/svg-protocol.md
Normal file
207
skills/lark-slides/references/svg-protocol.md
Normal 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 marker;marker 内只能有一个 chart metadata,metadata 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 marker;whiteboard 内容必须走 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 只 warning;preview 中可用 `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` 传入预上传 token,CLI 也会按同样规则替换和注入。
|
||||
|
||||
`assets.json` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"@./image.png": "boxcn...",
|
||||
"./other.png": "boxcn..."
|
||||
}
|
||||
```
|
||||
127
skills/lark-slides/references/svg-recipes.json
Normal file
127
skills/lark-slides/references/svg-recipes.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"version": "2026-06-15",
|
||||
"source": "SVGlide public runtime visual recipe registry",
|
||||
"notes": [
|
||||
"This file is the runtime source of truth for public SVGlide visual_recipe ids.",
|
||||
"Human-readable docs such as svg-visual-recipes.md must describe this registry, not redefine it.",
|
||||
"Research docs may use dotted recipe taxonomy, but slide_plan.json must use these underscore ids or route_private."
|
||||
],
|
||||
"recipes": {
|
||||
"hero_typography": {
|
||||
"family": "hero",
|
||||
"required_primitives": ["typography", "geometric_shape"]
|
||||
},
|
||||
"geometric_composition": {
|
||||
"family": "geometry",
|
||||
"required_primitives": ["geometric_shape", "path"]
|
||||
},
|
||||
"path_flow": {
|
||||
"family": "flow",
|
||||
"required_primitives": ["path", "annotation"]
|
||||
},
|
||||
"infographic_scorecard": {
|
||||
"family": "data",
|
||||
"required_primitives": ["typography", "micro_chart"]
|
||||
},
|
||||
"icon_capability_map": {
|
||||
"family": "icon",
|
||||
"required_primitives": ["icon", "geometric_shape"]
|
||||
},
|
||||
"gradient_depth": {
|
||||
"family": "depth",
|
||||
"required_primitives": ["gradient", "geometric_shape"]
|
||||
},
|
||||
"mask_clip_showcase": {
|
||||
"family": "showcase",
|
||||
"required_primitives": ["typography", "image_overlay"]
|
||||
},
|
||||
"technical_texture": {
|
||||
"family": "texture",
|
||||
"required_primitives": ["texture", "path"]
|
||||
},
|
||||
"metaphor_loop": {
|
||||
"family": "flow",
|
||||
"required_primitives": ["path", "geometric_shape"]
|
||||
},
|
||||
"spotlight_annotation": {
|
||||
"family": "annotation",
|
||||
"required_primitives": ["spotlight", "annotation"]
|
||||
},
|
||||
"fake_ui_dashboard": {
|
||||
"family": "data",
|
||||
"required_primitives": ["dashboard", "micro_chart"]
|
||||
},
|
||||
"brand_system": {
|
||||
"family": "brand",
|
||||
"required_primitives": ["typography", "geometric_shape"]
|
||||
}
|
||||
},
|
||||
"chart_type_contracts": {
|
||||
"kpi_cards": {
|
||||
"description": "2x2 or 1x4 KPI scorecard with readable hero numbers and micro chart evidence.",
|
||||
"min_card_like_rect": 2,
|
||||
"min_typography_boxes": 4,
|
||||
"recommended_visual_recipe": "infographic_scorecard"
|
||||
},
|
||||
"bar_chart": {
|
||||
"description": "Single-series vertical bar chart with at least three visible bars.",
|
||||
"min_bar_like_rect": 3,
|
||||
"recommended_visual_recipe": "infographic_scorecard"
|
||||
},
|
||||
"horizontal_bar_chart": {
|
||||
"description": "Ranking chart with at least three visible horizontal or bar-like rows.",
|
||||
"min_bar_like_rect": 3,
|
||||
"recommended_visual_recipe": "infographic_scorecard"
|
||||
},
|
||||
"dumbbell_chart": {
|
||||
"description": "Before/after comparison with repeated connector lines and endpoint nodes.",
|
||||
"min_line_or_path": 3,
|
||||
"min_round_nodes": 4,
|
||||
"recommended_visual_recipe": "path_flow"
|
||||
},
|
||||
"bubble_chart": {
|
||||
"description": "Multi-axis bubble layout with at least three round data marks.",
|
||||
"min_round_nodes": 3,
|
||||
"recommended_visual_recipe": "geometric_composition"
|
||||
},
|
||||
"donut_chart": {
|
||||
"description": "Donut or ring composition with a center KPI and segment evidence.",
|
||||
"min_round_nodes": 2,
|
||||
"min_typography_boxes": 3,
|
||||
"recommended_visual_recipe": "infographic_scorecard"
|
||||
},
|
||||
"comparison_table": {
|
||||
"description": "Dense row/column comparison matrix.",
|
||||
"min_typography_boxes": 6,
|
||||
"recommended_visual_recipe": "geometric_composition"
|
||||
},
|
||||
"sankey_chart": {
|
||||
"description": "Magnitude flow from source to nodes to sink.",
|
||||
"min_path": 3,
|
||||
"min_typography_boxes": 3,
|
||||
"recommended_visual_recipe": "path_flow"
|
||||
},
|
||||
"pareto_chart": {
|
||||
"description": "Descending bars plus cumulative or risk line.",
|
||||
"min_bar_like_rect": 3,
|
||||
"min_line_or_path": 1,
|
||||
"recommended_visual_recipe": "infographic_scorecard"
|
||||
},
|
||||
"hub_spoke": {
|
||||
"description": "Central hub with at least four surrounding nodes and spoke evidence.",
|
||||
"min_round_nodes": 5,
|
||||
"min_line_or_path": 3,
|
||||
"recommended_visual_recipe": "icon_capability_map"
|
||||
},
|
||||
"dual_axis_line_chart": {
|
||||
"description": "Two line series showing divergence or comparison over time.",
|
||||
"min_path": 2,
|
||||
"recommended_visual_recipe": "path_flow"
|
||||
},
|
||||
"quadrant_text_bullets": {
|
||||
"description": "2x2 framework with four visible cells.",
|
||||
"min_card_like_rect": 4,
|
||||
"recommended_visual_recipe": "geometric_composition"
|
||||
}
|
||||
}
|
||||
}
|
||||
2757
skills/lark-slides/references/svg-seeds.json
Normal file
2757
skills/lark-slides/references/svg-seeds.json
Normal file
File diff suppressed because it is too large
Load Diff
111
skills/lark-slides/references/svg-visual-recipes.md
Normal file
111
skills/lark-slides/references/svg-visual-recipes.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# SVGlide 视觉 Recipe
|
||||
|
||||
这份文档是 `slides +create-svg` 的短版可执行 recipe 指南。
|
||||
运行时 public recipe 真源是同目录下的 [svg-recipes.json](svg-recipes.json);
|
||||
`svg_preflight.py` 直接读取该 registry。本文只做人类可读说明和选择指南,
|
||||
不要在本文中重新定义一套与 registry 不一致的 runtime catalog。
|
||||
|
||||
更完整的研究资料保留在 CLI skill 外;公开生成上下文只使用
|
||||
`svg-recipes.json` 中列出的 underscore runtime id。
|
||||
|
||||
## 边界
|
||||
|
||||
- `visual_recipe` 定义页面结构,以及这一页为什么值得用 SVG。
|
||||
- `style_preset` 定义视觉语言、配色、纹理、密度和 motif。
|
||||
- `renderer_id` 定义具体几何渲染器。
|
||||
- `seed_id` / `layout_skeleton_id` 定义可校验版式骨架和容量预算。
|
||||
|
||||
不要用 `style_preset` 替代 `visual_recipe`。不要在 `slide_plan.json`
|
||||
里发明新的 recipe id。`visual_recipe` 也不能替代 seed skeleton,不能放宽
|
||||
`text_budget_by_role`、`footer_safe_zone` 或 `vertical_text_policy`。
|
||||
|
||||
## 硬默认值
|
||||
|
||||
- 画布:`width="960" height="540" viewBox="0 0 960 540"`。
|
||||
- 安全区:关键文本、标签、图表、卡片、节点和图例保持在
|
||||
`x=48..912` and `y=40..500`.
|
||||
- 网格:使用稳定的 12 栏或 8px 步进布局,避免临时手调坐标。
|
||||
- 文本:中文正文每行控制在约 28 个字;英文正文每行约 62 个字符。
|
||||
- Role budget:标题、正文、callout、label、footer 必须分别遵守 seed 的 `text_budget_by_role`。
|
||||
- Footer:footer/source/legal/page mark 只进入 `footer_safe_zone`;正文、图例、chart label 和标签不得侵入。
|
||||
- 竖排:默认禁用竖排正文、`writing-mode`、`text-orientation` 和旋转长文本;只有 seed 明确允许的短装饰标签可使用。
|
||||
- 装饰:装饰线、水印、纹理和背景几何不能抢夺标题/焦点内容的注意力,也不能贴住它们。
|
||||
- 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` 中使用 `svg-recipes.json` 支持的 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 名称不是有效运行时 id。
|
||||
写入 plan 前必须映射到上面的 underscore id。
|
||||
28
skills/lark-slides/references/svglide-absorption-matrix.md
Normal file
28
skills/lark-slides/references/svglide-absorption-matrix.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# SVGlide Reference Absorption Matrix
|
||||
|
||||
This matrix tracks which high-quality presentation-generation capabilities have been absorbed into the SVGlide SVG route. It is intentionally written in SVGlide terms: the CLI must run independently and must not depend on an external reference project at runtime.
|
||||
|
||||
| Capability | Status | CLI Landing Area | Acceptance Evidence |
|
||||
|---|---|---|---|
|
||||
| Project state machine | absorbed | `svglide_project_runner.py` | stages emit receipts and `receipts/timings.json` |
|
||||
| Source pack | absorbed | `source/source_pack.json`, `slide_plan.source_pack` | generation receipt includes `source_pack_digest` and status |
|
||||
| Design spec lock | planned | `source/design_spec.json`, `slide_plan.strategy_locks` | strategy receipt lists mode, visual style, style preset, chart policy |
|
||||
| Renderer registry | planned | `svglide-renderer-registry.json` | active renderers validate against seed and recipe catalogs |
|
||||
| Layout contracts | absorbed | `svg-seeds.json` | active slides carry `seed_id`, `layout_boxes`, budgets, safe zone |
|
||||
| Visual recipes | absorbed | `svg-recipes.json` | active slides carry `visual_recipe` and required primitives |
|
||||
| Style system | absorbed | `style-presets.json` | active plan carries `style_preset` and `style_system` |
|
||||
| Design pattern selection | absorbed | `design_pattern_selection`, `receipts/design-pattern-usage.json` | selected assets are proven by component report geometry |
|
||||
| Renderer assetization | planned | `svglide_gen_runtime.py`, renderer registry | each active renderer has page kind, seed, recipe, and runtime family |
|
||||
| Chart geometry verification | planned | `chart_verify` runner stage | chart pages emit `receipts/chart-verify.json` before quality gate |
|
||||
| Preview lint | absorbed | `svg_preview_lint.py` | `preview_lint` receipt includes score, issues, and validation profile |
|
||||
| Quality gate | absorbed | `quality_gate` runner stage | gate aggregates preflight, preview lint, components, design usage |
|
||||
| Timing receipt | absorbed | `receipts/timings.json` | every runner stage records elapsed time and over-budget status |
|
||||
| Golden smoke suite | absorbed | `svglide_golden_suite.py` | built-in cases cover AI capital, Aksu oasis, runtime smoke |
|
||||
| Editable PPTX export | not_applicable | outside SVGlide SVG route | SVG route publishes through `slides +create-svg` |
|
||||
| PowerPoint animation/audio/video | not_applicable | outside SVGlide SVG route | not required for Lark Slides SVG create flow |
|
||||
|
||||
## Rules
|
||||
|
||||
- Runtime assets must use SVGlide-native names.
|
||||
- External examples can inform contracts, but raw files are not copied into the CLI runtime path.
|
||||
- A capability is not `absorbed` unless it has a receipt, validator, test, or golden case that proves it is exercised.
|
||||
166
skills/lark-slides/references/svglide-craft.md
Normal file
166
skills/lark-slides/references/svglide-craft.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# SVGlide Craft
|
||||
|
||||
这份文档是 `slides +create-svg` 的短版 craft 规则。它只约束 SVG 生成质量,不改变 `svg-protocol.md` 和 `lark-slides-create-svg.md` 的硬协议。
|
||||
|
||||
## Context Order
|
||||
|
||||
生成 SVGlide SVG 时按这个顺序理解上下文:
|
||||
|
||||
```text
|
||||
svg-protocol.md
|
||||
-> lark-slides-create-svg.md
|
||||
-> style-presets.json / style-presets.md
|
||||
-> svg-seeds.json
|
||||
-> svg-recipes.json / svg-visual-recipes.md
|
||||
-> svglide-craft.md
|
||||
-> slide_plan.json
|
||||
-> SVG source
|
||||
-> svg_preflight.py
|
||||
-> preview review
|
||||
-> dry-run / live create / readback
|
||||
```
|
||||
|
||||
## Open Design Local Adaptation
|
||||
|
||||
SVGlide 复制 Open Design 的生成控制体系,不复制 HTML runtime/CSS。不要迁移 `1920x1080` stage、runtime.js、localStorage、keyboard navigation、CSS animation、Chart.js 或 canvas FX。SVGlide 输出仍然是 `960 x 540` protocol SVG,再由 Slides 服务转成 slide snapshot。
|
||||
|
||||
生成顺序:
|
||||
|
||||
```text
|
||||
choose style_preset
|
||||
-> choose seed_id from svg-seeds.json
|
||||
-> keep seed layout_skeleton / layout_boxes / text_budget_by_role / footer_safe_zone / vertical_text_policy
|
||||
-> replace content inside the existing boxes
|
||||
-> verify content_budget / text_capacity / role text budget
|
||||
-> write SVG
|
||||
-> run svg_preflight.py
|
||||
```
|
||||
|
||||
每页 plan 必须有 `seed_id`、`layout_skeleton_id`、`layout_boxes`、`content_budget` 或 `text_capacity`、`text_budget_by_role`、`one_idea` 或 `key_message`、`reserved_bands.footer`、`footer_safe_zone`、`vertical_text_policy`。如果内容放不进 seed,不要从空白画布重画;先删内容、拆页,或换一个更合适的 seed。
|
||||
|
||||
## SVGlide Design Pattern Lessons
|
||||
|
||||
SVGlide 内置设计模式的关键是页型先行和节奏先行。生成时只能借鉴流程与结构合同,不复制 PPTX 导出、DrawingML 限制或 raw SVG path。
|
||||
|
||||
每页先锁定:
|
||||
|
||||
```json
|
||||
{
|
||||
"page_rhythm": "anchor | breathing | dense",
|
||||
"page_type": "cover | editor_note | contents | chart_takeaway | chapter | closing",
|
||||
"chart_type": "bar_chart | sankey_chart | hub_spoke | ...",
|
||||
"main_visual_anchor": "the visible chart/scene/motif that makes this page memorable",
|
||||
"annotation_zone": {"role": "right_observation", "x": 690, "y": 126, "width": 206, "height": 246},
|
||||
"reference_asset": {"source": "svglide_design_pattern", "asset_id": "chart.bar_chart", "usage": "geometry pattern only"}
|
||||
}
|
||||
```
|
||||
|
||||
硬规则:
|
||||
|
||||
- `page_rhythm` 要有起伏:anchor/breathing 页给叙事留气口,dense 页才承载图表密度。
|
||||
- `main_visual_anchor` 必须能在截图里一眼看到;标题、三 bullet、普通卡片不算 anchor。
|
||||
- `chart_type` 一旦声明,SVG source 必须画出对应几何:bar 要有多根 bar,sankey 要有多条 flow path,hub 要有中心节点和 spokes,quadrant 要有 2x2 区块。
|
||||
- `bubble_chart` 和 `donut_chart` 不能退化成普通卡片页:bubble 至少用多枚圆形节点表达规模/关系,donut 至少用环形/圆形结构、分段和中心 KPI 表达构成。
|
||||
- dense 页的信息密度必须由 chart/table/flow/hub/quadrant 承担,不能靠堆文字或装饰线。
|
||||
- 图片 atmosphere 只服务 cover/chapter/showcase;图片必须无可见文字、预留 SVG 标题负空间,并有 asset_contract。
|
||||
- 浅字、白字、name-plate、label-back、badge 和 pill 必须有独立承载面;底板不能压住 note、source、正文或图表标签。
|
||||
- 高饱和红/金等强调色只用于核心数字、风险、章节锚点或极少数对比线,不能把每个组件都染成同等权重。
|
||||
|
||||
硬默认:
|
||||
|
||||
- 不从空白 SVG 起稿;seed skeleton 是版式合同,不是风格灵感。
|
||||
- `layout_boxes` 只能在 seed 容忍范围内微调;大改结构先换 seed 或新增 seed。
|
||||
- `text_budget_by_role` 只能收紧不能放宽;局部 title/body/callout/footer 超量时删内容、拆页或换 seed。
|
||||
- `footer/source/legal/page mark` 只放 `footer_safe_zone`;正文、图例、chart label、标签和解释文字不得进入或贴近 footer band。
|
||||
- 默认禁止竖排正文、`writing-mode`、`text-orientation` 和旋转长文本;只有 seed 允许的短装饰标签可保留。
|
||||
|
||||
## Layout And Typography
|
||||
|
||||
- 默认画布 `960 x 540`,关键内容保持在 `x=48..912`、`y=40..500`。
|
||||
- 先规划 `titleBox`、`visualBox`、`textBox`、`chartBox`、`calloutBox`、`imageBox`、`connectorPath`,再写 SVG。
|
||||
- `layout_boxes` 必须来自 seed,并且在 plan 中显式记录;生成 SVG 时所有标题、正文、图表、callout、footer 坐标都从这些盒子推导。
|
||||
- footer/source/legal/page mark 只放在 `reserved_bands.footer` / `footer_safe_zone`;正文、callout、label、legend、chart label 不能侵入 footer band。
|
||||
- label / chip / badge / 装饰块不能覆盖可读文字;它们要么有自己的短文本盒,要么离正文和竖排说明保持明确间距。
|
||||
- badge / pill 到标题至少 12-16px;装饰线到标题至少 18-24px;标题底部到任何 text surface 至少 24px。
|
||||
- 中文正文每行约 18-28 字;英文正文每行约 45-62 字符。
|
||||
- 正文不低于 14px;图表标签不低于 11px。
|
||||
- 修重叠和溢出时重算 layout boxes,不要只整体缩小。
|
||||
|
||||
## Text Surface Contract
|
||||
|
||||
承载可见文字的区域不能默认裸白底黑字。使用一种 preset 派生 surface:
|
||||
|
||||
- `accent_rail_card`
|
||||
- `tinted_panel`
|
||||
- `glass_overlay`
|
||||
- `dark_backing`
|
||||
- `label_chip`
|
||||
- `metric_tile`
|
||||
|
||||
禁止 connector line 穿过文字;禁止 label chip 承载长句;禁止多页重复裸白卡片。
|
||||
|
||||
禁止把可见文案放进隐藏或裁切容器:`display:none`、`visibility:hidden`、近零 opacity、`overflow:hidden`、`clip-path`、`mask` 都不能作为“塞下文本”的办法。
|
||||
|
||||
## SVG Advantage
|
||||
|
||||
每页必须声明并实现:
|
||||
|
||||
```json
|
||||
{
|
||||
"visual_signature": "curved route path plus ownership badges",
|
||||
"svg_effects": ["path", "connector_flow", "typography"],
|
||||
"xml_like_risk": "without the route path this becomes ordinary bullets"
|
||||
}
|
||||
```
|
||||
|
||||
可接受的 SVG advantage:
|
||||
|
||||
- path / route / flow spine
|
||||
- chart geometry
|
||||
- dashboard / grid geometry
|
||||
- image overlay
|
||||
- spotlight annotation
|
||||
- technical texture
|
||||
- watermark text
|
||||
- brand motif
|
||||
|
||||
不足以证明 SVG advantage:
|
||||
|
||||
- 标题 + bullets
|
||||
- 普通白卡片
|
||||
- 换色背景
|
||||
- 单个静态 emoji/icon
|
||||
- plan 声明了效果但 SVG source 中不存在。
|
||||
|
||||
## Anti AI Slop
|
||||
|
||||
live create 前必须清理:
|
||||
|
||||
- lorem、placeholder、`点击添加正文`、demo data;
|
||||
- 编造数字、客户、年份、来源;
|
||||
- source token、local path、preset 名、prompt、tool 名泄漏到可见文本;
|
||||
- 默认蓝紫泛科技渐变;
|
||||
- emoji 当图标系统;
|
||||
- 连续多页同一三卡片结构;
|
||||
- 空图片框、破图、未记录 preview-only 来源。
|
||||
|
||||
## Asset Lanes
|
||||
|
||||
| Lane | 用法 | 门禁 |
|
||||
|-|-|-|
|
||||
| `svg_reference_only` | 只参考外部 SVG 构图、线条、留白和配色。 | 必须有 `reference_source_contract`;不得复制 path/symbol/group。 |
|
||||
| `preview_image` | 预览阶段使用真实图片或截图增强效果。 | 必须记录 `retrieval_query`、`source_url`、`license=preview_unverified`、`replacement_required=true`。 |
|
||||
| `production_asset` | 正式交付使用的图片、图标、截图。 | 必须使用用户提供、自有、明确授权或可商用资产。 |
|
||||
|
||||
不要用纯 SVG lane 成功证明 image-token lane 一定成功;图片页必须单独 smoke/readback。
|
||||
|
||||
## Quality Gate
|
||||
|
||||
继续 live create 的条件:
|
||||
|
||||
- `svg_preflight.py` 无 error;
|
||||
- 本地 preview 已产出并记录 `preview_path`、`issue_ids`、`visual_score` 和 `action`;
|
||||
- 只有 `action=create_live` 才能继续 live API;P0 或未记录 preview action 时必须 `repair_and_rerun`;
|
||||
- P1 已修复或用户明确接受草稿;
|
||||
- dry-run 请求结构符合预期;
|
||||
- readback 风险、图片 token 风险和 fallback 选择被记录。
|
||||
@@ -0,0 +1,53 @@
|
||||
# SVGlide Design Pattern Inventory
|
||||
|
||||
This file describes internal SVGlide design patterns used by the SVG generation pipeline.
|
||||
The CLI must not read an external slide-generation project at runtime; all usable patterns here are abstracted into SVGlide-owned ids, contracts, and renderer inputs.
|
||||
|
||||
## Policy
|
||||
|
||||
- Runtime dependency: none.
|
||||
- Source SVG/PPTX assets are not copied into output.
|
||||
- Patterns represent page rhythm, layout archetypes, chart geometry, style cues, image composition, and review heuristics.
|
||||
- Production use still requires a normalized SVGlide renderer, clear asset/license status, and quality gate evidence.
|
||||
|
||||
## Counts
|
||||
|
||||
- `brand_preset`: 2
|
||||
- `chart_template`: 71
|
||||
- `deck_template`: 8
|
||||
- `example_media_files`: 259
|
||||
- `example_pages`: 356
|
||||
- `example_project`: 21
|
||||
- `icon_library`: 5
|
||||
- `icon_svg_files`: 11631
|
||||
- `image_palette`: 14
|
||||
- `image_reference_collection`: 3
|
||||
- `image_rendering`: 20
|
||||
- `image_type_template`: 11
|
||||
- `layout_template`: 7
|
||||
- `narrative_mode`: 5
|
||||
- `total_resources`: 191
|
||||
- `visual_style`: 18
|
||||
- `workflow_reference`: 6
|
||||
|
||||
## Pattern Kinds
|
||||
|
||||
- `brand_preset`: `brand.anthropic`, `brand.google`
|
||||
- `chart_template`: `chart.agenda_list`, `chart.arc_anchored_list`, `chart.area_chart`, `chart.bar_chart`, `chart.basic_table`, `chart.box_plot_chart`, `chart.bubble_chart`, `chart.bullet_chart` (+63)
|
||||
- `deck_template`: `deck.中国电信`, `deck.中国电建_常规`, `deck.中国电建_现代`, `deck.中汽研_商务`, `deck.中汽研_常规`, `deck.中汽研_现代`, `deck.招商银行`, `deck.重庆大学`
|
||||
- `example_project`: `example.svglide_16x9_attention_is_all_you_need`, `example.svglide_16x9_brutalist_ai_newspaper_2026`, `example.svglide_16x9_building_effective_agents`, `example.svglide_16x9_cangzhuo`, `example.svglide_16x9_fashion_weekly_digest`, `example.svglide_16x9_general_dark_tech_claude_code_auto_mode`, `example.svglide_16x9_glassmorphism_demo`, `example.svglide_16x9_editorial_ai_capital_2026` (+13)
|
||||
- `icon_library`: `icon_library.chunk-filled`, `icon_library.phosphor-duotone`, `icon_library.simple-icons`, `icon_library.tabler-filled`, `icon_library.tabler-outline`
|
||||
- `image_palette`: `image_palette.cool-corporate`, `image_palette.dark-cinematic`, `image_palette.duotone`, `image_palette.earthy-dusty`, `image_palette.editorial-classic`, `image_palette.frost-ice`, `image_palette.jewel-tone`, `image_palette.macaron` (+6)
|
||||
- `image_reference_collection`: `image_reference_collection.palette`, `image_reference_collection.rendering`, `image_reference_collection.type`
|
||||
- `image_rendering`: `image_rendering.3d-isometric`, `image_rendering.blueprint`, `image_rendering.chalkboard`, `image_rendering.corporate-photo`, `image_rendering.digital-dashboard`, `image_rendering.editorial`, `image_rendering.fantasy-animation`, `image_rendering.flat` (+12)
|
||||
- `image_type_template`: `image_type.comparison`, `image_type.cycle`, `image_type.flowchart`, `image_type.framework`, `image_type.funnel`, `image_type.infographic`, `image_type.map`, `image_type.matrix` (+3)
|
||||
- `layout_template`: `layout.academic_defense`, `layout.ai_ops`, `layout.government_blue`, `layout.government_red`, `layout.medical_university`, `layout.pixel_retro`, `layout.psychology_attachment`
|
||||
- `narrative_mode`: `mode.briefing`, `mode.instructional`, `mode.narrative`, `mode.pyramid`, `mode.showcase`
|
||||
- `visual_style`: `visual_style.blueprint`, `visual_style.brutalist`, `visual_style.chalkboard`, `visual_style.dark-tech`, `visual_style.data-journalism`, `visual_style.editorial`, `visual_style.glassmorphism`, `visual_style.ink-notes` (+10)
|
||||
- `workflow_reference`: `workflow.executor-base`, `workflow.image-layout-patterns`, `workflow.image-layout-spec`, `workflow.shared-standards`, `workflow.strategist`, `workflow.visual-review`
|
||||
|
||||
## Runtime Contract
|
||||
|
||||
- Select patterns through `design_pattern_selection.selected_assets`.
|
||||
- Prove emitted usage through `receipts/design-pattern-usage.json`.
|
||||
- Store page-level evidence in `page_usages[].component_ids` and `source_trace`.
|
||||
11250
skills/lark-slides/references/svglide-design-pattern-map.json
Normal file
11250
skills/lark-slides/references/svglide-design-pattern-map.json
Normal file
File diff suppressed because it is too large
Load Diff
333
skills/lark-slides/references/svglide-project-pipeline.md
Normal file
333
skills/lark-slides/references/svglide-project-pipeline.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# SVGlide Project Pipeline
|
||||
|
||||
This document owns local project execution artifacts for `slides +create-svg`.
|
||||
It does not replace `slide_plan.json`, `svg-protocol.md`, `lark-slides-create-svg.md`,
|
||||
or `svg_preflight.py`.
|
||||
|
||||
## Scope
|
||||
|
||||
The pipeline standardizes how a generated SVGlide deck is stored, resumed, timed,
|
||||
validated, and published.
|
||||
|
||||
It owns:
|
||||
|
||||
- `.lark-slides/plan/<deck-id>/project_manifest.json`
|
||||
- `.lark-slides/plan/<deck-id>/state.json`
|
||||
- `pages/`, `prepared/`, `preview/`, `logs/`, and `receipts/`
|
||||
- the stage order and runner receipts
|
||||
|
||||
It does not own:
|
||||
|
||||
- SVG protocol syntax
|
||||
- chart creation protocol
|
||||
- seed / recipe / style semantics
|
||||
- image upload implementation
|
||||
- Lark Slides server behavior
|
||||
- PPTX or DrawingML compatibility
|
||||
|
||||
## Stage Order
|
||||
|
||||
```text
|
||||
source -> strategy -> generate -> prepare -> preview -> preflight -> preview_lint -> chart_verify -> quality_gate -> dry_run -> ppe_proof -> live_create -> readback
|
||||
```
|
||||
|
||||
`render_contact_sheet` is an optional artifact stage after readback/raster
|
||||
receipts. It is not part of the default `--until dry_run` authoring path.
|
||||
|
||||
`dry_run` and `live_create` must consume `prepared/*.svg`, never the authoring
|
||||
`pages/*.svg`.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
.lark-slides/plan/<deck-id>/
|
||||
project_manifest.json
|
||||
state.json
|
||||
source/
|
||||
brief.md
|
||||
inputs.json
|
||||
evidence.json
|
||||
source_pack.json
|
||||
design_spec.json
|
||||
slide_plan.json
|
||||
assets/
|
||||
asset_manifest.json
|
||||
pages/
|
||||
page-001.svg
|
||||
prepared/
|
||||
page-001.svg
|
||||
preview/
|
||||
preview.html
|
||||
logs/
|
||||
source.log
|
||||
strategy.log
|
||||
generate.log
|
||||
prepare.log
|
||||
preview.log
|
||||
preflight.log
|
||||
preview-lint.log
|
||||
chart-verify.log
|
||||
dry-run.log
|
||||
live-create.log
|
||||
readback.log
|
||||
receipts/
|
||||
timings.json
|
||||
env.json
|
||||
source.json
|
||||
strategy.json
|
||||
prepare.json
|
||||
preflight.json
|
||||
preview-lint.json
|
||||
chart-verify.json
|
||||
quality-gate.json
|
||||
dry-run.json
|
||||
live-create.json
|
||||
readback.json
|
||||
```
|
||||
|
||||
## Manifest
|
||||
|
||||
`project_manifest.json` indexes execution files and commands. It must not redefine
|
||||
design truth already owned by `slide_plan.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"deck_id": "example-deck",
|
||||
"title": "Example Deck",
|
||||
"lane": "pure_svg",
|
||||
"plan": "slide_plan.json",
|
||||
"source": {
|
||||
"status": "user_prompt_only",
|
||||
"brief": "source/brief.md",
|
||||
"evidence": "source/evidence.json",
|
||||
"source_pack": "source/source_pack.json"
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"page": 1,
|
||||
"source_svg": "pages/page-001.svg",
|
||||
"prepared_svg": "prepared/page-001.svg"
|
||||
}
|
||||
],
|
||||
"stage_commands": {
|
||||
"generate": "python3 generate_deck.py",
|
||||
"prepare": "builtin:copy_and_normalize_svg",
|
||||
"preview": "python3 generate_preview.py"
|
||||
},
|
||||
"live_guard": {
|
||||
"target_env": "ppe_pure_svg",
|
||||
"requires_allow_live": true,
|
||||
"requires_auth_verified": true,
|
||||
"requires_proxy_receipt": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stage commands are parsed as argv and executed with `shell=False`. Project-local
|
||||
commands run from the project directory. Commands under `skills/lark-slides/`
|
||||
run from the CLI worktree root.
|
||||
|
||||
`slide_plan.json` is still the design source of truth. `project_manifest.json`
|
||||
is the execution index. Before `prepare`, these counts must agree:
|
||||
|
||||
- `slide_plan.page_count`
|
||||
- `len(slide_plan.slides)` when `slides` is present
|
||||
- `len(slide_plan.svg_files)` when `svg_files` is present
|
||||
- `len(project_manifest.pages)`
|
||||
|
||||
When the plan changes, regenerate or update the manifest in the same step.
|
||||
Do not let `prepare`, `preflight`, `dry_run`, or `live_create` consume a stale
|
||||
manifest after pages were added, deleted, or reordered.
|
||||
|
||||
## Source And Strategy Discipline
|
||||
|
||||
Pure topic input must first become structured source state before page SVG is
|
||||
rendered. Store the original brief in `source/brief.md`; store source status,
|
||||
evidence ids, numeric-claim policy, and missing-source notes in
|
||||
`source/source_pack.json` or top-level `slide_plan.source_pack`. Research notes
|
||||
or citation indexes belong in `source/evidence.json` when they exist. The
|
||||
runner-owned `source` stage writes these files before generation when the
|
||||
project starts from a prompt or manifest brief.
|
||||
|
||||
`slide_plan.json` must keep strategy decisions in the existing plan surface:
|
||||
|
||||
- `narrative_mode`: story mode, not visual style.
|
||||
- `visual_style`: visual language target.
|
||||
- `strategy_locks`: exactly eight locked decisions with `id`, `decision`, and
|
||||
`evidence_ref`.
|
||||
- `asset_strategy`, `chart_policy`, and `icon_policy`: deck-level selection
|
||||
policy.
|
||||
- page-level `source_refs`, `asset_selection_reason`,
|
||||
`rejected_asset_alternatives`, `chart_decision`, and `chart_verification`.
|
||||
|
||||
The runner-owned `strategy` stage writes `source/design_spec.json` and refreshes
|
||||
`slide_plan.json` with the current strategy locks, style system, renderer
|
||||
selection, source pack reference, and design spec reference. `design_spec.json`
|
||||
is a receipt-like summary for audit and comparison; `slide_plan.json` remains
|
||||
the protocol-facing plan.
|
||||
|
||||
The runner fingerprints source files, plan, catalogs, generated SVG, prepared
|
||||
SVG, and receipts. Source pack changes should invalidate receipts rather than
|
||||
letting old generation or quality evidence be reused silently.
|
||||
|
||||
`source`, `strategy`, `preview_lint`, `preflight`, `chart_verify`,
|
||||
`quality_gate`, `ppe_proof`, `dry_run`, `live_create`, and `readback` are
|
||||
runner-owned stages. Do not override `preview_lint` through `stage_commands`;
|
||||
the runner calls the bundled `scripts/svg_preview_lint.py` with a fixed
|
||||
argument contract.
|
||||
|
||||
## Prepare
|
||||
|
||||
`prepare` creates deterministic CLI-ready SVG files under `prepared/`.
|
||||
`dry_run` and `live_create` must consume `prepared/*.svg`, never authoring
|
||||
`pages/*.svg` directly.
|
||||
|
||||
Allowed P0 behavior:
|
||||
|
||||
- copy `pages/*.svg` to `prepared/*.svg`
|
||||
- normalize file placement and receipt metadata
|
||||
- record input and output digests
|
||||
|
||||
Disallowed P0 behavior:
|
||||
|
||||
- silently simplify visual effects
|
||||
- apply PPTX/DrawingML compatibility rewrites
|
||||
- mutate authoring `pages/*.svg`
|
||||
- replace images or tokens outside the existing `slides +create-svg` transport path
|
||||
|
||||
## SVGlide Design Pattern Receipt
|
||||
|
||||
SVGlide design pattern references are allowed only as structure, rhythm, chart geometry,
|
||||
style, and review inspiration. They are not runtime dependencies and raw SVG/PPTX
|
||||
assets must not be copied into SVGlide output.
|
||||
|
||||
For quality lanes, `selected_assets` means "actually used by the generated
|
||||
pages", not "interesting candidates found during research". Candidate assets
|
||||
stay in research notes. Used assets must be proven by
|
||||
`receipts/design-pattern-usage.json` with page-level trace entries that point
|
||||
to the SVG evidence. The quality gate must fail when `selected_assets` includes
|
||||
an asset that is not present in the usage receipt.
|
||||
|
||||
Every mutation must be recorded in `receipts/prepare.json`.
|
||||
|
||||
Chart pages should additionally write `receipts/chart-verify.json` when data
|
||||
coordinates are available. This receipt verifies source data against visible SVG
|
||||
or native chart geometry: expected mark count, bar height/width, line points,
|
||||
stacked proportions, labels, and plot-area alignment. When data is not available,
|
||||
the plan must say so and avoid numeric claims.
|
||||
|
||||
## Runner
|
||||
|
||||
The runner command is:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/svglide_project_runner.py run \
|
||||
--project .lark-slides/plan/<deck-id> \
|
||||
--cli ./lark-cli \
|
||||
--until dry-run
|
||||
```
|
||||
|
||||
Live creation requires explicit flags:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/svglide_project_runner.py run \
|
||||
--project .lark-slides/plan/<deck-id> \
|
||||
--cli ./lark-cli \
|
||||
--until readback \
|
||||
--env ppe_pure_svg \
|
||||
--env-proof receipts/env-proof.json \
|
||||
--allow-live
|
||||
```
|
||||
|
||||
P0 only allows `--env ppe_pure_svg` for live creation.
|
||||
|
||||
## Quality Gate
|
||||
|
||||
The project runner treats preview lint as a hard gate only in the project
|
||||
quality lane. Manual debugging may continue from preflight to dry-run/readback
|
||||
without `preview/preview.html`, but it must not proceed to guarded live creation
|
||||
or production/golden delivery until preview lint and quality gate have passed.
|
||||
|
||||
`chart_verify` reads `slide_plan.json` and `prepared/*.svg`. When a slide
|
||||
declares a required `chart_decision`, it writes `receipts/chart-verify.json`
|
||||
proving that the expected chart carrier exists in the prepared SVG. This first
|
||||
pass checks visible geometry and anchors; stricter data-to-coordinate checks can
|
||||
extend the same receipt.
|
||||
|
||||
`quality_gate` reads the latest preflight receipt, preview lint receipt,
|
||||
chart-verify receipt, raster report, allowlist, asset selection, visual design
|
||||
contract, and component report evidence. If a slide declares
|
||||
`visual_design_contract.required_visual_evidence`, the same page in
|
||||
`receipts/emitted_components.json` must prove those evidence tokens through
|
||||
component `effects`, `primitives`, `renderer_id`, or component id. During P0
|
||||
migration, authoring/debug dry-run may use an unexpired legacy component
|
||||
waiver. Production, golden, and live lanes must not use that waiver.
|
||||
|
||||
## PPE Proof
|
||||
|
||||
`ppe_proof` normalizes raw environment evidence into
|
||||
`receipts/env-proof.json`. Raw proof may contain `observed_at_ms + ttl_ms`; the
|
||||
runner writes a normalized `expires_at_ms` and live creation only reads the
|
||||
normalized receipt.
|
||||
|
||||
## Receipts
|
||||
|
||||
Receipts are compact evidence files. Large raw command output goes under `logs/`.
|
||||
|
||||
Every receipt should include:
|
||||
|
||||
- stage name
|
||||
- status
|
||||
- elapsed time
|
||||
- input digest and expanded `input_fingerprint`
|
||||
- command argv when applicable
|
||||
- log path
|
||||
- parsed summary when available
|
||||
|
||||
`receipts/timings.json` aggregates stage elapsed times.
|
||||
|
||||
## Live Guard
|
||||
|
||||
`live_create` must refuse to run unless:
|
||||
|
||||
- `--allow-live` is present
|
||||
- `--env ppe_pure_svg` is present
|
||||
- auth verification succeeds
|
||||
- `--env-proof` points to JSON evidence that `open.feishu.cn` was routed to
|
||||
`open.feishu-pre.cn` with `Env=Pre_release` and `x-tt-env=ppe_pure_svg`
|
||||
- `dry_run` passed after the latest `prepare`
|
||||
- `quality_gate` passed strictly after the latest preflight and preview lint
|
||||
- `ppe_proof` is fresh for the current CLI path/version, auth subject, target
|
||||
host/headers, and smoke lane
|
||||
- proxy/header configuration is recorded in `receipts/env.json`
|
||||
- duplicate live creation is not already recorded
|
||||
|
||||
Proxy/header proof must be explicit for live creation. Local proxy presence alone
|
||||
is recorded as `configured_not_observed` and is not enough to run `live_create`.
|
||||
|
||||
## Validation Profile
|
||||
|
||||
`validation_profile` may appear in `slide_plan.json` as a profile over existing
|
||||
fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"validation_profile": {
|
||||
"mode": "svglide_project_pipeline",
|
||||
"locked_fields": ["canvas", "style_preset", "style_system", "visual_recipe"],
|
||||
"drift_policy": "warn_first"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It is not a second source of truth. It must not redefine canvas, style, recipe,
|
||||
asset, or protocol values.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do not add a top-level `svglide_plan_lock`.
|
||||
- Do not introduce a fourth structure catalog.
|
||||
- Do not modify Open Design for this rollout.
|
||||
- Do not rewrite Go CLI code in P0.
|
||||
- Do not copy external runtime or raw assets.
|
||||
- Do not treat local preview as a replacement for live readback.
|
||||
185
skills/lark-slides/references/svglide-renderer-registry.json
Normal file
185
skills/lark-slides/references/svglide-renderer-registry.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"schema_version": "svglide-renderer-registry/v1",
|
||||
"source": "SVGlide internal renderer contracts for create-svg authoring",
|
||||
"rules": [
|
||||
"Active renderers must map to an existing layout seed and visual recipe.",
|
||||
"Active renderers must declare the runtime renderer family used by svglide_gen_runtime.py.",
|
||||
"Candidate renderers may document planned coverage, but strategist must not select them automatically."
|
||||
],
|
||||
"renderers": [
|
||||
{
|
||||
"id": "cover_hero_statement",
|
||||
"status": "active",
|
||||
"page_kind": "cover",
|
||||
"runtime_renderer_family": "layout.cover",
|
||||
"layout_seed_id": "cover_hero_statement",
|
||||
"visual_recipe_id": "hero_typography",
|
||||
"style_reskin_hooks": ["background", "hero_route", "title_field", "motif"],
|
||||
"required_primitives": ["typography", "geometric_shape", "path"]
|
||||
},
|
||||
{
|
||||
"id": "agenda_numbered_path",
|
||||
"status": "active",
|
||||
"page_kind": "agenda",
|
||||
"runtime_renderer_family": "chart.flow",
|
||||
"layout_seed_id": "agenda_numbered_path",
|
||||
"visual_recipe_id": "path_flow",
|
||||
"style_reskin_hooks": ["numbered_route", "connector", "section_label"],
|
||||
"required_primitives": ["typography", "path", "annotation"]
|
||||
},
|
||||
{
|
||||
"id": "section_divider_index",
|
||||
"status": "active",
|
||||
"page_kind": "section",
|
||||
"runtime_renderer_family": "layout.chapter",
|
||||
"layout_seed_id": "section_divider_index",
|
||||
"visual_recipe_id": "gradient_depth",
|
||||
"style_reskin_hooks": ["section_index", "signal_field", "motif"],
|
||||
"required_primitives": ["typography", "gradient", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "dashboard_kpi_grid",
|
||||
"status": "active",
|
||||
"page_kind": "kpi_cards",
|
||||
"runtime_renderer_family": "chart.kpi",
|
||||
"layout_seed_id": "dashboard_kpi_grid",
|
||||
"visual_recipe_id": "fake_ui_dashboard",
|
||||
"style_reskin_hooks": ["metric_card", "micro_trend", "insight_strip"],
|
||||
"required_primitives": ["typography", "dashboard", "micro_chart"]
|
||||
},
|
||||
{
|
||||
"id": "single_chart_takeaway",
|
||||
"status": "active",
|
||||
"page_kind": "bar_chart",
|
||||
"runtime_renderer_family": "chart.bar",
|
||||
"layout_seed_id": "single_chart_takeaway",
|
||||
"visual_recipe_id": "infographic_scorecard",
|
||||
"chart_types": ["bar_chart", "horizontal_bar_chart", "pareto_chart"],
|
||||
"style_reskin_hooks": ["plot_area", "bars", "axis_label", "insight_strip"],
|
||||
"required_primitives": ["typography", "micro_chart", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "timeline_roadmap",
|
||||
"status": "active",
|
||||
"page_kind": "timeline",
|
||||
"runtime_renderer_family": "chart.flow",
|
||||
"layout_seed_id": "timeline_roadmap",
|
||||
"visual_recipe_id": "path_flow",
|
||||
"style_reskin_hooks": ["phase_spine", "milestone", "ownership_label"],
|
||||
"required_primitives": ["typography", "path", "annotation"]
|
||||
},
|
||||
{
|
||||
"id": "process_pipeline",
|
||||
"status": "active",
|
||||
"page_kind": "process_flow",
|
||||
"runtime_renderer_family": "chart.flow",
|
||||
"layout_seed_id": "process_pipeline",
|
||||
"visual_recipe_id": "path_flow",
|
||||
"style_reskin_hooks": ["flow_lane", "connector", "input_output_anchor"],
|
||||
"required_primitives": ["typography", "path", "annotation"]
|
||||
},
|
||||
{
|
||||
"id": "comparison_two_column_decision",
|
||||
"status": "active",
|
||||
"page_kind": "comparison",
|
||||
"runtime_renderer_family": "chart.matrix",
|
||||
"layout_seed_id": "comparison_two_column_decision",
|
||||
"visual_recipe_id": "geometric_composition",
|
||||
"chart_types": ["comparison_table", "quadrant_text_bullets"],
|
||||
"style_reskin_hooks": ["decision_axis", "contrast_panel", "dimension_label"],
|
||||
"required_primitives": ["typography", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "capability_icon_map",
|
||||
"status": "active",
|
||||
"page_kind": "hub_spoke",
|
||||
"runtime_renderer_family": "chart.hub",
|
||||
"layout_seed_id": "capability_icon_map",
|
||||
"visual_recipe_id": "icon_capability_map",
|
||||
"chart_types": ["hub_spoke"],
|
||||
"style_reskin_hooks": ["hub", "spoke", "module_icon", "orbit"],
|
||||
"required_primitives": ["typography", "icon", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "spotlight_diagnosis_callout",
|
||||
"status": "active",
|
||||
"page_kind": "insight_callout",
|
||||
"runtime_renderer_family": "contract.annotation",
|
||||
"layout_seed_id": "spotlight_diagnosis_callout",
|
||||
"visual_recipe_id": "spotlight_annotation",
|
||||
"style_reskin_hooks": ["spotlight", "annotation_line", "callout_label"],
|
||||
"required_primitives": ["typography", "spotlight", "annotation"]
|
||||
},
|
||||
{
|
||||
"id": "closing_summary",
|
||||
"status": "active",
|
||||
"page_kind": "closing",
|
||||
"runtime_renderer_family": "layout.closing",
|
||||
"layout_seed_id": "closing_summary",
|
||||
"visual_recipe_id": "brand_system",
|
||||
"style_reskin_hooks": ["closing_ribbon", "action_card", "final_statement"],
|
||||
"required_primitives": ["typography", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "bubble_chart",
|
||||
"status": "active",
|
||||
"page_kind": "bubble_chart",
|
||||
"runtime_renderer_family": "chart.bubble",
|
||||
"layout_seed_id": "single_chart_takeaway",
|
||||
"visual_recipe_id": "infographic_scorecard",
|
||||
"chart_types": ["bubble_chart"],
|
||||
"style_reskin_hooks": ["plot_area", "bubble", "label_plate", "insight_band"],
|
||||
"required_primitives": ["typography", "micro_chart", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "donut_chart",
|
||||
"status": "active",
|
||||
"page_kind": "donut_chart",
|
||||
"runtime_renderer_family": "chart.donut",
|
||||
"layout_seed_id": "single_chart_takeaway",
|
||||
"visual_recipe_id": "infographic_scorecard",
|
||||
"chart_types": ["donut_chart"],
|
||||
"style_reskin_hooks": ["ring", "segment_label", "center_metric", "legend"],
|
||||
"required_primitives": ["typography", "micro_chart", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "sankey_chart",
|
||||
"status": "active",
|
||||
"page_kind": "sankey_chart",
|
||||
"runtime_renderer_family": "chart.sankey",
|
||||
"layout_seed_id": "process_pipeline",
|
||||
"visual_recipe_id": "path_flow",
|
||||
"chart_types": ["sankey_chart"],
|
||||
"style_reskin_hooks": ["flow_width", "source_node", "target_node", "return_path"],
|
||||
"required_primitives": ["typography", "path", "geometric_shape"]
|
||||
},
|
||||
{
|
||||
"id": "line_chart",
|
||||
"status": "candidate",
|
||||
"page_kind": "line_chart",
|
||||
"runtime_renderer_family": "chart.line",
|
||||
"layout_seed_id": "single_chart_takeaway",
|
||||
"visual_recipe_id": "infographic_scorecard",
|
||||
"chart_types": ["dual_axis_line_chart"],
|
||||
"activation_blocker": "needs line data-to-coordinate chart verification fixture"
|
||||
},
|
||||
{
|
||||
"id": "table_editorial",
|
||||
"status": "candidate",
|
||||
"page_kind": "table",
|
||||
"runtime_renderer_family": "chart.table",
|
||||
"layout_seed_id": "comparison_two_column_decision",
|
||||
"visual_recipe_id": "geometric_composition",
|
||||
"activation_blocker": "needs dense table preview lint fixture"
|
||||
},
|
||||
{
|
||||
"id": "regional_image_story",
|
||||
"status": "candidate",
|
||||
"page_kind": "image_story",
|
||||
"runtime_renderer_family": "layout.content",
|
||||
"layout_seed_id": "image_story_showcase",
|
||||
"visual_recipe_id": "mask_clip_showcase",
|
||||
"activation_blocker": "needs asset receipt lane for image-backed story pages"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,6 +44,183 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
|
||||
## Automated SVGlide Plan And SVG Preflight
|
||||
|
||||
走 `slides +create-svg` 前,必须先运行 SVG plan/source preflight:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/svg_preflight.py \
|
||||
--route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json \
|
||||
--report-scope public \
|
||||
--plan .lark-slides/plan/<deck-id>/slide_plan.json \
|
||||
--input .lark-slides/plan/<deck-id>/pages/page-001.svg
|
||||
```
|
||||
|
||||
通过标准:
|
||||
|
||||
- `summary.error_count == 0`,任何 error 都必须先修复再调用 live API。
|
||||
- SVG 生成脚本必须先完整结束,再运行 `svg_preflight.py`;不要让生成和 preflight 并行读写同一 output 目录。
|
||||
- `style_preset` 必须存在于 `references/style-presets.json`。
|
||||
- `seed_id` 必须存在于 `references/svg-seeds.json`,并且 plan 的 `layout_skeleton_id`、`layout_family`、`visual_recipe`、`layout_boxes`、`content_budget`、`text_budget_by_role`、`footer_safe_zone` 与 seed 一致。
|
||||
- public `visual_recipe` 必须存在于 `references/svg-recipes.json`;研究文档里的 dotted recipe 名称不能直接写入 `slide_plan.json`。
|
||||
- `style_selection_reason` 必须说明为什么这个 preset 适合当前 deck。
|
||||
- `style_system` 必须包含 palette、typography、background strategy 和 motif。
|
||||
- 多页 deck 必须声明 `page_rhythm` / `deck_rhythm`,用于检查节奏、密度变化和重复页风险;authoring profile 下缺失会给 warning,`validation_profile=golden` 或 strict profile 下为 error。
|
||||
- 每页必须包含 `seed_id`、`layout_skeleton_id`、`layout_boxes`、`content_budget` 或 `text_capacity`、`text_budget_by_role`、`one_idea` 或 `key_message`、`reserved_bands.footer`、`footer_safe_zone`、`vertical_text_policy`、`visual_recipe`、`visual_signature`、`svg_effects`、`required_primitives`、`svg_primitives`、`xml_like_risk`、`content_density_contract`、`risk_flags`、`source_policy`。
|
||||
- Strategist contract 字段必须可检查:每页声明 `page_type`、可定位到 layout box / SVG element / component / bbox 的 `main_visual_anchor`;`reference_asset` 不能只写描述性文字,必须是 `{source, asset_id/id}` 或带 source/license/path 的资产元数据。
|
||||
- declared `svg_effects` 和 `required_primitives` 必须能在对应 SVG source 中命中。
|
||||
- 可见 slide 文本不得泄漏 preset 名称、source token、prompt、tool name 或本地文件路径。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
| code | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| `plan_style_preset_unknown` | plan 引用了不存在的 35 preset | 从 `style-presets.json` 选择有效 `style_id` |
|
||||
| `plan_unknown_seed` | plan 引用了不存在的 `seed_id` | 从 `svg-seeds.json` 选择有效 seed |
|
||||
| `plan_seed_visual_recipe_mismatch` | seed 和 `visual_recipe` 不匹配 | 换 seed 或换 recipe,保持结构一致 |
|
||||
| `plan_seed_layout_skeleton_missing` / `plan_seed_layout_skeleton_mismatch` / `plan_seed_layout_skeleton_drift` | plan 没有继承 seed skeleton,或关键 layout box 偏离 seed 容忍范围 | 从 `svg-seeds.json` 复制 skeleton/boxes;需要大改结构时新增或更换 seed |
|
||||
| `plan_seed_content_budget_loosened` | plan 试图放宽 seed 的文本容量预算 | seed budget 是上限,只能收紧;超量时删内容、拆页或换 seed |
|
||||
| `plan_missing_text_budget_by_role` / `plan_seed_text_budget_loosened` / `plan_text_role_budget_exceeded` | 缺少 role 级文本预算,或局部 title/body/callout/footer 超量 | 按 role 删减、拆页或换 seed,不要缩小字号、隐藏文本或改竖排 |
|
||||
| `plan_missing_layout_boxes` | plan 没有声明 seed 派生 layout boxes,或缺必需 box role | 从 seed 复制并调整 title/body/visual/chart/footer boxes |
|
||||
| `plan_text_box_count_exceeded` / `plan_source_text_box_count_exceeded` | plan 或最终 SVG 的文本盒数量超过 seed 上限 | 减少文字表面或换更高容量 seed |
|
||||
| `plan_source_text_box_count_below_seed_minimum` | 最终 SVG 没保留 seed 要求的最低文本结构 | 补齐 seed 需要的可读文本盒,或换更稀疏 seed |
|
||||
| `plan_content_budget_exceeded` / `plan_title_capacity_exceeded` / `plan_body_capacity_exceeded` / `plan_footer_capacity_exceeded` | 文案超过 seed 的容量预算 | 删减文案、拆页或换更合适的 seed |
|
||||
| `plan_source_content_budget_exceeded` | 最终 SVG 可见文本超过 seed 容量 | 缩短实际渲染文案或拆页 |
|
||||
| `plan_source_role_text_budget_exceeded` | 最终 SVG 某个 role 的字符数、文本盒、行数或字号违反 seed 预算 | 修 source SVG 对应 role;不要只改 plan 字段 |
|
||||
| `plan_text_box_outside_seed_layout_box` | 最终 SVG 文本盒偏离 seed 派生 layout box | 按 plan box 重排 SVG,或先更新 plan box 再渲染 |
|
||||
| `plan_footer_reserved_band_violation` | footer/source/note 文本不在 footer 保留区,或正文侵入 footer band | 调整 body/footer boxes,让 footer 类文本落入 `reserved_bands.footer` |
|
||||
| `plan_missing_footer_safe_zone` / `footer_safe_zone_intrusion` | 缺少 footer safe-zone,或非 footer 文本进入/贴近 footer band | footer/source/legal/page mark 只放 zone 内;正文、图例、标签和 chart label 上移 |
|
||||
| `plan_vertical_text_policy_missing` / `unsupported_vertical_text` / `vertical_text_disallowed_role` / `vertical_text_budget_exceeded` | 未声明竖排策略,或正文/长文使用竖排、writing-mode、旋转文本 | 默认改回横排;只有 seed 允许的短装饰标签可保留 |
|
||||
| `label_text_overlap` / `right_title_safe_zone_crowded` | 标签、badge、装饰块或右上标题栏压住可读文本 | 移动标记、扩大文本承载面、拆分标题区或减少 chip 文案 |
|
||||
| `plan_required_for_create_svg_route` | create-svg route 只传 SVG,没传 `--plan` | 必须带 `slide_plan.json`,防止绕过 seed/recipe/layout gate |
|
||||
| `hidden_visible_text` / `clipped_visible_text` | 可见文案被 hidden/opacity/overflow/clip-path/mask 隐藏或裁切 | 删除隐藏文案、扩大 text box,或取消裁切 |
|
||||
| `plan_unknown_visual_recipe` | plan 引用了不存在的 public recipe,或把 dotted research id 当 runtime id | 从 `svg-recipes.json` 选择有效 underscore id,或在 create-svg private route 中使用 `visual_recipe=route_private` |
|
||||
| `plan_missing_visual_signature` | 页面没有声明 SVG 视觉记忆点 | 写清这页相对普通 PPT/XML 模板的独特视觉结构 |
|
||||
| `plan_missing_svg_effects` | 页面没有声明 SVG 表达能力 | 声明真实会绘制的 `path`、`connector_flow`、`gradient`、`texture`、`chart_geometry` 等 |
|
||||
| `plan_svg_effect_not_found` | plan 声明的 effect 没在 SVG source 中出现 | 修改 SVG source,或删除不真实的 effect 声明 |
|
||||
| `plan_missing_page_rhythm` | 多页 deck 没有声明节奏合同 | 添加 deck-level `page_rhythm`,说明封面/章节/内容/总结节奏和重复页约束 |
|
||||
| `plan_missing_page_type` | 页面缺少可检查页型 | 添加 `page_type`,并让 renderer/layout/visual_recipe 与页型一致 |
|
||||
| `plan_missing_main_visual_anchor` | 主视觉锚点缺失或只是自然语言 | 指向 layout box role、`#svg-element-id`、component_id,或写明确 bbox |
|
||||
| `plan_main_visual_anchor_not_met` | SVG source 没有在主视觉锚点区域生成可见几何 | 调整 source SVG,把主视觉放回声明的锚点区域,或先更新 plan anchor |
|
||||
| `plan_reference_asset_unstructured` | `reference_asset` 是纯文字或缺少 source/id/path | 改成结构化 source metadata;没有参考资产时显式写 no_asset |
|
||||
| `plan_style_preset_visible_leak` | 可见文本泄漏 preset 名/source token | 仅在 plan metadata 中保留 preset 信息,画面只写用户主题内容 |
|
||||
|
||||
## SVGlide Aesthetic Preview Review
|
||||
|
||||
`svg_preflight.py` 通过后,走 `slides +create-svg` 前还必须做本地预览审查。读取 [svg-aesthetic-review.md](svg-aesthetic-review.md),检查 rendered preview,而不是只看 plan 字段或静态 XML。
|
||||
|
||||
Project runner quality lane 还必须在 `dry_run` 前运行 `preview_lint` 和
|
||||
`quality_gate`。手工排障路径可以不跑 preview lint 继续定位问题,但该路径
|
||||
不得进入 guarded live creation、production delivery 或 golden regression promotion。
|
||||
|
||||
## SVGlide Archetype Drift Checks
|
||||
|
||||
SVGlide 项目必须同时检查计划、执行 manifest、SVG source 和 receipts。不要只
|
||||
验证单个文件。
|
||||
|
||||
- `slide_plan.page_count`、`slide_plan.slides`、`slide_plan.svg_files` 和
|
||||
`project_manifest.pages` 的数量必须一致;少传 SVG input 是 error,不允许
|
||||
preflight 只检查已传入的子集。
|
||||
- `prepare` receipt 在 plan、manifest 或 source SVG 变化后必须失效;后续
|
||||
`preflight`、`preview_lint`、`quality_gate`、`dry_run` 不得复用旧 prepare。
|
||||
- 声明 `chart_type` 或 SVGlide design pattern chart 参考时,SVG 必须命中对应几何合同:
|
||||
`bubble_chart` 至少有多枚圆形节点,`donut_chart` 至少有环形/圆形结构和中心
|
||||
文本,`bar_chart` 至少有可识别轴/条形/数值区域。不能把图表页退化成普通
|
||||
卡片、closing 或 bullet list。
|
||||
- `design_pattern_selection.selected_assets` 只放真正启用并落地的参考资产;
|
||||
`enabled:false` 可作为候选保留,但不进入 quality gate。启用资产必须由
|
||||
`receipts/design-pattern-usage.json` 的 page-level trace 证明。
|
||||
- `visual_design_contract.required_visual_evidence` 必须由
|
||||
`receipts/emitted_components.json` 的 page-level component `effects`、
|
||||
`primitives`、`renderer_id` 或 component id 证明。缺少 evidence 时
|
||||
`quality_gate` 失败;这类问题不能只改 plan 字段,必须修 renderer 或 SVG。
|
||||
- `quality_gate` 会把 preflight 中的 Strategist contract issue codes 写入
|
||||
`strategist_contract` 摘要,并把 visual design contract 证明写入
|
||||
`visual_design_contract` 摘要;`validation_profile=golden` 要求零 warning、结构化
|
||||
component report,以及正式 schema 的 design-pattern usage receipt。
|
||||
- SVGlide design pattern 参考只允许变成 SVGlide-safe 的页型、图表几何、节奏、色彩纪律
|
||||
和审查规则;不要复制 raw SVG、图片或 PPTX/DrawingML 导出实现。
|
||||
|
||||
通过标准:
|
||||
|
||||
- 所有页面都检查过,不只检查封面。
|
||||
- 无标题、正文、badge、装饰线、图片框、图表标签的明显重叠或裁切。
|
||||
- root 和主要内容遵循 `960 x 540` 画布和 safe area。
|
||||
- 每页有清晰 `visual_focal_point`,视觉焦点对应 `visual_signature`。
|
||||
- 页面不是普通卡片/bullet 页伪装成 SVG;应能看出 path、texture、chart geometry、connector flow、image overlay、icon system、dashboard frame 或其他 SVG-native 结构。
|
||||
- 多页没有重复出现同一个布局错误;如果有,必须修生成规则并重新生成相关页面。
|
||||
- 用户可见交付 deck 的审美目标默认不低于 `75/100`;低于 `65/100` 应重新生成或显式降级为草稿。
|
||||
- 验证记录包含 `preview_path`、`visual_score`、`threshold`、`issue_ids`、`action`。`action=create_live` 才能继续调用 live API;`action=repair_and_rerun` 必须先修 source SVG / plan 并重新跑 preflight。
|
||||
|
||||
live creation 要求 `quality_gate.status=passed`。`passed_with_waiver` 只允许
|
||||
authoring/debug dry-run,不得用于 production、golden 或 live lane。
|
||||
|
||||
## Chart Data Verification
|
||||
|
||||
当页面声明 `chart_type`、chart marker、或图表类 reference asset 时,不能只检查
|
||||
“有图表几何”。还要检查数据到视觉坐标的映射是否可信。
|
||||
|
||||
计划层必须包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_decision": {
|
||||
"chart_type": "bar_chart",
|
||||
"reason": "bar chart fits category comparison and supports one takeaway",
|
||||
"data_ref": "brief",
|
||||
"anchor_role": "chart",
|
||||
"bbox_tolerance_px": 12
|
||||
},
|
||||
"chart_verification": {
|
||||
"status": "required",
|
||||
"receipt": "receipts/chart-verify.json",
|
||||
"checks": ["plot_area", "mark_count", "label_alignment", "scale_mapping"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
验证记录建议写入 `receipts/chart-verify.json`,并至少包含:
|
||||
|
||||
- `data_source`: source pack id, inline chart spec id, or explicit unavailable marker.
|
||||
- `chart_type`: normalized chart type.
|
||||
- `mapping_formula`: how values map to bar height/width, line point y, stacked share, radar radius, or node/flow weight.
|
||||
- `expected_marks`: expected bars, points, stacks, sectors, vertices, or flows.
|
||||
- `verification.status`: `passed`, `failed`, or `not_applicable_missing_data`.
|
||||
|
||||
最终验证记录要写清:
|
||||
|
||||
```text
|
||||
Chart data: checked N/N chart pages; failed M; missing data K.
|
||||
```
|
||||
|
||||
若没有可信数据源,页面可以保留示意图,但必须在 `source_policy` 中写明 no numeric
|
||||
claims,不得伪造真实数值、排名、比例或来源。
|
||||
|
||||
## Live Create And Image Token Gate
|
||||
|
||||
`svg_preflight.py` 通过后,仍必须跑 `slides +create-svg --dry-run`。Dry-run 要确认:
|
||||
|
||||
- 请求链路是 create presentation 后按 `--file` 顺序追加 SVG 页。
|
||||
- 含 `@./assets/...` 的 SVG 会先出现 `medias/upload_all`,再在 page content 中出现 transport metadata。
|
||||
- 纯 SVG 发布版不得残留 `<image>`、`@./assets` 或 `uploaded_file_token`。
|
||||
- 所有 `url(#id)` 引用都有对应 `defs` id;dry-run 不一定能拦住未定义渐变。
|
||||
|
||||
对 `ppe_pure_svg` 或其他尚未稳定证明支持 image token 的 live lane,先单独 smoke:
|
||||
|
||||
- 一页纯 SVG:验证 lane 支持 SVGlide parser。
|
||||
- 一页含本地 `@./assets/...` 图片:验证 upload 后的 image token 能被 `/slide` 解析。
|
||||
|
||||
如果纯 SVG 页成功、图片页在上传成功后 `/slide` 报 `nodeServer internal error`,短期线上发布可切到单独 `online-pure` SVG 目录,用 shape、path、gradient 和 texture geometry 替代图片区域。这个 fallback 只用于 live 发布,不得覆盖带真实图片的 authoring preview,并必须在最终交付说明中标注。
|
||||
|
||||
Project runner live lane 中,`ppe_proof` 必须把 raw environment evidence
|
||||
标准化为 `receipts/env-proof.json`;`live_create` 只读取 normalized receipt。
|
||||
proxy 仅配置但未观测到实际命中,不足以发布。
|
||||
|
||||
这一步和 preflight 分工如下:
|
||||
|
||||
- `svg_preflight.py`: 负责协议、plan、枚举、必填字段、bbox、primitive 命中和确定性错误。
|
||||
- `svg-aesthetic-review.md`: 负责截图/预览视角的层级、节奏、压迫感、重复问题、可读性和 SVG 视觉优势。
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
|
||||
|
||||
@@ -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 SVG 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.
|
||||
|
||||
199
skills/lark-slides/scripts/svg_effect_classifier.py
Normal file
199
skills/lark-slides/scripts/svg_effect_classifier.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||||
UNSAFE_TAGS = {"script", "iframe", "object", "embed"}
|
||||
HARD_EFFECT_TAGS = {
|
||||
"filter",
|
||||
"mask",
|
||||
"clipPath",
|
||||
"pattern",
|
||||
"symbol",
|
||||
"use",
|
||||
"marker",
|
||||
"animate",
|
||||
"animateTransform",
|
||||
"animateMotion",
|
||||
}
|
||||
HARD_EFFECT_ATTRS = {"filter", "mask", "clip-path"}
|
||||
HARD_STYLE_PROPS = {
|
||||
"filter",
|
||||
"backdrop-filter",
|
||||
"mix-blend-mode",
|
||||
"clip-path",
|
||||
"mask",
|
||||
"box-shadow",
|
||||
}
|
||||
UNSUPPORTED_PATH_COMMAND_RE = re.compile(r"[AaSsTt]")
|
||||
DOCTYPE_RE = re.compile(r"<!DOCTYPE|<!ENTITY", re.IGNORECASE)
|
||||
JAVASCRIPT_URL_RE = re.compile(r"javascript\s*:", re.IGNORECASE)
|
||||
CSS_IMPORT_RE = re.compile(r"@import|url\(\s*['\"]?\s*https?://", re.IGNORECASE)
|
||||
HTTP_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
|
||||
SVG_ROOT_RE = re.compile(r"<svg\b", re.IGNORECASE)
|
||||
|
||||
|
||||
class SvgRasterSafetyError(ValueError):
|
||||
"""Raised when an SVG is unsafe to parse or render."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectDetection:
|
||||
kind: str
|
||||
reason: str
|
||||
element_id: str = ""
|
||||
tag: str = ""
|
||||
attribute: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
out = {"kind": self.kind, "reason": self.reason}
|
||||
if self.element_id:
|
||||
out["element_id"] = self.element_id
|
||||
if self.tag:
|
||||
out["tag"] = self.tag
|
||||
if self.attribute:
|
||||
out["attribute"] = self.attribute
|
||||
return out
|
||||
|
||||
|
||||
def local_name(name: str) -> str:
|
||||
if "}" in name:
|
||||
return name.rsplit("}", 1)[1]
|
||||
return name
|
||||
|
||||
|
||||
def normalize_style(style: str) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for item in style.split(";"):
|
||||
if ":" not in item:
|
||||
continue
|
||||
key, value = item.split(":", 1)
|
||||
key = key.strip().lower()
|
||||
if key:
|
||||
out[key] = value.strip()
|
||||
return out
|
||||
|
||||
|
||||
def is_hard_style_property(prop: str) -> bool:
|
||||
prop = prop.strip().lower()
|
||||
return prop in HARD_STYLE_PROPS or prop.startswith("mask-") or prop.startswith("clip-path")
|
||||
|
||||
|
||||
def parse_svg(svg: str) -> ET.Element:
|
||||
if not SVG_ROOT_RE.search(svg):
|
||||
raise SvgRasterSafetyError("input is not an SVG document")
|
||||
if DOCTYPE_RE.search(svg):
|
||||
raise SvgRasterSafetyError("SVG contains DTD or external entity markup")
|
||||
try:
|
||||
root = ET.fromstring(svg)
|
||||
except ET.ParseError as error:
|
||||
raise SvgRasterSafetyError(f"SVG XML parse failed: {error}") from error
|
||||
if local_name(root.tag) != "svg":
|
||||
raise SvgRasterSafetyError("input root element must be <svg>")
|
||||
return root
|
||||
|
||||
|
||||
def _attr_value(attrs: dict[str, str], name: str) -> str:
|
||||
for raw_name, value in attrs.items():
|
||||
if local_name(raw_name) == name:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _href_value(attrs: dict[str, str]) -> str:
|
||||
for raw_name, value in attrs.items():
|
||||
if local_name(raw_name) == "href":
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_external_stylesheet_or_script(tag: str, attrs: dict[str, str]) -> bool:
|
||||
href = _href_value(attrs)
|
||||
src = attrs.get("src", "").strip()
|
||||
rel = attrs.get("rel", "").strip().lower()
|
||||
type_value = attrs.get("type", "").strip().lower()
|
||||
if tag == "script" and (src or href):
|
||||
return True
|
||||
if tag == "link" and rel == "stylesheet":
|
||||
return True
|
||||
if tag == "style" and type_value in {"text/javascript", "application/javascript"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_or_reject(svg: str) -> ET.Element:
|
||||
root = parse_svg(svg)
|
||||
for elem in root.iter():
|
||||
tag = local_name(elem.tag)
|
||||
attrs = {local_name(k): v for k, v in elem.attrib.items()}
|
||||
if tag in UNSAFE_TAGS:
|
||||
raise SvgRasterSafetyError(f"unsafe SVG tag <{tag}> is not allowed")
|
||||
if _is_external_stylesheet_or_script(tag, attrs):
|
||||
raise SvgRasterSafetyError("external JavaScript or CSS is not allowed")
|
||||
if tag == "style" and elem.text and CSS_IMPORT_RE.search(elem.text):
|
||||
raise SvgRasterSafetyError("external CSS imports are not allowed")
|
||||
for attr_name, value in attrs.items():
|
||||
normalized_attr = attr_name.lower()
|
||||
normalized_value = value.strip()
|
||||
if normalized_attr.startswith("on"):
|
||||
raise SvgRasterSafetyError(f"event attribute {attr_name} is not allowed")
|
||||
if JAVASCRIPT_URL_RE.search(normalized_value):
|
||||
raise SvgRasterSafetyError("javascript: URLs are not allowed")
|
||||
if normalized_attr in {"href", "src"} and tag != "image" and HTTP_URL_RE.match(normalized_value):
|
||||
raise SvgRasterSafetyError("non-image external resources are not allowed")
|
||||
return root
|
||||
|
||||
|
||||
def _detect_element(elem: ET.Element, root: ET.Element, parent: ET.Element | None = None) -> Iterable[EffectDetection]:
|
||||
tag = local_name(elem.tag)
|
||||
elem_id = elem.attrib.get("id", "")
|
||||
if tag in HARD_EFFECT_TAGS:
|
||||
yield EffectDetection("tag", f"unsupported SVG tag <{tag}>", elem_id, tag)
|
||||
if parent is root and tag == "text":
|
||||
yield EffectDetection("text", "root-level text requires raster or safe rewrite", elem_id, tag)
|
||||
if tag in {"polygon", "polyline"}:
|
||||
yield EffectDetection("shape", f"<{tag}> requires raster or safe rewrite", elem_id, tag)
|
||||
if tag == "path" and UNSUPPORTED_PATH_COMMAND_RE.search(elem.attrib.get("d", "")):
|
||||
yield EffectDetection("path", "path contains unsupported A/S/T commands", elem_id, tag, "d")
|
||||
if tag == "foreignObject":
|
||||
style = normalize_style(elem.attrib.get("style", ""))
|
||||
rich_props = {"display", "position", "overflow", "transform", "background-image"} & set(style)
|
||||
if style.get("display", "").lower() in {"flex", "grid"}:
|
||||
yield EffectDetection("foreignObject", "foreignObject uses flex/grid layout", elem_id, tag, "style")
|
||||
elif style.get("position", "").lower() in {"absolute", "fixed"}:
|
||||
yield EffectDetection("foreignObject", "foreignObject uses absolute/fixed layout", elem_id, tag, "style")
|
||||
elif style.get("overflow", "").lower() in {"hidden", "clip"}:
|
||||
yield EffectDetection("foreignObject", "foreignObject clips HTML content", elem_id, tag, "style")
|
||||
elif rich_props - {"display", "position", "overflow"}:
|
||||
yield EffectDetection("foreignObject", "foreignObject uses rich CSS layout effects", elem_id, tag, "style")
|
||||
for raw_attr, value in elem.attrib.items():
|
||||
attr = local_name(raw_attr)
|
||||
if attr in HARD_EFFECT_ATTRS:
|
||||
yield EffectDetection("attribute", f"unsupported SVG attribute {attr}", elem_id, tag, attr)
|
||||
if attr == "style":
|
||||
style = normalize_style(value)
|
||||
for prop in sorted(style):
|
||||
if is_hard_style_property(prop):
|
||||
yield EffectDetection("style", f"unsupported CSS property {prop}", elem_id, tag, "style")
|
||||
|
||||
|
||||
def classify_effects(svg: str) -> list[EffectDetection]:
|
||||
root = sanitize_or_reject(svg)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
detections: list[EffectDetection] = []
|
||||
for elem in root.iter():
|
||||
detections.extend(_detect_element(elem, root, parents.get(elem)))
|
||||
return detections
|
||||
|
||||
|
||||
def detections_as_dicts(detections: Iterable[EffectDetection]) -> list[dict[str, str]]:
|
||||
return [detection.as_dict() for detection in detections]
|
||||
59
skills/lark-slides/scripts/svg_effect_classifier_test.py
Normal file
59
skills/lark-slides/scripts/svg_effect_classifier_test.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import svg_effect_classifier as classifier
|
||||
|
||||
|
||||
SAFE_SVG = """<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="#fff" /></linearGradient></defs>
|
||||
<path d="M10 10 L100 10 C120 20 140 20 160 10 Q180 0 200 10 Z" fill="url(#g)" />
|
||||
</svg>"""
|
||||
|
||||
|
||||
class SvgEffectClassifierTest(unittest.TestCase):
|
||||
def reasons(self, svg: str) -> list[str]:
|
||||
return [detection.reason for detection in classifier.classify_effects(svg)]
|
||||
|
||||
def test_rejects_unsafe_input_before_render(self) -> None:
|
||||
unsafe_inputs = [
|
||||
"<!DOCTYPE svg><svg></svg>",
|
||||
'<svg><script href="https://example.test/a.js" /></svg>',
|
||||
'<svg><rect onload="alert(1)" /></svg>',
|
||||
'<svg><image href="javascript:alert(1)" /></svg>',
|
||||
'<svg><iframe src="https://example.test" /></svg>',
|
||||
]
|
||||
|
||||
for svg in unsafe_inputs:
|
||||
with self.subTest(svg=svg):
|
||||
with self.assertRaises(classifier.SvgRasterSafetyError):
|
||||
classifier.sanitize_or_reject(svg)
|
||||
|
||||
def test_detects_rich_svg_effects(self) -> None:
|
||||
svg = """<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540">
|
||||
<defs><filter id="glow" /><mask id="m" /><clipPath id="c" /><symbol id="s"><rect /></symbol></defs>
|
||||
<use href="#s" filter="url(#glow)" />
|
||||
<path d="M10 10 A40 40 0 0 1 80 80" />
|
||||
<text x="10" y="20">Title</text>
|
||||
<polygon points="0,0 10,0 10,10" style="mix-blend-mode:multiply" />
|
||||
</svg>"""
|
||||
|
||||
reasons = "\n".join(self.reasons(svg))
|
||||
|
||||
self.assertIn("unsupported SVG tag <filter>", reasons)
|
||||
self.assertIn("unsupported SVG tag <mask>", reasons)
|
||||
self.assertIn("unsupported SVG tag <clipPath>", reasons)
|
||||
self.assertIn("unsupported SVG tag <symbol>", reasons)
|
||||
self.assertIn("unsupported SVG tag <use>", reasons)
|
||||
self.assertIn("path contains unsupported A/S/T commands", reasons)
|
||||
self.assertIn("root-level text requires raster or safe rewrite", reasons)
|
||||
self.assertIn("unsupported CSS property mix-blend-mode", reasons)
|
||||
|
||||
def test_safe_gradient_path_is_not_flagged(self) -> None:
|
||||
self.assertEqual(classifier.classify_effects(SAFE_SVG), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
4990
skills/lark-slides/scripts/svg_preflight.py
Normal file
4990
skills/lark-slides/scripts/svg_preflight.py
Normal file
File diff suppressed because it is too large
Load Diff
2412
skills/lark-slides/scripts/svg_preflight_test.py
Normal file
2412
skills/lark-slides/scripts/svg_preflight_test.py
Normal file
File diff suppressed because it is too large
Load Diff
1252
skills/lark-slides/scripts/svg_preview_lint.py
Normal file
1252
skills/lark-slides/scripts/svg_preview_lint.py
Normal file
File diff suppressed because it is too large
Load Diff
439
skills/lark-slides/scripts/svg_preview_lint_test.py
Normal file
439
skills/lark-slides/scripts/svg_preview_lint_test.py
Normal file
@@ -0,0 +1,439 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import svg_preview_lint
|
||||
|
||||
|
||||
def write_json(path: Path, data: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
class SvgPreviewLintTest(unittest.TestCase):
|
||||
def make_project(self) -> Path:
|
||||
root = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True))
|
||||
project = root / "demo"
|
||||
(project / "preview").mkdir(parents=True)
|
||||
(project / "prepared").mkdir()
|
||||
(project / "assets").mkdir()
|
||||
write_json(project / "slide_plan.json", {"svg_files": [{"page": 1, "path": "prepared/page-001.svg"}]})
|
||||
return project
|
||||
|
||||
def write_preview(self, project: Path, ref: str = "../prepared/page-001.svg") -> None:
|
||||
(project / "preview" / "preview.html").write_text(
|
||||
f"<html><body><img src=\"{ref}\" /></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def write_svg(self, project: Path, body: str) -> None:
|
||||
(project / "prepared" / "page-001.svg").write_text(
|
||||
f"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
{body}
|
||||
</svg>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def lint(self, project: Path, validation_profile: str = "") -> dict[str, object]:
|
||||
return svg_preview_lint.lint_project(
|
||||
project,
|
||||
project / "preview" / "preview.html",
|
||||
project / "slide_plan.json",
|
||||
validation_profile,
|
||||
)
|
||||
|
||||
def write_sparse_pages(self, project: Path, page_count: int, *, validation_profile: object | None = None) -> None:
|
||||
refs = []
|
||||
preview_images = []
|
||||
for page in range(1, page_count + 1):
|
||||
name = f"page-{page:03d}.svg"
|
||||
refs.append({"page": page, "path": f"prepared/{name}"})
|
||||
preview_images.append(f'<img src="../prepared/{name}" />')
|
||||
(project / "prepared" / name).write_text(
|
||||
f"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<path d="M80 420 C240 360 420 470 620 390" fill="none" stroke="#94a3b8" stroke-width="6" />
|
||||
<text x="80" y="120" font-size="34" fill="#111827">Thin visual idea {page}</text>
|
||||
<text x="80" y="172" font-size="18" fill="#334155">One short line is not enough structure.</text>
|
||||
</svg>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan: dict[str, object] = {"svg_files": refs}
|
||||
if validation_profile is not None:
|
||||
plan["validation_profile"] = validation_profile
|
||||
write_json(project / "slide_plan.json", plan)
|
||||
(project / "preview" / "preview.html").write_text("<html><body>" + "".join(preview_images) + "</body></html>", encoding="utf-8")
|
||||
|
||||
def codes(self, result: dict[str, object]) -> list[str]:
|
||||
checks = result.get("checks")
|
||||
self.assertIsInstance(checks, list)
|
||||
return [str(item.get("code")) for item in checks if isinstance(item, dict)]
|
||||
|
||||
def test_missing_preview_fails(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<text x="80" y="90" font-size="28" fill="#111827">Title</text>
|
||||
<text x="80" y="150" font-size="18" fill="#334155">Body</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["action"], "repair_and_rerun")
|
||||
self.assertIn("preview_missing", result["issue_ids"])
|
||||
self.assertIn("preview_missing", self.codes(result))
|
||||
|
||||
def test_missing_svg_fails(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("svg_file_missing", self.codes(result))
|
||||
|
||||
def test_svg_parse_failure_fails(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
(project / "prepared" / "page-001.svg").write_text("<svg><text>broken", encoding="utf-8")
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("svg_parse_failed", self.codes(result))
|
||||
|
||||
def test_plan_refs_do_not_duplicate_preview_pages(self) -> None:
|
||||
project = self.make_project()
|
||||
(project / "pages").mkdir()
|
||||
svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<rect x="80" y="120" width="220" height="120" fill="#dbeafe" />
|
||||
<rect x="340" y="120" width="220" height="120" fill="#dcfce7" />
|
||||
<text x="80" y="90" font-size="30" fill="#111827">Page title</text>
|
||||
<text x="80" y="290" font-size="18" fill="#334155">Semantic label and supporting copy.</text>
|
||||
</svg>
|
||||
"""
|
||||
preview_images = []
|
||||
plan_refs = []
|
||||
for page in (1, 2):
|
||||
name = f"page-{page:03d}.svg"
|
||||
(project / "prepared" / name).write_text(svg, encoding="utf-8")
|
||||
(project / "pages" / name).write_text(svg, encoding="utf-8")
|
||||
preview_images.append(f'<img src="../prepared/{name}" />')
|
||||
plan_refs.append({"page": page, "path": f"pages/{name}"})
|
||||
write_json(project / "slide_plan.json", {"svg_files": plan_refs})
|
||||
(project / "preview" / "preview.html").write_text("<html><body>" + "".join(preview_images) + "</body></html>", encoding="utf-8")
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["page_count"], 2)
|
||||
|
||||
def test_preview_missing_plan_page_fails_even_when_plan_svg_exists(self) -> None:
|
||||
project = self.make_project()
|
||||
(project / "pages").mkdir()
|
||||
svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<rect x="80" y="120" width="220" height="120" fill="#dbeafe" />
|
||||
<text x="80" y="90" font-size="30" fill="#111827">Page title</text>
|
||||
<text x="80" y="290" font-size="18" fill="#334155">Semantic label and supporting copy.</text>
|
||||
</svg>
|
||||
"""
|
||||
plan_refs = []
|
||||
for page in (1, 2):
|
||||
name = f"page-{page:03d}.svg"
|
||||
(project / "prepared" / name).write_text(svg, encoding="utf-8")
|
||||
(project / "pages" / name).write_text(svg, encoding="utf-8")
|
||||
plan_refs.append({"page": page, "path": f"pages/{name}"})
|
||||
write_json(project / "slide_plan.json", {"svg_files": plan_refs})
|
||||
(project / "preview" / "preview.html").write_text('<html><body><img src="../prepared/page-001.svg" /></body></html>', encoding="utf-8")
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["page_count"], 2)
|
||||
self.assertIn("preview_missing_plan_page", self.codes(result))
|
||||
|
||||
def test_detects_obvious_text_overlap(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<text x="120" y="150" font-size="36" fill="#111827">Overlap text</text>
|
||||
<text x="126" y="154" font-size="36" fill="#111827">Overlap text</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("text_overlap", self.codes(result))
|
||||
|
||||
def test_detects_bubble_label_backing_overlapping_note(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#111827" />
|
||||
<circle id="bubble-openai" cx="460" cy="260" r="82" fill="#2563eb" />
|
||||
<rect id="bubble-openai-name-plate" x="382" y="222" width="156" height="52" rx="10" fill="#0f172a" />
|
||||
<text id="bubble-openai-label" x="408" y="254" font-size="18" fill="#ffffff">OpenAI</text>
|
||||
<foreignObject id="bubble-openai-note" x="392" y="252" width="180" height="74" color="#e5e7eb">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Large funding round and GPU demand concentration.</div>
|
||||
</foreignObject>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("shape_text_overlap", self.codes(result))
|
||||
|
||||
def test_detects_light_text_without_dark_backing(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#ffffff" />
|
||||
<circle cx="720" cy="190" r="64" fill="#dbeafe" />
|
||||
<text x="120" y="150" font-size="34" fill="#ffffff">Invisible title</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("light_text_without_dark_backing", self.codes(result))
|
||||
|
||||
def test_normal_preview_passes_and_cli_outputs_schema(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
(project / "assets" / "hero.png").write_bytes(b"not-a-real-png-but-present")
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<rect x="76" y="118" width="280" height="150" fill="#e2e8f0" />
|
||||
<text x="80" y="82" font-size="28" fill="#111827">Strategy review</text>
|
||||
<text x="96" y="170" font-size="18" fill="#334155">Pipeline status</text>
|
||||
<image href="@./assets/hero.png" x="560" y="120" width="300" height="180" />
|
||||
""",
|
||||
)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
exit_code = svg_preview_lint.main(
|
||||
[
|
||||
"--project",
|
||||
str(project),
|
||||
"--preview",
|
||||
str(project / "preview" / "preview.html"),
|
||||
"--plan",
|
||||
str(project / "slide_plan.json"),
|
||||
]
|
||||
)
|
||||
result = json.loads(stdout.getvalue())
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(result["schema_version"], "svglide-preview-lint/v1")
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertEqual(result["action"], "create_live")
|
||||
self.assertEqual(result["issue_ids"], [])
|
||||
self.assertEqual(result["error_count"], 0)
|
||||
self.assertEqual(result["visual_score_mode"], "advisory")
|
||||
self.assertEqual(result["validation_profile"], "authoring")
|
||||
self.assertEqual(result["visual_score_threshold"], 75)
|
||||
|
||||
def test_sparse_decorative_page_gets_density_warning(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<path d="M80 420 C240 360 420 470 620 390" fill="none" stroke="#94a3b8" stroke-width="6" />
|
||||
<text x="80" y="120" font-size="34" fill="#111827">Thin visual idea</text>
|
||||
<text x="80" y="172" font-size="18" fill="#334155">One short line is not enough structure.</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertIn("low_information_density", self.codes(result))
|
||||
self.assertLess(result["visual_score"], 100)
|
||||
|
||||
def test_placeholder_renderer_copy_gets_warning(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<rect x="96" y="120" width="260" height="190" fill="#dbeafe" />
|
||||
<path d="M120 350 C240 260 420 380 620 250" fill="none" stroke="#2563eb" stroke-width="8" />
|
||||
<text x="80" y="96" font-size="30" fill="#111827">Smoke deck</text>
|
||||
<text x="80" y="156" font-size="18" fill="#334155">A generated page that still exposes implementation copy.</text>
|
||||
<text id="footer" x="320" y="512" font-size="10" fill="#64748b">SVGlide contract renderer · 01</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertIn("placeholder_renderer_copy", self.codes(result))
|
||||
self.assertLess(result["visual_score"], 100)
|
||||
|
||||
def test_unlabeled_visual_system_gets_density_warning(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<rect x="80" y="128" width="160" height="96" fill="#dbeafe" />
|
||||
<rect x="280" y="128" width="160" height="96" fill="#bfdbfe" />
|
||||
<rect x="480" y="128" width="160" height="96" fill="#93c5fd" />
|
||||
<rect x="680" y="128" width="160" height="96" fill="#60a5fa" />
|
||||
<path d="M120 340 L260 300 L420 360 L620 278 L820 330" fill="none" stroke="#2563eb" stroke-width="8" />
|
||||
<text x="80" y="82" font-size="30" fill="#111827">Shape-only dashboard</text>
|
||||
<text x="80" y="444" font-size="18" fill="#334155">The message is present but the visual system is not labeled.</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertIn("unlabeled_visual_system", self.codes(result))
|
||||
self.assertLess(result["visual_score"], 100)
|
||||
|
||||
def test_repeated_multi_page_layout_gets_variety_warning(self) -> None:
|
||||
project = self.make_project()
|
||||
refs = []
|
||||
preview_images = []
|
||||
for page in range(1, 5):
|
||||
name = f"page-{page:03d}.svg"
|
||||
refs.append({"page": page, "path": f"prepared/{name}"})
|
||||
preview_images.append(f'<img src="../prepared/{name}" />')
|
||||
(project / "prepared" / name).write_text(
|
||||
f"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<rect x="80" y="120" width="260" height="120" fill="#e2e8f0" />
|
||||
<rect x="380" y="140" width="160" height="24" fill="#2563eb" />
|
||||
<rect x="380" y="180" width="120" height="24" fill="#60a5fa" />
|
||||
<circle cx="760" cy="190" r="52" fill="#c7d2fe" />
|
||||
<text x="80" y="82" font-size="28" fill="#111827">Page {page}</text>
|
||||
<text x="96" y="170" font-size="18" fill="#334155">Status item</text>
|
||||
</svg>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_json(project / "slide_plan.json", {"svg_files": refs})
|
||||
(project / "preview" / "preview.html").write_text("<html><body>" + "".join(preview_images) + "</body></html>", encoding="utf-8")
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertIn("low_visual_variety", self.codes(result))
|
||||
|
||||
def test_authoring_allows_warnings_above_authoring_threshold(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_sparse_pages(project, 4)
|
||||
|
||||
result = self.lint(project, "authoring")
|
||||
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertEqual(result["warning_count"], 5)
|
||||
self.assertEqual(result["visual_score"], 65)
|
||||
self.assertEqual(result["visual_score_threshold"], 75)
|
||||
self.assertFalse(result["visual_score_passed"])
|
||||
self.assertEqual(result["visual_score_mode"], "advisory")
|
||||
|
||||
def test_production_fails_when_visual_score_is_below_threshold(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_sparse_pages(project, 3)
|
||||
|
||||
result = self.lint(project, "production")
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["warning_count"], 3)
|
||||
self.assertEqual(result["visual_score"], 79)
|
||||
self.assertEqual(result["visual_score_threshold"], 85)
|
||||
self.assertFalse(result["visual_score_passed"])
|
||||
|
||||
def test_golden_fails_when_warning_count_is_nonzero(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_preview(project)
|
||||
write_json(
|
||||
project / "slide_plan.json",
|
||||
{
|
||||
"validation_profile": {"profile": "golden"},
|
||||
"svg_files": [{"page": 1, "path": "prepared/page-001.svg"}],
|
||||
},
|
||||
)
|
||||
self.write_svg(
|
||||
project,
|
||||
"""
|
||||
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<path d="M80 420 C240 360 420 470 620 390" fill="none" stroke="#94a3b8" stroke-width="6" />
|
||||
<text x="80" y="120" font-size="34" fill="#111827">Thin visual idea</text>
|
||||
<text x="80" y="172" font-size="18" fill="#334155">One short line is not enough structure.</text>
|
||||
""",
|
||||
)
|
||||
|
||||
result = self.lint(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["validation_profile"], "golden")
|
||||
self.assertEqual(result["visual_score"], 93)
|
||||
self.assertEqual(result["visual_score_threshold"], 90)
|
||||
self.assertFalse(result["warning_gate_passed"])
|
||||
|
||||
def test_cli_reads_validation_profile_from_plan(self) -> None:
|
||||
project = self.make_project()
|
||||
self.write_sparse_pages(project, 3, validation_profile="production")
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
exit_code = svg_preview_lint.main(
|
||||
[
|
||||
"--project",
|
||||
str(project),
|
||||
"--preview",
|
||||
str(project / "preview" / "preview.html"),
|
||||
"--plan",
|
||||
str(project / "slide_plan.json"),
|
||||
]
|
||||
)
|
||||
result = json.loads(stdout.getvalue())
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(result["validation_profile"], "production")
|
||||
self.assertEqual(result["visual_score_threshold"], 85)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
294
skills/lark-slides/scripts/svg_private_docs_lint.py
Normal file
294
skills/lark-slides/scripts/svg_private_docs_lint.py
Normal file
@@ -0,0 +1,294 @@
|
||||
#!/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 dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
SKILL_ROOT = SCRIPT_PATH.parents[1]
|
||||
DEFAULT_REPO_ROOT = SCRIPT_PATH.parents[3]
|
||||
PRIVATE_MANIFEST_REL = Path("references/routes/create-svg/private-recipes.manifest.json")
|
||||
|
||||
SKILL_SCAN_TARGETS = [
|
||||
Path("SKILL.md"),
|
||||
Path("references"),
|
||||
Path("assets/templates"),
|
||||
Path("scripts"),
|
||||
Path("tests"),
|
||||
]
|
||||
|
||||
REPO_SCAN_TARGETS = [
|
||||
Path("shortcuts/slides"),
|
||||
Path("tests"),
|
||||
Path("tests/cli_e2e/slides/coverage.md"),
|
||||
Path("README.md"),
|
||||
Path("README.zh.md"),
|
||||
Path("docs"),
|
||||
]
|
||||
|
||||
TEXT_FILE_SUFFIXES = {
|
||||
"",
|
||||
".cfg",
|
||||
".css",
|
||||
".go",
|
||||
".html",
|
||||
".js",
|
||||
".json",
|
||||
".jsonl",
|
||||
".md",
|
||||
".mjs",
|
||||
".py",
|
||||
".svg",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Issue:
|
||||
path: str
|
||||
line: int
|
||||
column: int
|
||||
code: str
|
||||
token: str
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Lint public SVGlide create-svg docs for route-private recipe leaks."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
default=str(DEFAULT_REPO_ROOT),
|
||||
help="Repository root. Defaults to the root inferred from this script location.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit JSON instead of human-readable diagnostics.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as error:
|
||||
raise SystemExit(f"missing private recipe manifest: {path}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise SystemExit(f"invalid JSON in private recipe manifest: {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SystemExit(f"private recipe manifest must be a JSON object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def expect_string_list(value: Any, field: str) -> list[str]:
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
||||
raise SystemExit(f"manifest field must be a non-empty string list: {field}")
|
||||
return value
|
||||
|
||||
|
||||
def load_manifest_tokens(manifest: dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
|
||||
recipes = manifest.get("recipes")
|
||||
if not isinstance(recipes, (dict, list)) or not recipes:
|
||||
raise SystemExit('manifest field must be a non-empty object or list: recipes')
|
||||
|
||||
private_ids: list[str] = []
|
||||
if isinstance(recipes, dict):
|
||||
private_ids = [str(recipe_id) for recipe_id in recipes.keys() if str(recipe_id)]
|
||||
if len(private_ids) != len(recipes):
|
||||
raise SystemExit("manifest recipes object must not contain empty ids")
|
||||
else:
|
||||
for recipe in recipes:
|
||||
if not isinstance(recipe, dict):
|
||||
raise SystemExit("manifest recipes must contain JSON objects")
|
||||
recipe_id = recipe.get("recipe_id")
|
||||
if not isinstance(recipe_id, str) or not recipe_id:
|
||||
raise SystemExit('each manifest recipe must include a non-empty "recipe_id"')
|
||||
private_ids.append(recipe_id)
|
||||
|
||||
dotted_ids = expect_string_list(
|
||||
manifest.get("blocked_research_dotted_recipe_ids"),
|
||||
"blocked_research_dotted_recipe_ids",
|
||||
)
|
||||
absolute_paths = expect_string_list(
|
||||
manifest.get("blocked_absolute_paths"),
|
||||
"blocked_absolute_paths",
|
||||
)
|
||||
for label, values in (
|
||||
("private recipe ids", private_ids),
|
||||
("research dotted recipe ids", dotted_ids),
|
||||
("blocked absolute paths", absolute_paths),
|
||||
):
|
||||
duplicates = sorted({item for item in values if values.count(item) > 1})
|
||||
if duplicates:
|
||||
raise SystemExit(f"duplicate {label}: {', '.join(duplicates)}")
|
||||
return private_ids, dotted_ids, absolute_paths
|
||||
|
||||
|
||||
def normalize_rel(path: Path, root: Path) -> str | None:
|
||||
try:
|
||||
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_under(rel_path: str, prefix: str) -> bool:
|
||||
return rel_path == prefix or rel_path.startswith(f"{prefix}/")
|
||||
|
||||
|
||||
def is_allowed_route_private_path(path: Path, repo_root: Path) -> bool:
|
||||
skill_rel = normalize_rel(path, SKILL_ROOT)
|
||||
if skill_rel is not None:
|
||||
if skill_rel in {
|
||||
"references/routes/create-svg/private-recipes.manifest.json",
|
||||
}:
|
||||
return True
|
||||
if is_under(skill_rel, "references/routes/create-svg/private"):
|
||||
return True
|
||||
if is_under(skill_rel, "tests/fixtures/routes/create-svg/private"):
|
||||
return True
|
||||
if is_under(skill_rel, "tests/fixtures/routes/create-svg/internal-reports"):
|
||||
return True
|
||||
|
||||
repo_rel = normalize_rel(path, repo_root)
|
||||
if repo_rel is not None:
|
||||
if is_under(repo_rel, "tests/fixtures/routes/create-svg/private"):
|
||||
return True
|
||||
if is_under(repo_rel, "tests/fixtures/routes/create-svg/internal-reports"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def iter_existing_scan_roots(repo_root: Path) -> Iterable[Path]:
|
||||
yielded: set[Path] = set()
|
||||
for target in SKILL_SCAN_TARGETS:
|
||||
path = SKILL_ROOT / target
|
||||
if path.exists() and path.resolve() not in yielded:
|
||||
yielded.add(path.resolve())
|
||||
yield path
|
||||
for target in REPO_SCAN_TARGETS:
|
||||
path = repo_root / target
|
||||
if path.exists() and path.resolve() not in yielded:
|
||||
yielded.add(path.resolve())
|
||||
yield path
|
||||
|
||||
|
||||
def iter_text_files(root: Path) -> Iterable[Path]:
|
||||
if root.is_file():
|
||||
candidates = [root]
|
||||
else:
|
||||
candidates = [path for path in root.rglob("*") if path.is_file()]
|
||||
for path in candidates:
|
||||
if path.suffix.lower() in TEXT_FILE_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def line_column(text: str, index: int) -> tuple[int, int]:
|
||||
line = text.count("\n", 0, index) + 1
|
||||
line_start = text.rfind("\n", 0, index) + 1
|
||||
return line, index - line_start + 1
|
||||
|
||||
|
||||
def find_token_issues(path: Path, text: str, tokens: list[str], code: str) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
for token in tokens:
|
||||
pattern = re.compile(rf"(?<![A-Za-z0-9_.-]){re.escape(token)}(?![A-Za-z0-9_.-])")
|
||||
for match in pattern.finditer(text):
|
||||
line, column = line_column(text, match.start())
|
||||
issues.append(
|
||||
Issue(
|
||||
path=path.as_posix(),
|
||||
line=line,
|
||||
column=column,
|
||||
code=code,
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_file(
|
||||
path: Path,
|
||||
repo_root: Path,
|
||||
private_ids: list[str],
|
||||
dotted_ids: list[str],
|
||||
absolute_paths: list[str],
|
||||
) -> list[Issue]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return []
|
||||
|
||||
if is_allowed_route_private_path(path, repo_root):
|
||||
return []
|
||||
|
||||
display_path = Path(normalize_rel(path, repo_root) or path.as_posix())
|
||||
issues: list[Issue] = []
|
||||
issues.extend(find_token_issues(display_path, text, private_ids, "private_recipe_id_leak"))
|
||||
issues.extend(find_token_issues(display_path, text, dotted_ids, "research_dotted_recipe_id_leak"))
|
||||
issues.extend(find_token_issues(display_path, text, absolute_paths, "research_absolute_path_leak"))
|
||||
return issues
|
||||
|
||||
|
||||
def lint(repo_root: Path) -> list[Issue]:
|
||||
manifest_path = SKILL_ROOT / PRIVATE_MANIFEST_REL
|
||||
manifest = load_json(manifest_path)
|
||||
private_ids, dotted_ids, absolute_paths = load_manifest_tokens(manifest)
|
||||
|
||||
issues: list[Issue] = []
|
||||
seen_files: set[Path] = set()
|
||||
for root in iter_existing_scan_roots(repo_root):
|
||||
for path in iter_text_files(root):
|
||||
resolved = path.resolve()
|
||||
if resolved in seen_files:
|
||||
continue
|
||||
seen_files.add(resolved)
|
||||
issues.extend(lint_file(path, repo_root, private_ids, dotted_ids, absolute_paths))
|
||||
|
||||
return sorted(issues, key=lambda issue: (issue.path, issue.line, issue.column, issue.code, issue.token))
|
||||
|
||||
|
||||
def issue_to_dict(issue: Issue) -> dict[str, Any]:
|
||||
return {
|
||||
"path": issue.path,
|
||||
"line": issue.line,
|
||||
"column": issue.column,
|
||||
"code": issue.code,
|
||||
"token_hash": hashlib.sha256(issue.token.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
issues = lint(repo_root)
|
||||
if args.json:
|
||||
print(json.dumps({"issue_count": len(issues), "issues": [issue_to_dict(issue) for issue in issues]}, indent=2))
|
||||
else:
|
||||
for issue in issues:
|
||||
token_hash = hashlib.sha256(issue.token.encode("utf-8")).hexdigest()[:12]
|
||||
print(f"{issue.path}:{issue.line}:{issue.column}: {issue.code}: token_hash={token_hash}")
|
||||
if issues:
|
||||
print(f"svg-private-docs-lint: found {len(issues)} issue(s)", file=sys.stderr)
|
||||
return 1 if issues else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
135
skills/lark-slides/scripts/svg_raster_renderer.py
Normal file
135
skills/lark-slides/scripts/svg_raster_renderer.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover - exercised in minimal Python installs.
|
||||
Image = None # type: ignore[assignment]
|
||||
|
||||
|
||||
CANVAS_WIDTH = 960
|
||||
CANVAS_HEIGHT = 540
|
||||
MAX_PNG_BYTES = 20 * 1024 * 1024
|
||||
TRANSPARENT_1X1_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lM6c3QAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
class RasterRenderError(RuntimeError):
|
||||
"""Raised when Chromium rasterization or PNG validation fails."""
|
||||
|
||||
|
||||
class RasterRenderer(Protocol):
|
||||
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def viewport_size_from_svg(svg: str) -> tuple[int, int]:
|
||||
# P0 keeps the SVGlide root contract fixed. Go-side validation enforces the
|
||||
# full contract later, so the renderer defaults to the canonical canvas.
|
||||
return CANVAS_WIDTH, CANVAS_HEIGHT
|
||||
|
||||
|
||||
class PlaywrightRasterRenderer:
|
||||
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
|
||||
if scale < 2:
|
||||
raise RasterRenderError("svg raster scale must be >= 2")
|
||||
started = time.monotonic()
|
||||
output_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
width, height = viewport_size_from_svg(svg)
|
||||
html = self._preview_html(svg, width, height)
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as error:
|
||||
raise RasterRenderError(
|
||||
"python package playwright is required for SVG rasterization; install it and run `python3 -m playwright install chromium`"
|
||||
) from error
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height},
|
||||
device_scale_factor=scale,
|
||||
java_script_enabled=False,
|
||||
bypass_csp=False,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.route("**/*", lambda route: route.abort())
|
||||
page.set_content(html, wait_until="load")
|
||||
page.screenshot(path=str(output_png), clip={"x": 0, "y": 0, "width": width, "height": height}, omit_background=False)
|
||||
browser.close()
|
||||
except Exception as error: # pragma: no cover - depends on local Chromium.
|
||||
raise RasterRenderError(f"Chromium SVG rasterization failed: {error}") from error
|
||||
|
||||
validate_png(output_png, require_nontransparent=True)
|
||||
return {
|
||||
"output_png": str(output_png),
|
||||
"bbox": [0.0, 0.0, float(width), float(height)],
|
||||
"scale": scale,
|
||||
"bytes": output_png.stat().st_size,
|
||||
"render_ms": int((time.monotonic() - started) * 1000),
|
||||
"alpha_crop": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _preview_html(svg: str, width: int, height: int) -> str:
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\">"
|
||||
"<style>html,body{margin:0;width:%dpx;height:%dpx;background:#fff;overflow:hidden;}svg{display:block;}</style>"
|
||||
"</head><body>%s</body></html>"
|
||||
) % (width, height, svg)
|
||||
|
||||
|
||||
def png_dimensions(path: Path) -> tuple[int, int]:
|
||||
data = path.read_bytes()
|
||||
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise RasterRenderError(f"not a PNG file: {path}")
|
||||
width = int.from_bytes(data[16:20], "big")
|
||||
height = int.from_bytes(data[20:24], "big")
|
||||
return width, height
|
||||
|
||||
|
||||
def validate_png(path: Path, require_nontransparent: bool = True) -> None:
|
||||
if not path.exists():
|
||||
raise RasterRenderError(f"raster PNG does not exist: {path}")
|
||||
size = path.stat().st_size
|
||||
if size <= 0:
|
||||
raise RasterRenderError(f"raster PNG is empty: {path}")
|
||||
if size > MAX_PNG_BYTES:
|
||||
raise RasterRenderError(f"raster PNG exceeds {MAX_PNG_BYTES} bytes: {path}")
|
||||
width, height = png_dimensions(path)
|
||||
if width <= 0 or height <= 0:
|
||||
raise RasterRenderError(f"raster PNG has invalid dimensions: {path}")
|
||||
if require_nontransparent and Image is not None:
|
||||
with Image.open(path) as image:
|
||||
rgba = image.convert("RGBA")
|
||||
if not any(pixel[3] > 0 for pixel in rgba.getdata()):
|
||||
raise RasterRenderError(f"raster PNG is fully transparent: {path}")
|
||||
|
||||
|
||||
def render_islands(
|
||||
svg: str,
|
||||
islands: list[dict[str, object]],
|
||||
asset_dir: Path,
|
||||
scale: int,
|
||||
renderer: RasterRenderer | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
renderer = renderer or PlaywrightRasterRenderer()
|
||||
rendered: list[dict[str, object]] = []
|
||||
for index, island in enumerate(islands, start=1):
|
||||
if island.get("kind") != "full-page":
|
||||
raise RasterRenderError("only full-page raster islands are implemented in P0")
|
||||
output_png = asset_dir / f"page-001-island-{index:03d}.png"
|
||||
result = dict(renderer.render_full_page(svg, output_png, scale))
|
||||
validate_png(Path(str(result["output_png"])), require_nontransparent=True)
|
||||
rendered.append(result)
|
||||
return rendered
|
||||
57
skills/lark-slides/scripts/svg_raster_renderer_test.py
Normal file
57
skills/lark-slides/scripts/svg_raster_renderer_test.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import svg_raster_renderer as renderer
|
||||
|
||||
|
||||
SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><rect width="960" height="540" fill="#fff"/></svg>'
|
||||
|
||||
|
||||
class FakeRenderer:
|
||||
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
|
||||
image = Image.new("RGBA", (960 * scale, 540 * scale), (255, 255, 255, 255))
|
||||
output_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(output_png)
|
||||
return {
|
||||
"output_png": str(output_png),
|
||||
"bbox": [0.0, 0.0, 960.0, 540.0],
|
||||
"scale": scale,
|
||||
"bytes": output_png.stat().st_size,
|
||||
"render_ms": 1,
|
||||
"alpha_crop": False,
|
||||
}
|
||||
|
||||
|
||||
class SvgRasterRendererTest(unittest.TestCase):
|
||||
def test_render_islands_validates_nonempty_nontransparent_png(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
rendered = renderer.render_islands(
|
||||
SVG,
|
||||
[{"kind": "full-page"}],
|
||||
Path(temp),
|
||||
2,
|
||||
FakeRenderer(),
|
||||
)
|
||||
|
||||
output_png = Path(str(rendered[0]["output_png"]))
|
||||
self.assertTrue(output_png.exists())
|
||||
self.assertEqual(renderer.png_dimensions(output_png), (1920, 1080))
|
||||
|
||||
def test_validate_png_rejects_fully_transparent_image(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "transparent.png"
|
||||
Image.new("RGBA", (10, 10), (255, 255, 255, 0)).save(path)
|
||||
|
||||
with self.assertRaises(renderer.RasterRenderError):
|
||||
renderer.validate_png(path, require_nontransparent=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
199
skills/lark-slides/scripts/svg_rasterize_effects.py
Normal file
199
skills/lark-slides/scripts/svg_rasterize_effects.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/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
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import svg_effect_classifier as classifier
|
||||
import svg_raster_renderer as renderer_mod
|
||||
import svg_safe_rewrite
|
||||
|
||||
|
||||
VERSION = "1"
|
||||
MODES = {"off", "auto", "strict", "force-page"}
|
||||
|
||||
|
||||
class RasterizeError(RuntimeError):
|
||||
"""Raised when SVG rasterization cannot produce a safe output."""
|
||||
|
||||
|
||||
def load_svg(path: Path) -> str:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise RasterizeError(f"failed to read SVG input {path}: {error}") from error
|
||||
if not text.strip():
|
||||
raise RasterizeError(f"SVG input is empty: {path}")
|
||||
return text
|
||||
|
||||
|
||||
def write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def whole_page_island(reason: str) -> dict[str, object]:
|
||||
return {
|
||||
"id": "page-001-island-001",
|
||||
"kind": "full-page",
|
||||
"reason": reason,
|
||||
"bbox": [0.0, 0.0, 960.0, 540.0],
|
||||
}
|
||||
|
||||
|
||||
def plan_raster_islands(mode: str, detections: list[classifier.EffectDetection]) -> list[dict[str, object]]:
|
||||
if mode == "off":
|
||||
return []
|
||||
if mode == "force-page":
|
||||
return [whole_page_island("force-page")]
|
||||
if not detections:
|
||||
return []
|
||||
reasons = sorted({detection.reason for detection in detections})
|
||||
return [whole_page_island("conservative_full_page:" + "; ".join(reasons[:4]))]
|
||||
|
||||
|
||||
def _asset_rel(path: str, base_dir: Path) -> str:
|
||||
resolved = Path(path).resolve()
|
||||
try:
|
||||
return resolved.relative_to(base_dir.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def build_report(
|
||||
*,
|
||||
mode: str,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
base_dir: Path,
|
||||
detections: list[classifier.EffectDetection],
|
||||
islands: list[dict[str, object]],
|
||||
rendered_assets: list[dict[str, object]],
|
||||
render_started: float,
|
||||
) -> dict[str, object]:
|
||||
generated_assets = [_asset_rel(str(asset["output_png"]), base_dir) for asset in rendered_assets]
|
||||
island_reports: list[dict[str, object]] = []
|
||||
for island, asset in zip(islands, rendered_assets):
|
||||
island_reports.append(
|
||||
{
|
||||
"id": island.get("id", "page-001-island-001"),
|
||||
"reason": island.get("reason", ""),
|
||||
"source_node_ids": island.get("source_node_ids", []),
|
||||
"bbox": asset.get("bbox", island.get("bbox", [0.0, 0.0, 960.0, 540.0])),
|
||||
"output_png": _asset_rel(str(asset["output_png"]), base_dir),
|
||||
"scale": asset.get("scale", 2),
|
||||
"bytes": asset.get("bytes", 0),
|
||||
"render_ms": asset.get("render_ms", 0),
|
||||
"alpha_crop": asset.get("alpha_crop", False),
|
||||
}
|
||||
)
|
||||
total_bytes = sum(int(asset.get("bytes", 0)) for asset in rendered_assets)
|
||||
total_ms = sum(int(asset.get("render_ms", 0)) for asset in rendered_assets)
|
||||
full_page_count = sum(1 for island in islands if island.get("kind") == "full-page")
|
||||
return {
|
||||
"version": VERSION,
|
||||
"mode": mode,
|
||||
"run_id": Path(output_path).parent.name,
|
||||
"base_dir": str(base_dir.resolve()),
|
||||
"native_text_blocks": 0,
|
||||
"rasterized_text_blocks": 0,
|
||||
"raster_images": len(rendered_assets),
|
||||
"raster_total_bytes": total_bytes,
|
||||
"raster_total_ms": total_ms or int((time.monotonic() - render_started) * 1000),
|
||||
"full_page_fallback_count": full_page_count,
|
||||
"generated_assets": generated_assets,
|
||||
"detections": classifier.detections_as_dicts(detections),
|
||||
"visual_artifacts": {},
|
||||
"quality": {"gate_passed": True},
|
||||
"pages": [
|
||||
{
|
||||
"source_path": str(input_path),
|
||||
"safe_path": str(output_path),
|
||||
"mode": mode,
|
||||
"fallback_reason": islands[0].get("reason", "") if islands else "",
|
||||
"runtime_gate_ok": True,
|
||||
"pngs": generated_assets,
|
||||
"islands": island_reports,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def rasterize_svg(
|
||||
svg: str,
|
||||
*,
|
||||
mode: str,
|
||||
scale: int,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
asset_dir: Path,
|
||||
base_dir: Path,
|
||||
report_path: Path,
|
||||
raster_renderer: renderer_mod.RasterRenderer | None = None,
|
||||
) -> dict[str, object]:
|
||||
if mode not in MODES:
|
||||
raise RasterizeError(f"invalid rasterization mode: {mode}")
|
||||
if mode != "off" and scale < 2:
|
||||
raise RasterizeError("svg raster scale must be >= 2")
|
||||
classifier.sanitize_or_reject(svg)
|
||||
detections = classifier.classify_effects(svg)
|
||||
islands = plan_raster_islands(mode, detections)
|
||||
started = time.monotonic()
|
||||
rendered_assets = renderer_mod.render_islands(svg, islands, asset_dir, scale, raster_renderer) if islands else []
|
||||
safe_svg = svg_safe_rewrite.rewrite_svg(svg, islands, rendered_assets, base_dir)
|
||||
svg_safe_rewrite.validate_safe_subset_lightweight(safe_svg)
|
||||
write_text(output_path, safe_svg)
|
||||
report = build_report(
|
||||
mode=mode,
|
||||
input_path=input_path,
|
||||
output_path=output_path,
|
||||
base_dir=base_dir,
|
||||
detections=detections,
|
||||
islands=islands,
|
||||
rendered_assets=rendered_assets,
|
||||
render_started=started,
|
||||
)
|
||||
write_text(report_path, json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
return report
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Rasterize rich SVG effects into safe SVGlide SVG image assets.")
|
||||
parser.add_argument("--mode", choices=sorted(MODES), required=True)
|
||||
parser.add_argument("--scale", type=int, default=2)
|
||||
parser.add_argument("--input", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--asset-dir", required=True)
|
||||
parser.add_argument("--base-dir", required=True)
|
||||
parser.add_argument("--report", required=True)
|
||||
parser.add_argument("--preview-html", default="")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
rasterize_svg(
|
||||
load_svg(Path(args.input)),
|
||||
mode=args.mode,
|
||||
scale=args.scale,
|
||||
input_path=Path(args.input),
|
||||
output_path=Path(args.output),
|
||||
asset_dir=Path(args.asset_dir),
|
||||
base_dir=Path(args.base_dir),
|
||||
report_path=Path(args.report),
|
||||
)
|
||||
except (RasterizeError, classifier.SvgRasterSafetyError, svg_safe_rewrite.SafeRewriteError, renderer_mod.RasterRenderError) as error:
|
||||
print(f"svg_rasterize_effects: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
105
skills/lark-slides/scripts/svg_rasterize_effects_test.py
Normal file
105
skills/lark-slides/scripts/svg_rasterize_effects_test.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import svg_rasterize_effects as rasterize
|
||||
|
||||
|
||||
RICH_SVG = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs><filter id="glow"><feGaussianBlur stdDeviation="8" /></filter></defs>
|
||||
<rect x="0" y="0" width="960" height="540" fill="#fff" />
|
||||
<circle cx="480" cy="270" r="120" fill="#2563eb" filter="url(#glow)" />
|
||||
</svg>"""
|
||||
|
||||
|
||||
class FakeRenderer:
|
||||
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
|
||||
image = Image.new("RGBA", (960 * scale, 540 * scale), (255, 255, 255, 255))
|
||||
output_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(output_png)
|
||||
return {
|
||||
"output_png": str(output_png),
|
||||
"bbox": [0.0, 0.0, 960.0, 540.0],
|
||||
"scale": scale,
|
||||
"bytes": output_png.stat().st_size,
|
||||
"render_ms": 3,
|
||||
"alpha_crop": False,
|
||||
}
|
||||
|
||||
|
||||
class SvgRasterizeEffectsTest(unittest.TestCase):
|
||||
def test_force_page_rasterizes_to_safe_svg_and_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base_dir = Path(temp)
|
||||
output = base_dir / ".lark-slides" / "rasterized" / "run-1" / "page-001.safe.svg"
|
||||
report_path = base_dir / ".lark-slides" / "rasterized" / "run-1" / "raster-report.json"
|
||||
|
||||
report = rasterize.rasterize_svg(
|
||||
RICH_SVG,
|
||||
mode="force-page",
|
||||
scale=2,
|
||||
input_path=base_dir / "page-001.svg",
|
||||
output_path=output,
|
||||
asset_dir=output.parent,
|
||||
base_dir=base_dir,
|
||||
report_path=report_path,
|
||||
raster_renderer=FakeRenderer(),
|
||||
)
|
||||
|
||||
safe_svg = output.read_text(encoding="utf-8")
|
||||
persisted_report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["mode"], "force-page")
|
||||
self.assertEqual(persisted_report["raster_images"], 1)
|
||||
self.assertEqual(persisted_report["full_page_fallback_count"], 1)
|
||||
self.assertIn('href="@./.lark-slides/rasterized/run-1/page-001-island-001.png"', safe_svg)
|
||||
self.assertNotIn("<filter", safe_svg)
|
||||
self.assertTrue((output.parent / "page-001-island-001.png").exists())
|
||||
|
||||
def test_auto_uses_conservative_full_page_when_effects_are_detected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base_dir = Path(temp)
|
||||
output = base_dir / ".lark-slides" / "rasterized" / "run-1" / "page-001.safe.svg"
|
||||
report_path = output.parent / "raster-report.json"
|
||||
|
||||
report = rasterize.rasterize_svg(
|
||||
RICH_SVG,
|
||||
mode="auto",
|
||||
scale=2,
|
||||
input_path=base_dir / "page-001.svg",
|
||||
output_path=output,
|
||||
asset_dir=output.parent,
|
||||
base_dir=base_dir,
|
||||
report_path=report_path,
|
||||
raster_renderer=FakeRenderer(),
|
||||
)
|
||||
|
||||
self.assertEqual(report["full_page_fallback_count"], 1)
|
||||
self.assertTrue(str(report["pages"][0]["fallback_reason"]).startswith("conservative_full_page:"))
|
||||
|
||||
def test_scale_below_two_is_rejected_for_raster_modes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base_dir = Path(temp)
|
||||
with self.assertRaises(rasterize.RasterizeError):
|
||||
rasterize.rasterize_svg(
|
||||
RICH_SVG,
|
||||
mode="force-page",
|
||||
scale=1,
|
||||
input_path=base_dir / "page.svg",
|
||||
output_path=base_dir / "page.safe.svg",
|
||||
asset_dir=base_dir,
|
||||
base_dir=base_dir,
|
||||
report_path=base_dir / "report.json",
|
||||
raster_renderer=FakeRenderer(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
122
skills/lark-slides/scripts/svg_safe_rewrite.py
Normal file
122
skills/lark-slides/scripts/svg_safe_rewrite.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import svg_effect_classifier as classifier
|
||||
|
||||
|
||||
SLIDE_NS = "https://slides.bytedance.com/ns"
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
SVG_CONTRACT_VERSION = "svglide-authoring-contract/v1"
|
||||
DEFAULT_WIDTH = 960.0
|
||||
DEFAULT_HEIGHT = 540.0
|
||||
HARD_TAGS = classifier.HARD_EFFECT_TAGS
|
||||
HARD_ATTRS = classifier.HARD_EFFECT_ATTRS
|
||||
HARD_STYLE_PROPS = classifier.HARD_STYLE_PROPS
|
||||
NUMBER_RE = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:px)?$")
|
||||
|
||||
|
||||
class SafeRewriteError(ValueError):
|
||||
"""Raised when a safe SVG cannot be produced or validated."""
|
||||
|
||||
|
||||
def _number(value: str | None, default: float) -> float:
|
||||
if not value:
|
||||
return default
|
||||
value = value.strip()
|
||||
if not NUMBER_RE.match(value):
|
||||
return default
|
||||
if value.endswith("px"):
|
||||
value = value[:-2]
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _format_number(value: float) -> str:
|
||||
if abs(value - round(value)) < 0.0001:
|
||||
return str(int(round(value)))
|
||||
return f"{value:.4f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def svg_viewport(svg: str) -> tuple[float, float, str]:
|
||||
root = classifier.sanitize_or_reject(svg)
|
||||
width = _number(root.attrib.get("width"), DEFAULT_WIDTH)
|
||||
height = _number(root.attrib.get("height"), DEFAULT_HEIGHT)
|
||||
view_box = root.attrib.get("viewBox", "").strip()
|
||||
if not view_box:
|
||||
view_box = f"0 0 {_format_number(width)} {_format_number(height)}"
|
||||
return width, height, view_box
|
||||
|
||||
|
||||
def href_for_asset(asset_path: Path, base_dir: Path) -> str:
|
||||
asset_path = asset_path.resolve()
|
||||
base_dir = base_dir.resolve()
|
||||
try:
|
||||
rel = asset_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
if not str(asset_path).startswith("/private/tmp/"):
|
||||
raise SafeRewriteError(f"raster asset escapes base directory: {asset_path}")
|
||||
return str(asset_path)
|
||||
rel_text = rel.as_posix()
|
||||
if rel_text.startswith("../") or rel_text == "..":
|
||||
raise SafeRewriteError(f"raster asset escapes base directory: {asset_path}")
|
||||
return f"@./{rel_text}"
|
||||
|
||||
|
||||
def full_page_image_svg(original_svg: str, png_path: Path, base_dir: Path) -> str:
|
||||
width, height, view_box = svg_viewport(original_svg)
|
||||
href = href_for_asset(png_path, base_dir)
|
||||
return "\n".join(
|
||||
[
|
||||
f'<svg xmlns="{SVG_NS}" xmlns:slide="{SLIDE_NS}" slide:role="slide"',
|
||||
f' slide:contract-version="{SVG_CONTRACT_VERSION}"',
|
||||
f' width="{_format_number(width)}" height="{_format_number(height)}" viewBox="{html.escape(view_box)}">',
|
||||
f' <image slide:role="image" href="{html.escape(href)}" x="0" y="0" width="{_format_number(width)}" height="{_format_number(height)}" />',
|
||||
"</svg>",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _style_has_hard_props(style: str) -> bool:
|
||||
props = classifier.normalize_style(style)
|
||||
return any(classifier.is_hard_style_property(prop) for prop in props)
|
||||
|
||||
|
||||
def validate_safe_subset_lightweight(svg: str) -> None:
|
||||
root = classifier.sanitize_or_reject(svg)
|
||||
if classifier.local_name(root.tag) != "svg":
|
||||
raise SafeRewriteError("safe SVG root must be <svg>")
|
||||
for elem in root.iter():
|
||||
tag = classifier.local_name(elem.tag)
|
||||
if tag in HARD_TAGS:
|
||||
raise SafeRewriteError(f"safe SVG still contains unsupported tag <{tag}>")
|
||||
if tag in {"text", "polygon", "polyline"}:
|
||||
raise SafeRewriteError(f"safe SVG still contains unsupported root-safe tag <{tag}>")
|
||||
for raw_attr, value in elem.attrib.items():
|
||||
attr = classifier.local_name(raw_attr)
|
||||
if attr in HARD_ATTRS:
|
||||
raise SafeRewriteError(f"safe SVG still contains unsupported attribute {attr}")
|
||||
if attr == "style" and _style_has_hard_props(value):
|
||||
raise SafeRewriteError("safe SVG still contains unsupported CSS effect")
|
||||
|
||||
|
||||
def rewrite_svg(svg: str, islands: list[dict[str, object]], rendered_assets: list[dict[str, object]], base_dir: Path) -> str:
|
||||
if not islands:
|
||||
validate_safe_subset_lightweight(svg)
|
||||
return svg
|
||||
if len(islands) == 1 and islands[0].get("kind") == "full-page":
|
||||
png_path = Path(str(rendered_assets[0]["output_png"]))
|
||||
safe_svg = full_page_image_svg(svg, png_path, base_dir)
|
||||
validate_safe_subset_lightweight(safe_svg)
|
||||
return safe_svg
|
||||
raise SafeRewriteError("only full-page safe rewrite is implemented in P0")
|
||||
51
skills/lark-slides/scripts/svg_safe_rewrite_test.py
Normal file
51
skills/lark-slides/scripts/svg_safe_rewrite_test.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import svg_safe_rewrite
|
||||
|
||||
|
||||
SVG_WITH_FILTER = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs><filter id="glow" /></defs>
|
||||
<rect filter="url(#glow)" x="0" y="0" width="960" height="540" />
|
||||
</svg>"""
|
||||
|
||||
|
||||
class SvgSafeRewriteTest(unittest.TestCase):
|
||||
def test_full_page_rewrite_outputs_single_safe_image(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base_dir = Path(temp)
|
||||
png = base_dir / ".lark-slides" / "rasterized" / "run-1" / "page-001-island-001.png"
|
||||
png.parent.mkdir(parents=True)
|
||||
png.write_bytes(b"placeholder")
|
||||
|
||||
safe_svg = svg_safe_rewrite.full_page_image_svg(SVG_WITH_FILTER, png, base_dir)
|
||||
|
||||
svg_safe_rewrite.validate_safe_subset_lightweight(safe_svg)
|
||||
self.assertIn('slide:role="slide"', safe_svg)
|
||||
self.assertIn('href="@./.lark-slides/rasterized/run-1/page-001-island-001.png"', safe_svg)
|
||||
self.assertIn('x="0" y="0" width="960" height="540"', safe_svg)
|
||||
self.assertNotIn("<filter", safe_svg)
|
||||
|
||||
def test_safe_gate_rejects_residual_rich_effects(self) -> None:
|
||||
with self.assertRaises(svg_safe_rewrite.SafeRewriteError):
|
||||
svg_safe_rewrite.validate_safe_subset_lightweight(SVG_WITH_FILTER)
|
||||
|
||||
def test_asset_href_rejects_paths_outside_base_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base_dir = Path(temp) / "base"
|
||||
base_dir.mkdir()
|
||||
outside = Path(temp) / "outside.png"
|
||||
outside.write_bytes(b"placeholder")
|
||||
|
||||
with self.assertRaises(svg_safe_rewrite.SafeRewriteError):
|
||||
svg_safe_rewrite.href_for_asset(outside, base_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
308
skills/lark-slides/scripts/svglide_asset_selector.py
Normal file
308
skills/lark-slides/scripts/svglide_asset_selector.py
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/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
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
SELECTION_SCHEMA_VERSION = "svglide-asset-selection/v1"
|
||||
STRICT_LANES = {"production", "golden"}
|
||||
DEFAULT_BUDGET = {
|
||||
"brand": 1,
|
||||
"layout": 3,
|
||||
"deck": 1,
|
||||
"chart": 6,
|
||||
"icon_style": 1,
|
||||
"visual_style": 2,
|
||||
"image_palette": 1,
|
||||
"image_rendering": 1,
|
||||
"image_type": 2,
|
||||
"narrative_mode": 1,
|
||||
"example": 1,
|
||||
}
|
||||
DEFAULT_TOTAL_BUDGET = 12
|
||||
KIND_BUCKETS = {
|
||||
"brand_preset": "brand",
|
||||
"layout_template": "layout",
|
||||
"deck_template": "deck",
|
||||
"chart_template": "chart",
|
||||
"icon_library": "icon_style",
|
||||
"visual_style": "visual_style",
|
||||
"image_palette": "image_palette",
|
||||
"image_rendering": "image_rendering",
|
||||
"image_type_template": "image_type",
|
||||
"narrative_mode": "narrative_mode",
|
||||
"example_project": "example",
|
||||
}
|
||||
ACTIVATION_PRIORITY = {"active": 3, "validated": 2, "candidate": 1, "rejected": 0}
|
||||
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]+", re.IGNORECASE)
|
||||
|
||||
|
||||
class SelectorError(ValueError):
|
||||
"""Raised when selector inputs or policies are invalid."""
|
||||
|
||||
|
||||
def script_path() -> Path:
|
||||
return Path(__file__).resolve()
|
||||
|
||||
|
||||
def default_asset_map_path() -> Path:
|
||||
return script_path().parents[1] / "references/svglide-design-pattern-map.json"
|
||||
|
||||
|
||||
def load_asset_map(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def tokenize(*values: object) -> list[str]:
|
||||
tokens: set[str] = set()
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
tokens.update(tokenize(*value))
|
||||
continue
|
||||
tokens.update(match.group(0).lower() for match in TOKEN_RE.finditer(str(value)))
|
||||
return sorted(tokens)
|
||||
|
||||
|
||||
def canonical_digest(value: object) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()[:16]
|
||||
|
||||
|
||||
def kind_bucket(kind: str) -> str:
|
||||
return KIND_BUCKETS.get(kind, kind)
|
||||
|
||||
|
||||
def normalize_budget(budget: dict[str, int] | None = None) -> dict[str, int]:
|
||||
normalized = dict(DEFAULT_BUDGET)
|
||||
if budget:
|
||||
for key, value in budget.items():
|
||||
if value < 0:
|
||||
raise SelectorError(f"budget for {key} must be non-negative")
|
||||
normalized[key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_budget_override(raw: str) -> dict[str, int]:
|
||||
if not raw:
|
||||
return {}
|
||||
parsed: dict[str, int] = {}
|
||||
for item in raw.split(","):
|
||||
if not item.strip():
|
||||
continue
|
||||
if "=" not in item:
|
||||
raise SelectorError(f"invalid budget override {item!r}; expected kind=count")
|
||||
key, value = item.split("=", 1)
|
||||
parsed[key.strip()] = int(value.strip())
|
||||
return parsed
|
||||
|
||||
|
||||
def has_normalized_fixture(resource: dict) -> bool:
|
||||
if resource.get("normalized_fixture") or resource.get("normalized_fixture_path"):
|
||||
return True
|
||||
metadata = resource.get("metadata", {})
|
||||
return bool(metadata.get("normalized_fixture") or metadata.get("normalized_fixture_path"))
|
||||
|
||||
|
||||
def strict_policy_violation(resource: dict) -> str:
|
||||
status = resource.get("activation_status", "")
|
||||
if status not in {"validated", "active"}:
|
||||
return "activation_status_not_production_ready"
|
||||
if resource.get("license_status") in {"unknown", "reference_only", "unknown/reference_only"}:
|
||||
return "license_not_production_ready"
|
||||
if resource.get("protocol_compatibility") == "needs_normalization" and not has_normalized_fixture(resource):
|
||||
return "raw_asset_needs_normalization"
|
||||
return ""
|
||||
|
||||
|
||||
def resource_policy_violation(resource: dict, lane: str) -> str:
|
||||
if resource.get("activation_status") == "rejected":
|
||||
return "rejected_asset"
|
||||
if lane in STRICT_LANES:
|
||||
return strict_policy_violation(resource)
|
||||
return ""
|
||||
|
||||
|
||||
def searchable_tokens(resource: dict) -> set[str]:
|
||||
metadata = resource.get("metadata", {})
|
||||
return set(
|
||||
tokenize(
|
||||
resource.get("id", ""),
|
||||
resource.get("kind", ""),
|
||||
resource.get("source_path", ""),
|
||||
resource.get("summary", ""),
|
||||
resource.get("selection_tags", []),
|
||||
metadata.get("summary", ""),
|
||||
metadata.get("page_types", []),
|
||||
metadata.get("sample_icons", []),
|
||||
metadata.get("page_samples", []),
|
||||
metadata.get("media_samples", []),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def score_resource(resource: dict, brief_tokens: set[str], explicit_tags: set[str]) -> tuple[int, list[str]]:
|
||||
tokens = searchable_tokens(resource)
|
||||
matched_tags = sorted(explicit_tags & tokens)
|
||||
matched_brief = sorted(brief_tokens & tokens)
|
||||
score = len(matched_tags) * 8 + len(matched_brief) * 2
|
||||
if score:
|
||||
if resource.get("activation_status") == "active":
|
||||
score += 3
|
||||
elif resource.get("activation_status") == "validated":
|
||||
score += 2
|
||||
return score, matched_tags + [token for token in matched_brief if token not in matched_tags]
|
||||
|
||||
|
||||
def slim_asset(resource: dict, *, reason: str, score: int = 0, matched_terms: Iterable[str] = ()) -> dict:
|
||||
output = {
|
||||
"id": resource["id"],
|
||||
"kind": resource["kind"],
|
||||
"reason": reason,
|
||||
"activation_status": resource.get("activation_status", ""),
|
||||
}
|
||||
if resource.get("source_path"):
|
||||
output["source_path"] = resource["source_path"]
|
||||
if score:
|
||||
output["score"] = score
|
||||
terms = sorted(set(matched_terms))
|
||||
if terms:
|
||||
output["matched_terms"] = terms[:12]
|
||||
return output
|
||||
|
||||
|
||||
def select_assets(
|
||||
asset_map: dict,
|
||||
*,
|
||||
brief: str,
|
||||
tags: Iterable[str] = (),
|
||||
lane: str = "authoring",
|
||||
budget: dict[str, int] | None = None,
|
||||
max_total_assets: int = DEFAULT_TOTAL_BUDGET,
|
||||
) -> dict:
|
||||
if lane not in {"authoring", "research", "production", "golden"}:
|
||||
raise SelectorError(f"unsupported lane: {lane}")
|
||||
|
||||
normalized_budget = normalize_budget(budget)
|
||||
brief_tokens = set(tokenize(brief))
|
||||
explicit_tags = set(tokenize(list(tags)))
|
||||
selected: list[dict] = []
|
||||
excluded: list[dict] = []
|
||||
per_kind_counts = {key: 0 for key in normalized_budget}
|
||||
candidates: list[tuple[int, int, str, dict, list[str]]] = []
|
||||
|
||||
for resource in asset_map.get("resources", []):
|
||||
bucket = kind_bucket(resource.get("kind", ""))
|
||||
if bucket not in normalized_budget:
|
||||
continue
|
||||
score, matched = score_resource(resource, brief_tokens, explicit_tags)
|
||||
if score <= 0:
|
||||
continue
|
||||
violation = resource_policy_violation(resource, lane)
|
||||
if violation:
|
||||
excluded.append(slim_asset(resource, reason=violation, score=score, matched_terms=matched))
|
||||
continue
|
||||
priority = ACTIVATION_PRIORITY.get(resource.get("activation_status", ""), 0)
|
||||
candidates.append((score, priority, resource["id"], resource, matched))
|
||||
|
||||
for score, _priority, _resource_id, resource, matched in sorted(candidates, key=lambda item: (-item[0], -item[1], item[2])):
|
||||
bucket = kind_bucket(resource["kind"])
|
||||
if per_kind_counts[bucket] >= normalized_budget[bucket]:
|
||||
continue
|
||||
if len(selected) >= max_total_assets:
|
||||
break
|
||||
per_kind_counts[bucket] += 1
|
||||
reason = f"matches {', '.join(matched[:5])}" if matched else "matches selector query"
|
||||
selected.append(slim_asset(resource, reason=reason, score=score, matched_terms=matched))
|
||||
|
||||
request = {
|
||||
"brief": brief,
|
||||
"tags": sorted(explicit_tags),
|
||||
"lane": lane,
|
||||
"budget": normalized_budget,
|
||||
"max_total_assets": max_total_assets,
|
||||
"asset_map_digest": asset_map.get("summary", {}).get("digests", {}).get("all_source_files", canonical_digest(asset_map)),
|
||||
}
|
||||
output = {
|
||||
"schema_version": SELECTION_SCHEMA_VERSION,
|
||||
"deck_intent": infer_deck_intent(brief_tokens, explicit_tags),
|
||||
"lane": lane,
|
||||
"selected_assets": selected,
|
||||
"excluded_assets": sorted(excluded, key=lambda item: (-item.get("score", 0), item["id"]))[:50],
|
||||
"prompt_budget": {
|
||||
"max_assets_per_kind": normalized_budget,
|
||||
"max_total_assets": max_total_assets,
|
||||
"total_selected": len(selected),
|
||||
"selected_per_kind": per_kind_counts,
|
||||
"estimated_prompt_tokens": estimate_prompt_tokens(selected),
|
||||
},
|
||||
"request_digest": canonical_digest(request),
|
||||
}
|
||||
output["selection_digest"] = canonical_digest({"request_digest": output["request_digest"], "selected_assets": selected})
|
||||
return output
|
||||
|
||||
|
||||
def infer_deck_intent(brief_tokens: set[str], tags: set[str]) -> str:
|
||||
tokens = brief_tokens | tags
|
||||
if {"roadmap", "milestone", "timeline"} & tokens:
|
||||
return "roadmap"
|
||||
if {"strategy", "business", "market", "growth"} & tokens:
|
||||
return "business_strategy"
|
||||
if {"architecture", "system", "technical", "engineering", "ops"} & tokens:
|
||||
return "technical_architecture"
|
||||
if {"academic", "research", "thesis"} & tokens:
|
||||
return "academic_report"
|
||||
return "general_deck"
|
||||
|
||||
|
||||
def estimate_prompt_tokens(selected_assets: list[dict]) -> int:
|
||||
if not selected_assets:
|
||||
return 0
|
||||
encoded = json.dumps(selected_assets, ensure_ascii=False, sort_keys=True)
|
||||
return max(1, len(encoded) // 4)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Select a small SVGlide active asset context from svglide-design-pattern-map.json.")
|
||||
parser.add_argument("--asset-map", type=Path, default=default_asset_map_path())
|
||||
parser.add_argument("--brief", default="", help="User brief or deck topic.")
|
||||
parser.add_argument("--tags", default="", help="Comma-separated explicit selector tags.")
|
||||
parser.add_argument("--lane", default="authoring", choices=["authoring", "research", "production", "golden"])
|
||||
parser.add_argument("--budget", default="", help="Comma-separated per-kind overrides, e.g. chart=3,layout=2.")
|
||||
parser.add_argument("--max-total-assets", type=int, default=DEFAULT_TOTAL_BUDGET)
|
||||
parser.add_argument("--out-json", type=Path)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
asset_map = load_asset_map(args.asset_map)
|
||||
tags = [tag.strip() for tag in args.tags.split(",") if tag.strip()]
|
||||
selection = select_assets(
|
||||
asset_map,
|
||||
brief=args.brief,
|
||||
tags=tags,
|
||||
lane=args.lane,
|
||||
budget=parse_budget_override(args.budget),
|
||||
max_total_assets=args.max_total_assets,
|
||||
)
|
||||
encoded = json.dumps(selection, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.out_json:
|
||||
args.out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out_json.write_text(encoded, encoding="utf-8")
|
||||
else:
|
||||
print(encoded, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
193
skills/lark-slides/scripts/svglide_asset_selector_test.py
Normal file
193
skills/lark-slides/scripts/svglide_asset_selector_test.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import svglide_asset_selector as selector
|
||||
|
||||
|
||||
def resource(
|
||||
resource_id: str,
|
||||
*,
|
||||
kind: str = "chart_template",
|
||||
tags: list[str] | None = None,
|
||||
summary: str = "business strategy roadmap dashboard chart",
|
||||
activation_status: str = "active",
|
||||
license_status: str = "clear",
|
||||
protocol_compatibility: str = "svglide_compatible",
|
||||
normalized_fixture: str = "fixtures/demo.svg",
|
||||
) -> dict:
|
||||
return {
|
||||
"id": resource_id,
|
||||
"source_path": f"svglide-design-patterns/{resource_id}.svg",
|
||||
"kind": kind,
|
||||
"summary": summary,
|
||||
"selection_tags": tags or ["business", "strategy", "roadmap", "chart"],
|
||||
"activation_status": activation_status,
|
||||
"license_status": license_status,
|
||||
"protocol_compatibility": protocol_compatibility,
|
||||
"copy_policy": "derive_contract_only",
|
||||
"normalized_primitives": ["slide_role_shape"],
|
||||
"normalized_fixture": normalized_fixture,
|
||||
}
|
||||
|
||||
|
||||
def asset_map(resources: list[dict]) -> dict:
|
||||
return {
|
||||
"schema_version": "svglide-design-pattern-map/v1",
|
||||
"summary": {"digests": {"all_source_files": "fixture"}},
|
||||
"resources": resources,
|
||||
}
|
||||
|
||||
|
||||
class SVGlideAssetSelectorTest(unittest.TestCase):
|
||||
def test_skill_sources_do_not_reference_external_reference_project_names(self) -> None:
|
||||
skill_root = SCRIPT_DIR.parent
|
||||
banned_tokens = [
|
||||
"ppt" + "-master",
|
||||
"ppt" + "_master",
|
||||
"ppt" + " master",
|
||||
"hugo" + "he3",
|
||||
"ppt" + "169",
|
||||
"global" + "_ai" + "_capital",
|
||||
]
|
||||
hits: list[str] = []
|
||||
for path in skill_root.rglob("*"):
|
||||
if path.suffix not in {".py", ".md", ".json"} or not path.is_file():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8").lower()
|
||||
for token in banned_tokens:
|
||||
if token in text:
|
||||
hits.append(f"{path.relative_to(skill_root)} contains {token}")
|
||||
|
||||
self.assertEqual([], hits)
|
||||
|
||||
def test_reference_only_is_excluded_from_production(self) -> None:
|
||||
data = asset_map(
|
||||
[
|
||||
resource(
|
||||
"chart.reference_only",
|
||||
activation_status="active",
|
||||
license_status="reference_only",
|
||||
tags=["business", "strategy"],
|
||||
),
|
||||
resource("chart.ready", activation_status="validated", license_status="clear", tags=["business", "strategy"]),
|
||||
]
|
||||
)
|
||||
|
||||
selected = selector.select_assets(data, brief="business strategy", lane="production")
|
||||
|
||||
self.assertEqual([asset["id"] for asset in selected["selected_assets"]], ["chart.ready"])
|
||||
self.assertIn("license_not_production_ready", {asset["reason"] for asset in selected["excluded_assets"]})
|
||||
|
||||
def test_rejected_assets_are_never_selected(self) -> None:
|
||||
data = asset_map(
|
||||
[
|
||||
resource("chart.rejected", activation_status="rejected", tags=["roadmap"]),
|
||||
resource("chart.ready", activation_status="validated", license_status="clear", tags=["roadmap"]),
|
||||
]
|
||||
)
|
||||
|
||||
selected = selector.select_assets(data, brief="roadmap", lane="authoring")
|
||||
|
||||
self.assertEqual([asset["id"] for asset in selected["selected_assets"]], ["chart.ready"])
|
||||
self.assertIn("rejected_asset", {asset["reason"] for asset in selected["excluded_assets"]})
|
||||
|
||||
def test_raw_unnormalized_assets_are_excluded_from_production(self) -> None:
|
||||
data = asset_map(
|
||||
[
|
||||
resource(
|
||||
"chart.raw",
|
||||
activation_status="active",
|
||||
license_status="clear",
|
||||
protocol_compatibility="needs_normalization",
|
||||
normalized_fixture="",
|
||||
tags=["dashboard"],
|
||||
),
|
||||
resource("chart.ready", activation_status="active", license_status="clear", tags=["dashboard"]),
|
||||
]
|
||||
)
|
||||
|
||||
selected = selector.select_assets(data, brief="dashboard", lane="golden")
|
||||
|
||||
self.assertEqual([asset["id"] for asset in selected["selected_assets"]], ["chart.ready"])
|
||||
self.assertIn("raw_asset_needs_normalization", {asset["reason"] for asset in selected["excluded_assets"]})
|
||||
|
||||
def test_candidate_assets_are_allowed_only_outside_strict_lanes(self) -> None:
|
||||
data = asset_map([resource("chart.candidate", activation_status="candidate", license_status="reference_only", tags=["research"])])
|
||||
|
||||
authoring = selector.select_assets(data, brief="research", lane="authoring")
|
||||
production = selector.select_assets(data, brief="research", lane="production")
|
||||
|
||||
self.assertEqual([asset["id"] for asset in authoring["selected_assets"]], ["chart.candidate"])
|
||||
self.assertEqual(production["selected_assets"], [])
|
||||
self.assertIn("activation_status_not_production_ready", {asset["reason"] for asset in production["excluded_assets"]})
|
||||
|
||||
def test_prompt_budget_caps_per_kind_and_total_context(self) -> None:
|
||||
resources = [
|
||||
resource(f"chart.{index}", tags=["business", "chart"], activation_status="validated", license_status="clear")
|
||||
for index in range(10)
|
||||
]
|
||||
resources.extend(
|
||||
resource(
|
||||
f"layout.{index}",
|
||||
kind="layout_template",
|
||||
tags=["business", "layout"],
|
||||
activation_status="validated",
|
||||
license_status="clear",
|
||||
)
|
||||
for index in range(5)
|
||||
)
|
||||
data = asset_map(resources)
|
||||
|
||||
selected = selector.select_assets(
|
||||
data,
|
||||
brief="business chart layout",
|
||||
lane="production",
|
||||
budget={"chart": 2, "layout": 2},
|
||||
max_total_assets=3,
|
||||
)
|
||||
|
||||
self.assertLessEqual(selected["prompt_budget"]["total_selected"], 3)
|
||||
self.assertLessEqual(selected["prompt_budget"]["selected_per_kind"]["chart"], 2)
|
||||
self.assertLessEqual(selected["prompt_budget"]["selected_per_kind"]["layout"], 2)
|
||||
self.assertLess(selected["prompt_budget"]["estimated_prompt_tokens"], 500)
|
||||
self.assertNotIn("resources", selected)
|
||||
|
||||
def test_brief_changes_selection_digest(self) -> None:
|
||||
data = asset_map(
|
||||
[
|
||||
resource(
|
||||
"chart.roadmap",
|
||||
tags=["roadmap"],
|
||||
summary="roadmap milestones timeline",
|
||||
activation_status="validated",
|
||||
license_status="clear",
|
||||
),
|
||||
resource(
|
||||
"chart.market",
|
||||
tags=["market"],
|
||||
summary="market sizing and growth",
|
||||
activation_status="validated",
|
||||
license_status="clear",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
roadmap = selector.select_assets(data, brief="roadmap", lane="production")
|
||||
market = selector.select_assets(data, brief="market", lane="production")
|
||||
|
||||
self.assertNotEqual(roadmap["request_digest"], market["request_digest"])
|
||||
self.assertNotEqual(roadmap["selection_digest"], market["selection_digest"])
|
||||
self.assertEqual([asset["id"] for asset in roadmap["selected_assets"]], ["chart.roadmap"])
|
||||
self.assertEqual([asset["id"] for asset in market["selected_assets"]], ["chart.market"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
2243
skills/lark-slides/scripts/svglide_gen_runtime.py
Normal file
2243
skills/lark-slides/scripts/svglide_gen_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
880
skills/lark-slides/scripts/svglide_gen_runtime_test.py
Normal file
880
skills/lark-slides/scripts/svglide_gen_runtime_test.py
Normal file
@@ -0,0 +1,880 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import svglide_gen_runtime as runtime
|
||||
import svg_preview_lint as preview_lint
|
||||
|
||||
|
||||
class SVGlideGenRuntimeTest(unittest.TestCase):
|
||||
def test_render_demo_slide_emits_protocol_svg_and_component_report(self) -> None:
|
||||
report = runtime.ComponentReport()
|
||||
|
||||
svg = runtime.render_demo_slide(
|
||||
page=1,
|
||||
kind="timeline",
|
||||
title="Timeline",
|
||||
summary="Pick for milestone events.",
|
||||
asset_id="chart.timeline",
|
||||
report=report,
|
||||
)
|
||||
data = report.to_dict()
|
||||
|
||||
self.assertIn('slide:role="slide"', svg)
|
||||
self.assertIn('width="960"', svg)
|
||||
self.assertEqual(data["schema_version"], "svglide-component-report/v1")
|
||||
self.assertEqual(data["pages"][0]["page"], 1)
|
||||
self.assertGreaterEqual(len(data["pages"][0]["components"]), 3)
|
||||
component = data["pages"][0]["components"][0]
|
||||
self.assertIn("bbox", component)
|
||||
self.assertTrue(component["primitives"])
|
||||
|
||||
def test_design_pattern_usage_receipt_requires_page_trace(self) -> None:
|
||||
report = runtime.ComponentReport()
|
||||
runtime.render_demo_slide(
|
||||
page=2,
|
||||
kind="bar_chart",
|
||||
title="Bar Chart",
|
||||
summary="Pick for category comparison.",
|
||||
asset_id="chart.bar_chart",
|
||||
report=report,
|
||||
)
|
||||
|
||||
receipt = runtime.design_pattern_usage_receipt(report.to_dict())
|
||||
|
||||
self.assertEqual(receipt["schema_version"], "svglide-design-pattern-usage/v1")
|
||||
self.assertEqual(receipt["status"], "passed")
|
||||
self.assertEqual(receipt["page_usages"][0]["asset_id"], "chart.bar_chart")
|
||||
self.assertEqual(receipt["page_usages"][0]["page"], 2)
|
||||
self.assertTrue(receipt["page_usages"][0]["component_ids"])
|
||||
self.assertTrue(receipt["page_usages"][0]["source_trace"])
|
||||
|
||||
def test_design_pattern_visual_contracts_for_hero_pages(self) -> None:
|
||||
cover_report = runtime.ComponentReport()
|
||||
cover_svg = runtime.render_demo_slide(
|
||||
page=1,
|
||||
kind="cover",
|
||||
title="Global AI Capital 2026",
|
||||
summary="Capital, compute, and control.",
|
||||
asset_id="layout.page_type.cover",
|
||||
report=cover_report,
|
||||
)
|
||||
self.assertIn('id="cover-master-title"', cover_svg)
|
||||
self.assertIn('font-size:64px', cover_svg)
|
||||
self.assertIn("Cambria", cover_svg)
|
||||
self.assertIn('id="slash-1"', cover_svg)
|
||||
self.assertNotIn('id="title-surface"', cover_svg)
|
||||
self.assertIn("large_hero_type", json.dumps(cover_report.to_dict()))
|
||||
|
||||
note_report = runtime.ComponentReport()
|
||||
note_svg = runtime.render_demo_slide(
|
||||
page=2,
|
||||
kind="editor_note",
|
||||
title="Why AI Capital Now",
|
||||
summary="Editorial note.",
|
||||
asset_id="layout.page_type.content",
|
||||
report=note_report,
|
||||
)
|
||||
self.assertIn('id="quote_ticks-1"', note_svg)
|
||||
self.assertIn('font-size:50px', note_svg)
|
||||
self.assertIn("Cambria", note_svg)
|
||||
self.assertIn("hero_metrics", json.dumps(note_report.to_dict()))
|
||||
|
||||
closing_report = runtime.ComponentReport()
|
||||
closing_svg = runtime.render_demo_slide(
|
||||
page=8,
|
||||
kind="closing",
|
||||
title="Closing Thesis",
|
||||
summary="Takeaways.",
|
||||
asset_id="layout.page_type.ending",
|
||||
report=closing_report,
|
||||
)
|
||||
self.assertIn('id="closing-red-index-1"', closing_svg)
|
||||
self.assertIn("numbered_hierarchy", json.dumps(closing_report.to_dict()))
|
||||
|
||||
def test_hub_renderer_uses_orbit_system_not_plain_cards(self) -> None:
|
||||
report = runtime.ComponentReport()
|
||||
svg = runtime.render_demo_slide(
|
||||
page=7,
|
||||
kind="hub_spoke",
|
||||
title="Stargate Hub",
|
||||
summary="Project finance meets compute scarcity.",
|
||||
asset_id="chart.hub_spoke",
|
||||
report=report,
|
||||
)
|
||||
|
||||
self.assertIn('id="hub-orbit-outer"', svg)
|
||||
self.assertIn('id="hub-core-glow"', svg)
|
||||
self.assertIn("CAPEX LOOP", svg)
|
||||
self.assertIn("orbit_system", json.dumps(report.to_dict()))
|
||||
|
||||
def test_chart_renderers_use_design_pattern_asset_level_structures(self) -> None:
|
||||
cases = [
|
||||
("kpi_cards", "kpi-observation-label", "editorial_sidebar"),
|
||||
("bar_chart", "bar-insight-value", "editorial_sidebar"),
|
||||
("donut_chart", "donut-investor-label", "investor_breakdown"),
|
||||
("sankey_chart", "sankey-return-block", "return_flow"),
|
||||
]
|
||||
|
||||
for kind, required_id, required_effect in cases:
|
||||
with self.subTest(kind=kind):
|
||||
report = runtime.ComponentReport()
|
||||
svg = runtime.render_demo_slide(
|
||||
page=3,
|
||||
kind=kind,
|
||||
title=kind.replace("_", " "),
|
||||
summary="SVGlide pattern-level renderer contract",
|
||||
asset_id=f"chart.{kind}",
|
||||
report=report,
|
||||
)
|
||||
self.assertIn(f'id="{required_id}"', svg)
|
||||
self.assertIn(required_effect, json.dumps(report.to_dict()))
|
||||
self.assertIn("Cambria", svg)
|
||||
|
||||
def test_global_ai_pages_7_8_keep_design_pattern_bubble_then_donut_archetypes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Global AI Capital 2026",
|
||||
"slides": [
|
||||
{"page_kind": "cover", "title": "Cover"},
|
||||
{"page_kind": "editor_note", "title": "Editor's Note"},
|
||||
{"page_kind": "kpi_cards", "title": "Q1 VC Landscape"},
|
||||
{"page_kind": "bar_chart", "title": "Hyperscaler Capex"},
|
||||
{"page_kind": "donut_chart", "title": "OpenAI Investors"},
|
||||
{"page_kind": "sankey_chart", "title": "Nvidia Loop"},
|
||||
{
|
||||
"page_kind": "bubble_chart",
|
||||
"asset_id": "chart.bubble_chart",
|
||||
"title": "估值兑现度:气泡大小 = 投资人数",
|
||||
"summary": "Valuation × ARR × investor count",
|
||||
"bubbles": [
|
||||
{"name": "xAI", "arr": 5, "valuation": 230, "investors": 8, "note": "$5B / $230B · 8+ investors"},
|
||||
{"name": "OpenAI", "arr": 24, "valuation": 852, "investors": 7, "note": "$24B / $852B · 7+ investors"},
|
||||
{"name": "Anthropic", "arr": 30, "valuation": 380, "investors": 5, "note": "$30B / $380B · 5 investors"},
|
||||
],
|
||||
"insight": "所有气泡都站在公允线之上,xAI 偏离最远。",
|
||||
},
|
||||
{
|
||||
"page_kind": "donut_chart",
|
||||
"asset_id": "chart.donut_chart",
|
||||
"title": "OpenAI $122B 这笔钱,从哪来",
|
||||
"center": {"value": "$122B", "label": "TOTAL ROUND", "note": "@ $852B post-money"},
|
||||
"segments": [
|
||||
{"name": "Amazon", "value": "$50B", "share": 41, "note": "41% · AWS 算力承诺"},
|
||||
{"name": "Nvidia", "value": "$30B", "share": 25, "note": "25% · 10GW 系统部署"},
|
||||
{"name": "SoftBank", "value": "$30B", "share": 25, "note": "25% · Stargate 联合主体"},
|
||||
{"name": "MSFT + 其他", "value": "$12B", "share": 9, "note": "9% · 其他投资人"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
|
||||
|
||||
page7 = (project / "pages" / "page-007.svg").read_text(encoding="utf-8")
|
||||
page8 = (project / "pages" / "page-008.svg").read_text(encoding="utf-8")
|
||||
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
|
||||
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(cache["pages"][6]["page_kind"], "bubble_chart")
|
||||
self.assertEqual(cache["pages"][7]["page_kind"], "donut_chart")
|
||||
self.assertIn('id="bubble-openai"', page7)
|
||||
self.assertIn('id="bubble-anthropic"', page7)
|
||||
self.assertIn('id="bubble-xai"', page7)
|
||||
self.assertIn('id="bubble-insight-band"', page7)
|
||||
self.assertIn('id="bubble-openai-name-plate"', page7)
|
||||
self.assertNotIn('id="bubble-openai-label-back"', page7)
|
||||
self.assertIn('id="donut-track"', page8)
|
||||
self.assertIn("$122B", page8)
|
||||
self.assertNotIn('id="hub-orbit-outer"', page7)
|
||||
self.assertNotIn('id="closing-list"', page8)
|
||||
self.assertEqual(usage["page_usages"][6]["asset_id"], "chart.bubble_chart")
|
||||
self.assertEqual(usage["page_usages"][7]["asset_id"], "chart.donut_chart")
|
||||
root = ET.fromstring(page7)
|
||||
elements = {element.get("id"): element for element in root.iter() if element.get("id")}
|
||||
for name in ("bubble-xai", "bubble-openai", "bubble-anthropic"):
|
||||
label = elements[f"{name}-label"]
|
||||
note = elements[f"{name}-note"]
|
||||
plate = elements[f"{name}-name-plate"]
|
||||
self.assertGreaterEqual(float(note.get("y", "0")) - float(label.get("y", "0")), 24)
|
||||
self.assertLessEqual(float(plate.get("y", "0")) + float(plate.get("height", "0")), float(note.get("y", "0")))
|
||||
|
||||
def test_strategist_contract_renderer_uses_real_labels_not_placeholder_footer(self) -> None:
|
||||
spec = {
|
||||
"schema_version": "svglide-strategist-contract/v1",
|
||||
"page_type": "kpi_overview",
|
||||
"key_message": "低空物流网络的关键运营指标,包括时效、成本、覆盖、可靠性",
|
||||
"text_budget_by_role": {
|
||||
"title": {"max_chars": 18, "max_boxes": 1},
|
||||
"metric": {"max_chars": 12, "max_boxes": 1},
|
||||
"body": {"max_chars": 72, "max_boxes": 1},
|
||||
"footer": {"max_chars": 32, "max_boxes": 1},
|
||||
},
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 48, "y": 34, "width": 864, "height": 48},
|
||||
{"id": "primary-kpi", "role": "metric", "x": 64, "y": 106, "width": 260, "height": 128},
|
||||
{"id": "secondary-grid", "role": "grid", "x": 348, "y": 106, "width": 548, "height": 128},
|
||||
{"id": "chart-row", "role": "chart", "x": 64, "y": 258, "width": 832, "height": 150},
|
||||
{"id": "body", "role": "body", "x": 64, "y": 426, "width": 832, "height": 56},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
}
|
||||
report = runtime.ComponentReport()
|
||||
|
||||
svg = runtime.render_contract_slide(
|
||||
page=2,
|
||||
kind="kpi_cards",
|
||||
title="",
|
||||
summary="",
|
||||
asset_id="chart.kpi_cards",
|
||||
accent="#2563EB",
|
||||
spec=spec,
|
||||
report=report,
|
||||
deck_title="城市级低空物流网络策划案",
|
||||
)
|
||||
|
||||
self.assertNotIn("SVGlide contract renderer", svg)
|
||||
self.assertIn("准点率", svg)
|
||||
self.assertIn("时效", svg)
|
||||
self.assertIn("城市级低空物流网络策划案", svg)
|
||||
|
||||
def test_strategist_contract_dense_pages_emit_semantic_visual_labels(self) -> None:
|
||||
base_spec = {
|
||||
"schema_version": "svglide-strategist-contract/v1",
|
||||
"key_message": "低空物流网络需要把订单入口、空域调度、无人机执行和末端交付串成闭环",
|
||||
"text_budget_by_role": {
|
||||
"title": {"max_chars": 24, "max_boxes": 1},
|
||||
"body": {"max_chars": 96, "max_boxes": 1},
|
||||
"callout": {"max_chars": 60, "max_boxes": 1},
|
||||
"footer": {"max_chars": 32, "max_boxes": 1},
|
||||
},
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 64, "y": 46, "width": 680, "height": 52},
|
||||
{"id": "visual", "role": "visual", "x": 92, "y": 132, "width": 776, "height": 300},
|
||||
{"id": "body", "role": "body", "x": 96, "y": 388, "width": 760, "height": 46},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
}
|
||||
|
||||
cases = [
|
||||
("process_flow", "process_flow", 'id="callout"', "订单入口"),
|
||||
("capability_map", "hub_spoke", 'id="legend"', "空域调度"),
|
||||
("comparison", "comparison_table", 'id="comparison-dimension-1"', "拥堵"),
|
||||
("chart_takeaway", "bar_chart", 'id="callout"', "订单入口"),
|
||||
("closing", "closing", 'id="closing-step-card-1"', "一中台"),
|
||||
]
|
||||
|
||||
for page_type, kind, required_id, required_copy in cases:
|
||||
with self.subTest(page_type=page_type):
|
||||
spec = dict(base_spec, page_type=page_type, title=page_type)
|
||||
report = runtime.ComponentReport()
|
||||
svg = runtime.render_contract_slide(
|
||||
page=3,
|
||||
kind=kind,
|
||||
title="",
|
||||
summary="",
|
||||
asset_id=f"chart.{kind}",
|
||||
accent="#2563EB",
|
||||
spec=spec,
|
||||
report=report,
|
||||
deck_title="城市级低空物流网络策划案",
|
||||
)
|
||||
|
||||
self.assertIn(required_id, svg)
|
||||
self.assertIn(required_copy, svg)
|
||||
|
||||
def test_insight_callout_contract_uses_annotation_renderer_not_flow_fallback(self) -> None:
|
||||
spec = {
|
||||
"schema_version": "svglide-strategist-contract/v1",
|
||||
"page_type": "insight_callout",
|
||||
"title": "关键洞察",
|
||||
"key_message": "企业战略复盘需要把核心诊断、证据和下一步判断放在一个聚焦视场里",
|
||||
"visual_design_contract": {
|
||||
"required_visual_evidence": ["spotlight", "annotation", "semantic_labels"],
|
||||
},
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 64, "y": 56, "width": 640, "height": 56},
|
||||
{"id": "spotlight", "role": "spotlight", "x": 88, "y": 146, "width": 532, "height": 248},
|
||||
{"id": "callout", "role": "callout", "x": 650, "y": 168, "width": 218, "height": 176},
|
||||
{"id": "caption", "role": "caption", "x": 104, "y": 418, "width": 720, "height": 38},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
}
|
||||
report = runtime.ComponentReport()
|
||||
|
||||
svg = runtime.render_contract_slide(
|
||||
page=2,
|
||||
kind="insight_callout",
|
||||
title="",
|
||||
summary="",
|
||||
asset_id="chart.labeled_card",
|
||||
accent="#2563EB",
|
||||
spec=spec,
|
||||
report=report,
|
||||
deck_title="企业战略复盘",
|
||||
)
|
||||
encoded_report = json.dumps(report.to_dict(), ensure_ascii=False)
|
||||
|
||||
self.assertIn('id="spotlight-stage"', svg)
|
||||
self.assertIn('id="annotation-callout-panel"', svg)
|
||||
self.assertIn("contract.annotation", encoded_report)
|
||||
self.assertIn("semantic_labels", encoded_report)
|
||||
self.assertNotIn("contract.flow", encoded_report)
|
||||
|
||||
def test_strategist_contract_uses_full_page_archetype_geometry(self) -> None:
|
||||
base_spec = {
|
||||
"schema_version": "svglide-strategist-contract/v1",
|
||||
"key_message": "低空物流网络需要形成可运营、可调度、可复盘的城市级基础设施",
|
||||
"text_budget_by_role": {
|
||||
"title": {"max_chars": 24, "max_boxes": 1},
|
||||
"body": {"max_chars": 96, "max_boxes": 1},
|
||||
"callout": {"max_chars": 60, "max_boxes": 1},
|
||||
"footer": {"max_chars": 32, "max_boxes": 1},
|
||||
},
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 64, "y": 46, "width": 680, "height": 52},
|
||||
{"id": "visual", "role": "visual", "x": 92, "y": 132, "width": 776, "height": 300},
|
||||
{"id": "body", "role": "body", "x": 96, "y": 388, "width": 760, "height": 46},
|
||||
{"id": "callout", "role": "callout", "x": 612, "y": 380, "width": 250, "height": 60},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
}
|
||||
|
||||
cases = [
|
||||
("cover", "cover", ["cover-map-field", "cover-route-ribbon", "cover-coordinate-stack-1"], ["full_page_archetype", "hero_route", "title_field"]),
|
||||
("agenda", "agenda", ["agenda-route-backplane", "agenda-number-1", "agenda-route-path"], ["numbered_path", "section_index", "semantic_labels"]),
|
||||
("section_divider", "section", ["section-signal-field", "section-index-rail", "section-hero-number"], ["section_index", "hero_signal", "full_page_archetype"]),
|
||||
("process_flow", "process_flow", ["flow-backplane", "flow-lane-upper", "flow-lane-lower"], ["connector_flow", "flow_lanes", "full_page_archetype"]),
|
||||
("capability_map", "hub_spoke", ["hub-backplane", "hub-sector-1", "hub-satellite-panel-1"], ["hub_spoke", "sector_field", "semantic_labels"]),
|
||||
("chart_takeaway", "bar_chart", ["bar-plot-backplane", "bar-insight-strip", "bar-variance-path"], ["chart_geometry", "insight_strip", "full_page_archetype"]),
|
||||
("closing", "closing", ["closing-backplane", "closing-step-card-1", "closing-route-ribbon"], ["closing_ribbon", "action_cards", "full_page_archetype"]),
|
||||
]
|
||||
|
||||
for page_type, kind, required_ids, required_evidence in cases:
|
||||
with self.subTest(page_type=page_type):
|
||||
spec = dict(base_spec, page_type=page_type, title=page_type)
|
||||
spec["visual_design_contract"] = {
|
||||
"required_visual_evidence": required_evidence,
|
||||
}
|
||||
if page_type == "cover":
|
||||
spec["layout_boxes"] = [
|
||||
{"id": "title", "role": "title", "x": 72, "y": 150, "width": 560, "height": 120},
|
||||
{"id": "body", "role": "body", "x": 76, "y": 284, "width": 520, "height": 72},
|
||||
{"id": "visual", "role": "visual", "x": 600, "y": 84, "width": 288, "height": 360},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
]
|
||||
report = runtime.ComponentReport()
|
||||
|
||||
svg = runtime.render_contract_slide(
|
||||
page=4,
|
||||
kind=kind,
|
||||
title="",
|
||||
summary="",
|
||||
asset_id=f"chart.{kind}",
|
||||
accent="#2563EB",
|
||||
spec=spec,
|
||||
report=report,
|
||||
deck_title="城市级低空物流网络策划案",
|
||||
)
|
||||
|
||||
for required_id in required_ids:
|
||||
self.assertIn(f'id="{required_id}"', svg)
|
||||
if page_type == "agenda":
|
||||
self.assertIn('id="agenda-index-tick-1"', svg)
|
||||
self.assertNotIn('id="agenda-number-label-1"', svg)
|
||||
encoded_report = json.dumps(report.to_dict())
|
||||
for evidence in spec["visual_design_contract"]["required_visual_evidence"]:
|
||||
self.assertIn(evidence, encoded_report)
|
||||
|
||||
def test_evidence_effects_does_not_echo_contract_required_evidence_as_proof(self) -> None:
|
||||
effects = runtime.evidence_effects(
|
||||
{"visual_design_contract": {"required_visual_evidence": ["fake_contract_evidence"]}},
|
||||
["chart_geometry"],
|
||||
)
|
||||
|
||||
self.assertEqual(["chart_geometry"], effects)
|
||||
self.assertNotIn("fake_contract_evidence", effects)
|
||||
|
||||
def test_style_system_palette_changes_rendered_svg_fingerprint(self) -> None:
|
||||
def compose_with_accent(accent: str) -> str:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "svglide-strategist-contract/v1",
|
||||
"title": "Theme Accent Test",
|
||||
"style_system": {"palette": {"accent": accent}},
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"page_type": "agenda",
|
||||
"title": "Contents",
|
||||
"key_message": "Agenda route",
|
||||
"visual_design_contract": {
|
||||
"required_visual_evidence": ["numbered_path", "section_index", "semantic_labels"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime.compose_project(project, plan)
|
||||
return (project / "pages" / "page-001.svg").read_text(encoding="utf-8")
|
||||
|
||||
blue = compose_with_accent("#4A90E2")
|
||||
red = compose_with_accent("#E91E63")
|
||||
|
||||
self.assertIn("#4A90E2", blue)
|
||||
self.assertIn("#E91E63", red)
|
||||
self.assertNotEqual(blue, red)
|
||||
|
||||
def test_contract_theme_visual_language_varies_by_domain(self) -> None:
|
||||
base_spec = {
|
||||
"schema_version": "svglide-strategist-contract/v1",
|
||||
"page_type": "cover",
|
||||
"title": "Theme Cover",
|
||||
"key_message": "Theme-specific visual language should alter the SVG motif.",
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 72, "y": 150, "width": 560, "height": 120},
|
||||
{"id": "body", "role": "body", "x": 76, "y": 284, "width": 520, "height": 72},
|
||||
{"id": "visual", "role": "visual", "x": 600, "y": 84, "width": 288, "height": 360},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
}
|
||||
cases = [
|
||||
("新疆阿克苏城区生态居住区策划案", "theme-oasis-water-ribbon", "oasis_water_ribbon", "theme-ai-grid-field"),
|
||||
("Global AI Capital 2026", "theme-ai-grid-field", "ai_grid_field", "theme-oasis-water-ribbon"),
|
||||
("城市级低空物流网络策划案", "theme-logistics-air-lane-1", "logistics_air_lane", "theme-oasis-water-ribbon"),
|
||||
]
|
||||
|
||||
for deck_title, required_id, required_effect, forbidden_id in cases:
|
||||
with self.subTest(deck_title=deck_title):
|
||||
report = runtime.ComponentReport()
|
||||
svg = runtime.render_contract_slide(
|
||||
page=1,
|
||||
kind="cover",
|
||||
title="",
|
||||
summary="",
|
||||
asset_id="layout.page_type.cover",
|
||||
accent="#4A90E2",
|
||||
spec=base_spec,
|
||||
report=report,
|
||||
deck_title=deck_title,
|
||||
)
|
||||
encoded_report = json.dumps(report.to_dict(), ensure_ascii=False)
|
||||
self.assertIn(f'id="{required_id}"', svg)
|
||||
self.assertIn(required_effect, encoded_report)
|
||||
self.assertNotIn(f'id="{forbidden_id}"', svg)
|
||||
|
||||
def test_unseen_topics_extract_labels_from_brief_instead_of_defaulting(self) -> None:
|
||||
tea = {
|
||||
"title": "茶产业出海品牌策划",
|
||||
"key_message": "茶产业出海需要围绕产地故事、品牌信任、渠道试销、内容种草形成闭环",
|
||||
}
|
||||
ecommerce = {
|
||||
"title": "跨境电商增长方案",
|
||||
"key_message": "跨境电商增长聚焦选品矩阵、达人内容、物流履约、复购会员形成闭环",
|
||||
}
|
||||
|
||||
tea_labels = runtime.topic_node_labels(tea, "茶产业出海品牌策划", count=4)
|
||||
ecommerce_labels = runtime.topic_node_labels(ecommerce, "跨境电商增长方案", count=4)
|
||||
tea_metrics = runtime.dashboard_metrics_for_topic(tea, "茶产业出海品牌策划")
|
||||
ecommerce_metrics = runtime.dashboard_metrics_for_topic(ecommerce, "跨境电商增长方案")
|
||||
tea_rows = runtime.comparison_rows_for_topic(tea, "茶产业出海品牌策划")
|
||||
|
||||
self.assertIn("产地故事", tea_labels)
|
||||
self.assertIn("品牌信任", tea_labels)
|
||||
self.assertIn("选品矩阵", ecommerce_labels)
|
||||
self.assertIn("物流履约", ecommerce_labels)
|
||||
self.assertNotEqual(tea_labels, ecommerce_labels)
|
||||
self.assertNotEqual(tea_metrics, ecommerce_metrics)
|
||||
self.assertNotIn(("4", "关键抓手"), tea_metrics)
|
||||
self.assertEqual("产地故事", tea_rows[0][0])
|
||||
|
||||
def test_contract_navigation_pages_emit_enough_semantic_labels_for_preview_lint(self) -> None:
|
||||
deck_title = "新疆阿克苏城区居住区策划案"
|
||||
cases = [
|
||||
(
|
||||
"cover",
|
||||
"cover",
|
||||
{
|
||||
"page_type": "cover",
|
||||
"title": "以水为脉·四时为序",
|
||||
"key_message": "四境共生·四季归心的绿洲栖居范本",
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 72, "y": 150, "width": 560, "height": 120},
|
||||
{"id": "body", "role": "body", "x": 76, "y": 284, "width": 520, "height": 72},
|
||||
{"id": "visual", "role": "visual", "x": 600, "y": 84, "width": 288, "height": 360},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
"agenda",
|
||||
"agenda",
|
||||
{
|
||||
"page_type": "agenda",
|
||||
"title": "目录",
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 64, "y": 54, "width": 600, "height": 54},
|
||||
{"id": "rail", "role": "timeline", "x": 96, "y": 136, "width": 48, "height": 312},
|
||||
{"id": "body", "role": "body", "x": 154, "y": 126, "width": 650, "height": 330},
|
||||
{"id": "visual", "role": "visual", "x": 820, "y": 156, "width": 64, "height": 64},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"section",
|
||||
{
|
||||
"page_type": "section_divider",
|
||||
"title": "01 项目核心定位与愿景",
|
||||
"key_message": "以水串联四季,打造全季态生态居住区",
|
||||
"layout_boxes": [
|
||||
{"id": "section-number", "role": "kicker", "x": 72, "y": 92, "width": 160, "height": 90},
|
||||
{"id": "title", "role": "title", "x": 180, "y": 188, "width": 600, "height": 88},
|
||||
{"id": "body", "role": "body", "x": 184, "y": 292, "width": 560, "height": 38},
|
||||
{"id": "visual", "role": "visual", "x": 0, "y": 320, "width": 960, "height": 180},
|
||||
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw)
|
||||
for page, (kind, asset_id, spec) in enumerate(cases, 1):
|
||||
svg = runtime.render_contract_slide(
|
||||
page=page,
|
||||
kind=kind,
|
||||
title="",
|
||||
summary="",
|
||||
asset_id=asset_id,
|
||||
accent="#4A90E2",
|
||||
spec={**spec, "_deck_title": deck_title},
|
||||
report=runtime.ComponentReport(),
|
||||
deck_title=deck_title,
|
||||
)
|
||||
source = preview_lint.SvgSource(page=page, label=f"page-{page}", root=ET.fromstring(svg), base_dir=project)
|
||||
codes = {check["code"] for check in preview_lint.lint_svg_source(project, source)}
|
||||
self.assertNotIn("unlabeled_visual_system", codes)
|
||||
|
||||
def test_asset_marks_are_arc_free_and_reject_unknown_ids(self) -> None:
|
||||
spark = "".join(runtime.asset_mark("spark", 12, 24, 1.25, "#E63946", opacity=0.8))
|
||||
|
||||
self.assertIn("<path", spark)
|
||||
self.assertIn('id="spark-1"', spark)
|
||||
self.assertNotRegex(spark, r'\sd="[^"]*[Aa](?=[\s,\d.+-])')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "unknown asset mark"):
|
||||
runtime.asset_mark("missing", 0, 0, 1, "#000000")
|
||||
|
||||
def test_path_rejects_arc_commands(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "arc commands"):
|
||||
runtime.path("bad-arc", "M10 10 A20 20 0 0 1 30 30")
|
||||
|
||||
def test_compose_cli_writes_supported_pages_and_receipts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
kinds = [
|
||||
"cover",
|
||||
"editor_note",
|
||||
"kpi_cards",
|
||||
"bar_chart",
|
||||
"bubble_chart",
|
||||
"donut_chart",
|
||||
"sankey_chart",
|
||||
"hub_spoke",
|
||||
"closing",
|
||||
]
|
||||
chart_assets = [
|
||||
"chart.kpi_cards",
|
||||
"chart.bar_chart",
|
||||
"chart.bubble_chart",
|
||||
"chart.donut_chart",
|
||||
"chart.sankey_chart",
|
||||
"chart.hub_spoke",
|
||||
]
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Runtime Slice",
|
||||
"design_pattern_selection": {"selected_assets": [{"id": asset} for asset in chart_assets]},
|
||||
"slides": [
|
||||
{
|
||||
"page_kind": kind,
|
||||
"title": kind.replace("_", " ").title(),
|
||||
"summary": f"Compose fixture for {kind}",
|
||||
}
|
||||
for kind in kinds
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
exit_code = runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
result = json.loads(stdout.getvalue())
|
||||
self.assertEqual(result["schema_version"], "svglide-gen-runtime-cache/v1")
|
||||
self.assertEqual(result["page_count"], len(kinds))
|
||||
self.assertEqual([page["page_kind"] for page in result["pages"]], kinds)
|
||||
|
||||
for index in range(1, len(kinds) + 1):
|
||||
svg_path = project / "pages" / f"page-{index:03d}.svg"
|
||||
self.assertTrue(svg_path.exists())
|
||||
svg = svg_path.read_text(encoding="utf-8")
|
||||
self.assertIn('slide:role="slide"', svg)
|
||||
self.assertIn('viewBox="0 0 960 540"', svg)
|
||||
self.assertNotRegex(svg, r'<path\b[^>]*\sd="[^"]*[Aa](?=[\s,\d.+-])')
|
||||
ET.parse(svg_path)
|
||||
|
||||
component_report = json.loads((project / "receipts" / "emitted_components.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(component_report["schema_version"], "svglide-component-report/v1")
|
||||
self.assertEqual(len(component_report["pages"]), len(kinds))
|
||||
self.assertGreaterEqual(component_report["summary"]["component_count"], 16)
|
||||
|
||||
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(usage["schema_version"], "svglide-design-pattern-usage/v1")
|
||||
used_assets = {item["asset_id"] for item in usage["page_usages"]}
|
||||
self.assertTrue(set(chart_assets).issubset(used_assets))
|
||||
self.assertTrue(set(chart_assets).issubset(set(usage["used_asset_ids"])))
|
||||
|
||||
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(cache, result)
|
||||
self.assertTrue(set(kinds).issubset(set(cache["supported_page_kinds"])))
|
||||
self.assertIn("agenda", cache["supported_page_kinds"])
|
||||
self.assertIn("section", cache["supported_page_kinds"])
|
||||
|
||||
def test_compose_uses_real_slide_plan_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Field Mapping",
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"page_type": "chart",
|
||||
"chart_type": "bar_chart",
|
||||
"reference_asset": {"asset_id": "chart.bar_chart", "source": "svglide_design_pattern"},
|
||||
"visual_plan": {
|
||||
"key_message": "Capex bar chart",
|
||||
"body": "Renderer should use chart_type and nested copy.",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
|
||||
|
||||
svg = (project / "pages" / "page-001.svg").read_text(encoding="utf-8")
|
||||
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
|
||||
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(cache["pages"][0]["page_kind"], "bar_chart")
|
||||
self.assertIn("Capex bar chart", svg)
|
||||
self.assertIn("Renderer should use chart_type", svg)
|
||||
self.assertEqual(usage["page_usages"][0]["asset_id"], "chart.bar_chart")
|
||||
|
||||
def test_compose_uses_nested_reference_asset(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Nested Asset Mapping",
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"page_type": "content",
|
||||
"visual_plan": {
|
||||
"page_kind": "editor_note",
|
||||
"reference_asset": {"asset_id": "layout.page_type.content", "source": "svglide_design_pattern"},
|
||||
"key_message": "Nested reference should drive the receipt",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
|
||||
|
||||
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
|
||||
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(cache["pages"][0]["page_kind"], "editor_note")
|
||||
self.assertEqual(cache["pages"][0]["asset_id"], "layout.page_type.content")
|
||||
self.assertEqual(usage["page_usages"][0]["asset_id"], "layout.page_type.content")
|
||||
|
||||
def test_compose_uses_nested_asset_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Nested Asset ID Mapping",
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"page_type": "chart",
|
||||
"chart_type": "donut_chart",
|
||||
"visual_plan": {
|
||||
"asset_id": "chart.donut_chart",
|
||||
"key_message": "Nested asset_id should drive the receipt",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
|
||||
|
||||
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
|
||||
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(cache["pages"][0]["page_kind"], "donut_chart")
|
||||
self.assertEqual(cache["pages"][0]["asset_id"], "chart.donut_chart")
|
||||
self.assertEqual(usage["page_usages"][0]["asset_id"], "chart.donut_chart")
|
||||
|
||||
def test_compose_parameterizes_design_pattern_renderers_for_non_ai_topic(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
project = Path(raw) / "runtime-project"
|
||||
project.mkdir()
|
||||
plan = project / "slide_plan.json"
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Aksu Oasis Living District",
|
||||
"slides": [
|
||||
{
|
||||
"page_kind": "cover",
|
||||
"title": "以水为脉 四时为序",
|
||||
"summary": "阿克苏城区生态居住区策划案",
|
||||
"kicker": "AKSU OASIS / RESIDENTIAL STRATEGY",
|
||||
"meta_1": "OASIS PLANNING",
|
||||
"meta_2": "FOUR SEASONS\nLIVING MAP",
|
||||
"year": "2026",
|
||||
},
|
||||
{
|
||||
"page_kind": "kpi_cards",
|
||||
"title": "四季地块价值矩阵",
|
||||
"metrics": [
|
||||
{"value": "4", "label": "四季地块", "note": "春夏秋冬差异化主题"},
|
||||
{"value": "1", "label": "水系闭环", "note": "串联全区公共空间"},
|
||||
{"value": "3", "label": "价值引擎", "note": "配套 景观 文化"},
|
||||
{"value": "全年龄", "label": "人群覆盖", "note": "儿童 青年 长者"},
|
||||
],
|
||||
"insight": {
|
||||
"label": "PLANNING NOTE",
|
||||
"title": "水系不是装饰,\n是组织结构。",
|
||||
"copy": "用一条蓝绿生态脉络串联地块、配套和归家体验。",
|
||||
"number": "4境",
|
||||
},
|
||||
},
|
||||
{
|
||||
"page_kind": "sankey_chart",
|
||||
"title": "水系如何转化为空间价值",
|
||||
"origin": {"name": "水系", "value": "1环", "label": "ecological spine"},
|
||||
"targets": [
|
||||
{"name": "春配套", "value": "繁"},
|
||||
{"name": "夏活力", "value": "乐"},
|
||||
{"name": "秋静谧", "value": "享"},
|
||||
{"name": "冬暖居", "value": "暖"},
|
||||
],
|
||||
"return_flow": {"title": "归心体验", "value": "全年", "note": "four-season loop"},
|
||||
"insight": "Water organizes the community into a legible four-season living loop.",
|
||||
},
|
||||
{
|
||||
"page_kind": "hub_spoke",
|
||||
"title": "四境联动系统",
|
||||
"hub": {"value": "水脉", "label": "OASIS LOOP"},
|
||||
"nodes": [
|
||||
{"name": "春之地块", "note": "配套入口"},
|
||||
{"name": "夏之地块", "note": "运动活力"},
|
||||
{"name": "秋之地块", "note": "胡杨静谧"},
|
||||
{"name": "冬之地块", "note": "暖廊归家"},
|
||||
{"name": "艾德莱斯", "note": "地域纹样"},
|
||||
{"name": "公共服务", "note": "全年龄覆盖"},
|
||||
],
|
||||
"side_note": "四境共生 四季归心",
|
||||
"side_index": "OASIS LOOP",
|
||||
},
|
||||
{
|
||||
"page_kind": "closing",
|
||||
"title": "总结与展望",
|
||||
"takeaways": [
|
||||
"以水为脉组织归家体验",
|
||||
"四季地块形成差异化记忆",
|
||||
"胡杨与艾德莱斯强化地域识别",
|
||||
"配套共享覆盖全年龄需求",
|
||||
"生态景观提升长期溢价",
|
||||
"成为阿克苏人居升级样板",
|
||||
],
|
||||
"critical_copy": "四境共生,四季归心。",
|
||||
"sidebar": "OASIS\nLIVING\nAKSU",
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
|
||||
|
||||
all_svg = "\n".join(
|
||||
(project / "pages" / f"page-{index:03d}.svg").read_text(encoding="utf-8")
|
||||
for index in range(1, 6)
|
||||
)
|
||||
self.assertIn("AKSU OASIS", all_svg)
|
||||
self.assertIn("四季地块", all_svg)
|
||||
self.assertIn("水系不是装饰", all_svg)
|
||||
self.assertIn("归心体验", all_svg)
|
||||
self.assertIn("胡杨静谧", all_svg)
|
||||
self.assertIn("四境共生,四季归心", all_svg)
|
||||
self.assertNotIn("Global AI", all_svg)
|
||||
self.assertNotIn("Nvidia", all_svg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
134
skills/lark-slides/scripts/svglide_golden_suite.py
Normal file
134
skills/lark-slides/scripts/svglide_golden_suite.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
MANIFEST_SCHEMA_VERSION = "svglide-golden-suite-manifest/v1"
|
||||
|
||||
_GOLDEN_CASES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"case_id": "ai-capital-editorial",
|
||||
"theme_domain": "ai_infrastructure_finance",
|
||||
"prompt_summary": "Editorial deck about capital flows, compute commitments, and investor risk signals in the AI infrastructure cycle.",
|
||||
"expected_archetypes": [
|
||||
"cover",
|
||||
"editor_note",
|
||||
"kpi_cards",
|
||||
"bar_chart",
|
||||
"donut_chart",
|
||||
"sankey_chart",
|
||||
"bubble_chart",
|
||||
"closing",
|
||||
],
|
||||
"required_evidence": [
|
||||
"design_pattern_usage_receipt",
|
||||
"component_report_with_chart_archetypes",
|
||||
"svg_preflight_pass",
|
||||
],
|
||||
},
|
||||
{
|
||||
"case_id": "aksu-oasis-planning",
|
||||
"theme_domain": "urban_oasis_residential_planning",
|
||||
"prompt_summary": "Planning deck for an Aksu oasis living district, using water, seasonal blocks, and local identity as the organizing system.",
|
||||
"expected_archetypes": [
|
||||
"cover",
|
||||
"agenda",
|
||||
"section",
|
||||
"kpi_cards",
|
||||
"sankey_chart",
|
||||
"hub_spoke",
|
||||
"comparison_table",
|
||||
"closing",
|
||||
],
|
||||
"required_evidence": [
|
||||
"domain_copy_retained",
|
||||
"agenda_numbered_path",
|
||||
"section_signal",
|
||||
"non_ai_topic_parameterization",
|
||||
"svg_preflight_pass",
|
||||
],
|
||||
},
|
||||
{
|
||||
"case_id": "data-dense-business-report",
|
||||
"theme_domain": "business_operations_data_report",
|
||||
"prompt_summary": "Dense business report with executive summary, KPI dashboard, trend chart, comparison matrix, and action plan.",
|
||||
"expected_archetypes": [
|
||||
"cover",
|
||||
"agenda",
|
||||
"kpi_cards",
|
||||
"bar_chart",
|
||||
"line_chart",
|
||||
"comparison_table",
|
||||
"process_flow",
|
||||
"closing",
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_pack_digest",
|
||||
"renderer_registry_mapping",
|
||||
"chart_verify_receipt",
|
||||
"quality_gate_pass",
|
||||
"timing_receipt",
|
||||
],
|
||||
},
|
||||
{
|
||||
"case_id": "runtime-smoke",
|
||||
"theme_domain": "svglide_runtime_health",
|
||||
"prompt_summary": "Small deterministic smoke case for validating runtime composition, manifest plumbing, and receipt emission before larger regressions.",
|
||||
"expected_archetypes": [
|
||||
"cover",
|
||||
"kpi_cards",
|
||||
"bar_chart",
|
||||
"closing",
|
||||
],
|
||||
"required_evidence": [
|
||||
"runtime_cache_written",
|
||||
"component_report_written",
|
||||
"svg_preflight_pass",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def list_cases() -> list[dict[str, Any]]:
|
||||
return copy.deepcopy(list(_GOLDEN_CASES))
|
||||
|
||||
|
||||
def build_manifest() -> dict[str, Any]:
|
||||
cases = list_cases()
|
||||
return {
|
||||
"schema_version": MANIFEST_SCHEMA_VERSION,
|
||||
"case_count": len(cases),
|
||||
"cases": cases,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Emit the built-in SVGlide golden suite case manifest.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
list_parser = subparsers.add_parser("list", help="list built-in golden suite cases")
|
||||
list_parser.add_argument("--json", action="store_true", help="emit the case manifest as JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "list":
|
||||
if not args.json:
|
||||
parser.error("list currently supports only --json")
|
||||
print(json.dumps(build_manifest(), ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
parser.error(f"unsupported command: {args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
87
skills/lark-slides/scripts/svglide_golden_suite_test.py
Normal file
87
skills/lark-slides/scripts/svglide_golden_suite_test.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import svglide_golden_suite as golden_suite
|
||||
|
||||
|
||||
class SVGlideGoldenSuiteTest(unittest.TestCase):
|
||||
def test_list_json_outputs_manifest_with_required_cases(self) -> None:
|
||||
stdout = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
exit_code = golden_suite.main(["list", "--json"])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
manifest = json.loads(stdout.getvalue())
|
||||
self.assertEqual(manifest["schema_version"], "svglide-golden-suite-manifest/v1")
|
||||
self.assertEqual(manifest["case_count"], len(manifest["cases"]))
|
||||
self.assertGreaterEqual(manifest["case_count"], 3)
|
||||
case_ids = {case["case_id"] for case in manifest["cases"]}
|
||||
self.assertTrue(
|
||||
{
|
||||
"ai-capital-editorial",
|
||||
"aksu-oasis-planning",
|
||||
"data-dense-business-report",
|
||||
"runtime-smoke",
|
||||
}.issubset(case_ids)
|
||||
)
|
||||
|
||||
for case in manifest["cases"]:
|
||||
for key in ["case_id", "theme_domain", "prompt_summary", "expected_archetypes", "required_evidence"]:
|
||||
self.assertIn(key, case)
|
||||
self.assertIsInstance(case["expected_archetypes"], list)
|
||||
self.assertTrue(case["expected_archetypes"])
|
||||
|
||||
def test_manifest_has_no_external_reference_project_words(self) -> None:
|
||||
encoded = json.dumps(golden_suite.build_manifest(), ensure_ascii=False).lower()
|
||||
banned_tokens = [
|
||||
"ppt" + "-master",
|
||||
"ppt" + "_master",
|
||||
"ppt" + " master",
|
||||
"hugo" + "he3",
|
||||
"ppt" + "169",
|
||||
]
|
||||
|
||||
for token in banned_tokens:
|
||||
with self.subTest(token=token):
|
||||
self.assertNotIn(token, encoded)
|
||||
|
||||
def test_each_case_declares_required_evidence(self) -> None:
|
||||
for case in golden_suite.list_cases():
|
||||
with self.subTest(case_id=case["case_id"]):
|
||||
evidence = case["required_evidence"]
|
||||
self.assertIsInstance(evidence, list)
|
||||
self.assertTrue(evidence)
|
||||
self.assertTrue(all(isinstance(item, str) and item for item in evidence))
|
||||
|
||||
def test_aksu_case_locks_agenda_and_section_regression(self) -> None:
|
||||
cases = {case["case_id"]: case for case in golden_suite.list_cases()}
|
||||
aksu = cases["aksu-oasis-planning"]
|
||||
|
||||
self.assertIn("agenda", aksu["expected_archetypes"])
|
||||
self.assertIn("section", aksu["expected_archetypes"])
|
||||
self.assertIn("agenda_numbered_path", aksu["required_evidence"])
|
||||
self.assertIn("section_signal", aksu["required_evidence"])
|
||||
|
||||
def test_data_dense_case_requires_runner_quality_evidence(self) -> None:
|
||||
cases = {case["case_id"]: case for case in golden_suite.list_cases()}
|
||||
data_case = cases["data-dense-business-report"]
|
||||
|
||||
self.assertIn("chart_verify_receipt", data_case["required_evidence"])
|
||||
self.assertIn("quality_gate_pass", data_case["required_evidence"])
|
||||
self.assertIn("timing_receipt", data_case["required_evidence"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
3581
skills/lark-slides/scripts/svglide_project_runner.py
Normal file
3581
skills/lark-slides/scripts/svglide_project_runner.py
Normal file
File diff suppressed because it is too large
Load Diff
2050
skills/lark-slides/scripts/svglide_project_runner_test.py
Normal file
2050
skills/lark-slides/scripts/svglide_project_runner_test.py
Normal file
File diff suppressed because it is too large
Load Diff
170
skills/lark-slides/scripts/svglide_renderer_registry.py
Normal file
170
skills/lark-slides/scripts/svglide_renderer_registry.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/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
|
||||
|
||||
|
||||
REGISTRY_SCHEMA_VERSION = "svglide-renderer-registry/v1"
|
||||
ACTIVE_STATUSES = {"active", "candidate", "blocked", "deprecated"}
|
||||
|
||||
|
||||
def script_path() -> Path:
|
||||
return Path(__file__).resolve()
|
||||
|
||||
|
||||
def references_dir() -> Path:
|
||||
return script_path().parents[1] / "references"
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def registry_path(ref_dir: Path | None = None) -> Path:
|
||||
return (ref_dir or references_dir()) / "svglide-renderer-registry.json"
|
||||
|
||||
|
||||
def load_registry(ref_dir: Path | None = None) -> dict[str, Any]:
|
||||
data = read_json(registry_path(ref_dir))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("renderer registry must contain a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def load_catalog_ids(ref_dir: Path | None = None) -> dict[str, set[str]]:
|
||||
root = ref_dir or references_dir()
|
||||
seeds = read_json(root / "svg-seeds.json")
|
||||
recipes = read_json(root / "svg-recipes.json")
|
||||
if not isinstance(seeds, dict) or not isinstance(recipes, dict):
|
||||
raise ValueError("seed and recipe catalogs must contain JSON objects")
|
||||
seed_ids = set((seeds.get("seeds") or {}).keys()) if isinstance(seeds.get("seeds"), dict) else set()
|
||||
recipe_ids = set((recipes.get("recipes") or {}).keys()) if isinstance(recipes.get("recipes"), dict) else set()
|
||||
chart_type_ids = (
|
||||
set((recipes.get("chart_type_contracts") or {}).keys())
|
||||
if isinstance(recipes.get("chart_type_contracts"), dict)
|
||||
else set()
|
||||
)
|
||||
return {"seeds": seed_ids, "recipes": recipe_ids, "chart_types": chart_type_ids}
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def list_text(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [text(item) for item in value if text(item)]
|
||||
|
||||
|
||||
def validate_registry(data: dict[str, Any], catalog_ids: dict[str, set[str]]) -> dict[str, Any]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
if data.get("schema_version") != REGISTRY_SCHEMA_VERSION:
|
||||
issues.append({"level": "error", "code": "invalid_schema_version"})
|
||||
renderers = data.get("renderers")
|
||||
if not isinstance(renderers, list) or not renderers:
|
||||
issues.append({"level": "error", "code": "missing_renderers"})
|
||||
renderers = []
|
||||
|
||||
seen: set[str] = set()
|
||||
active_count = 0
|
||||
candidate_count = 0
|
||||
active_seed_ids: set[str] = set()
|
||||
active_recipe_ids: set[str] = set()
|
||||
active_page_kinds: set[str] = set()
|
||||
for index, item in enumerate(renderers, 1):
|
||||
if not isinstance(item, dict):
|
||||
issues.append({"level": "error", "code": "renderer_not_object", "index": index})
|
||||
continue
|
||||
renderer_id = text(item.get("id"))
|
||||
status = text(item.get("status")) or "candidate"
|
||||
seed_id = text(item.get("layout_seed_id"))
|
||||
recipe_id = text(item.get("visual_recipe_id"))
|
||||
page_kind = text(item.get("page_kind"))
|
||||
if not renderer_id:
|
||||
issues.append({"level": "error", "code": "missing_renderer_id", "index": index})
|
||||
continue
|
||||
if renderer_id in seen:
|
||||
issues.append({"level": "error", "code": "duplicate_renderer_id", "renderer_id": renderer_id})
|
||||
seen.add(renderer_id)
|
||||
if status not in ACTIVE_STATUSES:
|
||||
issues.append({"level": "error", "code": "invalid_status", "renderer_id": renderer_id, "status": status})
|
||||
if status == "active":
|
||||
active_count += 1
|
||||
active_seed_ids.add(seed_id)
|
||||
active_recipe_ids.add(recipe_id)
|
||||
active_page_kinds.add(page_kind)
|
||||
required = {
|
||||
"page_kind": page_kind,
|
||||
"runtime_renderer_family": text(item.get("runtime_renderer_family")),
|
||||
"layout_seed_id": seed_id,
|
||||
"visual_recipe_id": recipe_id,
|
||||
}
|
||||
for field, value in required.items():
|
||||
if not value:
|
||||
issues.append({"level": "error", "code": f"active_renderer_missing_{field}", "renderer_id": renderer_id})
|
||||
if seed_id and seed_id not in catalog_ids["seeds"]:
|
||||
issues.append({"level": "error", "code": "unknown_layout_seed", "renderer_id": renderer_id, "layout_seed_id": seed_id})
|
||||
if recipe_id and recipe_id not in catalog_ids["recipes"]:
|
||||
issues.append({"level": "error", "code": "unknown_visual_recipe", "renderer_id": renderer_id, "visual_recipe_id": recipe_id})
|
||||
for chart_type in list_text(item.get("chart_types")):
|
||||
if chart_type not in catalog_ids["chart_types"]:
|
||||
issues.append({"level": "error", "code": "unknown_chart_type", "renderer_id": renderer_id, "chart_type": chart_type})
|
||||
if not list_text(item.get("style_reskin_hooks")):
|
||||
issues.append({"level": "warning", "code": "missing_style_reskin_hooks", "renderer_id": renderer_id})
|
||||
if not list_text(item.get("required_primitives")):
|
||||
issues.append({"level": "warning", "code": "missing_required_primitives", "renderer_id": renderer_id})
|
||||
elif status == "candidate":
|
||||
candidate_count += 1
|
||||
|
||||
return {
|
||||
"schema_version": REGISTRY_SCHEMA_VERSION,
|
||||
"status": "passed" if not any(issue["level"] == "error" for issue in issues) else "failed",
|
||||
"summary": {
|
||||
"renderer_count": len(renderers),
|
||||
"active_count": active_count,
|
||||
"candidate_count": candidate_count,
|
||||
"active_seed_count": len({item for item in active_seed_ids if item}),
|
||||
"active_recipe_count": len({item for item in active_recipe_ids if item}),
|
||||
"active_page_kind_count": len({item for item in active_page_kinds if item}),
|
||||
"error_count": sum(1 for issue in issues if issue["level"] == "error"),
|
||||
"warning_count": sum(1 for issue in issues if issue["level"] == "warning"),
|
||||
},
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validate the SVGlide renderer registry.")
|
||||
parser.add_argument("--references-dir", default="", help="Override references directory")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON report")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
ref_dir = Path(args.references_dir).expanduser() if args.references_dir else references_dir()
|
||||
report = validate_registry(load_registry(ref_dir), load_catalog_ids(ref_dir))
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
else:
|
||||
summary = report["summary"]
|
||||
print(
|
||||
"renderer registry: "
|
||||
f"{report['status']} "
|
||||
f"({summary['active_count']} active, {summary['candidate_count']} candidate, "
|
||||
f"{summary['error_count']} errors, {summary['warning_count']} warnings)"
|
||||
)
|
||||
return 0 if report["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
54
skills/lark-slides/scripts/svglide_renderer_registry_test.py
Normal file
54
skills/lark-slides/scripts/svglide_renderer_registry_test.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import svglide_renderer_registry as registry
|
||||
|
||||
|
||||
class SVGlideRendererRegistryTest(unittest.TestCase):
|
||||
def test_registry_validates_active_renderers_against_catalogs(self) -> None:
|
||||
report = registry.validate_registry(registry.load_registry(), registry.load_catalog_ids())
|
||||
|
||||
self.assertEqual("passed", report["status"])
|
||||
self.assertGreaterEqual(report["summary"]["active_count"], 10)
|
||||
self.assertGreaterEqual(report["summary"]["active_page_kind_count"], 10)
|
||||
self.assertEqual(0, report["summary"]["error_count"])
|
||||
|
||||
def test_registry_has_no_external_reference_project_words(self) -> None:
|
||||
encoded = json.dumps(registry.load_registry(), ensure_ascii=False).lower()
|
||||
banned_tokens = [
|
||||
"ppt" + "-master",
|
||||
"ppt" + "_master",
|
||||
"ppt" + " master",
|
||||
"hugo" + "he3",
|
||||
"ppt" + "169",
|
||||
]
|
||||
|
||||
for token in banned_tokens:
|
||||
with self.subTest(token=token):
|
||||
self.assertNotIn(token, encoded)
|
||||
|
||||
def test_cli_json_report(self) -> None:
|
||||
stdout = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
exit_code = registry.main(["--json"])
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
report = json.loads(stdout.getvalue())
|
||||
self.assertEqual("svglide-renderer-registry/v1", report["schema_version"])
|
||||
self.assertEqual("passed", report["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
917
skills/lark-slides/scripts/svglide_strategist.py
Normal file
917
skills/lark-slides/scripts/svglide_strategist.py
Normal file
@@ -0,0 +1,917 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
CONTRACT_SCHEMA_VERSION = "svglide-strategist-contract/v1"
|
||||
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]+", re.IGNORECASE)
|
||||
HEX_COLOR_RE = re.compile(r"#[0-9A-Fa-f]{6}")
|
||||
|
||||
CANVAS = {"width": 960, "height": 540, "viewBox": "0 0 960 540"}
|
||||
SAFE_AREA = {"x": 48, "y": 40, "width": 864, "height": 460}
|
||||
DEFAULT_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",
|
||||
]
|
||||
|
||||
|
||||
PAGE_PROFILES: dict[str, dict[str, Any]] = {
|
||||
"cover": {
|
||||
"seed_id": "cover_hero_statement",
|
||||
"page_type": "cover",
|
||||
"composition_archetype": "full_bleed_field",
|
||||
"required_visual_evidence": ["full_page_archetype", "hero_route", "title_field"],
|
||||
"primary_motif": "hero_route",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "anchor",
|
||||
"renderer_id": "cover_hero_statement",
|
||||
"main_visual_anchor": {"layout_box_role": "visual", "description": "large thesis text paired with one abstract SVG motif"},
|
||||
"svg_effects": ["typography", "path"],
|
||||
"asset_id": "chart.vertical_list",
|
||||
"density_contract": "one thesis plus one visual motif",
|
||||
},
|
||||
"agenda": {
|
||||
"seed_id": "agenda_numbered_path",
|
||||
"page_type": "agenda",
|
||||
"composition_archetype": "indexed_path",
|
||||
"required_visual_evidence": ["numbered_path", "section_index", "semantic_labels"],
|
||||
"primary_motif": "numbered_route",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "breathing",
|
||||
"renderer_id": "agenda_numbered_path",
|
||||
"main_visual_anchor": {"layout_box_role": "timeline", "description": "numbered agenda route with compact section labels"},
|
||||
"svg_effects": ["typography", "connector_flow", "path"],
|
||||
"asset_id": "chart.agenda_list",
|
||||
"density_contract": "agenda route >= 4 section labels",
|
||||
},
|
||||
"section": {
|
||||
"seed_id": "section_divider_index",
|
||||
"page_type": "section_divider",
|
||||
"composition_archetype": "section_signal",
|
||||
"required_visual_evidence": ["section_index", "hero_signal", "full_page_archetype"],
|
||||
"primary_motif": "section_index",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "anchor",
|
||||
"renderer_id": "section_divider_index",
|
||||
"main_visual_anchor": {"layout_box_role": "visual", "description": "oversized chapter index paired with a full-page signal field"},
|
||||
"svg_effects": ["typography", "gradient"],
|
||||
"asset_id": "chart.numbered_steps",
|
||||
"density_contract": "one chapter signal plus one transition sentence",
|
||||
},
|
||||
"dashboard": {
|
||||
"seed_id": "dashboard_kpi_grid",
|
||||
"page_type": "kpi_overview",
|
||||
"composition_archetype": "data_stage",
|
||||
"required_visual_evidence": ["metric_hierarchy", "chart_geometry", "dashboard_grid"],
|
||||
"primary_motif": "metric_grid",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "dense",
|
||||
"renderer_id": "dashboard_kpi_grid",
|
||||
"main_visual_anchor": {"layout_box_role": "chart", "description": "KPI dashboard grid with hero metrics and micro trends"},
|
||||
"svg_effects": ["typography", "chart_geometry"],
|
||||
"asset_id": "chart.kpi_cards",
|
||||
"density_contract": "dashboard >= 4 metrics",
|
||||
},
|
||||
"roadmap": {
|
||||
"seed_id": "timeline_roadmap",
|
||||
"page_type": "roadmap",
|
||||
"composition_archetype": "layered_timeline",
|
||||
"required_visual_evidence": ["connector_flow", "phase_spine", "full_page_archetype"],
|
||||
"primary_motif": "phase_spine",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "dense",
|
||||
"renderer_id": "timeline_roadmap",
|
||||
"main_visual_anchor": {"layout_box_role": "timeline", "description": "milestone spine with compact phase labels"},
|
||||
"svg_effects": ["typography", "connector_flow", "path"],
|
||||
"asset_id": "chart.timeline",
|
||||
"density_contract": "timeline >= 3 milestones",
|
||||
},
|
||||
"process": {
|
||||
"seed_id": "process_pipeline",
|
||||
"page_type": "process_flow",
|
||||
"composition_archetype": "layered_timeline",
|
||||
"required_visual_evidence": ["connector_flow", "flow_lanes", "full_page_archetype"],
|
||||
"primary_motif": "flow_route",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "dense",
|
||||
"renderer_id": "process_pipeline",
|
||||
"main_visual_anchor": {"layout_box_role": "flow", "description": "left-to-right process path with input and output anchors"},
|
||||
"svg_effects": ["typography", "connector_flow", "path"],
|
||||
"asset_id": "chart.process_flow",
|
||||
"density_contract": "process path >= 4 steps",
|
||||
},
|
||||
"comparison": {
|
||||
"seed_id": "comparison_two_column_decision",
|
||||
"page_type": "comparison",
|
||||
"composition_archetype": "comparison_matrix",
|
||||
"required_visual_evidence": ["decision_matrix", "contrast_panels", "semantic_labels"],
|
||||
"primary_motif": "decision_axis",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "dense",
|
||||
"renderer_id": "comparison_two_column_decision",
|
||||
"main_visual_anchor": {"layout_box_role": "table", "description": "two-column decision matrix with dimension rail"},
|
||||
"svg_effects": ["typography", "path"],
|
||||
"asset_id": "chart.comparison_table",
|
||||
"density_contract": "comparison table >= 4 cells",
|
||||
},
|
||||
"capability": {
|
||||
"seed_id": "capability_icon_map",
|
||||
"page_type": "capability_map",
|
||||
"composition_archetype": "radial_system",
|
||||
"required_visual_evidence": ["hub_spoke", "sector_field", "semantic_labels"],
|
||||
"primary_motif": "radial_hub",
|
||||
"chart_type": "hub_spoke",
|
||||
"page_rhythm": "dense",
|
||||
"renderer_id": "capability_icon_map",
|
||||
"main_visual_anchor": {"layout_box_role": "visual", "description": "central capability node with surrounding module grid"},
|
||||
"svg_effects": ["typography", "connector_flow"],
|
||||
"asset_id": "chart.hub_spoke",
|
||||
"density_contract": "capability map >= 4 nodes",
|
||||
},
|
||||
"chart": {
|
||||
"seed_id": "single_chart_takeaway",
|
||||
"page_type": "chart_takeaway",
|
||||
"composition_archetype": "data_stage",
|
||||
"required_visual_evidence": ["chart_geometry", "insight_strip", "full_page_archetype"],
|
||||
"primary_motif": "takeaway_chart",
|
||||
"chart_type": "bar_chart",
|
||||
"page_rhythm": "dense",
|
||||
"renderer_id": "single_chart_takeaway",
|
||||
"main_visual_anchor": {"layout_box_role": "chart", "description": "single chart area with one takeaway annotation"},
|
||||
"svg_effects": ["typography", "chart_geometry"],
|
||||
"asset_id": "chart.bar_chart",
|
||||
"density_contract": "chart >= 3 visible marks",
|
||||
},
|
||||
"closing": {
|
||||
"seed_id": "closing_summary",
|
||||
"page_type": "closing",
|
||||
"composition_archetype": "closing_manifesto",
|
||||
"required_visual_evidence": ["closing_ribbon", "action_cards", "full_page_archetype"],
|
||||
"primary_motif": "closing_route",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "anchor",
|
||||
"renderer_id": "closing_summary",
|
||||
"main_visual_anchor": {"layout_box_role": "callout", "description": "closing statement plus next-action callout"},
|
||||
"svg_effects": ["typography"],
|
||||
"asset_id": "chart.numbered_steps",
|
||||
"density_contract": "one closing message plus one next action",
|
||||
},
|
||||
"annotation": {
|
||||
"seed_id": "spotlight_diagnosis_callout",
|
||||
"page_type": "insight_callout",
|
||||
"composition_archetype": "annotated_spotlight",
|
||||
"required_visual_evidence": ["spotlight", "annotation", "semantic_labels"],
|
||||
"primary_motif": "spotlight_field",
|
||||
"chart_type": "",
|
||||
"page_rhythm": "breathing",
|
||||
"renderer_id": "spotlight_diagnosis_callout",
|
||||
"main_visual_anchor": {"layout_box_role": "spotlight", "description": "annotated visual field with one spotlight and side note"},
|
||||
"svg_effects": ["typography", "spotlight"],
|
||||
"asset_id": "chart.labeled_card",
|
||||
"density_contract": "spotlight callout <= 2 targets",
|
||||
},
|
||||
}
|
||||
|
||||
KEYWORD_PROFILES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("agenda", ("agenda", "contents", "table of contents", "toc", "目录", "议程")),
|
||||
("section", ("section", "chapter", "section divider", "divider", "transition", "章节", "过渡", "转场", "第1章", "第2章", "第3章", "01 ", "02 ", "03 ")),
|
||||
("dashboard", ("dashboard", "kpi", "metric", "metrics", "status", "scorecard", "health", "看板", "指标", "状态")),
|
||||
("roadmap", ("roadmap", "timeline", "milestone", "phase", "plan", "规划", "里程碑", "阶段")),
|
||||
("process", ("process", "pipeline", "workflow", "flow", "funnel", "步骤", "流程", "链路")),
|
||||
("comparison", ("compare", "comparison", "versus", "vs", "matrix", "table", "decision", "对比", "比较", "矩阵")),
|
||||
("capability", ("capability", "module", "architecture", "system", "hub", "spoke", "能力", "模块", "架构")),
|
||||
("chart", ("chart", "bar", "line", "trend", "data", "evidence", "数据", "图表", "趋势")),
|
||||
("closing", ("closing", "summary", "next", "thanks", "q&a", "结尾", "总结", "下一步")),
|
||||
("cover", ("cover", "opening", "title", "thesis", "封面", "开场", "标题")),
|
||||
)
|
||||
|
||||
STYLE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("raw_grid", ("dashboard", "kpi", "metric", "technical", "dense", "status", "ops", "operation", "看板", "指标")),
|
||||
("long_table", ("process", "pipeline", "responsibility", "plan", "workflow", "流程", "责任")),
|
||||
("riptide_cobalt", ("technology", "architecture", "flow", "system", "tech", "技术", "架构")),
|
||||
("data_journalism_editorial", ("financial", "market", "report", "data", "analysis", "finance", "数据", "分析")),
|
||||
("monochrome", ("serious", "formal", "minimal", "decision", "compare", "正式", "决策")),
|
||||
)
|
||||
|
||||
VISUAL_STYLE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("operational_dashboard", ("dashboard", "kpi", "metric", "status", "ops", "operation", "看板", "指标", "运营")),
|
||||
("technical_system", ("technology", "architecture", "system", "platform", "infra", "technical", "技术", "架构", "系统", "平台")),
|
||||
("data_journalism", ("financial", "market", "capital", "report", "analysis", "data", "finance", "资本", "市场", "数据", "分析")),
|
||||
("premium_regional", ("regional", "city", "residence", "planning", "oasis", "water", "culture", "城市", "居住", "策划", "绿洲", "水系", "地域", "人文", "阿克苏", "新疆")),
|
||||
("process_playbook", ("process", "pipeline", "workflow", "roadmap", "playbook", "流程", "链路", "路线", "规划")),
|
||||
("executive_minimal", ("serious", "formal", "minimal", "decision", "board", "strategy", "正式", "决策", "战略")),
|
||||
)
|
||||
|
||||
IMAGE_STRATEGY_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("provided", ("attachment", "provided image", "source image", "uploaded", "附件", "已提供图片", "素材包")),
|
||||
("web", ("web search", "search image", "website", "online", "网页", "联网", "搜索图片")),
|
||||
("ai", ("generate image", "ai image", "illustration", "rendering", "生成图片", "插画", "效果图")),
|
||||
)
|
||||
|
||||
NARRATIVE_MODES = {"briefing", "instructional", "narrative", "pyramid", "showcase"}
|
||||
|
||||
MODE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("instructional", ("training", "tutorial", "course", "lesson", "how-to", "playbook", "教学", "培训", "教程")),
|
||||
("pyramid", ("decision", "board", "strategy", "proposal", "investment", "consulting", "决策", "战略", "提案", "投资")),
|
||||
("showcase", ("launch", "showcase", "portfolio", "brand", "event", "product reveal", "发布", "展示", "作品集", "品牌")),
|
||||
("narrative", ("story", "journey", "case", "vision", "future", "体验", "旅程", "故事", "案例", "愿景")),
|
||||
("briefing", ("briefing", "review", "report", "status", "operations", "weekly", "汇报", "报告", "复盘", "经营", "周报")),
|
||||
)
|
||||
|
||||
|
||||
def script_path() -> Path:
|
||||
return Path(__file__).resolve()
|
||||
|
||||
|
||||
def references_dir() -> Path:
|
||||
return script_path().parents[1] / "references"
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def clone_json(value: Any) -> Any:
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def tokenize(*values: object) -> set[str]:
|
||||
tokens: set[str] = set()
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
tokens.update(tokenize(*value.values()))
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
tokens.update(tokenize(*value))
|
||||
continue
|
||||
tokens.update(match.group(0).lower() for match in TOKEN_RE.finditer(str(value)))
|
||||
return tokens
|
||||
|
||||
|
||||
def compact_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, dict):
|
||||
return " ".join(compact_text(item) for item in value.values() if compact_text(item))
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return " ".join(compact_text(item) for item in value if compact_text(item))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def load_catalogs(ref_dir: Path | None = None) -> dict[str, Any]:
|
||||
root = ref_dir or references_dir()
|
||||
style_data = read_json(root / "style-presets.json")
|
||||
seed_data = read_json(root / "svg-seeds.json")
|
||||
recipe_data = read_json(root / "svg-recipes.json")
|
||||
pattern_data = read_json(root / "svglide-design-pattern-map.json")
|
||||
renderer_path = root / "svglide-renderer-registry.json"
|
||||
renderer_data = read_json(renderer_path) if renderer_path.exists() else {"renderers": []}
|
||||
renderers = {
|
||||
item["id"]: item
|
||||
for item in renderer_data.get("renderers", [])
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item.get("id")
|
||||
}
|
||||
return {
|
||||
"style_presets": {item["style_id"]: item for item in style_data.get("presets", []) if isinstance(item, dict) and item.get("style_id")},
|
||||
"seeds": seed_data.get("seeds", {}),
|
||||
"recipes": recipe_data.get("recipes", {}),
|
||||
"chart_type_contracts": recipe_data.get("chart_type_contracts", {}),
|
||||
"pattern_ids": {item.get("id") for item in pattern_data.get("resources", []) if isinstance(item, dict) and item.get("id")},
|
||||
"renderers": renderers,
|
||||
}
|
||||
|
||||
|
||||
def first_present(data: dict[str, Any], keys: tuple[str, ...]) -> str:
|
||||
for key in keys:
|
||||
value = compact_text(data.get(key))
|
||||
if value:
|
||||
return value
|
||||
visual_plan = data.get("visual_plan")
|
||||
if isinstance(visual_plan, dict):
|
||||
for key in keys:
|
||||
value = compact_text(visual_plan.get(key))
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def classify_profile(text: str, *, index: int, total: int) -> str:
|
||||
lowered = text.lower()
|
||||
if index == 0 and any(keyword in lowered for keyword in ("cover", "opening", "title", "thesis", "封面", "开场")):
|
||||
return "cover"
|
||||
if total > 1 and index == total - 1 and any(keyword in lowered for keyword in ("closing", "summary", "next", "thanks", "q&a", "结尾", "总结", "下一步")):
|
||||
return "closing"
|
||||
for profile, keywords in KEYWORD_PROFILES:
|
||||
if any(keyword in lowered for keyword in keywords):
|
||||
return profile
|
||||
if index == 0:
|
||||
return "cover"
|
||||
if total > 1 and index == total - 1:
|
||||
return "closing"
|
||||
return "annotation"
|
||||
|
||||
|
||||
def style_preset_from_brief(brief: str, catalogs: dict[str, Any]) -> str:
|
||||
lowered = brief.lower()
|
||||
for style_id, keywords in STYLE_HINTS:
|
||||
if style_id in catalogs["style_presets"] and any(keyword in lowered for keyword in keywords):
|
||||
return style_id
|
||||
return "raw_grid" if "raw_grid" in catalogs["style_presets"] else sorted(catalogs["style_presets"])[0]
|
||||
|
||||
|
||||
def narrative_mode_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
|
||||
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
|
||||
tokens = tokenize(lowered)
|
||||
for mode, keywords in MODE_HINTS:
|
||||
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
|
||||
return mode
|
||||
return "briefing"
|
||||
|
||||
|
||||
def visual_style_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
|
||||
explicit = normalize_public_token(first_present(slide_plan or {}, ("visual_style", "visualStyle", "style_target")))
|
||||
if explicit:
|
||||
return explicit
|
||||
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
|
||||
tokens = tokenize(lowered)
|
||||
for style, keywords in VISUAL_STYLE_HINTS:
|
||||
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
|
||||
return style
|
||||
return "data_journalism" if "data" in tokens else "executive_minimal"
|
||||
|
||||
|
||||
def image_strategy_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
|
||||
existing = slide_plan.get("asset_strategy") if isinstance(slide_plan, dict) else None
|
||||
if isinstance(existing, dict):
|
||||
mode = normalize_public_token(existing.get("mode") or existing.get("image_strategy") or existing.get("imageStrategy"))
|
||||
if mode in {"provided", "web", "ai", "svg", "none", "authoring_preview_rich", "online_pure_fallback", "production_asset_strict"}:
|
||||
return mode
|
||||
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
|
||||
tokens = tokenize(lowered)
|
||||
for mode, keywords in IMAGE_STRATEGY_HINTS:
|
||||
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
|
||||
return mode
|
||||
return "svg"
|
||||
|
||||
|
||||
def normalize_public_token(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9_]+", "_", compact_text(value).strip().lower()).strip("_")
|
||||
|
||||
|
||||
def style_system_from_preset(style_id: str, catalogs: dict[str, Any]) -> dict[str, Any]:
|
||||
preset = catalogs["style_presets"].get(style_id, {})
|
||||
palette = preset.get("palette") if isinstance(preset.get("palette"), dict) else {}
|
||||
shape_language = preset.get("shape_language") if isinstance(preset.get("shape_language"), dict) else {}
|
||||
density = preset.get("density") if isinstance(preset.get("density"), dict) else {}
|
||||
return {
|
||||
"palette": {
|
||||
"background": palette.get("background", "#F5F5F5"),
|
||||
"text": palette.get("text", "#0A0A0A"),
|
||||
"accent": palette.get("accent", "#2563EB"),
|
||||
"support": clone_json(palette.get("support", [])),
|
||||
},
|
||||
"typography": "strong title, readable native text labels",
|
||||
"background_strategy": shape_language.get("panel_treatment", "structured panels with explicit text surfaces"),
|
||||
"motif": shape_language.get("texture", "local SVG motif derived from selected page recipe"),
|
||||
"density": {
|
||||
"text_density": density.get("text_density", "medium"),
|
||||
"label_density": density.get("label_density", "medium"),
|
||||
"connector_density": density.get("connector_density", "medium"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_brief_palette(style_system: dict[str, Any], brief: str) -> dict[str, Any]:
|
||||
colors: list[str] = []
|
||||
for match in HEX_COLOR_RE.finditer(brief):
|
||||
color = match.group(0).upper()
|
||||
if color not in colors:
|
||||
colors.append(color)
|
||||
if not colors:
|
||||
return style_system
|
||||
output = clone_json(style_system)
|
||||
palette = output.setdefault("palette", {})
|
||||
if isinstance(palette, dict):
|
||||
palette["accent"] = colors[0]
|
||||
if len(colors) > 1:
|
||||
palette["support"] = colors[1:]
|
||||
output["palette_source"] = "brief_hex_colors"
|
||||
return output
|
||||
|
||||
|
||||
def slide_text(slide: dict[str, Any], fallback_description: str) -> str:
|
||||
parts = [
|
||||
first_present(slide, ("title", "headline", "name")),
|
||||
first_present(slide, ("description", "summary", "body", "key_message", "page_type", "chart_type", "visual_recipe")),
|
||||
fallback_description,
|
||||
]
|
||||
return " ".join(part for part in parts if part)
|
||||
|
||||
|
||||
def seed_for_profile(profile: str, catalogs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
seed_id = PAGE_PROFILES[profile]["seed_id"]
|
||||
seeds = catalogs["seeds"]
|
||||
if seed_id not in seeds:
|
||||
raise ValueError(f"missing SVG seed: {seed_id}")
|
||||
return seed_id, seeds[seed_id]
|
||||
|
||||
|
||||
def seed_for_slide(slide: dict[str, Any], profile: str, catalogs: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
||||
explicit_seed = compact_text(slide.get("seed_id"))
|
||||
seeds = catalogs["seeds"]
|
||||
if explicit_seed and explicit_seed in seeds:
|
||||
seed = seeds[explicit_seed]
|
||||
profile_data = dict(PAGE_PROFILES[profile])
|
||||
profile_data["seed_id"] = explicit_seed
|
||||
return explicit_seed, seed, profile_data
|
||||
|
||||
chart_type = compact_text(slide.get("chart_type")).replace("-", "_").lower()
|
||||
visual_recipe = compact_text(slide.get("visual_recipe")).replace("-", "_").lower()
|
||||
for candidate, data in PAGE_PROFILES.items():
|
||||
if chart_type and chart_type == data["chart_type"]:
|
||||
seed_id, seed = seed_for_profile(candidate, catalogs)
|
||||
return seed_id, seed, data
|
||||
if visual_recipe and seeds.get(data["seed_id"], {}).get("visual_recipe") == visual_recipe:
|
||||
seed_id, seed = seed_for_profile(candidate, catalogs)
|
||||
return seed_id, seed, data
|
||||
|
||||
seed_id, seed = seed_for_profile(profile, catalogs)
|
||||
return seed_id, seed, PAGE_PROFILES[profile]
|
||||
|
||||
|
||||
def list_union(*values: Any) -> list[str]:
|
||||
out: list[str] = []
|
||||
for value in values:
|
||||
if isinstance(value, str):
|
||||
candidates = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
candidates = [str(item) for item in value if str(item).strip()]
|
||||
else:
|
||||
candidates = []
|
||||
for item in candidates:
|
||||
if item not in out:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def setdefault_clone(target: dict[str, Any], key: str, value: Any) -> None:
|
||||
if key not in target or target[key] in (None, "", []):
|
||||
target[key] = clone_json(value)
|
||||
|
||||
|
||||
def normalize_empty_chart_type(value: Any) -> str:
|
||||
normalized = compact_text(value).replace("-", "_").lower()
|
||||
return "" if normalized in {"none", "na", "n_a", "not_applicable", "no_chart", "no"} else normalized
|
||||
|
||||
|
||||
def source_pack_from_plan(brief: str, output: dict[str, Any]) -> dict[str, Any]:
|
||||
existing = output.get("source_pack")
|
||||
if isinstance(existing, dict):
|
||||
return clone_json(existing)
|
||||
status = "user_prompt_only" if compact_text(brief or output) else "missing"
|
||||
return {
|
||||
"schema_version": "svglide-source-pack/v1",
|
||||
"source_status": status,
|
||||
"numeric_claim_policy": "cite_or_remove",
|
||||
"items": [
|
||||
{
|
||||
"id": "brief",
|
||||
"type": "user_prompt",
|
||||
"status": "available" if status != "missing" else "missing",
|
||||
"source_ref": "source/brief.md",
|
||||
"usage_pages": "all",
|
||||
"license": "user_provided",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def source_pack_item_ids(source_pack: dict[str, Any]) -> set[str]:
|
||||
items = source_pack.get("items")
|
||||
if not isinstance(items, list):
|
||||
return set()
|
||||
return {compact_text(item.get("id")) for item in items if isinstance(item, dict) and compact_text(item.get("id"))}
|
||||
|
||||
|
||||
def source_pack_status(source_pack: dict[str, Any]) -> str:
|
||||
status = compact_text(source_pack.get("source_status"))
|
||||
if status:
|
||||
return status
|
||||
items = source_pack.get("items")
|
||||
if isinstance(items, list) and items:
|
||||
return "user_provided"
|
||||
return "missing"
|
||||
|
||||
|
||||
def asset_strategy_contract(image_strategy: str) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "authoring_preview_rich" if image_strategy in {"provided", "web", "ai"} else "online_pure_fallback",
|
||||
"image_strategy": image_strategy,
|
||||
"fallback": "prefer SVGlide-safe vector primitives when source or license is unavailable",
|
||||
}
|
||||
|
||||
|
||||
def icon_policy_contract() -> dict[str, Any]:
|
||||
return {
|
||||
"style": "single_consistent_family",
|
||||
"semantic_mapping_required": True,
|
||||
"consistency_rule": "one deck uses one icon stroke/fill language; icons must label concepts rather than decorate empty space",
|
||||
}
|
||||
|
||||
|
||||
def chart_policy_contract() -> dict[str, Any]:
|
||||
return {
|
||||
"selection_rule": "data_relationship_first",
|
||||
"requires_data_coordinate_check": True,
|
||||
"receipt": "receipts/chart-verify.json",
|
||||
}
|
||||
|
||||
|
||||
def strategy_locks_from_contract(
|
||||
*,
|
||||
output: dict[str, Any],
|
||||
page_count: int,
|
||||
narrative_mode: str,
|
||||
visual_style: str,
|
||||
style_id: str,
|
||||
image_strategy: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
existing = output.get("strategy_locks")
|
||||
if isinstance(existing, list) and existing:
|
||||
return clone_json(existing)
|
||||
audience = compact_text(output.get("audience")) or "inferred_from_brief"
|
||||
return [
|
||||
{"id": "canvas", "decision": clone_json(CANVAS), "evidence_ref": "plan.canvas"},
|
||||
{"id": "page_count", "decision": page_count, "evidence_ref": "plan.page_count"},
|
||||
{"id": "audience", "decision": audience, "evidence_ref": "plan.audience"},
|
||||
{"id": "narrative_mode", "decision": narrative_mode, "evidence_ref": "plan.narrative_mode"},
|
||||
{"id": "visual_style", "decision": visual_style, "evidence_ref": "plan.visual_style"},
|
||||
{"id": "style_preset", "decision": style_id, "evidence_ref": "plan.style_preset"},
|
||||
{"id": "asset_strategy", "decision": image_strategy, "evidence_ref": "plan.asset_strategy.mode"},
|
||||
{"id": "chart_policy", "decision": "data_relationship_first", "evidence_ref": "plan.chart_policy"},
|
||||
]
|
||||
|
||||
|
||||
def chart_decision_for_slide(completed: dict[str, Any], profile_data: dict[str, Any]) -> dict[str, Any]:
|
||||
chart_type = compact_text(completed.get("chart_type"))
|
||||
anchor = completed.get("main_visual_anchor")
|
||||
anchor_role = ""
|
||||
if isinstance(anchor, dict):
|
||||
anchor_role = compact_text(anchor.get("layout_box_role") or anchor.get("role"))
|
||||
if not chart_type:
|
||||
return {
|
||||
"status": "not_required",
|
||||
"chart_type": "",
|
||||
"reason": "page visual carrier is not a data chart",
|
||||
"data_ref": "brief",
|
||||
"anchor_role": anchor_role,
|
||||
"bbox_tolerance_px": 12,
|
||||
}
|
||||
return {
|
||||
"status": "required",
|
||||
"chart_type": chart_type,
|
||||
"reason": f"{chart_type} fits {profile_data['page_type']} because the page needs {profile_data['density_contract']}",
|
||||
"data_ref": "brief",
|
||||
"anchor_role": anchor_role or "chart",
|
||||
"bbox_tolerance_px": 12,
|
||||
}
|
||||
|
||||
|
||||
def chart_verification_for_slide(chart_decision: dict[str, Any]) -> dict[str, Any]:
|
||||
if chart_decision.get("status") != "required":
|
||||
return {"status": "not_required"}
|
||||
return {
|
||||
"status": "required",
|
||||
"method": "verify data-to-geometry mapping for visible chart marks",
|
||||
"receipt": "receipts/chart-verify.json",
|
||||
"checks": ["plot_area", "mark_count", "label_alignment", "scale_mapping"],
|
||||
}
|
||||
|
||||
|
||||
def asset_selection_reason(profile_data: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"{profile_data['asset_id']} is selected for {profile_data['page_type']} because it provides "
|
||||
f"{profile_data['composition_archetype']} geometry and supports {profile_data['density_contract']}."
|
||||
)
|
||||
|
||||
|
||||
def rejected_asset_alternatives(profile_data: dict[str, Any]) -> list[dict[str, str]]:
|
||||
page_type = profile_data["page_type"]
|
||||
alternatives: list[dict[str, str]] = []
|
||||
if page_type not in {"kpi_overview", "chart_takeaway"}:
|
||||
alternatives.append({"asset_id": "chart.kpi_cards", "reason": "rejected because the page is not metric-led"})
|
||||
if page_type not in {"roadmap", "process_flow", "agenda"}:
|
||||
alternatives.append({"asset_id": "chart.timeline", "reason": "rejected because a phase spine would weaken the page purpose"})
|
||||
if page_type not in {"capability_map"}:
|
||||
alternatives.append({"asset_id": "chart.hub_spoke", "reason": "rejected because the page does not need a central-system relationship"})
|
||||
return alternatives[:2]
|
||||
|
||||
|
||||
def known_profile_evidence() -> set[str]:
|
||||
evidence: set[str] = set()
|
||||
for profile in PAGE_PROFILES.values():
|
||||
raw = profile.get("required_visual_evidence")
|
||||
if isinstance(raw, list):
|
||||
evidence.update(str(item) for item in raw if str(item).strip())
|
||||
return evidence
|
||||
|
||||
|
||||
def complete_visual_design_contract(completed: dict[str, Any], profile_data: dict[str, Any]) -> dict[str, Any]:
|
||||
existing = completed.get("visual_design_contract")
|
||||
contract = clone_json(existing) if isinstance(existing, dict) else {}
|
||||
thesis = first_present(completed, ("visual_thesis", "key_message", "one_idea", "title", "headline", "description"))
|
||||
existing_required = list_union(contract.get("required_visual_evidence"))
|
||||
manual_required = [item for item in existing_required if item not in known_profile_evidence()]
|
||||
required = list_union(manual_required, profile_data.get("required_visual_evidence"))
|
||||
pattern_bundle = list_union(contract.get("pattern_bundle"), profile_data.get("asset_id"))
|
||||
setdefault_clone(contract, "schema_version", "svglide-visual-design-contract/v1")
|
||||
setdefault_clone(contract, "page_kind", profile_data["page_type"])
|
||||
setdefault_clone(contract, "visual_thesis", thesis)
|
||||
setdefault_clone(contract, "composition_archetype", profile_data["composition_archetype"])
|
||||
contract["pattern_bundle"] = pattern_bundle
|
||||
setdefault_clone(contract, "density", profile_data["page_rhythm"])
|
||||
setdefault_clone(contract, "primary_motif", profile_data["primary_motif"])
|
||||
contract["required_visual_evidence"] = required
|
||||
setdefault_clone(contract, "renderer_id", profile_data["renderer_id"])
|
||||
setdefault_clone(contract, "layout_seed_id", completed.get("seed_id"))
|
||||
setdefault_clone(contract, "visual_recipe", completed.get("visual_recipe"))
|
||||
return contract
|
||||
|
||||
|
||||
def complete_slide(slide: dict[str, Any], *, brief: str, fallback_description: str, index: int, total: int, catalogs: dict[str, Any]) -> dict[str, Any]:
|
||||
completed = clone_json(slide)
|
||||
if "chart_type" in completed:
|
||||
completed["chart_type"] = normalize_empty_chart_type(completed.get("chart_type"))
|
||||
text = slide_text(completed, fallback_description) or brief
|
||||
profile = classify_profile(text, index=index, total=total)
|
||||
seed_id, seed, profile_data = seed_for_slide(completed, profile, catalogs)
|
||||
renderer_contract = catalogs.get("renderers", {}).get(profile_data["renderer_id"], {})
|
||||
recipe = compact_text(completed.get("visual_recipe")) or compact_text(seed.get("visual_recipe"))
|
||||
recipe_contract = catalogs["recipes"].get(recipe, {})
|
||||
required_primitives = list_union(recipe_contract.get("required_primitives"), seed.get("required_primitives"), completed.get("required_primitives"))
|
||||
svg_primitives = list_union(completed.get("svg_primitives"), required_primitives, ["typography", "geometric_shape"])
|
||||
|
||||
setdefault_clone(completed, "page", index + 1)
|
||||
setdefault_clone(completed, "key_message", first_present(completed, ("key_message", "one_idea", "title", "headline", "description")) or text)
|
||||
setdefault_clone(completed, "renderer_id", profile_data["renderer_id"])
|
||||
if isinstance(renderer_contract, dict) and renderer_contract:
|
||||
setdefault_clone(completed, "renderer_registry_status", renderer_contract.get("status"))
|
||||
setdefault_clone(completed, "runtime_renderer_family", renderer_contract.get("runtime_renderer_family"))
|
||||
setdefault_clone(completed, "style_reskin_hooks", renderer_contract.get("style_reskin_hooks", []))
|
||||
setdefault_clone(completed, "page_rhythm", profile_data["page_rhythm"])
|
||||
setdefault_clone(completed, "page_type", profile_data["page_type"])
|
||||
setdefault_clone(completed, "chart_type", profile_data["chart_type"])
|
||||
setdefault_clone(completed, "main_visual_anchor", profile_data["main_visual_anchor"])
|
||||
setdefault_clone(completed, "seed_id", seed_id)
|
||||
completed["layout_skeleton_id"] = clone_json(seed.get("layout_skeleton", {}).get("id", f"{seed_id}_skeleton"))
|
||||
completed["layout_family"] = clone_json(seed.get("layout_family", profile))
|
||||
setdefault_clone(completed, "visual_recipe", recipe)
|
||||
setdefault_clone(completed, "visual_signature", f"{profile_data['page_type']} / {profile_data['renderer_id']} / {profile_data['asset_id']}")
|
||||
completed["svg_effects"] = clone_json(profile_data["svg_effects"])
|
||||
completed["layout_boxes"] = clone_json(seed.get("layout_boxes", []))
|
||||
completed["content_budget"] = clone_json(seed.get("content_budget", {}))
|
||||
completed["text_capacity"] = clone_json(seed.get("default_text_capacity") or seed.get("content_budget", {}))
|
||||
completed["text_budget_by_role"] = clone_json(seed.get("text_budget_by_role", {}))
|
||||
completed["reserved_bands"] = clone_json(seed.get("reserved_bands", {}))
|
||||
completed["footer_safe_zone"] = clone_json(seed.get("footer_safe_zone", {}))
|
||||
completed["vertical_text_policy"] = clone_json(seed.get("vertical_text_policy", {"mode": "deny", "allowed_roles": [], "max_chars": 0, "max_lines": 0}))
|
||||
setdefault_clone(
|
||||
completed,
|
||||
"reference_asset",
|
||||
{
|
||||
"source": "svglide_design_pattern",
|
||||
"asset_id": profile_data["asset_id"],
|
||||
"usage": "page-type geometry only; do not copy raw SVG paths",
|
||||
},
|
||||
)
|
||||
setdefault_clone(completed, "asset_selection_reason", asset_selection_reason(profile_data))
|
||||
setdefault_clone(completed, "rejected_asset_alternatives", rejected_asset_alternatives(profile_data))
|
||||
setdefault_clone(completed, "visual_intent", f"use {recipe.replace('_', ' ')} structure to make the page readable as SVG-native content")
|
||||
setdefault_clone(completed, "visual_focal_point", profile_data["main_visual_anchor"])
|
||||
setdefault_clone(completed, "required_primitives", required_primitives)
|
||||
setdefault_clone(completed, "svg_primitives", svg_primitives)
|
||||
setdefault_clone(completed, "xml_like_risk", "without the declared SVG primitives this page would degrade into ordinary text boxes")
|
||||
setdefault_clone(completed, "content_density_contract", profile_data["density_contract"])
|
||||
setdefault_clone(completed, "asset_contract", "none_required")
|
||||
setdefault_clone(completed, "risk_flags", [])
|
||||
setdefault_clone(completed, "source_refs", ["brief"])
|
||||
setdefault_clone(completed, "source_status", "user_prompt_only")
|
||||
setdefault_clone(completed, "source_policy", "when source material is missing, mark missing evidence and avoid numeric claims")
|
||||
setdefault_clone(completed, "speaker_intent", f"Advance the deck by proving: {completed.get('key_message')}")
|
||||
chart_decision = chart_decision_for_slide(completed, profile_data)
|
||||
setdefault_clone(completed, "chart_decision", chart_decision)
|
||||
setdefault_clone(completed, "chart_verification", chart_verification_for_slide(completed.get("chart_decision", chart_decision)))
|
||||
setdefault_clone(completed, "layout_guardrails", DEFAULT_GUARDRAILS)
|
||||
completed["visual_design_contract"] = complete_visual_design_contract(completed, profile_data)
|
||||
return completed
|
||||
|
||||
|
||||
def normalize_slide_inputs(slide_plan: dict[str, Any] | None, page_descriptions: list[str]) -> list[dict[str, Any]]:
|
||||
plan = slide_plan or {}
|
||||
raw_slides = plan.get("slides")
|
||||
if isinstance(raw_slides, list) and raw_slides:
|
||||
slides = [clone_json(item) for item in raw_slides if isinstance(item, dict)]
|
||||
else:
|
||||
pages = plan.get("pages")
|
||||
slides = [clone_json(item) for item in pages if isinstance(item, dict)] if isinstance(pages, list) and pages else []
|
||||
if not slides:
|
||||
slides = [{"description": description} for description in page_descriptions]
|
||||
if not slides:
|
||||
slides = [{"description": "Cover: core message"}, {"description": "Closing: next steps"}]
|
||||
for index, description in enumerate(page_descriptions):
|
||||
if index >= len(slides):
|
||||
slides.append({"description": description})
|
||||
elif description and not compact_text(slides[index].get("description")):
|
||||
slides[index]["description"] = description
|
||||
return slides
|
||||
|
||||
|
||||
def selected_asset_id(slide: dict[str, Any]) -> str:
|
||||
reference = slide.get("reference_asset")
|
||||
if isinstance(reference, dict):
|
||||
asset_id = compact_text(reference.get("asset_id") or reference.get("id"))
|
||||
if asset_id:
|
||||
return asset_id
|
||||
asset_id = compact_text(slide.get("asset_id") or slide.get("design_pattern_id"))
|
||||
return asset_id
|
||||
|
||||
|
||||
def deck_rhythm_from_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rhythm: list[dict[str, Any]] = []
|
||||
for index, slide in enumerate(slides, 1):
|
||||
item = {
|
||||
"page": slide.get("page", index),
|
||||
"rhythm": compact_text(slide.get("page_rhythm")) or "breathing",
|
||||
"page_type": compact_text(slide.get("page_type")) or "content",
|
||||
}
|
||||
rhythm.append(item)
|
||||
return rhythm
|
||||
|
||||
|
||||
def build_design_pattern_selection(slides: list[dict[str, Any]], existing: Any, catalogs: dict[str, Any]) -> dict[str, Any]:
|
||||
selection = clone_json(existing) if isinstance(existing, dict) else {}
|
||||
raw_assets = selection.get("selected_assets")
|
||||
selected_assets: list[dict[str, Any]] = [clone_json(item) for item in raw_assets if isinstance(item, dict)] if isinstance(raw_assets, list) else []
|
||||
for asset in selected_assets:
|
||||
setdefault_clone(asset, "copy_policy", "derive_contract_only")
|
||||
setdefault_clone(asset, "selection_reason", "selected because it matches a slide page_type, visual_recipe, and density contract")
|
||||
seen = {compact_text(asset.get("id") or asset.get("asset_id")) for asset in selected_assets}
|
||||
pattern_ids = catalogs.get("pattern_ids", set())
|
||||
for slide in slides:
|
||||
asset_id = selected_asset_id(slide)
|
||||
if not asset_id or asset_id in seen or (pattern_ids and asset_id not in pattern_ids):
|
||||
continue
|
||||
reason = compact_text(slide.get("asset_selection_reason")) or "selected because it matches the slide page_type, visual_recipe, and density contract"
|
||||
selected_assets.append(
|
||||
{
|
||||
"id": asset_id,
|
||||
"kind": "chart_template",
|
||||
"usage": "geometry_contract",
|
||||
"copy_policy": "derive_contract_only",
|
||||
"selection_reason": reason,
|
||||
"rejected_alternatives": clone_json(slide.get("rejected_asset_alternatives", [])),
|
||||
}
|
||||
)
|
||||
seen.add(asset_id)
|
||||
setdefault_clone(selection, "schema_version", "svglide-design-pattern-selection/v1")
|
||||
setdefault_clone(selection, "mode", "local_contract")
|
||||
setdefault_clone(selection, "selected_assets", selected_assets)
|
||||
setdefault_clone(selection, "proof_status", "pending_component_report")
|
||||
return selection
|
||||
|
||||
|
||||
def build_contract(
|
||||
*,
|
||||
brief: str = "",
|
||||
slide_plan: dict[str, Any] | None = None,
|
||||
page_descriptions: list[str] | None = None,
|
||||
ref_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
catalogs = load_catalogs(ref_dir)
|
||||
descriptions = page_descriptions or []
|
||||
source_plan = clone_json(slide_plan) if isinstance(slide_plan, dict) else {}
|
||||
output = source_plan
|
||||
slides = normalize_slide_inputs(source_plan, descriptions)
|
||||
completed_slides = [
|
||||
complete_slide(slide, brief=brief, fallback_description=descriptions[index] if index < len(descriptions) else "", index=index, total=len(slides), catalogs=catalogs)
|
||||
for index, slide in enumerate(slides)
|
||||
]
|
||||
|
||||
style_id = compact_text(output.get("style_preset")) or style_preset_from_brief(brief or compact_text(output), catalogs)
|
||||
if style_id not in catalogs["style_presets"]:
|
||||
style_id = style_preset_from_brief(brief or compact_text(output), catalogs)
|
||||
explicit_mode = compact_text(output.get("narrative_mode") or output.get("mode"))
|
||||
narrative_mode = explicit_mode if explicit_mode in NARRATIVE_MODES else narrative_mode_from_brief(brief, output)
|
||||
visual_style = visual_style_from_brief(brief, output)
|
||||
image_strategy = image_strategy_from_brief(brief, output)
|
||||
source_pack = source_pack_from_plan(brief, output)
|
||||
|
||||
setdefault_clone(output, "schema_version", CONTRACT_SCHEMA_VERSION)
|
||||
setdefault_clone(output, "output_mode", "svglide-svg")
|
||||
output["mode"] = narrative_mode
|
||||
setdefault_clone(output, "narrative_mode", narrative_mode)
|
||||
output["visual_style"] = visual_style
|
||||
setdefault_clone(output, "canvas", CANVAS)
|
||||
setdefault_clone(output, "safe_area", SAFE_AREA)
|
||||
output["style_preset"] = style_id
|
||||
setdefault_clone(output, "style_selection_reason", f"{style_id} matches the brief and the selected SVG page recipes")
|
||||
output["style_system"] = apply_brief_palette(style_system_from_preset(style_id, catalogs), brief)
|
||||
output["source_pack"] = source_pack
|
||||
setdefault_clone(output, "input_profile", {"input_type": "topic", "source_status": source_pack_status(source_pack)})
|
||||
setdefault_clone(output, "source_brief", {"path": "source/brief.md", "evidence_index": "source/evidence.json", "numeric_claim_policy": "cite_or_remove"})
|
||||
setdefault_clone(output, "asset_strategy", asset_strategy_contract(image_strategy))
|
||||
setdefault_clone(output, "icon_policy", icon_policy_contract())
|
||||
setdefault_clone(output, "chart_policy", chart_policy_contract())
|
||||
output["strategy_locks"] = strategy_locks_from_contract(
|
||||
output=output,
|
||||
page_count=len(completed_slides),
|
||||
narrative_mode=narrative_mode,
|
||||
visual_style=visual_style,
|
||||
style_id=style_id,
|
||||
image_strategy=image_strategy,
|
||||
)
|
||||
setdefault_clone(
|
||||
output,
|
||||
"acceptance_criteria",
|
||||
[
|
||||
{"id": "source_refs_resolved", "status": "planned", "gate": "preflight"},
|
||||
{"id": "chart_anchor_aligned", "status": "planned", "gate": "preflight"},
|
||||
{"id": "preview_score_passed", "status": "planned", "gate": "preview_lint"},
|
||||
{"id": "quality_gate_passed", "status": "planned", "gate": "quality_gate"},
|
||||
],
|
||||
)
|
||||
for slide in completed_slides:
|
||||
contract = slide.get("visual_design_contract")
|
||||
if isinstance(contract, dict):
|
||||
setdefault_clone(contract, "style_preset", style_id)
|
||||
output["slides"] = completed_slides
|
||||
output["page_count"] = len(completed_slides)
|
||||
setdefault_clone(output, "page_rhythm", deck_rhythm_from_slides(completed_slides))
|
||||
output["design_pattern_selection"] = build_design_pattern_selection(completed_slides, output.get("design_pattern_selection"), catalogs)
|
||||
return output
|
||||
|
||||
|
||||
def read_text_arg(path: str | None, inline: str | None) -> str:
|
||||
parts: list[str] = []
|
||||
if inline:
|
||||
parts.append(inline)
|
||||
if path:
|
||||
parts.append(Path(path).expanduser().read_text(encoding="utf-8"))
|
||||
return "\n".join(part.strip() for part in parts if part.strip())
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Build or complete a conservative SVGlide plan contract.")
|
||||
parser.add_argument("--brief", help="path to a brief text file")
|
||||
parser.add_argument("--brief-text", help="inline brief text")
|
||||
parser.add_argument("--plan", help="existing slide_plan.json to complete")
|
||||
parser.add_argument("--page-description", action="append", default=[], help="simple page description; may be repeated")
|
||||
parser.add_argument("--output", help="output JSON path; defaults to stdout")
|
||||
parser.add_argument("--in-place", action="store_true", help="write the completed contract back to --plan")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.in_place and not args.plan:
|
||||
parser.error("--in-place requires --plan")
|
||||
|
||||
brief = read_text_arg(args.brief, args.brief_text)
|
||||
slide_plan = read_json(Path(args.plan).expanduser()) if args.plan else None
|
||||
if slide_plan is not None and not isinstance(slide_plan, dict):
|
||||
raise ValueError("--plan must point to a JSON object")
|
||||
|
||||
result = build_contract(brief=brief, slide_plan=slide_plan, page_descriptions=args.page_description)
|
||||
output_path = Path(args.output).expanduser() if args.output else (Path(args.plan).expanduser() if args.in_place else None)
|
||||
if output_path:
|
||||
write_json(output_path, result)
|
||||
else:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
265
skills/lark-slides/scripts/svglide_strategist_test.py
Normal file
265
skills/lark-slides/scripts/svglide_strategist_test.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# 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
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import svglide_strategist as strategist
|
||||
|
||||
|
||||
class SVGlideStrategistTest(unittest.TestCase):
|
||||
def test_build_contract_from_brief_and_page_descriptions(self) -> None:
|
||||
plan = strategist.build_contract(
|
||||
brief="Create an operations review with KPI dashboard, roadmap, and closing next steps.",
|
||||
page_descriptions=[
|
||||
"Cover: operating thesis and review scope",
|
||||
"KPI dashboard: four health metrics with micro trends",
|
||||
"Roadmap: three milestone phases and ownership",
|
||||
"Closing: next steps and decision request",
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual("svglide-svg", plan["output_mode"])
|
||||
self.assertEqual("briefing", plan["mode"])
|
||||
self.assertEqual("briefing", plan["narrative_mode"])
|
||||
self.assertEqual("operational_dashboard", plan["visual_style"])
|
||||
self.assertEqual("svg", plan["asset_strategy"]["image_strategy"])
|
||||
self.assertEqual("svglide-source-pack/v1", plan["source_pack"]["schema_version"])
|
||||
self.assertEqual("user_prompt_only", plan["source_pack"]["source_status"])
|
||||
self.assertEqual(8, len(plan["strategy_locks"]))
|
||||
self.assertEqual(
|
||||
["canvas", "page_count", "audience", "narrative_mode", "visual_style", "style_preset", "asset_strategy", "chart_policy"],
|
||||
[item["id"] for item in plan["strategy_locks"]],
|
||||
)
|
||||
self.assertEqual("raw_grid", plan["style_preset"])
|
||||
self.assertEqual({"background", "text", "accent"}, set(plan["style_system"]["palette"]).intersection({"background", "text", "accent"}))
|
||||
self.assertEqual(4, plan["page_count"])
|
||||
self.assertEqual(
|
||||
[
|
||||
{"page": 1, "rhythm": "anchor", "page_type": "cover"},
|
||||
{"page": 2, "rhythm": "dense", "page_type": "kpi_overview"},
|
||||
{"page": 3, "rhythm": "dense", "page_type": "roadmap"},
|
||||
{"page": 4, "rhythm": "anchor", "page_type": "closing"},
|
||||
],
|
||||
plan["page_rhythm"],
|
||||
)
|
||||
self.assertEqual(["chart.vertical_list", "chart.kpi_cards", "chart.timeline", "chart.numbered_steps"], [asset["id"] for asset in plan["design_pattern_selection"]["selected_assets"]])
|
||||
self.assertTrue(all(asset["copy_policy"] == "derive_contract_only" for asset in plan["design_pattern_selection"]["selected_assets"]))
|
||||
self.assertTrue(all(asset["selection_reason"] for asset in plan["design_pattern_selection"]["selected_assets"]))
|
||||
|
||||
dashboard = plan["slides"][1]
|
||||
self.assertEqual("KPI dashboard: four health metrics with micro trends", dashboard["key_message"])
|
||||
self.assertEqual("dense", dashboard["page_rhythm"])
|
||||
self.assertEqual("kpi_overview", dashboard["page_type"])
|
||||
self.assertEqual("", dashboard["chart_type"])
|
||||
self.assertEqual({"layout_box_role": "chart", "description": "KPI dashboard grid with hero metrics and micro trends"}, dashboard["main_visual_anchor"])
|
||||
self.assertEqual("kpi_overview / dashboard_kpi_grid / chart.kpi_cards", dashboard["visual_signature"])
|
||||
self.assertEqual(["typography", "chart_geometry"], dashboard["svg_effects"])
|
||||
self.assertEqual("fake_ui_dashboard", dashboard["visual_recipe"])
|
||||
self.assertEqual("chart.kpi_cards", dashboard["reference_asset"]["asset_id"])
|
||||
self.assertEqual(["brief"], dashboard["source_refs"])
|
||||
self.assertIn("chart.kpi_cards is selected", dashboard["asset_selection_reason"])
|
||||
self.assertEqual("not_required", dashboard["chart_decision"]["status"])
|
||||
self.assertIn("main text and chart labels stay inside safe area", dashboard["layout_guardrails"])
|
||||
self.assertEqual(
|
||||
{
|
||||
"schema_version": "svglide-visual-design-contract/v1",
|
||||
"page_kind": "kpi_overview",
|
||||
"visual_thesis": "KPI dashboard: four health metrics with micro trends",
|
||||
"composition_archetype": "data_stage",
|
||||
"pattern_bundle": ["chart.kpi_cards"],
|
||||
"density": "dense",
|
||||
"primary_motif": "metric_grid",
|
||||
"required_visual_evidence": ["metric_hierarchy", "chart_geometry", "dashboard_grid"],
|
||||
"renderer_id": "dashboard_kpi_grid",
|
||||
"layout_seed_id": "dashboard_kpi_grid",
|
||||
"visual_recipe": "fake_ui_dashboard",
|
||||
"style_preset": "raw_grid",
|
||||
},
|
||||
dashboard["visual_design_contract"],
|
||||
)
|
||||
|
||||
chart_contracts = strategist.load_catalogs()["chart_type_contracts"]
|
||||
allowed_rhythms = {"anchor", "breathing", "dense"}
|
||||
for slide in plan["slides"]:
|
||||
self.assertIn(slide["page_rhythm"], allowed_rhythms)
|
||||
if slide["chart_type"]:
|
||||
self.assertIn(slide["chart_type"], chart_contracts)
|
||||
|
||||
def test_brief_hex_colors_drive_style_system_palette(self) -> None:
|
||||
plan = strategist.build_contract(
|
||||
brief="新疆阿克苏城区居住区策划案,主色澄澈水蓝 #4A90E2,辅色春芽嫩绿 #8BC34A,强调色艾德莱斯绸朱红 #E91E63。",
|
||||
page_descriptions=["Cover: 以水为脉·四时为序"],
|
||||
)
|
||||
|
||||
palette = plan["style_system"]["palette"]
|
||||
self.assertEqual("#4A90E2", palette["accent"])
|
||||
self.assertEqual(["#8BC34A", "#E91E63"], palette["support"])
|
||||
self.assertEqual("brief_hex_colors", plan["style_system"]["palette_source"])
|
||||
|
||||
def test_visual_style_does_not_pollute_narrative_mode(self) -> None:
|
||||
plan = strategist.build_contract(
|
||||
brief="Global AI capital market data report.",
|
||||
slide_plan={
|
||||
"mode": "data_journalism",
|
||||
"visual_style": "data_journalism",
|
||||
"slides": [{"title": "Capital Flow", "description": "chart page with bar chart", "chart_type": "bar_chart"}],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("briefing", plan["mode"])
|
||||
self.assertEqual("briefing", plan["narrative_mode"])
|
||||
self.assertEqual("data_journalism", plan["visual_style"])
|
||||
slide = plan["slides"][0]
|
||||
self.assertEqual("required", slide["chart_decision"]["status"])
|
||||
self.assertEqual("bar_chart", slide["chart_decision"]["chart_type"])
|
||||
self.assertTrue(slide["chart_decision"]["reason"])
|
||||
self.assertEqual("brief", slide["chart_decision"]["data_ref"])
|
||||
self.assertEqual("required", slide["chart_verification"]["status"])
|
||||
|
||||
def test_complete_existing_plan_preserves_manual_fields(self) -> None:
|
||||
base = {
|
||||
"title": "Pipeline Decision",
|
||||
"style_preset": "monochrome",
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"title": "Keep this title",
|
||||
"page_type": "custom_decision",
|
||||
"chart_type": "comparison_table",
|
||||
"visual_recipe": "geometric_composition",
|
||||
"reference_asset": {"source": "manual", "asset_id": "chart.comparison_table", "usage": "approved"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
plan = strategist.build_contract(brief="Compare two launch options.", slide_plan=base)
|
||||
slide = plan["slides"][0]
|
||||
|
||||
self.assertEqual("monochrome", plan["style_preset"])
|
||||
self.assertEqual("pyramid", plan["mode"])
|
||||
self.assertEqual("Keep this title", slide["title"])
|
||||
self.assertEqual("custom_decision", slide["page_type"])
|
||||
self.assertEqual("comparison_table", slide["chart_type"])
|
||||
self.assertEqual("geometric_composition", slide["visual_recipe"])
|
||||
self.assertEqual({"source": "manual", "asset_id": "chart.comparison_table", "usage": "approved"}, slide["reference_asset"])
|
||||
self.assertIn("layout_boxes", slide)
|
||||
self.assertIn("footer_safe_zone", slide)
|
||||
self.assertIn("layout_guardrails", slide)
|
||||
|
||||
def test_existing_plan_refreshes_seed_derived_contract_fields(self) -> None:
|
||||
base = {
|
||||
"slides": [
|
||||
{
|
||||
"description": "KPI dashboard with secondary metric cards and chart labels",
|
||||
"seed_id": "dashboard_kpi_grid",
|
||||
"layout_boxes": [
|
||||
{"id": "title", "role": "title", "x": 48, "y": 34, "width": 864, "height": 48},
|
||||
{"id": "primary-kpi", "role": "metric", "x": 64, "y": 106, "width": 260, "height": 128},
|
||||
{"id": "secondary-grid", "role": "grid", "x": 348, "y": 106, "width": 548, "height": 128},
|
||||
],
|
||||
"text_budget_by_role": {
|
||||
"metric": {"max_chars": 80, "max_lines": 3, "max_boxes": 1, "min_font_px": 12},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plan = strategist.build_contract(brief="KPI dashboard", slide_plan=base)
|
||||
slide = plan["slides"][0]
|
||||
|
||||
self.assertIn("secondary-metric-grid", [box["id"] for box in slide["layout_boxes"]])
|
||||
self.assertEqual(7, slide["text_budget_by_role"]["metric"]["max_boxes"])
|
||||
self.assertEqual(4, slide["text_budget_by_role"]["chart"]["max_boxes"])
|
||||
|
||||
def test_normalizes_none_chart_type_to_empty_contract(self) -> None:
|
||||
plan = strategist.build_contract(
|
||||
brief="Brand showcase opening.",
|
||||
slide_plan={"slides": [{"title": "Opening", "chart_type": "none"}]},
|
||||
)
|
||||
|
||||
self.assertEqual("", plan["slides"][0]["chart_type"])
|
||||
|
||||
def test_classifies_agenda_and_section_as_first_class_profiles(self) -> None:
|
||||
plan = strategist.build_contract(
|
||||
brief="新疆阿克苏城区居住区策划案,包含目录和章节过渡。",
|
||||
page_descriptions=[
|
||||
"Cover: 以水为脉·四时为序",
|
||||
"目录:项目核心定位、春之地块、夏之地块、秋之地块、冬之地块",
|
||||
"章节过渡页:01 项目核心定位与愿景",
|
||||
],
|
||||
)
|
||||
|
||||
agenda = plan["slides"][1]
|
||||
section = plan["slides"][2]
|
||||
|
||||
self.assertEqual("agenda", agenda["page_type"])
|
||||
self.assertEqual("agenda_numbered_path", agenda["seed_id"])
|
||||
self.assertEqual("indexed_path", agenda["visual_design_contract"]["composition_archetype"])
|
||||
self.assertEqual(["numbered_path", "section_index", "semantic_labels"], agenda["visual_design_contract"]["required_visual_evidence"])
|
||||
self.assertEqual("chart.agenda_list", agenda["reference_asset"]["asset_id"])
|
||||
|
||||
self.assertEqual("section_divider", section["page_type"])
|
||||
self.assertEqual("section_divider_index", section["seed_id"])
|
||||
self.assertEqual("section_signal", section["visual_design_contract"]["composition_archetype"])
|
||||
self.assertEqual(["section_index", "hero_signal", "full_page_archetype"], section["visual_design_contract"]["required_visual_evidence"])
|
||||
self.assertEqual("chart.numbered_steps", section["reference_asset"]["asset_id"])
|
||||
|
||||
def test_cli_reads_text_and_plan_and_writes_clean_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
brief_path = tmp / "brief.txt"
|
||||
plan_path = tmp / "slide_plan.json"
|
||||
out_path = tmp / "contract.json"
|
||||
brief_path.write_text("Technical KPI dashboard for weekly status.", encoding="utf-8")
|
||||
plan_path.write_text(json.dumps({"slides": [{"title": "Status", "description": "KPI dashboard with micro bars"}]}), encoding="utf-8")
|
||||
|
||||
exit_code = strategist.main(["--brief", str(brief_path), "--plan", str(plan_path), "--output", str(out_path)])
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
result = json.loads(out_path.read_text(encoding="utf-8"))
|
||||
encoded = json.dumps(result, ensure_ascii=False).lower()
|
||||
self.assertEqual("fake_ui_dashboard", result["slides"][0]["visual_recipe"])
|
||||
self.assertNotIn("source" + "_token", encoded)
|
||||
self.assertNotIn("beautiful" + "-feishu-whiteboard", encoded)
|
||||
self.assertNotIn("ppt" + "-master", encoded)
|
||||
self.assertNotIn("hugo" + "he3", encoded)
|
||||
|
||||
def test_preserves_manual_visual_design_contract_and_fills_missing_fields(self) -> None:
|
||||
plan = strategist.build_contract(
|
||||
brief="AI capital market briefing with one chart.",
|
||||
slide_plan={
|
||||
"slides": [
|
||||
{
|
||||
"title": "Capital Flow",
|
||||
"chart_type": "bar_chart",
|
||||
"visual_design_contract": {
|
||||
"visual_thesis": "Manual thesis",
|
||||
"composition_archetype": "manual_data_stage",
|
||||
"required_visual_evidence": ["custom_evidence"],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
contract = plan["slides"][0]["visual_design_contract"]
|
||||
|
||||
self.assertEqual("Manual thesis", contract["visual_thesis"])
|
||||
self.assertEqual("manual_data_stage", contract["composition_archetype"])
|
||||
self.assertEqual(["custom_evidence", "chart_geometry", "insight_strip", "full_page_archetype"], contract["required_visual_evidence"])
|
||||
self.assertEqual(["chart.bar_chart"], contract["pattern_bundle"])
|
||||
self.assertEqual("dense", contract["density"])
|
||||
self.assertEqual("takeaway_chart", contract["primary_motif"])
|
||||
self.assertEqual(plan["style_preset"], contract["style_preset"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,17 +14,28 @@ import (
|
||||
|
||||
// skillsEmbedFS embeds each skill's agent-readable content (SKILL.md +
|
||||
// references/, plus lark-whiteboard's routes/ and scenes/) so the CLI serves
|
||||
// content matching the binary version; machine-resource dirs (assets/, scripts/)
|
||||
// are excluded, saving ~3.3 MB. It's a whitelist — a new subdirectory type is
|
||||
// silently omitted until added here.
|
||||
// content matching the binary version. Machine-resource dirs remain excluded by
|
||||
// default; lark-slides SVG runtime scripts are explicitly embedded because
|
||||
// create-svg can execute them in packaged CLI installs.
|
||||
//
|
||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes
|
||||
//go:embed skills/lark-slides/scripts/svg_rasterize_effects.py
|
||||
//go:embed skills/lark-slides/scripts/svg_effect_classifier.py
|
||||
//go:embed skills/lark-slides/scripts/svg_safe_rewrite.py
|
||||
//go:embed skills/lark-slides/scripts/svg_raster_renderer.py
|
||||
//go:embed skills/lark-slides/scripts/svglide_project_runner.py
|
||||
//go:embed skills/lark-slides/scripts/svg_preflight.py
|
||||
//go:embed skills/lark-slides/scripts/svg_preview_lint.py
|
||||
//go:embed skills/lark-slides/scripts/svglide_asset_selector.py
|
||||
//go:embed skills/lark-slides/scripts/svglide_strategist.py
|
||||
//go:embed skills/lark-slides/scripts/svglide_gen_runtime.py
|
||||
//go:embed skills/lark-slides/scripts/svglide_golden_suite.py
|
||||
var skillsEmbedFS embed.FS
|
||||
|
||||
// init wires the embedded tree in as the default skill content. It compiles into
|
||||
// `go build .` but not the single-file preview build (`go build ./main.go`), so
|
||||
// main.go stays self-contained and that build still compiles (shipping no
|
||||
// embedded skills). Assembly failure warns on stderr rather than panicking.
|
||||
// init wires the embedded tree in as the default skill content. Packaged builds
|
||||
// must compile the package (`go build .`) so this file is included; otherwise
|
||||
// skill commands have no embedded runtime content. Assembly failure warns on
|
||||
// stderr rather than panicking.
|
||||
func init() {
|
||||
sub, err := fs.Sub(skillsEmbedFS, "skills")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
# Slides CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 2 leaf commands
|
||||
- Covered: 1
|
||||
- Denominator: 4 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 50.0%
|
||||
|
||||
## Summary
|
||||
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
|
||||
- TestSlidesCreateSVGDryRunRequestShape: locks the `slides +create-svg --dry-run` request chain and recursive SVGlide validation, including `g` containers, geometry-required leaves, `px` geometry, nested defs/filter, shadow style preservation, and `foreignObject` XHTML `<br />`.
|
||||
- TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute: proves CLI blocks leaf shapes that would otherwise reach the server as `shape missing required attribute`.
|
||||
- TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry: proves CLI blocks non-absolute geometry before issuing API calls.
|
||||
- TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand: proves CLI blocks unsupported path commands before issuing API calls.
|
||||
- TestSlidesCreateSVGWorkflowAsUser: opt-in live workflow for `slides +create-svg` (`LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1`); creates a two-page SVG deck as user, asserts returned page count and IDs, then reads the presentation back.
|
||||
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
|
||||
- Blocked area: `slides +replace-slide` has focused unit coverage but no E2E workflow yet.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
|
||||
| ✓ | slides +create-svg | shortcut | slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRequestShape; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingChildRole; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand; slides_create_svg_live_test.go::TestSlidesCreateSVGWorkflowAsUser | repeated `--file`; `--title`; `--dry-run` | dry-run proves existing `/slide` route, `slide.content`, recursive SVGlide validation, server-known hard failures, numeric geometry gates, and path command gates before API call; live is opt-in and depends on the target server lane containing the updated SVGlide parser |
|
||||
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |
|
||||
| ✕ | slides +replace-slide | shortcut | | none | unit-covered shortcut; E2E workflow still pending |
|
||||
|
||||
159
tests/cli_e2e/slides/slides_create_svg_dryrun_test.go
Normal file
159
tests/cli_e2e/slides/slides_create_svg_dryrun_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setSlidesDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRequestShape(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><style>.primary{fill:#123456;stroke:#654321;stroke-width:2px;filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2))}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><g transform="translate(20 30)"><rect slide:role="shape" class="primary" x="0" y="0" width="320px" height="180px" filter="url(#shadow)"/></g></svg>`), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<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"><div xmlns="http://www.w3.org/1999/xhtml">two<br />lines</div></foreignObject></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", "Dry SVG",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<rect slide:role="shape"`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<g transform="translate(20 30)">`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<style>.primary`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<feDropShadow`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `slide:shape-type="text"`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `<br />`, "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsMissingChildRole(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect x="80" y="80" width="320" height="180"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect> must include slide:role="shape" or slide:role="image"`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<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" height="180"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect slide:role="shape"> missing required attribute "width"`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<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="50%" height="180"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `attribute "width" must be a number or px length`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `unsupported path command or character "A"`)
|
||||
}
|
||||
115
tests/cli_e2e/slides/slides_create_svg_live_test.go
Normal file
115
tests/cli_e2e/slides/slides_create_svg_live_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
if os.Getenv("LARKSUITE_CLI_RUN_SVGLIDE_LIVE") != "1" {
|
||||
t.Skip("set LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1 to run the live SVGlide service contract test")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><g fill="#E8EEF8" transform="translate(80 80)"><rect slide:role="shape" x="0" y="0" width="360" height="180"/></g></svg>`), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<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="120" y="120" width="420" height="100"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide live E2E</p></foreignObject></svg>`), 0o644))
|
||||
|
||||
parentT := t
|
||||
title := "svglide-e2e-" + clie2e.GenerateSuffix()
|
||||
var presentationID string
|
||||
|
||||
t.Run("create SVG deck as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", title,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
if created := extractSVGlideFailurePresentationID(result.Stderr); created != "" {
|
||||
presentationID = created
|
||||
registerSlidesCleanup(parentT, presentationID)
|
||||
}
|
||||
}
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
|
||||
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, title, gjson.Get(result.Stdout, "data.title").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.slides_added").Int(), "stdout:\n%s", result.Stdout)
|
||||
require.Len(t, gjson.Get(result.Stdout, "data.slide_ids").Array(), 2, "stdout:\n%s", result.Stdout)
|
||||
|
||||
registerSlidesCleanup(parentT, presentationID)
|
||||
})
|
||||
|
||||
t.Run("read back SVG-created presentation as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, presentationID, "presentation should be created before readback")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"revision_id": -1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
require.Equal(t, presentationID, gjson.Get(result.Stdout, "data.xml_presentation.presentation_id").String(), "stdout:\n%s", result.Stdout)
|
||||
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
|
||||
require.Contains(t, content, "<title>"+title+"</title>", "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
|
||||
func registerSlidesCleanup(t *testing.T, presentationID string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+delete",
|
||||
"--file-token", presentationID,
|
||||
"--type", "slides",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, "delete presentation "+presentationID, deleteResult, deleteErr)
|
||||
})
|
||||
}
|
||||
|
||||
func extractSVGlideFailurePresentationID(stderr string) string {
|
||||
const marker = "presentation "
|
||||
idx := strings.Index(stderr, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := stderr[idx+len(marker):]
|
||||
end := strings.IndexByte(rest, ' ')
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(rest[:end], ".,;:)")
|
||||
}
|
||||
Reference in New Issue
Block a user