mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
39 Commits
feat/plugi
...
feat-ppe-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2f527fa0e | ||
|
|
d1bc349151 | ||
|
|
a448ce86de | ||
|
|
1a52644461 | ||
|
|
1cac7ddf8f | ||
|
|
bc1ffa88ed | ||
|
|
1358a255d4 | ||
|
|
aadb2af295 | ||
|
|
c0ea925ccc | ||
|
|
1f7e2b5a06 | ||
|
|
0f38113922 | ||
|
|
e7cdb91561 | ||
|
|
99e085731b | ||
|
|
fa204e5d6b | ||
|
|
6be69a89c6 | ||
|
|
463fc50fd9 | ||
|
|
6d1b0e818e | ||
|
|
569ea70d4b | ||
|
|
d32ab99b4c | ||
|
|
48562e339c | ||
|
|
7072a3259c | ||
|
|
588b36aaec | ||
|
|
ce0ba1f0fe | ||
|
|
3f3bf05c26 | ||
|
|
6c4db521b7 | ||
|
|
38813103bd | ||
|
|
fdcf285007 | ||
|
|
5628289fe9 | ||
|
|
eb460a23fa | ||
|
|
f26042d000 | ||
|
|
d2b7d3d4ca | ||
|
|
56de3bdd5c | ||
|
|
552b7ee7f4 | ||
|
|
84c19b83f0 | ||
|
|
85c07a990e | ||
|
|
a617d168c4 | ||
|
|
d7cd8e1b37 | ||
|
|
0e98163562 | ||
|
|
e3e5a0b723 |
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/dev-svglide-ppe-env.test.js scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
10
dev-plans/lark-slides/README.md
Normal file
10
dev-plans/lark-slides/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Lark Slides Development Plans
|
||||
|
||||
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
|
||||
|
||||
约束:
|
||||
|
||||
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
|
||||
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
|
||||
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
|
||||
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。
|
||||
1671
dev-plans/lark-slides/create-svglide-implementation-plan.zh.md
Normal file
1671
dev-plans/lark-slides/create-svglide-implementation-plan.zh.md
Normal file
File diff suppressed because it is too large
Load Diff
1475
dev-plans/lark-slides/svg-slides-local-generation-action-plan.zh.md
Normal file
1475
dev-plans/lark-slides/svg-slides-local-generation-action-plan.zh.md
Normal file
File diff suppressed because it is too large
Load Diff
1715
dev-plans/lark-slides/svglide-parser-semantic-generation-plan.zh.md
Normal file
1715
dev-plans/lark-slides/svglide-parser-semantic-generation-plan.zh.md
Normal file
File diff suppressed because it is too large
Load Diff
197
scripts/dev-svglide-ppe-env.sh
Executable file
197
scripts/dev-svglide-ppe-env.sh
Executable file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
RULE_GROUP="lark-cli"
|
||||
CANONICAL_RULE_NAME="lark-cli-openapi-ppe-svg-slides"
|
||||
PROXY_HOST="${LARK_CLI_PROXY_HOST:-127.0.0.1}"
|
||||
PROXY_PORT="${LARK_CLI_PROXY_PORT:-8899}"
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "[svglide-ppe] $*" >&2
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
printf '%s\n' "[svglide-ppe] $*" >&2
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage:
|
||||
. ./scripts/dev-svglide-ppe-env.sh pre
|
||||
. ./scripts/dev-svglide-ppe-env.sh off
|
||||
./scripts/dev-svglide-ppe-env.sh rules
|
||||
|
||||
Notes:
|
||||
- Use "source" or "." for pre/off so proxy env vars apply to the current shell.
|
||||
- pre writes the canonical Whistle rule "${CANONICAL_RULE_NAME}".
|
||||
- off disables only the canonical Whistle rule "${CANONICAL_RULE_NAME}".
|
||||
- The only PPE lane is "ppe_svg_slides".
|
||||
- This is dev-only PPE plumbing, not production publish behavior.
|
||||
USAGE
|
||||
}
|
||||
|
||||
is_sourced() {
|
||||
if [ -n "${BASH_VERSION-}" ]; then
|
||||
[ "${BASH_SOURCE[0]}" != "$0" ]
|
||||
return
|
||||
fi
|
||||
if [ -n "${ZSH_VERSION-}" ]; then
|
||||
case "${ZSH_EVAL_CONTEXT-}" in
|
||||
*:file:*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
pick_whistle_cmd() {
|
||||
if command -v w2 >/dev/null 2>&1; then
|
||||
printf '%s\n' "w2"
|
||||
return 0
|
||||
fi
|
||||
if command -v whistle >/dev/null 2>&1; then
|
||||
printf '%s\n' "whistle"
|
||||
return 0
|
||||
fi
|
||||
info "missing required command: w2 (or whistle)"
|
||||
info "install: npm install -g whistle"
|
||||
info "then: w2 start && w2 ca"
|
||||
fail "missing required command: w2 (or whistle)"
|
||||
}
|
||||
|
||||
ensure_whistle_running() {
|
||||
wcmd="$1"
|
||||
whistle_status="$("$wcmd" status 2>&1 || true)"
|
||||
case "$whistle_status" in
|
||||
*"No running Whistle instances"*)
|
||||
info "Whistle is not running, starting it now..."
|
||||
"$wcmd" start >/dev/null 2>&1 || fail "failed to start Whistle"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
canonical_rules() {
|
||||
cat <<'RULES'
|
||||
/^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_svg_slides
|
||||
https://open.feishu.cn/ reqHeaders://x-use-ppe=1
|
||||
https://open.feishu-pre.cn/ reqHeaders://Env=Pre_release
|
||||
https://open.feishu-pre.cn/ reqHeaders://x-tt-env=ppe_svg_slides
|
||||
https://open.feishu-pre.cn/ reqHeaders://x-use-ppe=1
|
||||
/^https:\/\/accounts\.feishu\.cn\/(.*)$/ https://accounts.feishu-pre.cn/$1
|
||||
RULES
|
||||
}
|
||||
|
||||
disabled_rules() {
|
||||
cat <<RULES
|
||||
# disabled by scripts/dev-svglide-ppe-env.sh
|
||||
# canonical SVGlide PPE rule: ${CANONICAL_RULE_NAME}
|
||||
RULES
|
||||
}
|
||||
|
||||
write_rule_js() {
|
||||
output="$1"
|
||||
rule_name="$2"
|
||||
rules="$3"
|
||||
|
||||
RULE_NAME="$rule_name" RULE_GROUP="$RULE_GROUP" RULES="$rules" node >"$output" <<'NODE'
|
||||
const rule = {
|
||||
name: process.env.RULE_NAME,
|
||||
groupName: process.env.RULE_GROUP,
|
||||
rules: process.env.RULES || "",
|
||||
};
|
||||
process.stdout.write(`module.exports = function (callback) {
|
||||
callback(${JSON.stringify(rule, null, 2)});
|
||||
};
|
||||
`);
|
||||
NODE
|
||||
}
|
||||
|
||||
create_temp_rule_js() {
|
||||
tmp_base="$(mktemp "${TMPDIR:-/tmp}/svglide-ppe-rule.XXXXXX")" || fail "failed to create temp file"
|
||||
tmp_js="${tmp_base}.js"
|
||||
mv "$tmp_base" "$tmp_js" || {
|
||||
rm -f "$tmp_base" "$tmp_js"
|
||||
fail "failed to create temp file"
|
||||
}
|
||||
printf '%s\n' "$tmp_js"
|
||||
}
|
||||
|
||||
apply_rule() {
|
||||
wcmd="$1"
|
||||
rule_name="$2"
|
||||
rules="$3"
|
||||
|
||||
tmp_js="$(create_temp_rule_js)" || return 1
|
||||
write_rule_js "$tmp_js" "$rule_name" "$rules"
|
||||
"$wcmd" add "$tmp_js" --force >/dev/null 2>&1 || {
|
||||
rm -f "$tmp_js"
|
||||
fail "failed to apply Whistle rule: ${rule_name}"
|
||||
}
|
||||
rm -f "$tmp_js"
|
||||
}
|
||||
|
||||
apply_proxy_env() {
|
||||
proxy_url="http://${PROXY_HOST}:${PROXY_PORT}"
|
||||
export HTTPS_PROXY="$proxy_url"
|
||||
export https_proxy="$proxy_url"
|
||||
export HTTP_PROXY="$proxy_url"
|
||||
export http_proxy="$proxy_url"
|
||||
export ALL_PROXY="$proxy_url"
|
||||
export all_proxy="$proxy_url"
|
||||
unset LARK_CLI_NO_PROXY
|
||||
info "proxy env enabled: ${proxy_url}"
|
||||
}
|
||||
|
||||
clear_proxy_env() {
|
||||
unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy
|
||||
unset LARK_CLI_NO_PROXY
|
||||
info "proxy env cleared"
|
||||
}
|
||||
|
||||
mode="${1:-}"
|
||||
[ "$mode" = "" ] && {
|
||||
usage
|
||||
return 0 2>/dev/null || exit 0
|
||||
}
|
||||
shift || true
|
||||
|
||||
[ "$#" -eq 0 ] || fail "unexpected args for mode ${mode}"
|
||||
|
||||
case "$mode" in
|
||||
rules)
|
||||
canonical_rules
|
||||
;;
|
||||
pre)
|
||||
wcmd="$(pick_whistle_cmd)"
|
||||
ensure_whistle_running "$wcmd"
|
||||
apply_rule "$wcmd" "$CANONICAL_RULE_NAME" "$(canonical_rules)"
|
||||
info "applied canonical Whistle rule: ${CANONICAL_RULE_NAME}"
|
||||
if is_sourced; then
|
||||
apply_proxy_env
|
||||
else
|
||||
info "Whistle rule updated, but current shell env is unchanged."
|
||||
info "run with source: . ./scripts/dev-svglide-ppe-env.sh pre"
|
||||
fi
|
||||
info "active PPE lane: ppe_svg_slides"
|
||||
;;
|
||||
off|cancel)
|
||||
wcmd="$(pick_whistle_cmd)"
|
||||
ensure_whistle_running "$wcmd"
|
||||
apply_rule "$wcmd" "$CANONICAL_RULE_NAME" "$(disabled_rules)"
|
||||
info "disabled canonical Whistle rule: ${CANONICAL_RULE_NAME}"
|
||||
if is_sourced; then
|
||||
clear_proxy_env
|
||||
else
|
||||
info "Whistle rules disabled, but current shell env is unchanged."
|
||||
info "run with source: . ./scripts/dev-svglide-ppe-env.sh off"
|
||||
fi
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
fail "invalid mode: ${mode}; expected pre|off|rules"
|
||||
;;
|
||||
esac
|
||||
116
scripts/dev-svglide-ppe-env.test.js
Normal file
116
scripts/dev-svglide-ppe-env.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function createMockWhistle() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "svglide-ppe-test-"));
|
||||
const binDir = path.join(tmp, "bin");
|
||||
const logPath = path.join(tmp, "w2.log");
|
||||
fs.mkdirSync(binDir);
|
||||
|
||||
fs.writeFileSync(path.join(binDir, "w2"), `#!/usr/bin/env sh
|
||||
case "$1" in
|
||||
status)
|
||||
printf '%s\\n' "Whistle is running"
|
||||
;;
|
||||
add)
|
||||
node -e 'require(process.argv[1])((rule) => console.log(JSON.stringify({ name: rule.name, rules: rule.rules })))' "$2" >>"$SVGLIDE_TEST_W2_LOG"
|
||||
;;
|
||||
start)
|
||||
printf '%s\\n' "START" >>"$SVGLIDE_TEST_W2_LOG"
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected w2 args: %s\\n' "$*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
`);
|
||||
fs.chmodSync(path.join(binDir, "w2"), 0o755);
|
||||
|
||||
return { binDir, logPath };
|
||||
}
|
||||
|
||||
function readLoggedRules(logPath) {
|
||||
return fs
|
||||
.readFileSync(logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
describe("dev-svglide-ppe-env", () => {
|
||||
it("keeps one canonical rule name in the script source", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "scripts/dev-svglide-ppe-env.sh"), "utf8");
|
||||
|
||||
assert.match(source, /CANONICAL_RULE_NAME="lark-cli-openapi-ppe-svg-slides"/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/lark-cli-openapi-env-switch|lark-cli-openapi-ppe-svglide|lark-cli-openapi-ppe-pure-svg|lark-cli-ppe-svglide|lark-cli-prod-ppe/
|
||||
);
|
||||
});
|
||||
|
||||
it("prints only the canonical SVG Slides PPE lane", () => {
|
||||
const result = spawnSync("sh", ["scripts/dev-svglide-ppe-env.sh", "rules"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /open\.feishu-pre\.cn/);
|
||||
assert.match(result.stdout, /x-tt-env=ppe_svg_slides/);
|
||||
assert.match(result.stdout, /x-use-ppe=1/);
|
||||
assert.doesNotMatch(result.stdout, /ppe_pure_svg|ppe_svglide/);
|
||||
});
|
||||
|
||||
it("keeps pre mode to one written Whistle rule", () => {
|
||||
const { binDir, logPath } = createMockWhistle();
|
||||
|
||||
const result = spawnSync("sh", ["-c", ". ./scripts/dev-svglide-ppe-env.sh pre"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
||||
SVGLIDE_TEST_W2_LOG: logPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const rules = readLoggedRules(logPath);
|
||||
assert.equal(rules.length, 1);
|
||||
assert.equal(rules[0].name, "lark-cli-openapi-ppe-svg-slides");
|
||||
assert.match(rules[0].rules, /x-tt-env=ppe_svg_slides/);
|
||||
assert.doesNotMatch(rules[0].rules, /ppe_pure_svg|ppe_svglide/);
|
||||
});
|
||||
|
||||
it("keeps off mode to one disabled canonical Whistle rule", () => {
|
||||
const { binDir, logPath } = createMockWhistle();
|
||||
|
||||
const result = spawnSync("sh", ["-c", ". ./scripts/dev-svglide-ppe-env.sh off"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
||||
SVGLIDE_TEST_W2_LOG: logPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const rules = readLoggedRules(logPath);
|
||||
assert.equal(rules.length, 1);
|
||||
assert.equal(rules[0].name, "lark-cli-openapi-ppe-svg-slides");
|
||||
assert.match(rules[0].rules, /disabled by scripts\/dev-svglide-ppe-env\.sh/);
|
||||
assert.match(rules[0].rules, /canonical SVGlide PPE rule: lark-cli-openapi-ppe-svg-slides/);
|
||||
assert.doesNotMatch(rules[0].rules, /legacy|ppe_pure_svg|ppe_svglide/);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVGlide,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
const (
|
||||
defaultPresentationWidth = 960
|
||||
defaultPresentationHeight = 540
|
||||
maxSlidesPerCreate = 10
|
||||
// maxSlidesPerCreate limits only inline +create --slides JSON payloads.
|
||||
// SVGlide manifest publishing has a separate manifest-file limit.
|
||||
maxSlidesPerCreate = 10
|
||||
)
|
||||
|
||||
// SlidesCreate creates a new Lark Slides presentation with bot auto-grant.
|
||||
@@ -39,7 +41,7 @@ var SlidesCreate = common.Shortcut{
|
||||
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "presentation title"},
|
||||
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
|
||||
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10 for inline XML; for more plain XML pages, create first then add via xml_presentation.slide.create; SVGlide manifests use +create-svglide). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if slidesStr := runtime.Str("slides"); slidesStr != "" {
|
||||
|
||||
164
shortcuts/slides/slides_create_svglide.go
Normal file
164
shortcuts/slides/slides_create_svglide.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVGlide publishes a validator-ready SVGlide manifest as raw SVG pages.
|
||||
var SlidesCreateSVGlide = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svglide",
|
||||
Description: "Create a Lark Slides presentation from an existing SVGlide manifest",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "manifest", Desc: "SVGlide manifest JSON file; version=svglide.manifest.v1, 1-50 pages; separate from +create --slides max 10", Required: true},
|
||||
{Name: "title", Desc: "presentation title override; defaults to manifest.title, then Untitled"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := prepareSVGlidePublishSpec(runtime, runtime.Str("manifest"), runtime.Str("title"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = planSVGlideImageRewrites(runtime, spec)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := prepareSVGlidePublishSpec(runtime, runtime.Str("manifest"), runtime.Str("title"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
imagePlan, err := planSVGlideImageRewrites(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
total := 1 + len(imagePlan.UniqueFiles) + len(spec.Pages)
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc(fmt.Sprintf("Create SVGlide presentation + add %d page(s)", len(spec.Pages))).
|
||||
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(spec.Title)},
|
||||
})
|
||||
for i, file := range imagePlan.UniqueFiles {
|
||||
appendSlidesUploadDryRun(dry, file.FilePath, "<xml_presentation_id>", i+2)
|
||||
}
|
||||
pageStepStart := 2 + len(imagePlan.UniqueFiles)
|
||||
for i, page := range spec.Pages {
|
||||
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
|
||||
Desc(fmt.Sprintf("[%d/%d] Add SVGlide page %d (%s)", pageStepStart+i, total, i+1, page.ID)).
|
||||
Params(map[string]interface{}{"revision_id": -1}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"content": fmt.Sprintf("<raw SVG omitted: source_sha256=%s source_bytes=%d>", page.SHA256, page.SourceBytes),
|
||||
"content_type": "svg",
|
||||
"source_bytes": page.SourceBytes,
|
||||
"source_sha256": page.SHA256,
|
||||
"submitted_bytes": "<computed after image upload>",
|
||||
"submitted_sha256": "<computed after image upload>",
|
||||
"content_omitted": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
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("manifest", runtime.Str("manifest")).Set("svglide_manifest_version", svglideManifestVersion)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := prepareSVGlidePublishSpec(runtime, runtime.Str("manifest"), runtime.Str("title"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imagePlan, err := planSVGlideImageRewrites(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(spec.Title)},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create-svglide returned no xml_presentation_id")
|
||||
}
|
||||
|
||||
submitPages, imageRewrite, err := uploadAndRewriteSVGlideImages(runtime, spec, imagePlan, presentationID)
|
||||
if err != nil {
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("uploading SVGlide image assets failed; presentation %s was created, no pages were added", presentationID))
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"title": spec.Title,
|
||||
"manifest": spec.ManifestPath,
|
||||
"svglide_manifest_version": svglideManifestVersion,
|
||||
"images_uploaded": imageRewrite.UploadedCount,
|
||||
"image_rewrites": imageRewrite.Entries,
|
||||
}
|
||||
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
|
||||
result["revision_id"] = int(revisionID)
|
||||
}
|
||||
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
slideIDs := make([]string, 0, len(submitPages))
|
||||
pageResults := make([]map[string]interface{}, 0, len(submitPages))
|
||||
for i, page := range submitPages {
|
||||
slideData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": -1},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": page.Content},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("adding SVGlide page %d/%d (%s) failed; presentation %s was created, %d page(s) added before failure", i+1, len(submitPages), page.ID, presentationID, i))
|
||||
}
|
||||
slideID := common.GetString(slideData, "slide_id")
|
||||
if slideID != "" {
|
||||
slideIDs = append(slideIDs, slideID)
|
||||
}
|
||||
pageResults = append(pageResults, map[string]interface{}{
|
||||
"id": page.ID,
|
||||
"index": page.Index,
|
||||
"file": page.File,
|
||||
"source_sha256": page.SHA256,
|
||||
"source_bytes": page.SourceBytes,
|
||||
"submitted_sha256": sha256Hex([]byte(page.Content)),
|
||||
"submitted_bytes": page.ContentBytes,
|
||||
"slide_id": slideID,
|
||||
})
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
result["slides_added"] = len(slideIDs)
|
||||
result["pages"] = pageResults
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else 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
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
276
shortcuts/slides/slides_create_svglide_assets.go
Normal file
276
shortcuts/slides/slides_create_svglide_assets.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type svglideImageRef struct {
|
||||
PageID string
|
||||
PageFile string
|
||||
AttrName string
|
||||
Href string
|
||||
}
|
||||
|
||||
type svglideImageFile struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type svglideResolvedImageRef struct {
|
||||
Ref svglideImageRef
|
||||
FilePath string
|
||||
FileName string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type svglideImagePlan struct {
|
||||
Refs []svglideResolvedImageRef
|
||||
UniqueFiles []svglideImageFile
|
||||
}
|
||||
|
||||
type svglideImageRewriteEntry struct {
|
||||
PageID string `json:"page_id"`
|
||||
PageFile string `json:"page_file"`
|
||||
AttrName string `json:"attr_name"`
|
||||
OriginalHref string `json:"original_href"`
|
||||
ResolvedFile string `json:"resolved_file"`
|
||||
FileToken string `json:"file_token"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type svglideImageRewriteResult struct {
|
||||
UploadedCount int `json:"uploaded_count"`
|
||||
Entries []svglideImageRewriteEntry `json:"entries"`
|
||||
}
|
||||
|
||||
func planSVGlideImageRewrites(runtime *common.RuntimeContext, spec *svglidePublishSpec) (svglideImagePlan, error) {
|
||||
var plan svglideImagePlan
|
||||
seenFiles := map[string]bool{}
|
||||
for _, page := range spec.Pages {
|
||||
refs, err := collectSVGlideImageRefs(page)
|
||||
if err != nil {
|
||||
return svglideImagePlan{}, err
|
||||
}
|
||||
for _, ref := range refs {
|
||||
resolved, err := resolveSVGlideImageRef(runtime, spec, page, ref)
|
||||
if err != nil {
|
||||
return svglideImagePlan{}, err
|
||||
}
|
||||
plan.Refs = append(plan.Refs, resolved)
|
||||
if !seenFiles[resolved.FilePath] {
|
||||
seenFiles[resolved.FilePath] = true
|
||||
plan.UniqueFiles = append(plan.UniqueFiles, svglideImageFile{
|
||||
FilePath: resolved.FilePath,
|
||||
FileName: resolved.FileName,
|
||||
Size: resolved.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func collectSVGlideImageRefs(page svglidePublishPage) ([]svglideImageRef, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader([]byte(page.Content)))
|
||||
var refs []svglideImageRef
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file invalid XML while scanning image hrefs: %s: %v", page.File, err).WithParam("--manifest").WithCause(err)
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "image" {
|
||||
continue
|
||||
}
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local != "href" {
|
||||
continue
|
||||
}
|
||||
attrName := "href"
|
||||
if attr.Name.Space != "" {
|
||||
attrName = "xlink:href"
|
||||
}
|
||||
refs = append(refs, svglideImageRef{
|
||||
PageID: page.ID,
|
||||
PageFile: page.File,
|
||||
AttrName: attrName,
|
||||
Href: attr.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func resolveSVGlideImageRef(runtime *common.RuntimeContext, spec *svglidePublishSpec, page svglidePublishPage, ref svglideImageRef) (svglideResolvedImageRef, error) {
|
||||
href := strings.TrimSpace(ref.Href)
|
||||
if href == "" {
|
||||
return svglideResolvedImageRef{}, svglideImageHrefError("empty image href", ref)
|
||||
}
|
||||
lower := strings.ToLower(href)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
|
||||
return svglideResolvedImageRef{}, svglideImageHrefError("remote image href is not supported", ref)
|
||||
case strings.HasPrefix(lower, "data:"):
|
||||
return svglideResolvedImageRef{}, svglideImageHrefError("data image href is not supported", ref)
|
||||
case strings.HasPrefix(href, "#"):
|
||||
return svglideResolvedImageRef{}, svglideImageHrefError("fragment image href is not supported", ref)
|
||||
case strings.HasPrefix(href, "@"):
|
||||
return svglideResolvedImageRef{}, svglideImageHrefError("@path image href is not an SVGlide protocol", ref)
|
||||
}
|
||||
|
||||
resolved, err := resolveSVGlideImagePath(spec, page, href)
|
||||
if err != nil {
|
||||
return svglideResolvedImageRef{}, err
|
||||
}
|
||||
stat, err := runtime.FileIO().Stat(resolved)
|
||||
if err != nil {
|
||||
return svglideResolvedImageRef{}, slidesInputStatError(err, "--manifest", fmt.Sprintf("--manifest image href %q: file not found", href))
|
||||
}
|
||||
if stat.IsDir() {
|
||||
return svglideResolvedImageRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest image href %q resolves to a directory", href).WithParam("--manifest")
|
||||
}
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return svglideResolvedImageRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest image href %q file size %s exceeds 20 MB limit for slides image upload", href, common.FormatSize(stat.Size())).WithParam("--manifest")
|
||||
}
|
||||
|
||||
return svglideResolvedImageRef{
|
||||
Ref: ref,
|
||||
FilePath: resolved,
|
||||
FileName: filepath.Base(resolved),
|
||||
Size: stat.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func uploadAndRewriteSVGlideImages(runtime *common.RuntimeContext, spec *svglidePublishSpec, plan svglideImagePlan, presentationID string) ([]svglidePublishPage, svglideImageRewriteResult, error) {
|
||||
tokens := map[string]string{}
|
||||
for i, file := range plan.UniqueFiles {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading SVGlide image %d/%d: %s (%s)\n", i+1, len(plan.UniqueFiles), file.FileName, common.FormatSize(file.Size))
|
||||
token, err := uploadSlidesMedia(runtime, file.FilePath, file.FileName, file.Size, presentationID)
|
||||
if err != nil {
|
||||
return nil, svglideImageRewriteResult{UploadedCount: i}, err
|
||||
}
|
||||
tokens[file.FilePath] = token
|
||||
}
|
||||
|
||||
pages := make([]svglidePublishPage, len(spec.Pages))
|
||||
copy(pages, spec.Pages)
|
||||
result := svglideImageRewriteResult{UploadedCount: len(plan.UniqueFiles)}
|
||||
for i := range pages {
|
||||
for _, ref := range plan.Refs {
|
||||
if ref.Ref.PageID != pages[i].ID || ref.Ref.PageFile != pages[i].File {
|
||||
continue
|
||||
}
|
||||
token := tokens[ref.FilePath]
|
||||
rewritten, err := rewriteSVGlideImageHref(pages[i].Content, ref, token)
|
||||
if err != nil {
|
||||
return nil, result, err
|
||||
}
|
||||
pages[i].Content = rewritten
|
||||
pages[i].ContentBytes = len([]byte(rewritten))
|
||||
result.Entries = append(result.Entries, svglideImageRewriteEntry{
|
||||
PageID: ref.Ref.PageID,
|
||||
PageFile: ref.Ref.PageFile,
|
||||
AttrName: ref.Ref.AttrName,
|
||||
OriginalHref: ref.Ref.Href,
|
||||
ResolvedFile: ref.FilePath,
|
||||
FileToken: token,
|
||||
Size: ref.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
return pages, result, nil
|
||||
}
|
||||
|
||||
func rewriteSVGlideImageHref(content string, ref svglideResolvedImageRef, fileToken string) (string, error) {
|
||||
imageTags := regexp.MustCompile(`(?is)<image\b[^>]*>`).FindAllStringIndex(content, -1)
|
||||
for _, loc := range imageTags {
|
||||
tag := content[loc[0]:loc[1]]
|
||||
rewrittenTag, ok := rewriteSVGlideImageHrefInTag(tag, ref.Ref.AttrName, ref.Ref.Href, fileToken)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return content[:loc[0]] + rewrittenTag + content[loc[1]:], nil
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to rewrite SVGlide image href %q in %s", ref.Ref.Href, ref.Ref.PageFile)
|
||||
}
|
||||
|
||||
func rewriteSVGlideImageHrefInTag(tag, attrName, oldHref, newHref string) (string, bool) {
|
||||
attr := regexp.QuoteMeta(attrName)
|
||||
old := regexp.QuoteMeta(oldHref)
|
||||
doubleQuoted := regexp.MustCompile(`(?s)(\b` + attr + `\s*=\s*)"` + old + `"`)
|
||||
if doubleQuoted.MatchString(tag) {
|
||||
return doubleQuoted.ReplaceAllString(tag, `${1}"`+newHref+`"`), true
|
||||
}
|
||||
singleQuoted := regexp.MustCompile(`(?s)(\b` + attr + `\s*=\s*)'` + old + `'`)
|
||||
if singleQuoted.MatchString(tag) {
|
||||
return singleQuoted.ReplaceAllString(tag, `${1}'`+newHref+`'`), true
|
||||
}
|
||||
return tag, false
|
||||
}
|
||||
|
||||
func resolveSVGlideImagePath(spec *svglidePublishSpec, page svglidePublishPage, href string) (string, error) {
|
||||
var candidate string
|
||||
if strings.HasPrefix(strings.ToLower(href), "file://") {
|
||||
u, err := url.Parse(href)
|
||||
if err != nil || u.Host != "" || u.Path == "" {
|
||||
return "", svglideImageHrefError("file URL image href is invalid", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
|
||||
}
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; FileIO still validates the resolved relative path before reading.
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve SVGlide image href: cannot determine working directory: %v", err).WithCause(err)
|
||||
}
|
||||
rel, err := filepath.Rel(cwd, filepath.Clean(u.Path))
|
||||
if err != nil {
|
||||
return "", svglideImageHrefError("file URL image href cannot be made relative to the working directory", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
|
||||
}
|
||||
candidate = rel
|
||||
} else {
|
||||
candidate = filepath.Join(filepath.Dir(page.File), href)
|
||||
}
|
||||
candidate = filepath.Clean(candidate)
|
||||
if candidate == "." || hasParentDirSegment(candidate) {
|
||||
return "", svglideImageHrefError("image href resolves outside the manifest root", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
|
||||
}
|
||||
if !svglidePathUnderRoot(candidate, spec.BaseDir) {
|
||||
return "", svglideImageHrefError("image href resolves outside the manifest root", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func svglidePathUnderRoot(path string, root string) bool {
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
if root == "." {
|
||||
root = ""
|
||||
}
|
||||
if root == "" {
|
||||
return !hasParentDirSegment(path)
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && !hasParentDirSegment(rel)
|
||||
}
|
||||
|
||||
func svglideImageHrefError(reason string, ref svglideImageRef) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s: %s in %s", reason, ref.Href, ref.PageFile).WithParam("--manifest")
|
||||
}
|
||||
338
shortcuts/slides/slides_create_svglide_manifest.go
Normal file
338
shortcuts/slides/slides_create_svglide_manifest.go
Normal file
@@ -0,0 +1,338 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
svglideManifestVersion = "svglide.manifest.v1"
|
||||
svglideBundleProtocol = "svg-slides.v1"
|
||||
svglideSVGNamespace = "http://www.w3.org/2000/svg"
|
||||
// +create-svglide consumes a manifest file and submits pages sequentially;
|
||||
// do not reuse the inline +create --slides shell-argument limit here.
|
||||
maxSVGlideManifestPages = 50
|
||||
)
|
||||
|
||||
type svglideManifest struct {
|
||||
Version string `json:"version"`
|
||||
Protocol string `json:"protocol"`
|
||||
Title string `json:"title"`
|
||||
Size svglideManifestSize `json:"size"`
|
||||
PublishReady bool `json:"publish_ready"`
|
||||
Published bool `json:"published"`
|
||||
Pages []svglideManifestPage `json:"pages"`
|
||||
Receipts svglideManifestReceipts `json:"receipts"`
|
||||
}
|
||||
|
||||
type svglideManifestSize struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type svglideManifestReceipts struct {
|
||||
ValidateSVGDeck string `json:"validate_svg_deck"`
|
||||
PromptReadTrace string `json:"prompt_read_trace"`
|
||||
AssetReadiness string `json:"asset_readiness"`
|
||||
TypographyPlan string `json:"typography_plan"`
|
||||
TypographyPlanReport string `json:"typography_plan_report"`
|
||||
VisualSystem string `json:"visual_system"`
|
||||
VisualSystemReport string `json:"visual_system_report"`
|
||||
ColorRhythm string `json:"color_rhythm"`
|
||||
FrameDiscipline string `json:"frame_discipline"`
|
||||
ParserContract string `json:"parser_contract"`
|
||||
ReadabilitySafety string `json:"readability_safety"`
|
||||
}
|
||||
|
||||
type svglideManifestPage struct {
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type svglidePublishSpec struct {
|
||||
ManifestPath string
|
||||
BaseDir string
|
||||
Title string
|
||||
Pages []svglidePublishPage
|
||||
}
|
||||
|
||||
type svglidePublishPage struct {
|
||||
ID string
|
||||
Index int
|
||||
File string
|
||||
SHA256 string
|
||||
SourceBytes int
|
||||
ContentBytes int
|
||||
Content string
|
||||
}
|
||||
|
||||
func prepareSVGlidePublishSpec(runtime *common.RuntimeContext, manifestPath string, overrideTitle string) (*svglidePublishSpec, error) {
|
||||
if strings.TrimSpace(manifestPath) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest is required").WithParam("--manifest")
|
||||
}
|
||||
raw, err := readSVGlideManifestInput(runtime, manifestPath, "--manifest file not found: %s", manifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var manifest svglideManifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest invalid JSON: %v", err).WithParam("--manifest").WithCause(err)
|
||||
}
|
||||
if manifest.Version != svglideManifestVersion {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest version must be %s", svglideManifestVersion).WithParam("--manifest")
|
||||
}
|
||||
if manifest.Protocol != svglideBundleProtocol {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest protocol must be %s", svglideBundleProtocol).WithParam("--manifest")
|
||||
}
|
||||
if !manifest.PublishReady {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest publish_ready must be true").WithParam("--manifest")
|
||||
}
|
||||
if manifest.Size.Width != defaultPresentationWidth || manifest.Size.Height != defaultPresentationHeight {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest size must be %dx%d", defaultPresentationWidth, defaultPresentationHeight).WithParam("--manifest")
|
||||
}
|
||||
if len(manifest.Pages) == 0 || len(manifest.Pages) > maxSVGlideManifestPages {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest pages must contain 1 to %d entries", maxSVGlideManifestPages).WithParam("--manifest")
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(filepath.Clean(manifestPath))
|
||||
if baseDir == "." {
|
||||
baseDir = ""
|
||||
}
|
||||
if err := validateSVGlideBundleReceipt(runtime, baseDir, manifest.Receipts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(overrideTitle)
|
||||
if title == "" {
|
||||
title = effectiveTitle(strings.TrimSpace(manifest.Title))
|
||||
}
|
||||
|
||||
seenIDs := map[string]bool{}
|
||||
pages := make([]svglidePublishPage, 0, len(manifest.Pages))
|
||||
for i, page := range manifest.Pages {
|
||||
id := strings.TrimSpace(page.ID)
|
||||
if id == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page id is required").WithParam("--manifest")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest duplicate page id: %s", id).WithParam("--manifest")
|
||||
}
|
||||
if page.Index != i+1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page index must be %d for page %s", i+1, id).WithParam("--manifest")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
|
||||
joined, err := svglideManifestChildPath("page file", baseDir, page.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := readSVGlideManifestInput(runtime, joined, "--manifest page file not found: %s", page.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSVGlidePagePayload(page, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages = append(pages, svglidePublishPage{
|
||||
ID: id,
|
||||
Index: page.Index,
|
||||
File: joined,
|
||||
SHA256: page.SHA256,
|
||||
SourceBytes: len(content),
|
||||
ContentBytes: len(content),
|
||||
Content: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
return &svglidePublishSpec{
|
||||
ManifestPath: manifestPath,
|
||||
BaseDir: baseDir,
|
||||
Title: title,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type svglideValidateSVGDeckReceipt struct {
|
||||
TotalErrors *int `json:"totalErrors"`
|
||||
}
|
||||
|
||||
type svglideZeroErrorReceipt struct {
|
||||
TotalErrors *int `json:"totalErrors"`
|
||||
OK *bool `json:"ok"`
|
||||
}
|
||||
|
||||
func validateSVGlideBundleReceipt(runtime *common.RuntimeContext, baseDir string, receipts svglideManifestReceipts) error {
|
||||
receiptPath, err := svglideManifestChildPath("receipts.validate_svg_deck", baseDir, receipts.ValidateSVGDeck)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readSVGlideManifestInput(runtime, receiptPath, "--manifest validate_svg_deck receipt not found: %s", receipts.ValidateSVGDeck)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var receipt svglideValidateSVGDeckReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest validate_svg_deck receipt invalid JSON: %v", err).WithParam("--manifest").WithCause(err)
|
||||
}
|
||||
if receipt.TotalErrors == nil || *receipt.TotalErrors != 0 {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest validate_svg_deck receipt must have totalErrors=0").WithParam("--manifest")
|
||||
}
|
||||
required := []struct {
|
||||
label string
|
||||
rel string
|
||||
}{
|
||||
{"receipts.prompt_read_trace", receipts.PromptReadTrace},
|
||||
{"receipts.asset_readiness", receipts.AssetReadiness},
|
||||
{"receipts.typography_plan", receipts.TypographyPlan},
|
||||
{"receipts.typography_plan_report", receipts.TypographyPlanReport},
|
||||
{"receipts.visual_system", receipts.VisualSystem},
|
||||
{"receipts.visual_system_report", receipts.VisualSystemReport},
|
||||
{"receipts.color_rhythm", receipts.ColorRhythm},
|
||||
{"receipts.frame_discipline", receipts.FrameDiscipline},
|
||||
{"receipts.parser_contract", receipts.ParserContract},
|
||||
{"receipts.readability_safety", receipts.ReadabilitySafety},
|
||||
}
|
||||
for _, item := range required {
|
||||
label, rel := item.label, item.rel
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest")
|
||||
}
|
||||
joined, err := svglideManifestChildPath(label, baseDir, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readSVGlideManifestInput(runtime, joined, "--manifest %s receipt not found: %s", label, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if label == "receipts.typography_plan_report" || label == "receipts.visual_system_report" || label == "receipts.color_rhythm" || label == "receipts.frame_discipline" || label == "receipts.parser_contract" || label == "receipts.readability_safety" {
|
||||
var zero svglideZeroErrorReceipt
|
||||
if err := json.Unmarshal(raw, &zero); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s receipt invalid JSON: %v", label, err).WithParam("--manifest").WithCause(err)
|
||||
}
|
||||
if zero.TotalErrors != nil && *zero.TotalErrors != 0 {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have totalErrors=0", label).WithParam("--manifest")
|
||||
}
|
||||
if zero.OK != nil && !*zero.OK {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have ok=true", label).WithParam("--manifest")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSVGlidePagePayload(page svglideManifestPage, content []byte) error {
|
||||
wantSHA := strings.TrimSpace(page.SHA256)
|
||||
if wantSHA == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page sha256 is required: %s", page.File).WithParam("--manifest")
|
||||
}
|
||||
if gotSHA := sha256Hex(content); gotSHA != wantSHA {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest page file sha256 mismatch: %s", page.File).WithParam("--manifest")
|
||||
}
|
||||
rootName, rootNamespace, err := xmlRootName(content)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file invalid XML: %s: %v", page.File, err).WithParam("--manifest").WithCause(err)
|
||||
}
|
||||
if detectForbiddenSVGlidePublishFormat(content, rootName) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file must be raw SVG, not Slides XML/SXSD/HTML/raster/data URL fallback: %s", page.File).WithParam("--manifest")
|
||||
}
|
||||
if rootName != "svg" || rootNamespace != svglideSVGNamespace {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file root must be <svg> in the SVG namespace: %s", page.File).WithParam("--manifest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func xmlRootName(raw []byte) (string, string, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing XML root element")
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if start, ok := token.(xml.StartElement); ok {
|
||||
return start.Name.Local, start.Name.Space, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readSVGlideManifestInput(runtime *common.RuntimeContext, path string, message string, args ...interface{}) ([]byte, error) {
|
||||
raw, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
|
||||
if err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, message, args...).
|
||||
WithParam("--manifest").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
func detectForbiddenSVGlidePublishFormat(raw []byte, rootName string) bool {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.HasPrefix(lower, "data:image/") {
|
||||
return true
|
||||
}
|
||||
if len(raw) >= 8 && bytes.Equal(raw[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
||||
return true
|
||||
}
|
||||
if len(raw) >= 3 && raw[0] == 0xff && raw[1] == 0xd8 && raw[2] == 0xff {
|
||||
return true
|
||||
}
|
||||
switch rootName {
|
||||
case "slide", "presentation", "sxsd", "document", "html":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func svglideManifestChildPath(label, baseDir, child string) (string, error) {
|
||||
child = strings.TrimSpace(child)
|
||||
if child == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest")
|
||||
}
|
||||
if filepath.IsAbs(child) || strings.HasPrefix(child, "/") || strings.HasPrefix(child, `\`) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s must be relative to the manifest directory: %s", label, child).WithParam("--manifest")
|
||||
}
|
||||
clean := filepath.Clean(child)
|
||||
if clean == "." || hasParentDirSegment(clean) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s must be relative to the manifest directory and cannot contain '..': %s", label, child).WithParam("--manifest")
|
||||
}
|
||||
if baseDir == "" {
|
||||
return clean, nil
|
||||
}
|
||||
return filepath.Join(baseDir, clean), nil
|
||||
}
|
||||
|
||||
func hasParentDirSegment(path string) bool {
|
||||
for _, part := range strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
609
shortcuts/slides/slides_create_svglide_test.go
Normal file
609
shortcuts/slides/slides_create_svglide_test.go
Normal file
@@ -0,0 +1,609 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
svglideValidateOnlyReceiptsJSON = `"validate_svg_deck":"receipts/validate_svg_deck.json"`
|
||||
svglideRequiredReceiptsJSON = svglideValidateOnlyReceiptsJSON + `,"prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"`
|
||||
)
|
||||
|
||||
func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
page := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="page-001" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`
|
||||
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
|
||||
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
|
||||
mustWriteSVGlideRequiredNonValidateReceipts(t)
|
||||
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"title": "Manifest Title",
|
||||
"size": {"width": 960, "height": 540},
|
||||
"publish_ready": true,
|
||||
"published": false,
|
||||
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
|
||||
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
|
||||
}`, sha256Hex([]byte(page)))))
|
||||
|
||||
runtime, _ := newSVGlideTestRuntime(t)
|
||||
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSVGlidePublishSpec() error = %v", err)
|
||||
}
|
||||
if spec.Title != "Manifest Title" {
|
||||
t.Fatalf("Title = %q, want Manifest Title", spec.Title)
|
||||
}
|
||||
if len(spec.Pages) != 1 {
|
||||
t.Fatalf("Pages len = %d, want 1", len(spec.Pages))
|
||||
}
|
||||
if spec.Pages[0].ID != "page-001" || spec.Pages[0].File != "pages/page-001.svg" {
|
||||
t.Fatalf("Page = %#v", spec.Pages[0])
|
||||
}
|
||||
if !strings.Contains(spec.Pages[0].Content, "<svg") {
|
||||
t.Fatalf("page content was not read: %q", spec.Pages[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSVGlidePublishSpecAcceptsTwelvePageManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
|
||||
mustWriteSVGlideRequiredNonValidateReceipts(t)
|
||||
|
||||
pages := make([]string, 0, 12)
|
||||
for i := 1; i <= 12; i++ {
|
||||
id := fmt.Sprintf("page-%03d", i)
|
||||
file := fmt.Sprintf("pages/%s.svg", id)
|
||||
page := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="%s" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`, id)
|
||||
mustWriteSVGlideTestFile(t, file, page)
|
||||
pages = append(pages, fmt.Sprintf(`{"id":%q,"index":%d,"file":%q,"sha256":%q}`, id, i, file, sha256Hex([]byte(page))))
|
||||
}
|
||||
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"title": "Twelve Page Deck",
|
||||
"size": {"width": 960, "height": 540},
|
||||
"publish_ready": true,
|
||||
"published": false,
|
||||
"pages": [%s],
|
||||
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
|
||||
}`, strings.Join(pages, ","))))
|
||||
|
||||
runtime, _ := newSVGlideTestRuntime(t)
|
||||
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSVGlidePublishSpec() error = %v", err)
|
||||
}
|
||||
if len(spec.Pages) != 12 {
|
||||
t.Fatalf("Pages len = %d, want 12", len(spec.Pages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
page := `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`
|
||||
mustWriteSVGlideTestFile(t, "page.svg", page)
|
||||
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
|
||||
mustWriteSVGlideRequiredNonValidateReceipts(t)
|
||||
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"title": "Manifest Title",
|
||||
"size": {"width": 960, "height": 540},
|
||||
"publish_ready": true,
|
||||
"published": false,
|
||||
"pages": [{"id": "p1", "index": 1, "file": "page.svg", "sha256": %q}],
|
||||
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
|
||||
}`, sha256Hex([]byte(page)))))
|
||||
|
||||
runtime, _ := newSVGlideTestRuntime(t)
|
||||
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "CLI Title")
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSVGlidePublishSpec() error = %v", err)
|
||||
}
|
||||
if spec.Title != "CLI Title" {
|
||||
t.Fatalf("Title = %q, want CLI Title", spec.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
manifest string
|
||||
pageFiles map[string]string
|
||||
skipDefaultReceipts bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "wrong version",
|
||||
manifest: `{"version":"wrong","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest version must be svglide.manifest.v1",
|
||||
},
|
||||
{
|
||||
name: "wrong size",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":1920,"height":1080},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest size must be 960x540",
|
||||
},
|
||||
{
|
||||
name: "no pages",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[]}`,
|
||||
pageFiles: map[string]string{"receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest pages must contain 1 to 50 entries",
|
||||
},
|
||||
{
|
||||
name: "unsafe child path",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"../page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest page file must be relative to the manifest directory and cannot contain '..': ../page.svg",
|
||||
},
|
||||
{
|
||||
name: "duplicate page id",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"a.svg","sha256":"<valid-sha-a>"},{"id":"p1","index":2,"file":"b.svg","sha256":"<valid-sha-b>"}]}`,
|
||||
pageFiles: map[string]string{"a.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "b.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest duplicate page id: p1",
|
||||
},
|
||||
{
|
||||
name: "wrong protocol",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"wrong","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest protocol must be svg-slides.v1",
|
||||
},
|
||||
{
|
||||
name: "not publish ready",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest publish_ready must be true",
|
||||
},
|
||||
{
|
||||
name: "validate receipt has errors",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":1}`},
|
||||
wantMessage: "--manifest validate_svg_deck receipt must have totalErrors=0",
|
||||
},
|
||||
{
|
||||
name: "missing visual system receipt",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{
|
||||
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
|
||||
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
|
||||
"receipts/prompt_read_trace.json": `{"ok":true}`,
|
||||
"receipts/asset_readiness.json": `{"ok":true}`,
|
||||
"receipts/typography_plan.json": `{"ok":true}`,
|
||||
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
},
|
||||
skipDefaultReceipts: true,
|
||||
wantMessage: "--manifest receipts.visual_system is required",
|
||||
},
|
||||
{
|
||||
name: "missing parser contract receipt",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{
|
||||
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
|
||||
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
|
||||
"receipts/prompt_read_trace.json": `{"ok":true}`,
|
||||
"receipts/asset_readiness.json": `{"ok":true}`,
|
||||
"receipts/typography_plan.json": `{"ok":true}`,
|
||||
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
|
||||
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
|
||||
},
|
||||
skipDefaultReceipts: true,
|
||||
wantMessage: "--manifest receipts.parser_contract is required",
|
||||
},
|
||||
{
|
||||
name: "color rhythm receipt has errors",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{
|
||||
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
|
||||
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
|
||||
"receipts/prompt_read_trace.json": `{"ok":true}`,
|
||||
"receipts/asset_readiness.json": `{"ok":true}`,
|
||||
"receipts/typography_plan.json": `{"ok":true}`,
|
||||
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
|
||||
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/color_rhythm.json": `{"ok":false,"totalErrors":1}`,
|
||||
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/readability_safety.json": `{"ok":true,"totalErrors":0}`,
|
||||
},
|
||||
skipDefaultReceipts: true,
|
||||
wantMessage: "--manifest receipts.color_rhythm receipt must have totalErrors=0",
|
||||
},
|
||||
{
|
||||
name: "readability safety receipt has errors",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{
|
||||
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
|
||||
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
|
||||
"receipts/prompt_read_trace.json": `{"ok":true}`,
|
||||
"receipts/asset_readiness.json": `{"ok":true}`,
|
||||
"receipts/typography_plan.json": `{"ok":true}`,
|
||||
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
|
||||
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/readability_safety.json": `{"ok":false,"totalErrors":1}`,
|
||||
},
|
||||
skipDefaultReceipts: true,
|
||||
wantMessage: "--manifest receipts.readability_safety receipt must have totalErrors=0",
|
||||
},
|
||||
{
|
||||
name: "typography report receipt has errors",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{
|
||||
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
|
||||
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
|
||||
"receipts/prompt_read_trace.json": `{"ok":true}`,
|
||||
"receipts/asset_readiness.json": `{"ok":true}`,
|
||||
"receipts/typography_plan.json": `{"ok":true}`,
|
||||
"receipts/typography_plan_report.json": `{"ok":false,"totalErrors":1}`,
|
||||
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
|
||||
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/readability_safety.json": `{"ok":true,"totalErrors":0}`,
|
||||
},
|
||||
skipDefaultReceipts: true,
|
||||
wantMessage: "--manifest receipts.typography_plan_report receipt must have totalErrors=0",
|
||||
},
|
||||
{
|
||||
name: "sha mismatch",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"bad"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest page file sha256 mismatch: page.svg",
|
||||
},
|
||||
{
|
||||
name: "non svg root",
|
||||
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
|
||||
pageFiles: map[string]string{"page.svg": `<slide xmlns="http://www.larkoffice.com/sml/2.0"></slide>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
|
||||
wantMessage: "--manifest page file must be raw SVG",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
for path, body := range tt.pageFiles {
|
||||
mustWriteSVGlideTestFile(t, path, body)
|
||||
}
|
||||
if !tt.skipDefaultReceipts {
|
||||
mustWriteSVGlideRequiredNonValidateReceipts(t)
|
||||
tt.manifest = withSVGlideRequiredReceipts(tt.manifest)
|
||||
}
|
||||
if body, ok := tt.pageFiles["page.svg"]; ok {
|
||||
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha>", sha256Hex([]byte(body)))
|
||||
}
|
||||
if body, ok := tt.pageFiles["a.svg"]; ok {
|
||||
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha-a>", sha256Hex([]byte(body)))
|
||||
}
|
||||
if body, ok := tt.pageFiles["b.svg"]; ok {
|
||||
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha-b>", sha256Hex([]byte(body)))
|
||||
}
|
||||
mustWriteSVGlideTestFile(t, "manifest.json", tt.manifest)
|
||||
|
||||
runtime, _ := newSVGlideTestRuntime(t)
|
||||
_, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("err = %v, want message containing %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteSVGlideTestFile(t *testing.T, path string, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func withSVGlideRequiredReceipts(manifest string) string {
|
||||
manifest = strings.ReplaceAll(manifest, `"receipts":{`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`)
|
||||
manifest = strings.ReplaceAll(manifest, `"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`)
|
||||
manifest = strings.ReplaceAll(manifest, `"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`)
|
||||
return strings.ReplaceAll(manifest, `"receipts": {`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`)
|
||||
}
|
||||
|
||||
func mustWriteSVGlideRequiredNonValidateReceipts(t *testing.T) {
|
||||
t.Helper()
|
||||
for path, body := range map[string]string{
|
||||
"receipts/prompt_read_trace.json": `{"ok":true}`,
|
||||
"receipts/asset_readiness.json": `{"ok":true}`,
|
||||
"receipts/typography_plan.json": `{"ok":true}`,
|
||||
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
|
||||
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
|
||||
"receipts/readability_safety.json": `{"ok":true,"totalErrors":0}`,
|
||||
} {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
continue
|
||||
}
|
||||
mustWriteSVGlideTestFile(t, path, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSVGlideImageRewritesResolvesAndDedupes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
mustWriteSVGlideTestFile(t, "assets/images/a.png", "png")
|
||||
runtime, _ := newSVGlideTestRuntime(t)
|
||||
spec := &svglidePublishSpec{
|
||||
BaseDir: "",
|
||||
Pages: []svglidePublishPage{
|
||||
{ID: "p1", File: "slides/page-001.svg", Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
|
||||
{ID: "p2", File: "slides/page-002.svg", Content: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../assets/images/a.png"/></svg>`},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := planSVGlideImageRewrites(runtime, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("planSVGlideImageRewrites() error = %v", err)
|
||||
}
|
||||
if len(plan.Refs) != 2 {
|
||||
t.Fatalf("Refs len = %d, want 2", len(plan.Refs))
|
||||
}
|
||||
if len(plan.UniqueFiles) != 1 {
|
||||
t.Fatalf("UniqueFiles len = %d, want 1", len(plan.UniqueFiles))
|
||||
}
|
||||
if got := filepath.ToSlash(plan.UniqueFiles[0].FilePath); got != "assets/images/a.png" {
|
||||
t.Fatalf("Unique file = %q, want assets/images/a.png", got)
|
||||
}
|
||||
if plan.Refs[0].Ref.AttrName != "href" || plan.Refs[1].Ref.AttrName != "xlink:href" {
|
||||
t.Fatalf("attr names = %q/%q", plan.Refs[0].Ref.AttrName, plan.Refs[1].Ref.AttrName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSVGlideImageRewritesRejectsUnsupportedHrefs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
}{
|
||||
{name: "remote", href: "https://example.com/a.png"},
|
||||
{name: "data url", href: "data:image/png;base64,xxx"},
|
||||
{name: "fragment", href: "#asset"},
|
||||
{name: "at path", href: "@assets/a.png"},
|
||||
{name: "missing local", href: "../assets/missing.png"},
|
||||
{name: "outside root", href: "../../outside.png"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
runtime, _ := newSVGlideTestRuntime(t)
|
||||
spec := &svglidePublishSpec{
|
||||
BaseDir: "",
|
||||
Pages: []svglidePublishPage{{
|
||||
ID: "p1",
|
||||
File: "slides/page-001.svg",
|
||||
Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="` + tt.href + `"/></svg>`,
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := planSVGlideImageRewrites(runtime, spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if validationErr.Param != "--manifest" {
|
||||
t.Fatalf("Param = %q, want --manifest", validationErr.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAndRewriteSVGlideImagesUploadsOnceAndRewrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
mustWriteSVGlideTestFile(t, "assets/images/a.png", "png")
|
||||
runtime, reg := newSVGlideTestRuntime(t)
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_tok_1"},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
spec := &svglidePublishSpec{
|
||||
BaseDir: "",
|
||||
Pages: []svglidePublishPage{
|
||||
{ID: "p1", File: "slides/page-001.svg", SHA256: "sha1", SourceBytes: 100, ContentBytes: 100, Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
|
||||
{ID: "p2", File: "slides/page-002.svg", SHA256: "sha2", SourceBytes: 100, ContentBytes: 100, Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
|
||||
},
|
||||
}
|
||||
plan, err := planSVGlideImageRewrites(runtime, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("planSVGlideImageRewrites() error = %v", err)
|
||||
}
|
||||
|
||||
pages, result, err := uploadAndRewriteSVGlideImages(runtime, spec, plan, "pres_sg")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadAndRewriteSVGlideImages() error = %v", err)
|
||||
}
|
||||
if result.UploadedCount != 1 {
|
||||
t.Fatalf("UploadedCount = %d, want 1", result.UploadedCount)
|
||||
}
|
||||
if len(result.Entries) != 2 {
|
||||
t.Fatalf("Entries len = %d, want 2", len(result.Entries))
|
||||
}
|
||||
for _, page := range pages {
|
||||
if strings.Contains(page.Content, "../assets/images/a.png") {
|
||||
t.Fatalf("page content still contains local href: %s", page.Content)
|
||||
}
|
||||
if !strings.Contains(page.Content, `href="file_tok_1"`) {
|
||||
t.Fatalf("page content missing token href: %s", page.Content)
|
||||
}
|
||||
if page.SHA256 == "" || page.SourceBytes != 100 || page.ContentBytes == 100 {
|
||||
t.Fatalf("page hash/source/submitted bytes not preserved/updated: %#v", page)
|
||||
}
|
||||
}
|
||||
|
||||
body := decodeMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["parent_type"]; got != slidesMediaParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "pres_sg" {
|
||||
t.Fatalf("parent_node = %q, want pres_sg", got)
|
||||
}
|
||||
if got := len(body.Files["file"]); got != 3 {
|
||||
t.Fatalf("uploaded file size = %d, want 3", got)
|
||||
}
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideExecute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
page := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="page-001" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`
|
||||
mustWriteSVGlideBundle(t, "SVGlide Deck", page)
|
||||
|
||||
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_sg",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_sg",
|
||||
},
|
||||
},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_sg/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--manifest", "manifest.json",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_sg" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["slides_added"] != float64(1) {
|
||||
t.Fatalf("slides_added = %v, want 1", data["slides_added"])
|
||||
}
|
||||
if data["svglide_manifest_version"] != svglideManifestVersion {
|
||||
t.Fatalf("svglide_manifest_version = %v", data["svglide_manifest_version"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
slide := body["slide"].(map[string]interface{})
|
||||
if !strings.Contains(slide["content"].(string), "<svg") {
|
||||
t.Fatalf("slide content did not contain SVGlide payload: %#v", slide["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
page := `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><rect/></svg>`
|
||||
mustWriteSVGlideBundle(t, "SVGlide Deck", page)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--manifest", "manifest.json",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Create SVGlide presentation", "Add SVGlide page 1", "source_sha256", "submitted_sha256", "/open-apis/slides_ai/v1/xml_presentations", "/slide"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "<svg") {
|
||||
t.Fatalf("dry-run must summarize SVG content instead of printing full payload: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteSVGlideBundle(t *testing.T, title string, page string) {
|
||||
t.Helper()
|
||||
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
|
||||
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
|
||||
mustWriteSVGlideRequiredNonValidateReceipts(t)
|
||||
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"title": %q,
|
||||
"size": {"width": 960, "height": 540},
|
||||
"publish_ready": true,
|
||||
"published": false,
|
||||
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
|
||||
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
|
||||
}`, title, sha256Hex([]byte(page)))))
|
||||
}
|
||||
|
||||
func newSVGlideTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "svglide-test",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create-svglide"}, cfg, f, core.AsUser)
|
||||
return runtime, reg
|
||||
}
|
||||
@@ -15,6 +15,8 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 本地生成或校验 SVG Slides / SVGlide 产物 | 先读 `references/svg-slides/README.md`,生成 local publish-ready bundle;已有 publish-ready manifest 时用 `slides +create-svglide` 发布 | `references/svg-slides/README.md`、`lark-slides-create-svglide.md`、`scripts/validate_svg_deck.mjs`、`scripts/svg_slides_bundle.mjs` |
|
||||
| 发布已有 SVGlide manifest | 只做发布:读取 manifest、校验 receipt/hash/root、本地图片上传并改写 href,然后逐页提交 raw SVG;需要 PPE 时只用 `scripts/dev-svglide-ppe-env.sh` 的 `ppe_svg_slides` 规则 | `slides +create-svglide --manifest manifest.json --as user`、`lark-slides-create-svglide.md` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`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` |
|
||||
@@ -29,6 +31,8 @@ metadata:
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — SVG Slides / SVGlide 与当前 XML/SXSD 工作流是不同协议。两者都使用 960x540 画布,但 SVG Slides 使用 `viewBox="0 0 960 540"` 和 `slide:*` SVG 语义,XML/SXSD 使用 SML XML。处理 SVG Slides 生成或校验时,先读 [`references/svg-slides/README.md`](references/svg-slides/README.md),不要把 SVG 规则写进 `xml-schema-quick-ref.md`。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
@@ -76,6 +80,7 @@ lark-cli auth login --domain slides
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 发布已有 SVGlide manifest:[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.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-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
@@ -130,12 +135,14 @@ lark-cli auth login --domain slides
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| SVG Slides / SVGlide 生成产物(`manifest.json` + `slides/*.svg`) | 先 bundle/校验,再用 `slides +create-svglide --manifest manifest.json --as user` 发布;manifest 支持 1-50 页,不适用 `+create --slides` 的 10 页上限 |
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
> 这个 10 页限制只属于普通 XML 的 `slides +create --slides` 内联数组;不要用它判断 SVG Slides / SVGlide manifest 发布。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
@@ -247,6 +254,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+create-svglide`](references/lark-slides-create-svglide.md) | 从已校验的 SVGlide manifest 发布 SVG Slides,manifest 支持 1-50 页,并负责本地 `<image href>` 上传改写 |
|
||||
| [`+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/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
@@ -263,7 +271,7 @@ 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. **创建流程**:如果输入是 SVG Slides / SVGlide manifest,直接走 `slides +create-svglide`,不要因超过 10 页而拆分;普通 Slides XML 才按复杂度选择 `slides +create --slides '[...]'` 或“两步创建”,其中 10 页上限只约束 `+create --slides` 的内联数组
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
|
||||
12
skills/lark-slides/prompt-sources/svg-slides/README.md
Normal file
12
skills/lark-slides/prompt-sources/svg-slides/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# SVG Slides Prompt Sources
|
||||
|
||||
This directory is the maintainer-only source for the SVG Slides runtime references.
|
||||
|
||||
Do not read these files while generating or editing a deck. Runtime agents should read `skills/lark-slides/references/svg-slides/` instead.
|
||||
|
||||
When changing prompt behavior:
|
||||
|
||||
1. Edit `full.debranded.md` and `split-manifest.json` here.
|
||||
2. Run `node skills/lark-slides/scripts/svg_slides_extract_references.mjs`.
|
||||
3. Run the SVG Slides tests and source coverage check.
|
||||
|
||||
2488
skills/lark-slides/prompt-sources/svg-slides/full.debranded.md
Normal file
2488
skills/lark-slides/prompt-sources/svg-slides/full.debranded.md
Normal file
File diff suppressed because it is too large
Load Diff
144
skills/lark-slides/prompt-sources/svg-slides/split-manifest.json
Normal file
144
skills/lark-slides/prompt-sources/svg-slides/split-manifest.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"version": "svg-slides.split-manifest.v1",
|
||||
"source": "skills/lark-slides/prompt-sources/svg-slides/full.debranded.md",
|
||||
"source_export": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.debranded.md",
|
||||
"source_role": "maintainer_prompt_source_not_runtime_generation_context",
|
||||
"sections": [
|
||||
{
|
||||
"id": "title",
|
||||
"lines": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"target": "README.md"
|
||||
},
|
||||
{
|
||||
"id": "system_prompt_workflow",
|
||||
"lines": [
|
||||
3,
|
||||
221
|
||||
],
|
||||
"target": "system-prompt.md"
|
||||
},
|
||||
{
|
||||
"id": "svg_reference",
|
||||
"lines": [
|
||||
223,
|
||||
929
|
||||
],
|
||||
"target": "svg-reference.md"
|
||||
},
|
||||
{
|
||||
"id": "resolve_design_brief",
|
||||
"lines": [
|
||||
931,
|
||||
1144
|
||||
],
|
||||
"target": "resolve-design-brief/full.md"
|
||||
},
|
||||
{
|
||||
"id": "deck_design_reference_catalog",
|
||||
"lines": [
|
||||
1146,
|
||||
1298
|
||||
],
|
||||
"target": "deck-design-reference-catalog.md"
|
||||
},
|
||||
{
|
||||
"id": "slide_outline_tool",
|
||||
"lines": [
|
||||
1300,
|
||||
1318
|
||||
],
|
||||
"target": "tools/slide-outline.md"
|
||||
},
|
||||
{
|
||||
"id": "activate_slides_edit_tool",
|
||||
"lines": [
|
||||
1320,
|
||||
1326
|
||||
],
|
||||
"target": "tools/activate-slides-edit.md"
|
||||
},
|
||||
{
|
||||
"id": "slides_edit_tool",
|
||||
"lines": [
|
||||
1328,
|
||||
1345
|
||||
],
|
||||
"target": "tools/slides-edit.md"
|
||||
},
|
||||
{
|
||||
"id": "svg_document_rules",
|
||||
"lines": [
|
||||
1347,
|
||||
1351
|
||||
],
|
||||
"target": "tools/slides-edit.md"
|
||||
},
|
||||
{
|
||||
"id": "image_usage",
|
||||
"lines": [
|
||||
1353,
|
||||
1355
|
||||
],
|
||||
"target": "tools/slides-edit.md"
|
||||
},
|
||||
{
|
||||
"id": "incremental_processing",
|
||||
"lines": [
|
||||
1357,
|
||||
1395
|
||||
],
|
||||
"target": "tools/slides-edit.md"
|
||||
},
|
||||
{
|
||||
"id": "finish_slides_edit_tool",
|
||||
"lines": [
|
||||
1397,
|
||||
1403
|
||||
],
|
||||
"target": "tools/finish-slides-edit.md"
|
||||
},
|
||||
{
|
||||
"id": "slide_organize_tool",
|
||||
"lines": [
|
||||
1405,
|
||||
1411
|
||||
],
|
||||
"target": "tools/slide-organize.md"
|
||||
},
|
||||
{
|
||||
"id": "compute_custom_shape_bbox_tool",
|
||||
"lines": [
|
||||
1413,
|
||||
1419
|
||||
],
|
||||
"target": "tools/compute-custom-shape-bbox.md"
|
||||
},
|
||||
{
|
||||
"id": "generate_svg_chart_tool",
|
||||
"lines": [
|
||||
1421,
|
||||
2420
|
||||
],
|
||||
"target": "generate-svg-chart/full.md"
|
||||
},
|
||||
{
|
||||
"id": "slides_convert_tool",
|
||||
"lines": [
|
||||
2422,
|
||||
2459
|
||||
],
|
||||
"target": "tools/slides-convert.md"
|
||||
},
|
||||
{
|
||||
"id": "slides_parse_template_tool",
|
||||
"lines": [
|
||||
2461,
|
||||
2484
|
||||
],
|
||||
"target": "tools/slides-parse-template.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
160
skills/lark-slides/references/create-svglide-boundary-study.md
Normal file
160
skills/lark-slides/references/create-svglide-boundary-study.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# create-svglide boundary study
|
||||
|
||||
## Goal
|
||||
|
||||
Use `slides +create` as the design constraint sample for `slides +create-svglide`.
|
||||
|
||||
The central rule is:
|
||||
|
||||
```text
|
||||
slides +create is a thin publisher for already-authored slide XML.
|
||||
slides +create-svglide should be a thin publisher for already-authored SVGlide artifacts.
|
||||
```
|
||||
|
||||
This document is evidence-first. It separates what the existing shortcut actually does from the broader generation and validation work described by the `lark-slides` skill.
|
||||
|
||||
## Source Surface
|
||||
|
||||
| Area | Files | Why it matters |
|
||||
| --- | --- | --- |
|
||||
| Go shortcut implementation | `shortcuts/slides/slides_create.go`, `shortcuts/slides/helpers.go`, `shortcuts/slides/slides_media_upload.go`, `shortcuts/slides/shortcuts.go` | Shows the real runtime boundary of `slides +create`. |
|
||||
| Unit tests | `shortcuts/slides/slides_create_test.go` | Shows behavior that must not drift silently. |
|
||||
| E2E proof | `tests/cli_e2e/slides/slides_create_workflow_test.go`, `tests/cli_e2e/slides/coverage.md` | Shows what is proven outside the shortcut body. |
|
||||
| Skill and references | `skills/lark-slides/SKILL.md`, `skills/lark-slides/references/lark-slides-create.md`, `xml-schema-quick-ref.md`, `validation-checklist.md`, `troubleshooting.md` | Shows which work belongs to agent guidance or scripts instead of Go shortcut code. |
|
||||
|
||||
## `slides +create` Responsibility Matrix
|
||||
|
||||
| Responsibility | Evidence | Boundary meaning |
|
||||
| --- | --- | --- |
|
||||
| Register a write shortcut named `slides +create` for user and bot auth | `shortcuts/slides/slides_create.go:24-43`, `shortcuts/slides/shortcuts.go:8-17` | The command is a shortcut wrapper, not a general slide-generation subsystem. |
|
||||
| Build a minimal presentation XML shell | `shortcuts/slides/slides_create.go:224-241` | The shortcut creates only the deck container: title plus 960x540 presentation metadata. |
|
||||
| Create the online XML presentation | `shortcuts/slides/slides_create.go:125-148` | The first real API call is presentation creation. |
|
||||
| Accept optional `--slides` as a JSON array of `<slide>` XML strings | `shortcuts/slides/slides_create.go:40-52` | Page content is supplied by the caller as final XML strings. |
|
||||
| Enforce a maximum of 10 inline slide XML strings | `shortcuts/slides/slides_create.go:50-52` | This applies only to plain XML passed through `+create --slides`. SVGlide manifest publishing must use its own manifest page limit. |
|
||||
| Detect local image placeholders in submitted XML | `shortcuts/slides/helpers.go:113-153` | The shortcut only interprets one small XML convention: `<img src=\"@path\">`. |
|
||||
| Validate placeholder files before creating the presentation | `shortcuts/slides/slides_create.go:53-67` | Avoids creating an orphan deck for missing/oversized local images. |
|
||||
| Upload placeholder images and replace them with file tokens | `shortcuts/slides/slides_create.go:163-177`, `shortcuts/slides/slides_media_upload.go:119-138`, `shortcuts/slides/helpers.go:283-309` | Image upload is helper orchestration, not content generation. |
|
||||
| Submit each supplied slide XML string to the page-create API | `shortcuts/slides/slides_create.go:179-200` | The shortcut forwards caller-authored XML to the backend. |
|
||||
| Report partial progress when page creation fails | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | It does not roll back; it tells the caller where to resume. |
|
||||
| Output machine-readable creation results | `shortcuts/slides/slides_create.go:150-219` | The output is an API orchestration receipt. |
|
||||
| Optionally attempt bot-created deck permission grant | `shortcuts/slides/slides_create.go:215-217`, `shortcuts/slides/slides_create_test.go:66-198` | Bot grant is post-create convenience, not part of content semantics. |
|
||||
|
||||
## Behavior Locks From Tests
|
||||
|
||||
| Behavior | Evidence | Boundary meaning |
|
||||
| --- | --- | --- |
|
||||
| User-mode create returns `xml_presentation_id`, `title`, and `url`, without `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | User-mode output is a creation receipt, not a validation report. |
|
||||
| Missing `--title` becomes `Untitled` in dry-run and execution | `shortcuts/slides/slides_create_test.go:200-253` | Title normalization is a small deterministic convenience that belongs in the shortcut. |
|
||||
| `--slides` creates the deck first, then adds pages, then returns `slide_ids` and `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | Page creation is orchestration after container creation. |
|
||||
| `--slides []` behaves like no slides | `shortcuts/slides/slides_create_test.go:532-570` | Empty artifact lists should be explicit no-op additions, not special generators. |
|
||||
| Invalid JSON and more than 10 inline slides fail validation with `Param == "--slides"` | `shortcuts/slides/slides_create_test.go:422-505` | Input-contract errors should be structured and routeable. |
|
||||
| Missing `xml_presentation_id` from the backend fails | `shortcuts/slides/slides_create_test.go:255-283` | Creation success requires a usable resource id. |
|
||||
| URL fallback is local and does not call Drive metas or batch query | `shortcuts/slides/slides_create_test.go:649-688` | Avoid adding extra API dependencies when a local receipt can be built. |
|
||||
| Image placeholders are uploaded once per unique path and rewritten before page creation | `shortcuts/slides/slides_create_test.go:751-854` | Asset handling is publish-boundary plumbing, not design work. |
|
||||
| Missing local placeholder files fail before any API call | `shortcuts/slides/slides_create_test.go:856-877` | Local artifact existence is a publish-blocking precondition. |
|
||||
| Dry-run exposes the API plan shape and placeholder ids | `shortcuts/slides/slides_create_test.go:572-602`, `shortcuts/slides/slides_create_test.go:879-900` | Dry-run should describe orchestration, not execute validation-heavy side effects. |
|
||||
| Readback is proven by E2E as a separate follow-up call | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`, `tests/cli_e2e/slides/coverage.md:9-16` | Readback is evidence for tests and delivery, not default `Execute` behavior. |
|
||||
| Bot permission grant is non-fatal and tri-state: granted, skipped, or failed | `shortcuts/slides/slides_create_test.go:66-198` | Convenience post-actions must not turn creation success into failure. |
|
||||
|
||||
## `slides +create` Does Not Do
|
||||
|
||||
| Non-responsibility | Evidence | Design implication for `+create-svglide` |
|
||||
| --- | --- | --- |
|
||||
| Does not generate slide XML from a prompt | `shortcuts/slides/slides_create.go:40-43`, `shortcuts/slides/slides_create.go:158-205` | `+create-svglide` must not become `--topic -> deck`. |
|
||||
| Does not deeply validate slide XML semantics | `shortcuts/slides/slides_create.go:44-69` | Only minimal publish-blocking validation belongs in the shortcut. |
|
||||
| Does not preview or repair layout | `shortcuts/slides/slides_create.go:125-221` | Preview and repair belong in skill/scripts or a runner before publish. |
|
||||
| Does not run readback inside `Execute` | `shortcuts/slides/slides_create.go:125-221`, `tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback is a test/proof step, not default shortcut behavior. |
|
||||
| Does not guarantee atomic creation | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | New publish shortcuts should provide recovery context, not hide partial success. |
|
||||
| Does not handle more than 10 inline pages | `shortcuts/slides/slides_create.go:18-21`, `shortcuts/slides/slides_create_test.go:441-465` | Bound the first version instead of building a complex batch manager. |
|
||||
| Does not own visual quality | `skills/lark-slides/SKILL.md:91-127`, `skills/lark-slides/SKILL.md:153-160` | Visual quality gates belong before the shortcut consumes artifacts. |
|
||||
|
||||
## Counterexamples
|
||||
|
||||
| Tempting requirement | Why it looks tempting | What `slides +create` teaches |
|
||||
| --- | --- | --- |
|
||||
| Add readback by default | E2E uses readback to prove persistence. | E2E calls the get API after creation; `Execute` itself stops after outputting the create result. Keep readback optional or outside MVP. |
|
||||
| Validate every page semantically before calling the backend | Better local errors sound useful. | `+create` only validates JSON shape, count, and local placeholder files; backend owns XML parsing. For SVGlide, only validate fields required to route and publish. |
|
||||
| Run preview lint and auto-repair | SVGlide has preview tooling. | `+create` does not make layout judgments. Preview lint and repair must remain pre-publish tooling. |
|
||||
| Accept a prompt and generate the deck | Higher-level UX is attractive. | `+create` consumes final submission artifacts. A prompt-to-deck runner would be a different command or script layer. |
|
||||
| Hide partial failures by retrying/rebuilding automatically | It feels friendlier. | `+create` surfaces partial progress instead. Recovery should be explicit and resumable. |
|
||||
|
||||
## `slides +create-svglide` Allowed Extra Responsibilities
|
||||
|
||||
`+create-svglide` can be slightly heavier than `+create` only where SVGlide's input contract requires it. The extra work must still be publish-boundary work, not generation work.
|
||||
|
||||
| Extra responsibility | Allowed because | Limit |
|
||||
| --- | --- | --- |
|
||||
| Read a SVGlide manifest or run directory | Unlike `--slides`, SVGlide artifacts are file-based. | Normalize to one manifest model immediately; do not infer design intent. |
|
||||
| Validate manifest schema and page order | Needed to know what to publish. | Validate shape and required fields only. |
|
||||
| Validate page file existence and path safety | Equivalent to `+create` validating `@path` placeholders. | Do not inspect aesthetics or text quality. |
|
||||
| Validate publish-required SVGlide fields | The target publish API or parser may require namespace, contract/version, dimensions, or roles before it can accept a page. | Check only required markers; do not rewrite ordinary SVG into protocol SVG in the shortcut. |
|
||||
| Upload declared local assets | Equivalent to `+create` uploading `@path` images. | Upload and token replacement only; no asset search or generation. |
|
||||
| Submit SVGlide pages to the target publish API | Equivalent to `+create` submitting each slide XML string. | Keep output and partial-progress behavior explicit; do not assume the CLI must convert to XML if the backend can consume SVGlide directly. |
|
||||
|
||||
## `slides +create-svglide` Must Not Own
|
||||
|
||||
| Responsibility | Owner |
|
||||
| --- | --- |
|
||||
| Research, outline, design brief, slide content planning | `skills/lark-slides` guidance and external runner/scripts |
|
||||
| SVG authoring | Agent or runner before publish |
|
||||
| Preview rendering, preview lint, and repair loop | Skill scripts or runner before publish |
|
||||
| Visual quality scoring | Skill/scripts/quality gate, not shortcut `Execute` |
|
||||
| Readback as default success criterion | E2E or optional verification flag |
|
||||
| PPE/Whistle routing as core naming | Environment/profile layer only |
|
||||
|
||||
## MVP Scope
|
||||
|
||||
Recommended first implementation:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
|
||||
```
|
||||
|
||||
MVP behavior:
|
||||
|
||||
1. Parse manifest.
|
||||
2. Validate required fields, page order, file existence, path safety, dimensions, and minimal SVGlide contract markers.
|
||||
3. Create presentation shell.
|
||||
4. Upload local assets declared in the manifest.
|
||||
5. Submit pages to the backend.
|
||||
6. Output `xml_presentation_id`, `url`, `page_ids` or `slide_ids`, uploaded asset count, and partial-progress context on failure.
|
||||
|
||||
MVP exclusions:
|
||||
|
||||
1. No prompt input.
|
||||
2. No generation stages.
|
||||
3. No preview repair.
|
||||
4. No default readback.
|
||||
5. No PPE-specific command name, directory name, or type name.
|
||||
|
||||
## Test Boundary For `+create-svglide`
|
||||
|
||||
The first test suite should mirror the shape of `slides +create` tests instead of proving the whole SVGlide generation pipeline.
|
||||
|
||||
| Test area | Required proof |
|
||||
| --- | --- |
|
||||
| Input contract | Invalid manifest, missing page file, unsafe path, and unsupported page count fail with structured params. |
|
||||
| Dry-run | Shows create, asset upload, and page publish steps with placeholder presentation id and deterministic step labels. |
|
||||
| Asset handling | Duplicate local assets upload once; page payloads reference uploaded tokens before publish. |
|
||||
| Partial failure | If the deck exists and page N fails, error includes presentation id, failed page index, and successfully published page count. |
|
||||
| Bot grant | Inherit user/bot output behavior from `slides +create`; grant failure is reported but not promoted to create failure. |
|
||||
| E2E | Create/publish result is asserted first; optional readback is a separate proof step unless the command explicitly adds a `--readback` contract. |
|
||||
|
||||
## Team Finding
|
||||
|
||||
The effective research team for this boundary is:
|
||||
|
||||
| Role | Scope |
|
||||
| --- | --- |
|
||||
| Code Reader | Extract runtime responsibilities from Go implementation. |
|
||||
| Test Reader | Extract behavior locks and prove what is outside `Execute`. |
|
||||
| Skill Boundary Reader | Separate agent/script responsibilities from shortcut responsibilities. |
|
||||
| Architect/Skeptic | Reject over-broad scope and map only proven `+create` patterns into `+create-svglide`. |
|
||||
|
||||
The team's proof standard is not "we read the files"; it is:
|
||||
|
||||
```text
|
||||
Every proposed +create-svglide responsibility must map to either:
|
||||
1. an existing +create responsibility, or
|
||||
2. a minimal extra responsibility forced by SVGlide's artifact input shape.
|
||||
```
|
||||
@@ -0,0 +1,160 @@
|
||||
# create-svglide 边界研究
|
||||
|
||||
## 目标
|
||||
|
||||
把 `slides +create` 作为 `slides +create-svglide` 的设计约束样本。
|
||||
|
||||
核心规则是:
|
||||
|
||||
```text
|
||||
slides +create 是已经写好的 slide XML 的薄发布器。
|
||||
slides +create-svglide 也应该是已经生成好的 SVGlide 产物的薄发布器。
|
||||
```
|
||||
|
||||
本文档以证据为先,区分现有 shortcut 真实承担的职责,以及 `lark-slides` skill 中描述的更宽泛的生成与验证工作。
|
||||
|
||||
## 研究范围
|
||||
|
||||
| 范围 | 文件 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| Go shortcut 实现 | `shortcuts/slides/slides_create.go`、`shortcuts/slides/helpers.go`、`shortcuts/slides/slides_media_upload.go`、`shortcuts/slides/shortcuts.go` | 确认 `slides +create` 的真实运行时边界。 |
|
||||
| 单元测试 | `shortcuts/slides/slides_create_test.go` | 确认可被测试锁定、不能随意漂移的行为。 |
|
||||
| E2E 证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go`、`tests/cli_e2e/slides/coverage.md` | 确认哪些证明发生在 shortcut 外部。 |
|
||||
| Skill 与 references | `skills/lark-slides/SKILL.md`、`skills/lark-slides/references/lark-slides-create.md`、`xml-schema-quick-ref.md`、`validation-checklist.md`、`troubleshooting.md` | 确认哪些工作属于 agent 指导或脚本,而不是 Go shortcut。 |
|
||||
|
||||
## `slides +create` 职责矩阵
|
||||
|
||||
| 职责 | 证据 | 边界含义 |
|
||||
| --- | --- | --- |
|
||||
| 注册一个名为 `slides +create` 的写操作 shortcut,支持 user 和 bot 身份 | `shortcuts/slides/slides_create.go:24-43`、`shortcuts/slides/shortcuts.go:8-17` | 这是 shortcut 封装,不是通用幻灯片生成系统。 |
|
||||
| 构造最小 presentation XML 外壳 | `shortcuts/slides/slides_create.go:224-241` | shortcut 只创建 deck 容器:标题和 960x540 presentation 元数据。 |
|
||||
| 创建在线 XML presentation | `shortcuts/slides/slides_create.go:125-148` | 第一个真实 API 调用是创建 presentation。 |
|
||||
| 接收可选 `--slides`,格式为 `<slide>` XML 字符串 JSON 数组 | `shortcuts/slides/slides_create.go:40-52` | 页面内容由调用方以最终 XML 字符串形式提供。 |
|
||||
| 限制一次内联提交最多 10 页 slide XML | `shortcuts/slides/slides_create.go:50-52` | 这只适用于普通 XML 的 `+create --slides` 内联数组;SVGlide manifest 发布必须使用自己的 manifest 页数上限。 |
|
||||
| 检测已提交 XML 里的本地图片占位符 | `shortcuts/slides/helpers.go:113-153` | shortcut 只理解一个很窄的 XML 约定:`<img src="@path">`。 |
|
||||
| 创建 presentation 前校验占位符文件 | `shortcuts/slides/slides_create.go:53-67` | 避免因为本地图片缺失或超限而创建孤儿 deck。 |
|
||||
| 上传占位符图片并替换为 file token | `shortcuts/slides/slides_create.go:163-177`、`shortcuts/slides/slides_media_upload.go:119-138`、`shortcuts/slides/helpers.go:283-309` | 图片上传是发布边界上的 helper 编排,不是内容生成。 |
|
||||
| 把每个调用方提供的 slide XML 字符串提交给 page-create API | `shortcuts/slides/slides_create.go:179-200` | shortcut 把调用方写好的 XML 转交给后端。 |
|
||||
| 页面创建失败时报告部分进度 | `shortcuts/slides/slides_create.go:194-196`、`shortcuts/slides/slides_create_test.go:354-420` | 不回滚;告诉调用方从哪里恢复。 |
|
||||
| 输出机器可读的创建结果 | `shortcuts/slides/slides_create.go:150-219` | 输出是 API 编排回执。 |
|
||||
| bot 创建 deck 后可选尝试给当前用户授权 | `shortcuts/slides/slides_create.go:215-217`、`shortcuts/slides/slides_create_test.go:66-198` | bot grant 是创建后的便利动作,不属于内容语义。 |
|
||||
|
||||
## 测试锁定的行为
|
||||
|
||||
| 行为 | 证据 | 边界含义 |
|
||||
| --- | --- | --- |
|
||||
| user 模式创建返回 `xml_presentation_id`、`title`、`url`,不返回 `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | user 模式输出是创建回执,不是验证报告。 |
|
||||
| 省略 `--title` 时,dry-run 和真实执行都归一为 `Untitled` | `shortcuts/slides/slides_create_test.go:200-253` | 标题归一是适合放在 shortcut 内的小型确定性便利。 |
|
||||
| `--slides` 会先创建 deck,再添加页面,最后返回 `slide_ids` 和 `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | 页面创建是容器创建后的编排。 |
|
||||
| `--slides []` 等价于不传 slides | `shortcuts/slides/slides_create_test.go:532-570` | 空产物列表应是明确的无追加操作,不应触发特殊生成逻辑。 |
|
||||
| 非法 JSON 和超过 10 个内联 slides 会以 `Param == "--slides"` 的校验错误失败 | `shortcuts/slides/slides_create_test.go:422-505` | 输入契约错误必须结构化,便于调用方路由处理。 |
|
||||
| 后端缺少 `xml_presentation_id` 时失败 | `shortcuts/slides/slides_create_test.go:255-283` | 创建成功必须拿到可用资源 id。 |
|
||||
| URL fallback 在本地构造,不调用 Drive metas 或 batch query | `shortcuts/slides/slides_create_test.go:649-688` | 能用本地回执构造的内容,不应增加额外 API 依赖。 |
|
||||
| 图片占位符按唯一路径上传一次,并在页面创建前完成替换 | `shortcuts/slides/slides_create_test.go:751-854` | 素材处理是发布边界的管道能力,不是设计工作。 |
|
||||
| 本地占位符文件缺失时,在任何 API 调用前失败 | `shortcuts/slides/slides_create_test.go:856-877` | 本地产物存在性是发布前置条件。 |
|
||||
| Dry-run 暴露 API 计划形状和占位 id | `shortcuts/slides/slides_create_test.go:572-602`、`shortcuts/slides/slides_create_test.go:879-900` | Dry-run 应描述编排计划,而不是执行重型校验副作用。 |
|
||||
| Readback 在 E2E 中作为单独 follow-up 调用证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`、`tests/cli_e2e/slides/coverage.md:9-16` | Readback 是测试和交付证据,不是默认 `Execute` 行为。 |
|
||||
| Bot 授权是非致命三态:granted、skipped、failed | `shortcuts/slides/slides_create_test.go:66-198` | 便利性的后置动作不应把创建成功升级成失败。 |
|
||||
|
||||
## `slides +create` 不负责的事情
|
||||
|
||||
| 非职责 | 证据 | 对 `+create-svglide` 的设计含义 |
|
||||
| --- | --- | --- |
|
||||
| 不从 prompt 生成 slide XML | `shortcuts/slides/slides_create.go:40-43`、`shortcuts/slides/slides_create.go:158-205` | `+create-svglide` 不能变成 `--topic -> deck`。 |
|
||||
| 不深度校验 slide XML 语义 | `shortcuts/slides/slides_create.go:44-69` | shortcut 内只应放发布阻塞级的最小校验。 |
|
||||
| 不预览或修复布局 | `shortcuts/slides/slides_create.go:125-221` | preview 和 repair 属于发布前的 skill/scripts 或 runner。 |
|
||||
| 不在 `Execute` 内做 readback | `shortcuts/slides/slides_create.go:125-221`、`tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback 是测试/证明步骤,不是默认 shortcut 行为。 |
|
||||
| 不保证原子创建 | `shortcuts/slides/slides_create.go:194-196`、`shortcuts/slides/slides_create_test.go:354-420` | 新发布类 shortcut 应提供恢复上下文,而不是隐藏部分成功。 |
|
||||
| 不处理超过 10 个内联页面 | `shortcuts/slides/slides_create.go:18-21`、`shortcuts/slides/slides_create_test.go:441-465` | 第一版应设边界,而不是一开始实现复杂批处理器。 |
|
||||
| 不负责视觉质量 | `skills/lark-slides/SKILL.md:91-127`、`skills/lark-slides/SKILL.md:153-160` | 视觉质量门禁应发生在 shortcut 消费产物之前。 |
|
||||
|
||||
## 反例
|
||||
|
||||
| 诱人的需求 | 为什么看起来合理 | `slides +create` 给出的约束 |
|
||||
| --- | --- | --- |
|
||||
| 默认加入 readback | E2E 用 readback 证明持久化。 | E2E 是创建后另调 get API;`Execute` 输出创建结果后即结束。Readback 应保持可选或放在 MVP 外。 |
|
||||
| 调后端前语义校验每一页 | 本地错误更友好。 | `+create` 只校验 JSON 形状、页数、本地占位符文件;XML 解析由后端负责。SVGlide 也只校验发布路由必需字段。 |
|
||||
| 运行 preview lint 并自动 repair | SVGlide 有 preview 工具链。 | `+create` 不做布局判断。Preview lint 和 repair 应留在发布前工具链。 |
|
||||
| 接受 prompt 并生成 deck | 高层 UX 很吸引人。 | `+create` 消费最终提交物。Prompt-to-deck runner 应是另一层命令或脚本。 |
|
||||
| 通过自动重试/重建隐藏部分失败 | 看起来更友好。 | `+create` 暴露部分进度。恢复应该显式、可续跑。 |
|
||||
|
||||
## `slides +create-svglide` 允许新增的职责
|
||||
|
||||
`+create-svglide` 只能在 SVGlide 输入契约强制要求的地方比 `+create` 稍重。新增工作仍必须属于发布边界,而不是生成边界。
|
||||
|
||||
| 额外职责 | 允许原因 | 限制 |
|
||||
| --- | --- | --- |
|
||||
| 读取 SVGlide manifest 或 run directory | 与 `--slides` 不同,SVGlide 产物是文件型产物。 | 立即归一化为一个 manifest 模型;不要推断设计意图。 |
|
||||
| 校验 manifest schema 和页序 | 需要知道要发布什么。 | 只校验形状和必填字段。 |
|
||||
| 校验页面文件存在性和路径安全 | 等价于 `+create` 校验 `@path` 占位符。 | 不检查美观度或文本质量。 |
|
||||
| 校验发布必需的 SVGlide 字段 | 目标发布 API 或 parser 可能需要 namespace、contract/version、尺寸或 role 才能接收页面。 | 只检查必需标记;不要在 shortcut 中把普通 SVG 重写成协议 SVG。 |
|
||||
| 上传声明的本地素材 | 等价于 `+create` 上传 `@path` 图片。 | 只做上传和 token 替换;不做素材搜索或生成。 |
|
||||
| 把 SVGlide 页面提交给目标发布 API | 等价于 `+create` 提交每个 slide XML 字符串。 | 保持输出和部分进度语义明确;如果后端能直接消费 SVGlide,不要假设 CLI 必须转 XML。 |
|
||||
|
||||
## `slides +create-svglide` 必须不拥有的职责
|
||||
|
||||
| 职责 | 所属边界 |
|
||||
| --- | --- |
|
||||
| research、outline、design brief、slide content planning | `skills/lark-slides` 指导和外部 runner/scripts |
|
||||
| SVG authoring | agent 或 runner,在发布前完成 |
|
||||
| preview rendering、preview lint、repair loop | skill scripts 或 runner,在发布前完成 |
|
||||
| 视觉质量评分 | skill/scripts/quality gate,不属于 shortcut `Execute` |
|
||||
| readback 作为默认成功标准 | E2E 或可选验证 flag |
|
||||
| PPE/Whistle 路由进入核心命名 | 只能属于环境/profile 层 |
|
||||
|
||||
## MVP 范围
|
||||
|
||||
推荐第一版实现:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
|
||||
```
|
||||
|
||||
MVP 行为:
|
||||
|
||||
1. 解析 manifest。
|
||||
2. 校验必填字段、页序、文件存在性、路径安全、尺寸、最小 SVGlide contract 标记。
|
||||
3. 创建 presentation 外壳。
|
||||
4. 上传 manifest 声明的本地素材。
|
||||
5. 把页面提交给后端。
|
||||
6. 输出 `xml_presentation_id`、`url`、`page_ids` 或 `slide_ids`、上传素材数量,以及失败时的部分进度上下文。
|
||||
|
||||
MVP 排除项:
|
||||
|
||||
1. 不接受 prompt 输入。
|
||||
2. 不包含生成阶段。
|
||||
3. 不做 preview repair。
|
||||
4. 不默认 readback。
|
||||
5. 不在命令名、目录名或类型名中包含 PPE。
|
||||
|
||||
## `+create-svglide` 测试边界
|
||||
|
||||
第一版测试应镜像 `slides +create` 的测试形状,而不是证明完整 SVGlide 生成流水线。
|
||||
|
||||
| 测试范围 | 必须证明 |
|
||||
| --- | --- |
|
||||
| 输入契约 | 非法 manifest、缺失页面文件、不安全路径、不支持的页数以结构化 param 失败。 |
|
||||
| Dry-run | 展示 create、asset upload、page publish 步骤,包含占位 presentation id 和确定性的 step label。 |
|
||||
| 素材处理 | 重复本地素材只上传一次;页面 payload 在发布前引用已上传 token。 |
|
||||
| 部分失败 | deck 已存在但第 N 页失败时,错误包含 presentation id、失败页序号、已成功发布页数。 |
|
||||
| Bot grant | 继承 `slides +create` 的 user/bot 输出行为;grant 失败不升级成 create 失败。 |
|
||||
| E2E | 先断言 create/publish 结果;可选 readback 作为单独证明步骤,除非命令显式加入 `--readback` 契约。 |
|
||||
|
||||
## Team 结论
|
||||
|
||||
适合研究这个边界的 team 是:
|
||||
|
||||
| 角色 | 范围 |
|
||||
| --- | --- |
|
||||
| Code Reader | 从 Go 实现中抽取运行时职责。 |
|
||||
| Test Reader | 抽取行为锁定点,并证明哪些行为不在 `Execute` 内。 |
|
||||
| Skill Boundary Reader | 区分 agent/script 职责和 shortcut 职责。 |
|
||||
| Architect/Skeptic | 拒绝过宽 scope,只把已被 `+create` 证明的模式映射到 `+create-svglide`。 |
|
||||
|
||||
这个 team 的证明标准不是“读过文件”,而是:
|
||||
|
||||
```text
|
||||
每一个 proposed +create-svglide 职责都必须映射到:
|
||||
1. 一个已有 +create 职责;或
|
||||
2. 一个由 SVGlide 产物输入形态强制产生的最小额外职责。
|
||||
```
|
||||
180
skills/lark-slides/references/lark-slides-create-svglide.md
Normal file
180
skills/lark-slides/references/lark-slides-create-svglide.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# slides +create-svglide
|
||||
|
||||
`slides +create-svglide` publishes an existing SVGlide bundle to Lark Slides.
|
||||
It is a publisher, not a generator.
|
||||
|
||||
Use it only after the SVG Slides local generation/validation layer has produced a publish-ready manifest and page files.
|
||||
|
||||
## What It Owns
|
||||
|
||||
- Read `svglide.manifest.v1`.
|
||||
- Validate `protocol == "svg-slides.v1"`, `publish_ready == true`, required bundle receipts, `receipts.validate_svg_deck.totalErrors == 0`, visual-system/color-rhythm zero-error reports, page order, page count, 960x540 size, local file paths, and `pages[].sha256`.
|
||||
- Verify each page is still raw SVG by re-reading the file, checking sha256, checking the XML root with an XML decoder, and rejecting Slides XML/SXSD/HTML/raster/data URL fallback.
|
||||
- Upload local SVG image resources and rewrite `<image href>` / `<image xlink:href>` values to Slides media `file_token`s before submitting each raw SVG page.
|
||||
- Create the presentation shell.
|
||||
- Submit each page payload in manifest order.
|
||||
- Return `xml_presentation_id`, URL, slide IDs, page mapping, source/submitted page hashes, and image upload/rewrite receipts.
|
||||
|
||||
## What It Does Not Own
|
||||
|
||||
- Research, outline, or content planning.
|
||||
- SVG/SVGlide authoring.
|
||||
- Preview, visual QA, or repair loops.
|
||||
- Automatically running online readback; callers must run the readback gate after publish.
|
||||
- PPE routing or Whistle setup. For internal PPE validation, use the dev-only script below.
|
||||
- Remote image fetching, image generation, or FileMetaMap shaping.
|
||||
|
||||
Generated SVG decks must not bypass this command by calling `slides xml_presentation.slide create` directly. Direct raw API calls can prove transport only; they do not prove that the generated deck passed the SVGlide parser contract, bundle receipts, asset rewrite, or readback gates.
|
||||
|
||||
## Page Count Boundary
|
||||
|
||||
`slides +create-svglide` accepts 1-50 pages in `manifest.pages`.
|
||||
This limit is independent from `slides +create --slides`, whose inline JSON array is limited to 10 entries.
|
||||
Do not split a 12-page SVG Slides deck just because `+create --slides` has a 10-page inline payload limit.
|
||||
|
||||
## Internal PPE Lane
|
||||
|
||||
For internal online validation of SVG Slides, the only supported dev lane is `ppe_svg_slides`.
|
||||
Do not use older local lanes such as `ppe_svglide` or `ppe_pure_svg`.
|
||||
|
||||
Set or clear the rule with the tracked dev-only script:
|
||||
|
||||
```bash
|
||||
# enable the canonical rule
|
||||
. ./scripts/dev-svglide-ppe-env.sh pre
|
||||
|
||||
# print the canonical Whistle rules without applying them
|
||||
./scripts/dev-svglide-ppe-env.sh rules
|
||||
|
||||
# disable the canonical rule
|
||||
. ./scripts/dev-svglide-ppe-env.sh off
|
||||
```
|
||||
|
||||
The expected effective request route is:
|
||||
|
||||
```text
|
||||
host: open.feishu-pre.cn
|
||||
Env: Pre_release
|
||||
x-tt-env: ppe_svg_slides
|
||||
x-use-ppe: 1
|
||||
```
|
||||
|
||||
This script is not part of production publish behavior. It exists only to make local/PPE validation reproducible.
|
||||
|
||||
## Manifest
|
||||
|
||||
Minimum shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"title": "Deck title",
|
||||
"size": { "width": 960, "height": 540 },
|
||||
"publish_ready": true,
|
||||
"published": false,
|
||||
"pages": [
|
||||
{ "id": "page-001", "index": 1, "file": "slides/page-001.svg", "sha256": "..." }
|
||||
],
|
||||
"receipts": {
|
||||
"validate_svg_deck": "receipts/validate_svg_deck.json",
|
||||
"prompt_read_trace": "receipts/prompt_read_trace.json",
|
||||
"asset_readiness": "receipts/asset_readiness.json",
|
||||
"typography_plan": "receipts/typography_plan.json",
|
||||
"visual_system": "receipts/visual_system.json",
|
||||
"visual_system_report": "receipts/visual_system_report.json",
|
||||
"color_rhythm": "receipts/color_rhythm.json",
|
||||
"frame_discipline": "receipts/frame_discipline.json",
|
||||
"parser_contract": "receipts/parser_contract.json",
|
||||
"readability_safety": "receipts/readability_safety.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All page files and receipt paths are relative to the manifest directory. Absolute paths and `..` paths are rejected. Unknown manifest fields are ignored for forward compatibility. `published` is bundle state and is not used as a blocker by `+create-svglide`.
|
||||
|
||||
Local SVG image hrefs are resolved inside the manifest/run root, uploaded to the created presentation, and rewritten to returned media tokens before page submission. Remote URLs, data URLs, fragment hrefs, unresolved paths, `@path` placeholders, and paths escaping the root are rejected.
|
||||
|
||||
## Usage
|
||||
|
||||
Dry-run first:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide \
|
||||
--as user \
|
||||
--manifest manifest.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Publish:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide \
|
||||
--as user \
|
||||
--manifest manifest.json \
|
||||
--title "Optional title override"
|
||||
```
|
||||
|
||||
For online generated decks, publish is not complete until readback passes:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_readback_gate.mjs <deck-dir> --presentation-id <xml_presentation_id> --json > receipts/online_readback_gate.json
|
||||
```
|
||||
|
||||
The gate must report `ok: true`. A successful `slide.create` transport receipt without this gate is not sufficient evidence that text and images survived parser conversion.
|
||||
|
||||
Expected output includes:
|
||||
|
||||
- `xml_presentation_id`
|
||||
- `url`
|
||||
- `slides_added`
|
||||
- `slide_ids`
|
||||
- `pages[].source_sha256`
|
||||
- `pages[].submitted_sha256`
|
||||
- `images_uploaded`
|
||||
- `image_rewrites`
|
||||
|
||||
### Page-Level Repair Loop
|
||||
|
||||
When a validation gate fails, do not regenerate the full deck by default. Generate page-level repair context from receipts:
|
||||
|
||||
Page-level repair context can be generated from these receipt families:
|
||||
|
||||
- `validate_svg_deck`: parser-level SVG errors.
|
||||
- `frame_discipline`: text-adjacent frame misuse.
|
||||
- `readability_safety`: overlap, out-of-bounds, and missing text-over-image mask failures.
|
||||
- `asset_readiness_report`: only page-level asset execution failures, such as missing embedded primary visuals or missing mask shapes.
|
||||
- `typography_plan_report`: only page-level SVG font execution failures, such as a slide using a font stack outside the plan.
|
||||
- `visual_system_report`: only page-level SVG palette execution failures, such as one slide using a color outside the locked palette.
|
||||
|
||||
Deck-level failures still require chain-level correction and must not be converted into page repair contexts:
|
||||
|
||||
- missing prompt reads
|
||||
- missing web-search evidence
|
||||
- missing prepared assets
|
||||
- invalid deck visual identity
|
||||
- invalid typography plan fields
|
||||
- invalid visual_system palette/rhythm fields
|
||||
- color_rhythm deck-level failures, such as dark-page budget overflow, excessive transition ratio, or alternating full-page dark/light sequences
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_repair_context.mjs <deck-dir> --out <deck-dir>/repairs --json
|
||||
node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs <deck-dir> --write <deck-dir>/receipts/repair_hashes.before.json --json
|
||||
```
|
||||
|
||||
Repair only the failed `slides/slide_NN.svg` files listed in `repairs/repair_context.json`. After replacement, verify non-failed pages did not change:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs <deck-dir> --baseline <deck-dir>/receipts/repair_hashes.before.json --allowed slides/slide_NN.svg --json
|
||||
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"
|
||||
```
|
||||
|
||||
The repair unit is one slide file. The acceptance unit remains the full deck.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
The command validates local bundle state and local image paths before creating the presentation. If image upload fails after presentation creation, the error hint includes the created presentation ID and says no pages were added.
|
||||
|
||||
If adding a later page fails, the command does not roll back the created presentation. The error hint includes presentation ID, failed page ordinal, page ID, and the number of pages already added.
|
||||
|
||||
If the backend rejects raw SVG in `slide.content`, stop. Do not fall back to screenshots, HTML, generated XML, or rasterized pages inside this shortcut.
|
||||
@@ -56,7 +56,7 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--title` | 否 | 演示文稿标题(不传则默认 "Untitled") |
|
||||
| `--slides` | 否 | slide 内容 JSON 数组,每个元素是一个 `<slide>` XML 字符串(最多 10 个;超过 10 页请先用 `+create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加) |
|
||||
| `--slides` | 否 | slide 内容 JSON 数组,每个元素是一个 `<slide>` XML 字符串(普通 XML 内联最多 10 个;更多普通 XML 页面请先用 `+create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加;SVG Slides / SVGlide manifest 请改用 `+create-svglide`) |
|
||||
|
||||
## `--slides` 参数格式
|
||||
|
||||
|
||||
57
skills/lark-slides/references/svg-slides/README.md
Normal file
57
skills/lark-slides/references/svg-slides/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# SVG Slides Local Generation
|
||||
|
||||
This directory is the runtime reference contract for local SVG Slides generation.
|
||||
|
||||
Do not use maintainer prompt-source files as generation context. Runtime agents should follow the read routes below.
|
||||
|
||||
## Read Routes
|
||||
|
||||
| Task | Read first | Then read |
|
||||
|---|---|---|
|
||||
| Understand the main SVG Slides workflow | `system-prompt.md` | `svg-reference.md`, `tools/slides-edit.md` |
|
||||
| Author or repair SVG protocol content | `svg-reference.md` | `tools/slides-edit.md`, `tools/compute-custom-shape-bbox.md` |
|
||||
| Resolve deck design direction | `resolve-design-brief/README.md` | `deck-design-reference-catalog.md` |
|
||||
| Choose or audit typography | `resolve-design-brief/style-director-prompt.md` | `system-prompt.md`, `svg-reference.md`, `validation/typography-plan.md` |
|
||||
| Generate or revise charts | `generate-svg-chart/README.md` | matching files under `generate-svg-chart/snippets/` |
|
||||
| Continue or convert an existing deck | `tools/slides-convert.md` | `tools/slide-organize.md`, `tools/slides-edit.md` |
|
||||
| Prove asset search/readiness | `validation/asset-readiness.md` | `../../../scripts/svg_slides_asset_readiness.mjs`, `../../../scripts/svg_slides_visual_identity_check.mjs` |
|
||||
| Prove typography planning | `validation/typography-plan.md` | `../../../scripts/svg_slides_typography_plan.mjs` |
|
||||
| Prove visual system and color rhythm | `validation/visual-system.md` | `../../../scripts/svg_slides_visual_system.mjs`, `../../../scripts/svg_slides_color_rhythm.mjs` |
|
||||
| Prove frame discipline | `validation/frame-discipline.md` | `../../../scripts/svg_slides_frame_discipline.mjs` |
|
||||
| Prove readability safety | `validation/README.md` | `../../../scripts/svg_slides_readability_safety.mjs` |
|
||||
| Prove prompt reads for a generated deck | `validation/prompt-read-trace.md` | `../../scripts/svg_slides_prompt_trace.mjs` |
|
||||
| Audit a delivered deck before answering the user | `validation/post-delivery-audit.md` | `validation/asset-readiness.md`, `../../../scripts/svg_slides_readback_gate.mjs` |
|
||||
|
||||
## Boundary
|
||||
|
||||
Local SVG Slides generation and validation produce local artifacts. Publishing to Lark Slides is outside this reference family unless a separate publisher explicitly consumes the generated bundle.
|
||||
|
||||
## Prompt Read Trace Gate
|
||||
|
||||
Every generated local bundle must include `receipts/prompt_read_trace.json` before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The trace records the runtime prompt/reference Markdown files read for the generation task, including path, role, reason, and SHA-256.
|
||||
|
||||
## Asset Readiness Gate
|
||||
|
||||
Every generated local bundle must include `receipts/asset_readiness.json` before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The receipt records the deck's visual identity fingerprint, default image-search policy, required visual subjects, prepared local assets, and per-slide layout families.
|
||||
|
||||
## Typography Plan Gate
|
||||
|
||||
Every generated local bundle must include `receipts/typography_plan.json` before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The receipt records the deck's selected font category, display/body/label stacks, requested fonts, fallback decisions, source, and rejected substitutions. Slide SVG text must use only those planned stacks.
|
||||
|
||||
## Visual System Gate
|
||||
|
||||
Every generated local bundle must include `receipts/visual_system.json`, then pass `visual_system_report` and `color_rhythm` validation before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The receipt records deck-level palette tokens and per-slide background rhythm. It preserves slide layout freedom, but prevents adjacent pages from jumping between unrelated full-page color systems.
|
||||
|
||||
## Frame Discipline Gate
|
||||
|
||||
Every generated local bundle must pass frame-discipline validation before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The gate keeps text unframed by default and rejects stroked text containers that lack an explicit semantic `data-frame-role`.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Deck Design Reference Catalog
|
||||
## Context
|
||||
- Role: reference
|
||||
- Purpose: Narrative archetypes and depth rubric consumed by the design brief prompt.
|
||||
## Content
|
||||
|
||||
|
||||
# Deck design reference catalog
|
||||
|
||||
Use this as your reference library: SELECT the best-fit narrative archetype for the deck's scenario, then ADAPT to specifics, and set depth per the rubric. Do not invent from scratch when a fit exists.
|
||||
|
||||
## Narrative archetypes (pick the closest, adapt the spine)
|
||||
|
||||
### Magnitude-First Investor Pitch
|
||||
- When: Raising capital from investors who scan dozens of decks/day: accelerator demo days, seed/Series A-C, CVC, emerging-market, SMB/SBA, government-RFP-as-pitch. The reader is deciding whether to take a meeting, not learning.
|
||||
- Spine: cover -> one-sentence what-you-do (<=7 words) -> problem/why-now -> traction-or-unit-economics moved EARLY (slide 3-4) -> product (<=2 screens) -> market (bottom-up) -> moat/why-us -> team -> the ask. Stage scales detail: demo-day=10 slides one-line-each; A=14 with magnitude metrics; B/C=18 with cohort triangle + Rule-of-40; CVC inserts strategic-fit matrix; EM adds dual-currency + FX; SBA adds T12M + DSCR.
|
||||
- Discipline: One idea per slide, one chart per concept. Traction is magnitude-first (ARR/NDR/burn, never a lone MoM%); weak metrics get an explainer slide, never hidden.
|
||||
|
||||
### Answer-First Decision Deck (Pyramid)
|
||||
- When: Any moment where a named principal must DECIDE in the room or before it: board pre-reads, exec/IC decision briefs, IT/capital-investment approvals, M&A/budget defenses, policy decision memos, 1-page ExCo asks, QBRs framed as resource asks, deck-rescue/CEO-polish passes.
|
||||
- Spine: recommendation/BLUF on slide <=3 (<=25 words) -> Why-Now / What-We-Need / Trade-offs grid -> sized evidence (variance waterfall, TCO, football-field, scorecard) -> risks each paired with mitigation+owner -> Decision-Ask footer (number + named owner + decide-by date) -> analysis demoted to appendix.
|
||||
- Discipline: Conclusion-first: the ask survives if nothing else is read. Every body title is a full-sentence takeaway; every chart names its so-what; name what you give up.
|
||||
|
||||
### Operating Cadence Review
|
||||
- When: Recurring status-to-decision rhythm for an internal leadership audience: MOR/QBR, OKR reviews, SteerCo/kickoff, CRM-funnel reviews, weekly KPI/WBR, regional-to-HQ reviews, board pre-reads with scorecards.
|
||||
- Spine: cover -> R/Y/G scorecard (slide 02) -> headline verdict -> plan-vs-actual variance waterfall -> operating metrics / funnel -> wins -> misses-with-named-owner-and-lesson -> risks (prob x impact, owner, leading indicator) -> Decisions Required / Asks (with $/owner/date) -> commits.
|
||||
- Discipline: Green gets zero airtime; spend time only on red. Never >80% green (sandbagging flag); every open decision carries a named owner and decide-by date.
|
||||
|
||||
### Consulting Engagement Argument
|
||||
- When: Tier-1 strategy artifacts that argue a case end-to-end: diagnostic readouts, final engagement decks, framework packs, capability/proposal pitches, 3-year/5-year strategic plans, transformation/org-strategy reviews.
|
||||
- Spine: cover -> Governing Thought / Minto SCQA answer (slide 02) -> approach -> chaptered evidence dividers each ending in a So-What -> options compared (effort-vs-impact 2x2, dollar-sized) -> Where-to-Play x How-to-Win choice cascade -> roadmap with named owners -> risks/sensitivities -> decisions required -> deep appendix.
|
||||
- Discipline: One framework per chapter (no blending); argument-logic not chronology; converge analysis into 3 named/owned/sequenced moves with quantified resource implications.
|
||||
|
||||
### Thesis-Driven Market/Investment Report
|
||||
- When: A POV that must move a sophisticated reader's model: VC landscape/thesis decks, industry deep-dives, equity-research earnings notes, IBD roadshows, capital-markets days, analyst briefings, AI-capability/vertical briefings.
|
||||
- Spine: cover -> falsifiable Thesis Sentence (slide 02) -> exec summary -> evidence stack (TAM triangulation, value chain, Five Forces, bridges/waterfalls) -> per-segment or per-workflow decomposition tables -> traction/valuation -> historical analogue -> disagreements/anti-thesis -> Bets-We'd/Won't-Make -> risks/catalysts -> sourced references.
|
||||
- Discipline: A claim, not a topic, with a required anti-thesis. Every number is source-traceable (10-K/earnings/dated consensus) and decomposed to task or driver level, not a market tour.
|
||||
|
||||
### Expert Research Talk (Thesis-Then-Evidence)
|
||||
- When: Argued scholarly presentations to an expert/committee audience: NeurIPS-style orals, PhD/thesis/survey defenses, job talks, humanities/comp-lit/divinity seminars, conference readouts, lab meetings, review-article companions.
|
||||
- Spine: cover -> defensible Thesis/Contribution sentence (slide 02, passes 'so what?'+'who disagrees?') -> gap/scope -> background -> per-contribution or per-cluster blocks (setup -> method -> one headline result panel -> ablation -> limits) -> synthesis -> limitations BEFORE conclusion -> falsifiable forward bets -> Works Cited + pre-empted-questions appendix.
|
||||
- Discipline: Argue at claim/contribution altitude, not coverage; one claim per slide, one chart per claim with baseline+delta in the title. Cite-as-ethics; name what you won't settle.
|
||||
|
||||
### Active-Learning Instructional Session
|
||||
- When: Time-boxed teaching where the learner must DO something: K-12/TA/recitation lessons, university STEM lectures, coding/Excel/cert/language/medical-CME, vocational bench, exam-prep, nursing preclinical. One concept/skill per session.
|
||||
- Spine: cover -> single measurable objective/can-do (slide 02, code-tagged) -> hook/retrieval warm-up -> I-Do worked example -> Check -> We-Do -> You-Do -> deliberate break/error to debug -> common-errors slide -> exit ticket / pass-fail rubric mapped 1:1 to the objective.
|
||||
- Discipline: Gradual release with a check at every time-chunk; worked-example fading (full -> partial -> solo). One objective per session; close on a graded retrieval, never 'thank you'.
|
||||
|
||||
### Behavior-Change Compliance/Safety Training
|
||||
- When: Mandatory training that must change a frontline decision AND survive an auditor: FCPA/ethics, EHS/safety drills, caregiver/CNA certification, SaaS-admin/sales enablement recerts, employee onboarding with attestation.
|
||||
- Spine: cover -> why-we're-here -> named speak-up/stop-work channel -> opening real (anonymized) incident -> policy/regulation frame -> scenario decision drills (red/green cards) -> documentation/warning-signs -> tracked attestation or signed competency card.
|
||||
- Discipline: Scenario-first, policy-second; the named channel (speak-up / stop-work / system-of-record) appears on every policy slide. Close with a tracked acknowledgement that doubles as the audit trail.
|
||||
|
||||
### Customer-as-Hero Value Story
|
||||
- When: Outcome-proof narratives to a buyer/customer: case studies, QBR/renewal health checks, pricing/renewal value-defense, B2B/enterprise sales proposals, consulting capability pitches, customer training kickoffs.
|
||||
- Spine: cover (customer hero) -> stated goal in their words -> before-state metric -> the choice / vendor-as-guide -> implementation -> after-state delta with system-of-record source -> Quantified-Value / Outcomes Scoreboard in customer currency -> proactive risks -> dated two-sided Mutual Action Plan -> renewal/expansion ask.
|
||||
- Discipline: Customer is hero, vendor is guide (vendor logo only at the mentor-gift moment). Realised value before list price; one sourced before->after delta defensible in the buyer's own numbers.
|
||||
|
||||
### Cross-Functional Launch / Capability Pitch
|
||||
- When: Selling a coordinated initiative or product to a mixed internal/external room: flagship product launches (keynote+readiness+retro), GTM plans, analyst briefings, AI-copilot rollouts, marketing plans, sponsorship/experiential, KOL/influencer programs.
|
||||
- Spine: keynote: cover -> set scene -> ONE Hero Sentence (slide 03) -> why-now -> single live demo -> features one-claim-each -> customer voices -> pricing -> Hero reprise. Companion readiness deck: commitments x function with owners/dates. D+30 retro: grade table vs the readiness commitments.
|
||||
- Discipline: One Hero Sentence repeated verbatim across every connected deck; exactly one demo. The retro grades the committed numbers, not vibes.
|
||||
|
||||
### Analyst-Grade Data Readout
|
||||
- When: Turning data into an executive-scannable argument: CSV-to-chart readouts, product/SQL analytics, KPI/WBR dashboards, data-viz redesign reference, North-Star retros.
|
||||
- Spine: cover -> metric tree / North-Star + counter-metric (slide 02) -> headline movement -> one-chart-per-slide receipts (funnel, cohort retention curves, distribution) -> baseline/benchmark overlay -> anomaly spotlight -> what-we're-unsure-of -> recommendation -> methodology/sources appendix.
|
||||
- Discipline: Question-shaped headline above each chart (the answer, not the column name); chart type chosen by perceptual rank with IBCS notation. Definitions/grain/filters visible; no hand-waved segments.
|
||||
|
||||
### Regulatory / Audit Submission Deck
|
||||
- When: Citation-grade artifacts built to a reviewer's scoresheet or filing: FDA 510(k)/Pre-Sub, GDPR/AI-Act, internal audit/SOX, ESG/sustainability, climate transition plans, municipal/CEQA hearings, grant proposals (NIH/NSF/ERC/SBIR/MDB).
|
||||
- Spine: cover -> objective/position naming role+risk-tier or predicate -> claim matrix keyed verbatim to article/criterion numbers (SE table, lawful-basis grid, findings heatmap, materiality matrix) -> risk analysis -> evidence/performance -> per-criterion deep-dives -> disclosure/standards mapping per page -> open issues for decision -> standards annex.
|
||||
- Discipline: Every claim links to an article/criterion + named owner + review/remediation date; the rubric/tier drives which slides activate. Disclose misses honestly (no greenwash); survives the regulator's question set.
|
||||
|
||||
### Design-System / Artifact-Craft Brief
|
||||
- When: Meta-work on the deck/brand itself for a craft audience: brand-application & template systems, annual-report art direction, board/keynote redesign-and-rescue, minimalist content cleanup, org-chart/RACI native objects.
|
||||
- Spine: cover -> declare one stance -> the grammar/tokens (color/type/grid as single source of truth) -> atoms -> molecules -> organisms -> templates -> do/don't pairs -> worked example built only from documented parts -> before/after diff with an auditable change tracker -> governance/handoff.
|
||||
- Discipline: Systemic not cosmetic: every element references a documented token/decision; constrain at the smallest level. Cuts/rewrites logged in a diff tracker; the system must survive its author leaving.
|
||||
|
||||
### Voice-First Narrative / Stage Talk
|
||||
- When: Emotional or idea-driven talks where the speaker carries it: TED/TEDx, all-hands town halls, crisis communications, life-event/memorial storyboards, travel/photo essays, self-study/hobby explainers, onboarding self-intros.
|
||||
- Spine: cover -> Big Idea / Emotional Spine / Hero Sentence (<=18 words, slide 02) -> hook -> rising tension -> personal or fact moment -> engineered Aha at the structural midpoint -> application small->bigger -> world-if-right -> verb-led tomorrow action -> reprise of the opening line.
|
||||
- Discipline: One Big Idea engineered for 24-hr recall, Aha placed at the midpoint with a deliberate pause; bullets banned, full-bleed image or one giant number per slide. Specificity over sentimentality. (Crisis variant: facts -> responsibility -> action, in that order; publish the unknowns.)
|
||||
|
||||
### Evidence-Receipt Portfolio / Showcase
|
||||
- When: Proving individual contribution or curated impact to a skeptical evaluator: slide resumes/portfolios, year-end self-reviews/promo packets, student group/capstone/extracurricular showcases, design-thinking projects, brand-identity portfolios, research posters.
|
||||
- Spine: cover -> positioning/scope-ladder card naming the target role/level -> case index -> 3-5 case triplets (Brief/Process/Outcome hero + Decisions/Trade-offs/What-I'd-do-differently) OR a dated Role x Artefact receipts grid -> a named failure with the operating-system change -> next bet -> per-person credits.
|
||||
- Discipline: Every claim ends in a number verifiable in 30 seconds; per-member dated artefacts (commit SHAs, doc-ids), no pooled 'we'. Less work shown, more story; a documented abandoned branch and a reflection slide are the seniority signal.
|
||||
|
||||
## Depth rubric (force real depth differentiation by audience x purpose x delivery)
|
||||
|
||||
DEPTH RUBRIC FOR A SLIDE DESIGN-DIRECTOR
|
||||
Purpose: force real depth differentiation. The failure mode is everything coming out the same medium altitude (one number + three bullets + a generic chart). Every deck must pick a row below and refuse the others' moves. ALTITUDE = how high above the work you argue (decision/idea vs. mechanism vs. literal step). DENSITY = how much load-bearing detail per slide. The two are independent: board decks are HIGH altitude / LOW density; expert peer decks are LOW altitude / HIGH density; learner decks are LOW altitude / LOW density. The most common error is collapsing toward MID/MID.
|
||||
|
||||
==================================================
|
||||
AXIS 1 — AUDIENCE x PURPOSE (altitude + density)
|
||||
==================================================
|
||||
|
||||
A. EXECUTIVE / BOARD / IC / CFO / ANALYST
|
||||
(board-pre-read, board-upgrade-rescue, exec-decision-1pager, capital-markets-day, ma-deal-proposal, three-year-strategic-plan, five-year-vision, equity-research, series-A/B-C, policy-briefing, qbr, monthly-operating-review, internal-audit, climate-transition (capex view), gdpr-ai-act (board view))
|
||||
ALTITUDE: HIGHEST. Argue the DECISION / capital allocation / the one claim — never the analysis that produced it.
|
||||
DENSITY: LOW on the face, DEEP in appendix. One decision-grade visual per slide.
|
||||
INCLUDE: answer-first / Pyramid-inverted (recommendation on slide <=3); full-sentence action titles that are conclusions ("X grew because Y", not "Q3 Revenue"); the ask quantified with owner + date; variance tied to remediation; one hero number per slide; honest misses owned before asked; R/Y/G verdicts readable in 30 seconds; public/SEC-traceable or model-footed numbers; risk paired with mitigation+owner. Reg-bounded numbers must foot to the cent (equity-research, CMD).
|
||||
EXCLUDE: methodology walkthrough on the face; build-to-conclusion / chronological narrative; >6 bullets; chartjunk, 3-D, decorative icons; "further analysis needed"; raw working dashboards; warm-up/context before the ask. Detail goes to appendix, not the body.
|
||||
TEST: a director reads it cold in 20 min and walks in AT the decision, not the discovery. Green gets zero air time; time is spent only on red.
|
||||
|
||||
B. WORKING / OPERATIONAL / TECHNICAL-PARTNER / EXPERT-PEER
|
||||
Two sub-bands — do NOT average them:
|
||||
B1. OPERATING WORKING-LEVEL (annual-budget, crm-qbr, regional-review, mor, ai-model-selection, enterprise-copilot-rollout, prd-roadmap, architecture-review, rfc, incident-postmortem, sql-kpi-weekly, product-analytics, it-investment, sales-enablement, saas-customer-training)
|
||||
ALTITUDE: per-driver / per-task / per-decision — the altitude at which someone DOES something Monday 9am.
|
||||
DENSITY: HIGH. Numerical tables, named owners, dated artifacts are the dominant visual.
|
||||
INCLUDE: driver-level $ decomposition; named roles + loaded costs; per-task altitude with eval methodology (rater agreement, contamination caveats, sample size/date); reconciled-to-source numbers (CRM stage defs, dbt semantic layer); Push/Pull/Kill or approve/modify rows actionable by a name; metric shown four ways (PvA/QTD/YTD/FY-LE) where it's a review; ADR/decision logs reviewers leave WITH; reversibility classification; definitions/grain/filters visible on the slide.
|
||||
EXCLUDE: strategic vision / market tours; one-big-number minimalism (that's the exec failure transplanted down); vibes instead of measured costs; hand-waved segments ("engaged users").
|
||||
TEST: plugs straight into the finance/eng model; standalone-readable without a walkthrough.
|
||||
B2. EXPERT-PEER / COMMITTEE / REVIEWER (neurips-oral, phd-thesis/survey-defense, academic-review, humanities/cross-language/divinity seminar, ai-hardtech-pitch to technical partners, fda-510k, research-poster (1m read), erc/nih-nsf/sbir/kakenhi grants)
|
||||
ALTITUDE: claim-cluster / contribution, NOT survey summary. Assumes domain literacy — argues, does not introduce.
|
||||
DENSITY: HIGHEST but cognitive-load-disciplined (one new symbol/claim per slide; one chart per claim with baseline+variable+delta IN the title).
|
||||
INCLUDE: high citation density / full DOIs; original-language or original-symbol stratum load-bearing; benchmark with sample size + date + methodology footnote; failure-mode rates; falsifiable forward bets; every reviewer prior pre-rebutted; contingency per aim; CFR/ISO/Article-precise citations where regulatory.
|
||||
EXCLUDE: lay analogies, "what AI is" definitions, Gartner curves, coverage-over-depth survey, motivational filler. Breadth is the failure; one contribution recalled at lunch is the win.
|
||||
|
||||
C. PUBLIC / LEARNER / CONSUMER / LAY
|
||||
Three sub-bands:
|
||||
C1. STAGE / SCANNED-PITCH (ted/tedx, keynote-redesign, accelerator-demo-day, flagship-launch keynote, life-event, travel-essay, curiosity-hobby)
|
||||
ALTITUDE: IDEA altitude, not detail. One recitable sentence; the speaker carries narrative, slides are slides not documents.
|
||||
DENSITY: LOWEST. One idea/verb/image per slide, <=15 words, ~50% negative space; <=7-word "what you do" test for pitch.
|
||||
INCLUDE: one Big Idea <=18 words; engineered Aha at structural midpoint; full-bleed image OR one giant number; numbers-forward but minimal detail (back-of-room legible in seconds); for pitch: traction on slide 3-4, magnitude metrics (ARR/NDR/burn) not lone MoM%.
|
||||
EXCLUDE: bullets, dense tables, methodology, multi-claim slides, anything readable only up close.
|
||||
TEST: a stranger recites the one line 24h later.
|
||||
C2. NON-TECHNICAL ADULT / CIVIC / DONOR / CLIENT-EDU (ai-101, patient-public-health, personal-finance-client, wellness, community-event, nonprofit-fundraising, self-study-explainer)
|
||||
ALTITUDE: one mechanism layer below a familiar artifact; ONE decision/behavior per deck.
|
||||
DENSITY: LOW. One key message per slide.
|
||||
INCLUDE: plain language (Flesch-Kincaid <=8 for patient/health; grade 8-9 for finance); adoption/behaviour metrics over benchmark scores; Teach-Back / Monday-action / one-ritual closer; one repeated behavioral recommendation; for fundraising/finance, a DUAL altitude — human-story open then CFO-grade cost-per-outcome / signed-paperwork Decision Card close.
|
||||
EXCLUDE: benchmark charts, pathophysiology, Gartner curves, market-outlook, multiple decisions, jargon without an on-slide gloss.
|
||||
C3. INSTRUCTIONAL WORKING-LEVEL (k12/ta/coding-bootcamp/cefr/excel-power/vocational/caregiver-cert/cert-exam/ehs-safety/compliance/picture-book)
|
||||
ALTITUDE: bounded to ONE concept/skill/CEFR-can-do; concrete and observable.
|
||||
DENSITY: LOW per slide, but procedurally exact (literal temp/torque/angle/formula/step).
|
||||
INCLUDE: I-Do/We-Do/You-Do gradual release with a check every 15 min; worked-example fading (full->partial->problem); break-on-purpose / debug-in-session retrieval; observable pass/fail rubric; for regulated training, on-slide standard tag (42 CFR / NNAAP / OSHA / DOJ-ECCP) so it's BOTH 7am-aide-legible AND auditor-defensible; picture-book = zero exposition, one feeling + 6-12 word caption.
|
||||
EXCLUDE: law-school depth, abstract definitions before a concrete example, coverage of the whole curriculum, marketing pitch tone.
|
||||
TEST: learner reproduces / ships / debugs within 90 seconds, not watches.
|
||||
|
||||
==================================================
|
||||
AXIS 2 — DELIVERY MODE (presented vs self-read), modulates density only
|
||||
==================================================
|
||||
|
||||
PRESENTED-LIVE (stage, town-hall, defense talk, oral pitch, lecture, lesson):
|
||||
LOWER density per slide — speaker is the bandwidth. One idea/claim/chart per slide; speaker-note timing; bullets banned at the stage end; slides "disappear into the talk." A self-read-dense slide projected live is the failure (audience reads instead of listening).
|
||||
|
||||
SELF-READ / PRE-READ / LEAVE-BEHIND (board pre-read, RFC, rfp-response doc, equity note, investor update, sales-battlecard, qbr pre-read, year-end-review, prd-as-doc):
|
||||
HIGHER density, MUST be standalone-readable — every chart carries its so-what, every title is a conclusion, sources on every slide, navigable in 8 seconds by skim AND defensible on deep read. No reliance on a narrator. Action titles + footnoted sources are mandatory, not optional.
|
||||
|
||||
DUAL-MODE (the hardest; many consulting/finance decks): build the skim layer (Minto answer in 2 slides, action titles) ON TOP of an auditable deep layer beneath (dollar-sizing methodology, appendix). Examples: consulting-final-deck (Partner on screen / Director-reviewed PDF), industry-deep-dive (4-min skim + defensible read), incident-postmortem (brutal technical detail internally + plain-English customer summary). Rule: the two layers share one structure; never produce a single MID/MID artifact that serves neither.
|
||||
|
||||
AUTO-REFRESH / CADENCE (sql-kpi-weekly, investor-update, lab-meeting): fixed structure cloned for diff-ability; "are you stuck?" / variance answer readable in 90 seconds; on-time and re-runnable beats comprehensive.
|
||||
|
||||
==================================================
|
||||
THREE QUICK DISAMBIGUATIONS (where decks wrongly converge to MID)
|
||||
==================================================
|
||||
1. Same metric, different altitude: a board QBR shows ONE variance walk + the ask (high altitude / low density); the operating MOR behind it shows every driver four ways (low altitude / high density). Don't ship the MOR to the board or the QBR to ops.
|
||||
2. Expert vs lay on the SAME topic (ai-hardtech-pitch vs ai-101): both about AI — one is eval tables + failure-mode rates for technical partners, the other bans benchmark charts entirely. Audience, not topic, sets altitude.
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_embed" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="56" width="680" height="72" style="font-size:36px;font-family:Noto Sans SC;color:rgba(17,24,39,1);font-weight:800;line-height:1.15;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:36px;line-height:1.15;color:rgba(17,24,39,1);letter-spacing:0px">Chart is a referenced sidecar</h2>
|
||||
</foreignObject>
|
||||
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="160" width="560" height="350"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="690" y="190" width="190" height="118" style="font-size:19px;font-family:Noto Sans SC;color:rgba(55,65,81,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:19px;line-height:1.38;color:rgba(55,65,81,1);letter-spacing:0px">The chart payload lives outside the slide and is referenced by href.</p>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="group_card" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="card_grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,1)"/>
|
||||
<stop offset="100%" stop-color="rgba(226,232,240,1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(241,245,249,1)"/>
|
||||
<g slide:role="group" id="card_primary">
|
||||
<rect slide:role="shape" slide:shape-type="round-rect" x="120" y="140" width="520" height="300" rx="24" ry="24" fill="url(#card_grad)" stroke="rgba(148,163,184,1)" stroke-width="1"/>
|
||||
<circle slide:role="shape" slide:shape-type="circle" cx="180" cy="206" r="26" fill="rgba(37,99,235,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="224" y="178" width="340" height="50" style="font-size:28px;font-family:Noto Sans SC;color:rgba(15,23,42,1);font-weight:800;line-height:1.2;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:28px;line-height:1.2;color:rgba(15,23,42,1);letter-spacing:0px">Grouped card</h2>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="154" y="264" width="420" height="86" style="font-size:20px;font-family:Noto Sans SC;color:rgba(51,65,85,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:20px;line-height:1.38;color:rgba(51,65,85,1);letter-spacing:0px">A card is a group; every visual child still carries its own slide role.</p>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="minimal_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(248,250,252,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="760" height="92" style="font-size:42px;font-family:Noto Sans SC;color:rgba(15,23,42,1);font-weight:800;line-height:1.12;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:42px;line-height:1.12;color:rgba(15,23,42,1);letter-spacing:0px">One protocol-compliant SVG slide</h1>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,11 @@
|
||||
# Generate SVG Chart
|
||||
|
||||
Before generating or revising any chart, read these files in order:
|
||||
|
||||
1. `input-schema.md`
|
||||
2. `chart-agent-contract.md`
|
||||
3. `design-envelope.md`
|
||||
4. `snippets/README.md`
|
||||
5. Snippets whose frontmatter `chart_types` include the selected `chart_type`
|
||||
|
||||
If snippet routing is uncertain, read `full.md`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# SVG Chart Agent Contract
|
||||
## Context
|
||||
- Role: prompt
|
||||
- Purpose: Defines the chart sub-agent response contract.
|
||||
## Content
|
||||
③ 契约模板 chart contract template:
|
||||
|
||||
```text
|
||||
You are a dedicated data visualization subagent specializing in professional, McKinsey-style SVG charts.
|
||||
|
||||
Each call generates exactly ONE chart of type {{.ChartType}}. In a single response, you produce two blocks in order:
|
||||
|
||||
1. A <design>...</design> block — your structural planning and coordinate math.
|
||||
2. A <svg>...</svg> block — the chart itself.
|
||||
|
||||
The design block MUST come first; the svg block MUST follow. Output NOTHING outside these two tag pairs. Begin your response immediately with <design> and end it immediately after </svg>.
|
||||
|
||||
## design block (first)
|
||||
|
||||
Inside <design>...</design>, output a STRICT YAML structure to plan your chart. This acts as your scratchpad. Resolve coordinate math here so the SVG is a clean transcription.
|
||||
|
||||
<design>
|
||||
Story:
|
||||
Takeaway: "..."
|
||||
Hero_Point: "..."
|
||||
Key_Data_Points: { Min: "...", Max: "...", Start: "...", End: "..." }
|
||||
Insight_Marker: "Name the insight device(s) that state the takeaway — the minimal set of storytelling markers for this ONE message (often one; a coordinated set like reference line + delta, or gap bracket + value, when the claim needs it). Choose by claim type, and for a comparative / quantified claim quantify the gap (reference line / bracket + delta) rather than merely spotlighting the hero — full device map in §A Insight device. No device for a second message; keep the set through space pressure."
|
||||
Color_Mapping:
|
||||
Hero: "[accent]"
|
||||
Series2: "[context]"
|
||||
Design_Review: "Judge the plan as a design director seeing it at the REAL display size (the display: line) — answer each briefly: (1) First glance: what does the eye land on first? It must be the hero / the claim. (2) Breathing: data ink commands ~two-thirds of the plot box — say where the margins live; if the plan strains against plot_top or a legend band, add air, not ink. (3) Families: list each repeated-element family (badges, value labels, separators, ticks) and confirm ONE consistent construction per family — emphasis by weight only. (4) Color: each fill used at most once for data; adjacent areas perceptibly distinct; every color earned. (5) Device: does the insight device(s) make the claim explicit — and for a comparative / quantified claim, quantify the gap (reference line / bracket + delta) rather than merely spotlight the hero? No marker earning a second message. Close with the ONE change this review caused (or 'none')."
|
||||
Type_Fit: "State your tier and the scaffold constants you will compute with (from the type_scale section of your input prompt), then run three fit checks — these are FLOORS, not targets: (1) top headroom — tallest mark tops at >= plot_top + its label font size + label_gap (tier M hero example: 28+23+10 = 61); if a top-of-plot legend band is present, add one legend row (legend baseline + label font + label_gap) to that floor; (2) densest label zone INCLUDING the insight device(s) — e.g. 8 endpoint labels over 180px -> 180/8 = 22.5 >= collision_threshold 20, fits; the delta badge / bracket caption keeps >= collision_threshold clearance from the value labels it annotates; (3) edges — edge category labels AND every device caption (reference-line label, badge, bracket text) stay within x ∈ [20, 940], reference-line captions anchored end by x<=932; a line chart's right label gutter holds its longest 'Name value' at [fs-label] size."
|
||||
Scale_Math: "Max value 95 -> Ceiling to a 'Nice Number' (nearest 5, 10, 50): 100. Plot_Height = baseline_y - plot_top (tier L example: 548 - 28 = 520). Scale = 520 / 100 = 5.2. Top-headroom check (ties back to Type_Fit): tallest mark top = 548 - 95*5.2 = 54 = plot_top + [fs-hero] + label_gap, fits. Bar_Width = 60, Gap = Bar_Width * 0.4 = 24. (If negatives exist: compute Zero_Line_Y < baseline_y). If introducing a 1.5x/2x gestalt gap, count it as a 'virtual data point' in width division to prevent overflowing the 940px limit."
|
||||
Collision_Resolution: "For endpoints or dense clusters, check for overlap. Example with collision_threshold=16: y1=300, y2=304 -> collision. Adjust text to y1=290, y2=314, and plan leader-line paths."
|
||||
Coordinates:
|
||||
# y grows DOWNWARDS from the top-left origin. y = Zero_Line_Y - (value * scale)
|
||||
# RULE: Check label collision. If |y1 - y2| < collision_threshold or |x1 - x2| < collision_threshold, apply offset or leader-line snippet.
|
||||
Bar1: { value: 95, height: 494, y: 54 }
|
||||
Bar2: { value: 49, height: 255, y: 293 }
|
||||
</design>
|
||||
|
||||
## svg block (second)
|
||||
|
||||
After </design>, compile your layout into the <svg>...</svg> block.
|
||||
|
||||
**CRITICAL: ZERO `[token]` placeholders allowed in SVG.** Substitute every token with its exact value from your input prompt — color tokens (`[accent]`, `[ink]`, ...) from the `color_palette` section, font-size tokens (`[fs-hero]`, `[fs-label]`, `[fs-meta]`, `[fs-footnote]`) from the `type_scale` section. Unresolved tokens cause hard validation failures.
|
||||
|
||||
- **Right:** `<rect ... fill="rgba(34,81,255,1)" />` / `<text ... font-size="14">` — with YOUR injected values, not these illustration numbers
|
||||
- **Wrong:** `<rect ... fill="[accent]" />` / `<text ... font-size="[fs-label]">`
|
||||
|
||||
Hard rules:
|
||||
1. viewBox MUST be exactly `0 0 960 600`.
|
||||
2. NO `<script>`, `<foreignObject>`, or `<image>`.
|
||||
3. NO title or subtitle inside the SVG.
|
||||
|
||||
**Examples are illustrative, not templates.** Every numeric coordinate, bar count, slice count, scale factor, and category in the snippet examples is shaped for *that example's* data, NOT yours. Re-derive all geometry from YOUR `data` and item count in the `<design>` block (`Scale_Math` plus `Coordinates`). Never copy an example's coordinates or category count; match the layout strategy, then compute your own numbers.
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# Chart Design Envelope
|
||||
## Context
|
||||
- Role: reference
|
||||
- Purpose: Defines chart craft rules, grid, palette roles, typography, labels, and invariants.
|
||||
## Content
|
||||
④ 设计简报 \_envelope.md(始终注入;§A 工艺原则 / §B 画布网格 Box Model / §C 色板角色 / §D 排版 / §E 线条 / §F 放置词汇 / §G 不变量。[fs-\*] 字号、scaffold 网格常数、[token] 色板均 Go 侧按 width/accent/theme 注入,此处为规则文本,示例几何按 tier L 常数锚定):
|
||||
|
||||
````markdown
|
||||
# Design Brief — Consulting-Grade Exhibit
|
||||
|
||||
You are crafting ONE exhibit for a slide: a chart whose single claim (the takeaway) must land within three seconds at its real display size. Work like a design director at a top consultancy — McKinsey-grade craft, not a fixed McKinsey template. You have absorbed the world's best charts; make this one of them: precise, restrained, intentional.
|
||||
|
||||
Division of labor: the input prompt injects FACTS you cannot derive yourself — exact palette values (`color_palette`), display-compensated type sizes (`type_scale`), and grid constants (`scaffold`). Within those facts, the design decisions are YOURS: composition, label density, emphasis, color sequencing, where the whitespace lives. Decide them by judgment of the whole, not by mechanical habit — and judge your own plan in `Design_Review` before transcribing.
|
||||
|
||||
## A. Craft Principles (how your plan will be judged)
|
||||
|
||||
- **Hierarchy — one hero.** The eye must land on WHO the chart is about before reading a single label: hero on `[accent]`, its value label bold; everything else recedes to exactly the presence the story needs — greys for backdrop and benchmarks, in-family tints when a secondary element carries meaning the reader still compares. Then make the claim explicit with its insight device(s) (see next bullet).
|
||||
- **Insight device — minimal markers, ONE message.** The takeaway is one message; make it explicit with the **fewest storytelling markers that land it** — often a single marker, but a *coordinated set serving that same message* is right when the claim needs it (a reference line **and** its delta; a gap bracket **and** the gap value; a CAGR arrow **and** a delta badge). The constraint is on **messages, not marker count**: the line you never cross is a **second message** — no device pointing at a relationship the takeaway doesn't claim, no decorative marker that earns nothing. These markers are the storytelling layer, distinct from the per-point value labels (§D). Pick by what the takeaway claims: **change → Δ badge · rate/growth → CAGR arrow · vs-target → reference line + delta · vs-peer or ranking → gap bracket + delta · single-point spotlight → hero call-out**. A **comparative or quantified** claim (vs-peer, vs-target, a ranking, "X% higher/lower", "biggest/smallest") must **quantify the gap, not merely spotlight the hero** — anchor on a benchmark / target / average reference line and state the delta on it (e.g. an average line plus "−17 pts"), so the reader sees both *who* and *by how much*; a bare hero highlight under-serves it. Under space pressure, pare a multi-marker set toward its core but keep the message alive (§G), cutting secondary value labels first (§F).
|
||||
- **Breathing — ink occupies, space frames.** Data ink should command roughly two-thirds of the plot box; margins and gaps are part of the design, not waste to be eliminated. The top-headroom formula (§B) is a collision FLOOR, not a fullness target — a chart straining against its frame or its legend reads cheap. When something must give, give up label count; never give up air, and never give up type size.
|
||||
- **System consistency — repeated elements are one family.** A row of badges, a set of value labels, slice separators, tick labels: one shape, one size, one color logic across every member. Emphasis WITHIN a family is carried by weight — bold text, the hero member on `[accent]` — never by switching one member to a different construction (filled vs outlined, a different shape, a different font size). A reader should be able to describe the family's rule in one sentence.
|
||||
- **Color rhythm — every color must be earned.** Fewer colors read calmer. One fill means one thing (§G); sequence in-family tints so adjacent areas stay perceptibly distinct — the injected palette steps are pre-spaced for exactly this.
|
||||
- **Honest scale.** Visual magnitude matches numeric magnitude: a 12% rise is a gentle slope, not a cliff; equal values look equal; bars start at zero. If a zoomed/delta view is genuinely needed, the device (axis-break marker, Δ badge) must declare it.
|
||||
- **Self-explanation.** Understandable without a title (the host owns headings): units live inside the value labels, time periods are visible, sources/notes go on the reserved footnote line.
|
||||
|
||||
## B. Canvas & Grid (Box Model)
|
||||
|
||||
Think in non-overlapping regions, not arbitrary coordinates. Within the `0 0 960 600` ViewBox, all ink stays within `x ∈ [20, 940]`. If the dataset is sparse (2-3 items), do not mechanically stretch shapes across the full width — cap mark width (e.g. 80px columns), keep proportionate gaps, center the group, and let the margins frame it.
|
||||
|
||||
Font-coupled layout constants — `plot_top`, `baseline_y`, `tick_y`, `footnote_y`, `label_gap`, `collision_threshold`, `min_segment_for_label` (and sometimes `max_direct_labels`) — are provided per-call in the `scaffold` line of your input prompt, sized to match your `type_scale`. Wherever this document names one, use the injected value.
|
||||
|
||||
- **Plot region ("Fill the plot")**: `y ∈ [plot_top, baseline_y]` (tier L: 28/548). Data marks live here and should command it — bars/columns grounded on the baseline, the tallest reaching toward (not straining against) the top; never a composition floating in one corner with a dead band beside it. "Fill" means command the box, not eliminate its air (§A Breathing).
|
||||
- **Top headroom (a FLOOR, not a target)**: a value label above a mark is ink too — the tallest mark tops no higher than `plot_top + its label's font size + label_gap` (tier L hero: 28 + 18 + 8 = 54). When a top-of-plot legend band is present, that floor moves down by one legend row (legend baseline + label font + label_gap). Verify in `Type_Fit`; choose your scale from it.
|
||||
- **Shared scaffold (below the baseline)**: `y ∈ [baseline_y, 600]` carries the X-axis (1px line at `y=baseline_y`, `x1=28`→`x2=932`), the tick-label row at `tick_y` (may wrap to a second `dy="1.4em"` line — the band reserves that space), and a **permanently reserved footnote line at `footnote_y`** — render the source/note there when you have one, otherwise leave it empty; never place other ink in it.
|
||||
- **Zero-Line for Negatives**: with negative values, `Zero_Line_Y` cannot be `baseline_y` — compute it from the absolute minimum and draw the zero axis as a solid `[ink]` line. Labels of negative shapes go below the shape's bottom edge (`y = bottom_edge_y + collision_threshold`).
|
||||
- **No-Axis Charts**: scatter / bubble / pie / doughnut / time-series line don't use this category split — they fill down to the floor their own skeleton states (stated for tier L; at M/S keep the same proportional bottom margin). The reserved footnote line applies to them too. Omit the Y-axis whenever direct labels carry the values.
|
||||
- **Gestalt Grouping**: a logical break in the data (e.g. Actual vs Forecast) gets a ≥40px gap between those specific marks.
|
||||
|
||||
## C. Color — Palette Roles
|
||||
|
||||
Use EXACTLY the pre-calculated `rgba(...)` values from the `color_palette` section of your input prompt. Do NOT invent or calculate colors.
|
||||
|
||||
The palette is one hue family plus neutrals — a design system with roles, not a menu of case rules:
|
||||
|
||||
| token | role |
|
||||
| :--- | :--- |
|
||||
| `[accent]` | the hero — exactly one data element owns it |
|
||||
| `[accent-deep]` | strong sibling: the hero's direct named competitor, or the deep end of an ordered ramp |
|
||||
| `[accent-light]` → `[accent-pale]` | ordered siblings the reader still compares: ranked tiers, named slices, cohort sequences |
|
||||
| `[context]` / `[context-light]` | backdrop: benchmarks, anonymous peers, the residual "Other" |
|
||||
| `[warm]` / `[positive]` | semantic marks ONLY — see the gate below |
|
||||
| `[ink]` / `[axis]` / `[footnote]` | text tiers; `[bg]` tracks the host background (use for separator strokes) |
|
||||
|
||||
- One fill maps to at most ONE data element per chart (§G) — a 5-slice composition is five distinct steps laid in data order, never a repeated fill.
|
||||
- When color is the ONLY series cue (e.g. clustered groups — no position/length redundancy), keep the two series far apart: `[accent]` + `[context]`, or `[accent]` + `[accent-pale]` — not near-adjacent tints.
|
||||
- **Semantic gate**: `[positive]` only for a genuine turnaround claim (loss→profit, 扭亏/转正); `[warm]` only when the takeaway itself emphasizes the negative outcome (same gate if `[warm]` would color a series). A signed value alone never justifies a semantic fill — a "−90% error rate" is good news and stays in the family; likewise the hero of a ranking or magnitude claim — even one framed as the "worst", a "pain point", or a "loss" — stays on `[accent]` so it binds to the chart's subject (the lowest bar in a comparison is not a `[warm]` cue). `[warm]` is reserved for a genuine bad *outcome* the takeaway's whole point is to sound the alarm on. Insight devices default to `[ink]` or `[accent]`.
|
||||
|
||||
## D. Typography & Contrast
|
||||
|
||||
- **Typography Scale**: ONLY the 4 levels below; each level's exact px is injected in `type_scale` (precomputed so text stays physically readable after the chart scales into its host container). Substitute them wherever a `[fs-*]` token appears. They are floors: NEVER set text smaller — when space is tight, cut label COUNT or shorten text, not font size.
|
||||
1. Hero / Total: `[fs-hero]` bold — the ONE number the takeaway turns on.
|
||||
2. Data Label: `[fs-label]` — all numeric values.
|
||||
3. Meta / Axis: `[fs-meta]` — categories, axis labels, annotations.
|
||||
4. Footnote: `[fs-footnote]` — source notes only (never for standalone units).
|
||||
- **Number Formatting & Units**: bake units into the value labels themselves (`$1.5M`, `20%`) — never a floating unit callout, and the footnote line is never the only place a unit lives. Unified commercial formatting; aligned decimal precision within a series; strip trailing zeros; don't mix formats.
|
||||
- **Strict Alignment**: `text-anchor="end"` for right-aligned tabular numbers; `middle` for X-axis categories; `start` for labels right of marks (with `label_gap` padding).
|
||||
- **Label-color consistency**: all labels of one tier share one color. Exception: the ONE hero value label may (should) use `[accent]` to bind it to the hero mark; all other values stay neutral (`[ink]`, `[axis]`, or `#FFFFFF` inside accented shapes).
|
||||
- **Semantic Wrapping** (text > ~15 chars), `x` identical in every tspan:
|
||||
```xml
|
||||
<text x="150" y="400" text-anchor="middle" font-size="[fs-meta]" fill="[ink]">
|
||||
<tspan x="150" dy="0">Strategic Growth</tspan>
|
||||
<tspan x="150" dy="1.4em">in APAC Region</tspan>
|
||||
</text>
|
||||
```
|
||||
- **Thin-segment rule**: never omit a hero label; a non-hero segment too thin for its label gets a leader line out to the text.
|
||||
|
||||
## E. Lines & Stroke Hierarchy
|
||||
|
||||
- Hero line `stroke-width="4"`; context/benchmark line `2`; axis/gridline `1` (gridlines, if any, `stroke-dasharray="4 4"`).
|
||||
|
||||
## F. Label Placement Vocabulary
|
||||
|
||||
When skeletons name a placement strategy, these are the geometric rules:
|
||||
- **Label Density**: ≤5 points → label each; >5 → label only start, end, min/max, hero. If `scaffold` provides `max_direct_labels`, never exceed it.
|
||||
- **Endpoint / Stacked-edges**: labels at line ends; if endpoints bunch within `collision_threshold`, sort by Y, space evenly, connect with thin `[axis]` leader lines.
|
||||
- **In-segment center**: bold value centered in a stacked segment; omit below `min_segment_for_label` (the hero label is never omitted — §D). Smart Contrast: `[accent]`/`[accent-deep]` fills → `#FFFFFF` text; `[accent-light]`/`[accent-pale]`/`[context]` fills → `[ink]` text.
|
||||
- **Total above column / at row end**: the stack total `label_gap` past the bar end / above the column top.
|
||||
- **Right-row series label**: series names anchored `x≥830` at the rightmost column's segment centers.
|
||||
- **Top-of-plot color key**: swatch row just above the plot (key baseline ≈ `plot_top + 6`), ONLY when direct per-mark labeling is impossible; reading order MUST match the visual stacking order.
|
||||
- **100% indicator**: `"100% = $X"` centered above each column of a 100%-stacked chart.
|
||||
- **Devices are ink too**: each insight device participates in collision checks like any label — keep `collision_threshold` clearance from the values it annotates (resolve in `Collision_Resolution`); device text stays within `x ∈ [20, 940]`; a reference-line caption is `text-anchor="end"` ending by x≈932.
|
||||
- **Compact density** (when `scaffold` says `density: compact`): the chart displays small — trade label count for size: label only start/end/min/max/hero; ≤6 category ticks (abbreviate; two-line wrap allowed — the band reserves it); fold any legend into its most compact inline form; prefer hero `[accent]` + ONE supporting color — at this size every extra color costs clarity; further series go `[context]`/`[context-light]`. NEVER shrink text below `type_scale`, and NEVER drop the message's insight device(s) — cut secondary value labels instead.
|
||||
|
||||
## G. Invariants — Anti-patterns (NEVER DO THESE)
|
||||
|
||||
1. A second hue family beyond `style.accent` (the gated `[warm]`/`[positive]` marks are the only exception).
|
||||
2. Misleading encoding: truncated axes exaggerating change, equal values styled to look different, non-exclusive values renormalized into a fake whole, a device citing a value it doesn't point at.
|
||||
3. Reusing one fill for two data elements in the same chart.
|
||||
4. Text below the injected `[fs-*]` sizes, or any ink colliding, clipping, or leaving `x ∈ [20, 940]` — devices included.
|
||||
5. Dropping the insight device(s) that carry the message under space pressure (cut secondary labels, instead).
|
||||
6. Rounded corners on data marks — bars, columns, value-encoding slices are sharp (a small badge pill's `rx` is chrome, not data).
|
||||
7. Gridlines + tick marks + data labels simultaneously; with direct labels, gridlines and the Y-axis go.
|
||||
8. A bordered legend box; a title or subtitle inside the SVG (the host owns headings); a **global / full-canvas background fill** — the backing stays transparent so the chart inherits the host slide background (a *local* background shape — an emphasis band, a shaded forecast/threshold zone, a callout card in a palette color — is welcome when it strengthens the message); external resources, `<script>`, `<foreignObject>`.
|
||||
````
|
||||
1013
skills/lark-slides/references/svg-slides/generate-svg-chart/full.md
Normal file
1013
skills/lark-slides/references/svg-slides/generate-svg-chart/full.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
# Generate SVG Chart Input Schema
|
||||
## Context
|
||||
- Role: schema
|
||||
- Purpose: Defines parameters, chart routing, composition gate, and output path rules.
|
||||
## Content
|
||||
② 入参 schema(generate_svg_chart 参数逐个;chart_type 路由规则 = 选型方法论唯一真相源,见 chart service implementation):
|
||||
|
||||
```text
|
||||
generate_svg_chart 入参 schema
|
||||
Required: label, chart_type, takeaway, data, emphasis, style, width, height, output_path
|
||||
Optional: revision_instruction, reference_design_path
|
||||
(chart_type 参数描述 = 选型方法论的唯一真相源:message-first 路由 + 构成族资格门 + 默认偏置。所有消费方 slides / html slides / anyclaw slide / doc 经工具 schema 自动继承,caller prompt 只保留指针,改选型规则只改 chart service implementation 这一处。)
|
||||
|
||||
--- label (required, string) ---
|
||||
Describe what this tool call is doing in less than 10 words, follow the user's language. Always output this parameter FIRST before all other parameters. For languages with letter case (English, German, French, etc.), the first letter MUST be capitalized (e.g., 'Searching for latest news', 'Saving data to report.txt', 'Clicking submit button').
|
||||
|
||||
--- chart_type (required, enum) ---
|
||||
Chart type to render. One of: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination, clustered-column.
|
||||
|
||||
ROUTE BY THE TAKEAWAY, NOT THE DATA. The chart is evidence for the `takeaway` you pass — write the takeaway first, classify what it CLAIMS, then pick within that family. A list of percentages is NOT automatically a composition.
|
||||
- Ranking claim ('A leads / exceeds / outranks B') → `bar` (sorted — the default), `column` (≤10 short labels), `clustered-column` (2-3 sub-items per category), `diverging-bar` (positive vs negative, gap-to-target).
|
||||
- Change-over-time claim ('grew / declined / doubled / CAGR') → `line` (many periods, ≤5 series), `column` (≤8 discrete periods), `indexed-line` (different scales, growth rebased to 100), `stacked-area` (total + composition over time), `small-multiples` (incomparable scales, one panel each).
|
||||
- Share-of-a-named-whole claim ('X is N% OF <whole>') → composition family, gated below: `pie` (plain share), `doughnut` (share + center total), `stacked-column` / `stacked-bar` (total + internal breakdown across categories), `hundred-percent-stacked-column` (only relative shares matter), `marimekko` (width=total AND height=share both carry meaning).
|
||||
- Step-drivers claim ('what bridges A to B') → `waterfall` (P&L-style walk), `diverging-bar` (zero-centered deltas).
|
||||
- Correlation claim ('X varies with / drives Y') → `scatter` (10-200 points), `bubble` (+ 3rd variable as size), `combination` (bars + line on dual axes — use sparingly, dual axes invite misreading).
|
||||
|
||||
COMPOSITION GATE. Every composition type asserts 'segments are mutually-exclusive parts of one whole' — verify before choosing one:
|
||||
(1) One whole: all values are non-overlapping parts of ONE named whole (percentages sum to ≈100; raw amounts sum to the whole). Multi-select survey results (sum > 100) and independent metrics FAIL — use `bar` with each item labeled independently (e.g. '% of respondents'); NEVER renormalize them into a fake whole.
|
||||
(2) Values are amounts or shares — a rate / growth / reduction percentage is a CHANGE, never a slice ('revenue +30%' → a `column`/`bar` with a Δ badge, or `line` if there is a time axis).
|
||||
(3) `pie`/`doughnut` additionally need ≤5 slices (fold the smallest into 'Other') AND a clearly leading hero (≳1.5× the runner-up, or the claim itself is 'majority / over half'). Near-equal slices mean the claim is really a ranking → sorted `bar`.
|
||||
Comparing the composition of SEVERAL wholes → `stacked-column` / `hundred-percent-stacked-column` / `marimekko`, never multiple pies.
|
||||
|
||||
DEFAULTS: when in doubt, a sorted `bar` is almost always right — bar/column/line cover the vast majority of business charts; `pie`/`doughnut` is the qualified exception, not a peer default. One claim per chart: a takeaway making two different comparisons means two charts.
|
||||
|
||||
--- takeaway (required, string) ---
|
||||
One-sentence claim the chart should make (e.g., 'EU led activation in Q3 2024, reaching 67%'). Decide it BEFORE chart_type — its wording (ranking / change / share-of-whole / drivers / correlation) determines the chart_type routing.
|
||||
|
||||
--- data (required, object) ---
|
||||
Chart data as JSON. Shape varies by chart_type — these are the common patterns:
|
||||
- categorical (column / stacked-column / hundred-percent-stacked-column / bar / stacked-bar / clustered-column / marimekko / diverging-bar): {"categories":["A","B"],"series":[{"name":"X","values":[1,2]}]}
|
||||
- share-of-total (pie / doughnut): {"slices":[{"label":"A","value":40},{"label":"B","value":30}]}
|
||||
- time-series (line / indexed-line / stacked-area / combination): {"x":["Q1","Q2"],"series":[{"name":"X","values":[10,11]}]}
|
||||
- scatter: {"points":[{"x":1.2,"y":3.4,"label":"A"}]}
|
||||
- bubble: {"points":[{"x":1.2,"y":3.4,"r":5,"label":"A"}]}
|
||||
- waterfall: {"items":[{"label":"Start","value":460,"kind":"total"},{"label":"Driver","value":60,"kind":"driver"}]}
|
||||
- small-multiples: {"panels":[{"title":"P1","x":[...],"y":[...]}]}
|
||||
Deviate only if the chart type genuinely needs a different shape; subagent will inspect structure either way.
|
||||
|
||||
--- emphasis (required, object) ---
|
||||
Which element is the narrative protagonist.
|
||||
· who (required, string): Which series / category / data point to elevate. E.g., 'United States', 'Q3 2024'.
|
||||
|
||||
--- style (required, object) ---
|
||||
Per-chart style decided by the caller. theme (light/dark) drives text/axis colors; accent drives hero series color; bg is the host background the chart sits on (for contrast reasoning inside the subagent; the chart backing stays transparent).
|
||||
· theme (required, enum light|dark): Host background kind: 'light' (light solid bg) or 'dark' (dark solid bg). The chart's backing rect is always transparent (fill=none) and inherits the host background.
|
||||
· accent (required, string): Hero accent color in rgba(R,G,B,A) form. Typically the host document's primary accent color — pass the SAME accent for every chart in one deliverable so they read as a family; override per-chart only when clarity demands it.
|
||||
· bg (required, string): Host background color (the canvas behind the chart) in rgba(R,G,B,A) form.
|
||||
|
||||
--- width (required, integer) ---
|
||||
Chart's display WIDTH in px as embedded in the host — pass the REAL embed width, the subagent derives its text sizes from it. The SVG renders at a fixed 1.6 (16:10) ratio, so set height = round(width/1.6). 480px is the hard floor — in a slot narrower than 480px a chart is unreadable, use a full-width band or a text callout instead.
|
||||
|
||||
--- height (required, integer) ---
|
||||
Chart's display HEIGHT in px as embedded in the host (≈ round(width / 1.6)).
|
||||
|
||||
--- output_path (required, string) ---
|
||||
Full VFS path to write the SVG to. MUST be inside `/home/user/workspace/` and end with `.svg`. A `<output_path>.design.md` sidecar will be written next to it for auditability.
|
||||
|
||||
--- revision_instruction (optional, string) ---
|
||||
OPTIONAL. Use ONLY for revising an existing chart. Provide specific visual or structural adjustments needed (e.g., 'Make the font larger', 'Show +200 label'). Leave empty for initial chart creation.
|
||||
|
||||
--- reference_design_path (optional, string) ---
|
||||
OPTIONAL. Provide this if and only if 'revision_instruction' is provided. Path to the previous design.md file to be used as the baseline for revision.
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# 100-percent-indicator
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: hundred-percent-stacked-column, marimekko
|
||||
## Content
|
||||
---
|
||||
title: 100% indicator
|
||||
categories: [hero-numeric-claim]
|
||||
chart_types: [hundred-percent-stacked-column, marimekko]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Small label above a 100%-stacked column showing the **absolute total** (the
|
||||
denominator that "100%" refers to). On `hundred-percent-stacked-column` it
|
||||
replaces the "Total above column" pattern entirely — the inside-segment numbers
|
||||
are percentages, this band tells the reader what the total dollars/units are.
|
||||
|
||||
Skip on regular `stacked-column` (the per-column total IS the takeaway; use
|
||||
"Total above column" instead).
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Centered above the column at x=218, just above the top of the plot (y=48).
|
||||
Replace "100% = $530B" with your column's absolute total. -->
|
||||
<text x="218" y="48" font-size="[fs-meta]" font-weight="bold" fill="[ink]" text-anchor="middle">
|
||||
100% = $530B
|
||||
</text>
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generate SVG Chart Snippets
|
||||
|
||||
Load snippets whose frontmatter `chart_types` include the selected `chart_type`.
|
||||
|
||||
## Snippet Index
|
||||
|
||||
- `100-percent-indicator.md`: hundred-percent-stacked-column, marimekko
|
||||
- `above-cluster-group-title.md`: clustered-column
|
||||
- `axis-break-marker.md`: column, stacked-column, bar, stacked-bar, line, stacked-area, waterfall, clustered-column, diverging-bar, combination, small-multiples
|
||||
- `bottom-multi-column-legend.md`: pie, doughnut
|
||||
- `cagr-arrow.md`: column, line, indexed-line, stacked-area, small-multiples, combination, clustered-column
|
||||
- `category-span-bracket.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, diverging-bar, small-multiples, combination
|
||||
- `column-divider.md`: clustered-column, marimekko
|
||||
- `delta-badge.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
|
||||
- `ellipse-callout.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
|
||||
- `endpoint-difference-bracket.md`: column, stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, waterfall, diverging-bar, combination, clustered-column
|
||||
- `error-bar.md`: column, bar, line, scatter, bubble, combination
|
||||
- `mini-legend-pair.md`: line, indexed-line, stacked-area, scatter, bubble, combination
|
||||
- `quadrant-guides.md`: scatter, bubble
|
||||
- `range-band.md`: line, indexed-line, stacked-area, combination
|
||||
- `secondary-metric-overlay.md`: column, stacked-column, hundred-percent-stacked-column, diverging-bar, clustered-column
|
||||
- `series-connector.md`: stacked-column, hundred-percent-stacked-column, stacked-bar, marimekko, combination
|
||||
- `slice-breakout.md`: pie, doughnut
|
||||
- `total-difference-arrow.md`: stacked-column, hundred-percent-stacked-column, stacked-area, stacked-bar
|
||||
- `triangle-pointer.md`: column, bar, line, stacked-area, bubble, waterfall, combination
|
||||
- `value-line.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, diverging-bar, small-multiples, combination, clustered-column
|
||||
- `vertical-bracket-measurement.md`: stacked-column, hundred-percent-stacked-column, stacked-bar, waterfall, diverging-bar, clustered-column, marimekko
|
||||
- `waterfall-connector.md`: waterfall
|
||||
@@ -0,0 +1,28 @@
|
||||
# above-cluster-group-title
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: clustered-column
|
||||
## Content
|
||||
---
|
||||
title: Above-cluster group title
|
||||
categories: [hero-call-out]
|
||||
chart_types: [clustered-column]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A bold group name centered **above a cluster of columns**, identifying a
|
||||
secondary categorical dimension (e.g., "Regular format" above 3 columns
|
||||
representing 3 sub-metrics). Use when the data has two categorical axes and
|
||||
column-level x-tick labels would be too crowded.
|
||||
|
||||
Prefer this over a separate legend when the groups are visually contiguous
|
||||
(adjacent columns) — the label sits where the reader's eye lands.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Group title centered over a cluster spanning x = 240..400 (center 320), at y=110.
|
||||
Replace "Regular format" with your group label. -->
|
||||
<text x="320" y="110" font-size="[fs-meta]" font-weight="bold" fill="[ink]" text-anchor="middle">Regular format</text>
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# axis-break-marker
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, bar, stacked-bar, line, stacked-area, waterfall, clustered-column, diverging-bar, combination, small-multiples
|
||||
## Content
|
||||
---
|
||||
title: Axis break marker
|
||||
categories: [structural-scale]
|
||||
chart_types: [column, stacked-column, bar, stacked-bar, line, stacked-area, waterfall, clustered-column, diverging-bar, combination, small-multiples]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A pair of short parallel diagonals across a value-axis line, signaling that the
|
||||
axis **skips a range**. Use when one outlier would dwarf the rest and you want
|
||||
to keep the chart compact. Annotate the compressed value explicitly nearby so
|
||||
readers don't misread the chart as continuous.
|
||||
|
||||
Three variants:
|
||||
- **Axis break** — on the y-axis itself, just below the top visible tick
|
||||
- **In-bar break** — a small zigzag inside one bar's stroke at the clip point
|
||||
- **Broken-bar chevron gap** — split a horizontal bar into two pieces with a `〉`-shaped slice
|
||||
of background (the labelled piece tapers to a `〉` point, then a gap, then the bar resumes).
|
||||
Signals "cut / compressed off-scale". Best for horizontal-bar / diverging-bar outliers;
|
||||
always label the true value on the labelled piece. **Keep the broken bar in its normal
|
||||
non-hero color (`[context]`) — the break exists to stop an off-scale NON-hero from setting
|
||||
the scale and stealing focus from the hero. Do NOT recolor it `[warm]`: `[warm]` is
|
||||
sign/Δ-semantic (envelope P4), not a bar identity, and a red outlier competes with the hero.**
|
||||
|
||||
If the takeaway depends on the absolute magnitude of the outlier (not its
|
||||
relative position), prefer adjusting the data presentation (split chart, two
|
||||
panels) over a break.
|
||||
|
||||
**Multiple off-scale bars must NOT come out equal-length.** A break *compresses*
|
||||
the scale; it does not *flatten* values. When several bars are broken, plot their
|
||||
past-break portions on one shared compressed scale so the largest-magnitude bar is
|
||||
still the longest and the order / rough proportions read correctly. Clipping
|
||||
−19k / −55k / −124k all to the same length erases the comparison — instead map them
|
||||
to e.g. ~70 / ~115 / ~160px (monotonic in value), each with its own value label.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Y-axis break at axis x=80, around y=124. Two parallel diagonals cross the axis;
|
||||
"≈" sits just to the left as a written cue. -->
|
||||
<line x1="74" y1="130" x2="86" y2="118" stroke="[axis]" stroke-width="1.5"/>
|
||||
<line x1="74" y1="136" x2="86" y2="124" stroke="[axis]" stroke-width="1.5"/>
|
||||
<text x="68" y="129" font-size="[fs-meta]" fill="[axis]" text-anchor="end">≈</text>
|
||||
|
||||
<!-- In-bar break: a zigzag in the bar's outline color near where the bar is clipped. -->
|
||||
<path d="M 200 130 L 208 124 L 216 136 L 224 124 L 232 130"
|
||||
stroke="[ink]" stroke-width="1.5" fill="none"/>
|
||||
|
||||
<!-- Broken-bar zigzag slit (bar y=440..472): two polygons in the bar's NON-HERO color
|
||||
([context], NOT [warm]) with a THIN, SHALLOW 〉-shaped slit of background between them
|
||||
(~5px wide). Left piece's right edge is a shallow 〉 (point x=309, juts only ~9px); the
|
||||
resume piece has a parallel 〉 edge. Keep the slit thin and the chevron shallow — a fine
|
||||
zigzag cut, NOT a fat solid arrow. -->
|
||||
<polygon points="200,440 300,440 309,456 300,472 200,472" fill="[context]"/>
|
||||
<polygon points="305,440 314,456 305,472 420,472 420,440" fill="[context]"/>
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# bottom-multi-column-legend
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: pie, doughnut
|
||||
## Content
|
||||
---
|
||||
title: Bottom multi-column legend
|
||||
categories: [structural-legend]
|
||||
chart_types: [pie, doughnut]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A horizontal multi-column legend below the pie / doughnut for **4-5 slices**.
|
||||
A single-row inline legend gets too wide past ~4 entries; a 2-row × 2-col
|
||||
grid (or 1 × 4) stays compact in the empty strip between the slice geometry
|
||||
(slice bottom ≈ y=480, given the canonical `cx=480 cy=300 r=180`) and the
|
||||
reserved footnote line at footnote_y (tier L: 592).
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- 4-category legend, single row × 4 cols. Column anchors at x=56, 256, 456, 656.
|
||||
Row anchor y=540. Each entry: 10×10 swatch + [fs-meta] label.
|
||||
Hero entry bold + [ink]; others regular + [axis]. -->
|
||||
<rect x="56" y="540" width="10" height="10" fill="[accent]"/>
|
||||
<text x="72" y="549" font-size="[fs-meta]" font-weight="bold" fill="[ink]">Laptops</text>
|
||||
<rect x="256" y="540" width="10" height="10" fill="[accent-light]"/>
|
||||
<text x="272" y="549" font-size="[fs-meta]" fill="[axis]">Smartphones</text>
|
||||
<rect x="456" y="540" width="10" height="10" fill="[accent-pale]"/>
|
||||
<text x="472" y="549" font-size="[fs-meta]" fill="[axis]">Printers</text>
|
||||
<rect x="656" y="540" width="10" height="10" fill="[context]"/>
|
||||
<text x="672" y="549" font-size="[fs-meta]" fill="[axis]">Other</text>
|
||||
```
|
||||
|
||||
If you need a 5th entry, drop to a 2-row × 3-col grid with row y=540 and y=568,
|
||||
each row anchored at x=56 / 312 / 568 (3 cols wide). Anything past 5 entries
|
||||
means the pie has too many slices — fold smaller ones into "Other" first.
|
||||
@@ -0,0 +1,41 @@
|
||||
# cagr-arrow
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, line, indexed-line, stacked-area, small-multiples, combination, clustered-column
|
||||
## Content
|
||||
---
|
||||
title: CAGR arrow
|
||||
categories: [structural-scale]
|
||||
chart_types: [column, line, indexed-line, stacked-area, small-multiples, combination, clustered-column]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A flat curved arrow spanning multiple periods (start → end) with a centered CAGR
|
||||
label. Use when the takeaway is the **growth rate over a period** rather than
|
||||
any single point — typical for multi-year revenue / volume charts.
|
||||
|
||||
Pin the Q-control's y to the curve's END y so the end tangent is purely
|
||||
horizontal — the arrowhead then aligns cleanly with the curve. The chord still
|
||||
gives a clear sense of direction; the bulge keeps the arc readable as an
|
||||
annotation, not a series line.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Quadratic curve from (80, 280) → (848, 210). Control = (480, 210); since
|
||||
control_y == end_y, the end tangent direction is (end_x − control_x, 0) =
|
||||
(368, 0), pure horizontal-right — the arrowhead polygon below is also pure
|
||||
horizontal so the two meet flush. The trade-off: the bulge is bigger than
|
||||
a near-chord control would give — curve midpoint at t=0.5 is y=227.5, which
|
||||
is 17.5px above the chord midpoint (245). Still reads as an annotation
|
||||
because of the thin 1.5px [accent] stroke.
|
||||
Replace "+6.4%" with your CAGR; for a deeper trend, lower control_y while
|
||||
keeping control_y == end_y to preserve the flush arrowhead. -->
|
||||
<path d="M 80 280 Q 480 210 848 210"
|
||||
fill="none" stroke="[accent]" stroke-width="1.5"/>
|
||||
<polygon points="842,206 850,210 842,214" fill="[accent]"/>
|
||||
<text x="480" y="215" font-size="[fs-label]" font-weight="bold" fill="[accent]" text-anchor="middle">
|
||||
CAGR +6.4%
|
||||
</text>
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# category-span-bracket
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, diverging-bar, small-multiples, combination
|
||||
## Content
|
||||
---
|
||||
title: Category-span bracket
|
||||
categories: [hero-numeric-claim]
|
||||
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, diverging-bar, small-multiples, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A square bracket on the plot's left or right edge spanning **multiple categories**,
|
||||
with a label outside. Use for category-spanning numeric claims ("+90M jobs across
|
||||
5 sectors", "Top-3 hold 62%"). If the claim sits on a single column instead:
|
||||
|
||||
- On stacked variants (`stacked-column` / `hundred-percent-stacked-column` /
|
||||
`stacked-bar` / `clustered-column` / `diverging-bar` / `marimekko` /
|
||||
`waterfall`) where the claim is a stack total or segment span (e.g., "54%"),
|
||||
use **Vertical bracket measurement** — short bracket on one column's span.
|
||||
- On plain `column` / `bar` / `line`, a single-bar height claim is best
|
||||
conveyed by a bold `[accent]` data label above the bar, not a bracket.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Left bracket [ spanning y=[220,280], opening right toward the spanned bars. INSET it enough
|
||||
that the outside (left) label stays inside the viewBox — anchoring a long label at the very
|
||||
edge (x≈36) runs the text off-canvas. Here the spine is at x=150 so the label fits in x≥28.
|
||||
Mirror x for a right bracket. Replace the tspans; 2 lines fit, 3rd via dy="1.2em". -->
|
||||
<path d="M 158 280 L 150 280 L 150 220 L 158 220"
|
||||
fill="none" stroke="[ink]" stroke-width="1"/>
|
||||
<line x1="150" y1="250" x2="142" y2="250" stroke="[ink]" stroke-width="1"/>
|
||||
<text x="138" y="247" font-size="[fs-label]" font-weight="bold" fill="[ink]" text-anchor="end">
|
||||
<tspan x="138" dy="0">+90M jobs</tspan>
|
||||
<tspan x="138" dy="1.2em">needed by 2030</tspan>
|
||||
</text>
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# column-divider
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: clustered-column, marimekko
|
||||
## Content
|
||||
---
|
||||
title: Column divider
|
||||
categories: [structural-connector]
|
||||
chart_types: [clustered-column, marimekko]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A 1px ink vertical line between grouped column clusters (or between marimekko
|
||||
sub-blocks). Place at the midpoint of the gap between adjacent groups. Use when
|
||||
generous group gaps alone aren't enough to read the grouping structure.
|
||||
|
||||
For marimekko, draw dividers in **white** between sub-blocks within the same
|
||||
column (it's a separator inside a filled rect, not on top of the host background).
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Divider at gap-midpoint x=324. Top = plot top (no title band); bottom = the host's
|
||||
baseline (clustered-column: stop at y=480 to clear the Δ-badge band; marimekko: y=548). -->
|
||||
<line x1="324" y1="48" x2="324" y2="480"
|
||||
stroke="[ink]" stroke-width="1"/>
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# delta-badge
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
|
||||
## Content
|
||||
---
|
||||
title: Delta badge
|
||||
categories: [hero-numeric-claim]
|
||||
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Rounded rectangle with `+N%` / `−N%` text on a colored fill. Use to spotlight a
|
||||
*single-point* change (one bar vs prior period, one slice vs benchmark). Compact
|
||||
and visually quiet — for heavier emphasis or when the badge needs a leader stem
|
||||
pointing at its referent, use **Ellipse callout** instead.
|
||||
|
||||
**Badges form ONE family** (envelope §A System consistency): every badge in the chart
|
||||
shares one construction — same shape, same size, same fill logic. Pick the family style
|
||||
once: filled pills (`[context]` fill, bold `#ffffff` text) read as one quiet system;
|
||||
the hero badge alone may step up to `[accent]` — or `[warm]`/`[positive]` under the
|
||||
envelope §C semantic gate (the takeaway itself claims the negative outcome / turnaround;
|
||||
a signed value alone is not a reason). Never mix filled and outlined pills, or two
|
||||
different pill sizes, in one chart.
|
||||
|
||||
The `rx=4` here is the ONE blessed rounded corner — it is chrome on an annotation pill,
|
||||
not a data mark. Never carry `rx` onto bars, columns, or slices (envelope "Anti-patterns").
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- A badge family: uniform [context] pills, hero steps up to [warm] (here the takeaway
|
||||
itself is about the decline). All pills identical in shape and size. -->
|
||||
<rect x="400" y="200" width="44" height="20" rx="4" ry="4" fill="[warm]"/>
|
||||
<text x="422" y="214" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">−75%</text>
|
||||
|
||||
<rect x="540" y="240" width="44" height="20" rx="4" ry="4" fill="[context]"/>
|
||||
<text x="562" y="254" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">−60%</text>
|
||||
|
||||
<rect x="680" y="260" width="44" height="20" rx="4" ry="4" fill="[context]"/>
|
||||
<text x="702" y="274" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">−55%</text>
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# ellipse-callout
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
|
||||
## Content
|
||||
---
|
||||
title: Ellipse callout
|
||||
categories: [hero-numeric-claim, hero-call-out]
|
||||
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
An ellipse (or circle) bubble around a Δ value, **optionally connected by a stem
|
||||
+ arrowhead** to the data point it's calling out. Heavier visual weight than a
|
||||
**Delta badge** — use it for the 1-2 most important points on a chart, not every
|
||||
datum.
|
||||
|
||||
Stem variant: when the bubble is above/below the point, the stem ties the two
|
||||
together visually. Drop the stem when the callout sits *beside* the element.
|
||||
|
||||
Circle variant (rx=ry): use for 2-line measurement annotations (e.g.,
|
||||
"Gap vs target / 159").
|
||||
|
||||
Color: `[accent]` by default (the callout extends the hero's emphasis; bold white text);
|
||||
the circle measurement variant is `[ink]` outline + `[ink]` text. `[positive]`/`[warm]`
|
||||
only under the envelope §C gate — a "−90% error rate" is GOOD news and stays on `[accent]`.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Ellipse callout above a line point at (380, 230). Bubble centered at (380, 180);
|
||||
stem from bubble bottom (y=194) to point (y=222); arrowhead at the point.
|
||||
Replace "+33%" with your value. -->
|
||||
<ellipse cx="380" cy="180" rx="34" ry="14" fill="[accent]"/>
|
||||
<text x="380" y="184" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">+33%</text>
|
||||
<line x1="380" y1="194" x2="380" y2="222" stroke="[accent]" stroke-width="1.5"/>
|
||||
<polygon points="376,222 380,230 384,222" fill="[accent]"/>
|
||||
|
||||
<!-- Circle variant for a 2-line measurement annotation, beside (not above) the element.
|
||||
No stem; drop the bubble's saturated fill and let the ink outline carry the weight. -->
|
||||
<circle cx="600" cy="300" r="32" fill="none" stroke="[ink]" stroke-width="1.5"/>
|
||||
<text x="600" y="294" font-size="[fs-meta]" fill="[ink]" text-anchor="middle">
|
||||
<tspan x="600" dy="0">Gap vs</tspan>
|
||||
<tspan x="600" dy="1.2em">target</tspan>
|
||||
<tspan x="600" dy="1.2em" font-size="[fs-label]" font-weight="bold">159</tspan>
|
||||
</text>
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# endpoint-difference-bracket
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, waterfall, diverging-bar, combination, clustered-column
|
||||
## Content
|
||||
---
|
||||
title: Endpoint difference bracket
|
||||
categories: [hero-numeric-claim]
|
||||
chart_types: [column, stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, waterfall, diverging-bar, combination, clustered-column]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A bracket on the **outside of exactly two columns** (or two endpoints) with a
|
||||
single-end arrow and a Δ label. Use when the takeaway is a pairwise comparison
|
||||
("+18% Q3 vs Q2", "EU vs APAC: +24pts"). For category-spanning claims (3+ bars),
|
||||
use **Category-span bracket** instead.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Bracket on the right of two columns: spine x=672, top y=200, bottom y=320.
|
||||
Arrow points right; label sits beside the arrowhead.
|
||||
Replace "+18%" with your delta value. -->
|
||||
<path d="M 660 200 L 672 200 L 672 320 L 660 320"
|
||||
fill="none" stroke="[ink]" stroke-width="1"/>
|
||||
<line x1="672" y1="260" x2="690" y2="260" stroke="[ink]" stroke-width="1"/>
|
||||
<polygon points="690,256 698,260 690,264" fill="[ink]"/>
|
||||
<text x="702" y="264" font-size="[fs-label]" font-weight="bold" fill="[ink]">+18%</text>
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# error-bar
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, bar, line, scatter, bubble, combination
|
||||
## Content
|
||||
---
|
||||
title: Error bar
|
||||
categories: [hero-reference-marker]
|
||||
chart_types: [column, bar, line, scatter, bubble, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A thin vertical whisker centered on a data point, with horizontal caps at top
|
||||
and bottom, marking a value range — typically a **confidence interval**, error
|
||||
bar, or min/max range around the point estimate. Use when each data point is a
|
||||
summary of underlying data and the spread is worth communicating (sample
|
||||
variance, valuation low/high, NPS confidence band).
|
||||
|
||||
For a wide horizontal envelope at a single time-period (rectangle backdrop
|
||||
behind a polyline showing 3-year range), use **Range band** instead — that's
|
||||
a 2D backdrop, this is a 1D whisker per point.
|
||||
|
||||
For horizontal charts (`bar`), transpose the primitive 90° — the whisker spans
|
||||
horizontally and the caps are vertical lines.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Vertical error bar on a data point at (240, 280) with err = ±25 plot units.
|
||||
Top end y = 280 - 25 = 255; bottom end y = 280 + 25 = 305.
|
||||
Caps 8px wide, centered horizontally on the data point's x = 240.
|
||||
Replace center / err per your data; clone for each point that needs a whisker.
|
||||
For bar (horizontal), rotate 90° — main line spans horizontally, caps vertical. -->
|
||||
<line x1="240" y1="255" x2="240" y2="305" stroke="[ink]" stroke-width="1"/>
|
||||
<line x1="236" y1="255" x2="244" y2="255" stroke="[ink]" stroke-width="1"/>
|
||||
<line x1="236" y1="305" x2="244" y2="305" stroke="[ink]" stroke-width="1"/>
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# mini-legend-pair
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: line, indexed-line, stacked-area, scatter, bubble, combination
|
||||
## Content
|
||||
---
|
||||
title: Mini-legend pair
|
||||
categories: [structural-legend]
|
||||
chart_types: [line, indexed-line, stacked-area, scatter, bubble, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
An inline legend combining **multiple marker types** (line + dashed line + circle
|
||||
dot + filled square) for charts that mix more than one visual primitive — e.g.
|
||||
solid line for actuals + dashed line for forecast / 3-year-avg. One row, 8-12px
|
||||
between items, no border, no swatch box.
|
||||
|
||||
For 4+ categorical entries that share one primitive (all line, all bar), prefer
|
||||
inline endpoint labels (envelope "Label Placement Vocabulary" "Endpoint" / "Stacked-edges"
|
||||
category edges") instead — this snippet is meant for primitive-mixing legends,
|
||||
not categorical fanout.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- 2-entry mini-legend in the reclaimed top of the plot at y=40. Replace labels per your series. -->
|
||||
<!-- Entry 1: dashed line + label -->
|
||||
<line x1="56" y1="40" x2="80" y2="40" stroke="[context]" stroke-width="1.5" stroke-dasharray="3,3"/>
|
||||
<text x="84" y="44" font-size="[fs-meta]" fill="[ink]">3-year avg</text>
|
||||
|
||||
<!-- Entry 2: solid line + circle dot + label -->
|
||||
<line x1="180" y1="40" x2="204" y2="40" stroke="[accent]" stroke-width="1.5"/>
|
||||
<circle cx="192" cy="40" r="3" fill="[accent]"/>
|
||||
<text x="208" y="44" font-size="[fs-meta]" fill="[ink]">Fact</text>
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# quadrant-guides
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: scatter, bubble
|
||||
## Content
|
||||
---
|
||||
title: Quadrant guides
|
||||
categories: [hero-reference-marker]
|
||||
chart_types: [scatter, bubble]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Two crossing dashed reference lines partitioning a scatter plot into 4 quadrants,
|
||||
each labeled with the framework's name. Use when the chart is a **2×2 matrix**
|
||||
(BCG growth-share, fit-vs-priority, impact-vs-effort, etc.) and the takeaway is
|
||||
*which quadrant* a data point sits in — not its precise coordinates.
|
||||
|
||||
The guides sit visually behind the dots — render BEFORE any scatter markers
|
||||
(document order). Low opacity (~0.35) keeps them as a backdrop.
|
||||
|
||||
Labels go at the outer corner of each quadrant (far from the crossover),
|
||||
text-anchored to the matching edge so they grow inward.
|
||||
|
||||
For a single threshold on one axis (e.g., "above target / below target"), use
|
||||
**Value line** instead — quadrant guides imply *both* axes carry a meaningful
|
||||
midpoint.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Quadrant guides for a 2×2 matrix on a scatter. Plot area x=48..904, y=44..544
|
||||
(matches scatter.md / bubble.md scaffold — DO NOT mismatch or guides will fall
|
||||
outside the plot). Crossover at the geometric midpoint (476, 294). Replace
|
||||
if your threshold isn't at the midpoint (e.g., industry-average lines).
|
||||
- Vertical guide spans full plot height; horizontal guide spans full plot width.
|
||||
- Labels at outer corners, inset ~10px from plot edges; text-anchor "end" for
|
||||
right quadrants, "start" for left, so they grow inward.
|
||||
- [axis] color marks the labels as meta-frame so data dots dominate visually.
|
||||
Replace label text per your framework. -->
|
||||
|
||||
<!-- Crossing dashed lines. Draw BEFORE scatter dots so dots overlap them. -->
|
||||
<line x1="476" y1="44" x2="476" y2="544" stroke="[ink]" stroke-width="1" stroke-dasharray="4,3" stroke-opacity="0.35"/>
|
||||
<line x1="48" y1="294" x2="904" y2="294" stroke="[ink]" stroke-width="1" stroke-dasharray="4,3" stroke-opacity="0.35"/>
|
||||
|
||||
<!-- Quadrant labels at outer corners (insets from plot edges). -->
|
||||
<text x="894" y="64" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="end">Stars</text>
|
||||
<text x="58" y="64" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="start">Question marks</text>
|
||||
<text x="894" y="532" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="end">Cash cows</text>
|
||||
<text x="58" y="532" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="start">Dogs</text>
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# range-band
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: line, indexed-line, stacked-area, combination
|
||||
## Content
|
||||
---
|
||||
title: Range band
|
||||
categories: [hero-reference-marker]
|
||||
chart_types: [line, indexed-line, stacked-area, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A semi-transparent vertical rectangle showing a min–max range at a single
|
||||
time-period (e.g., 3-year price range, daily high–low). Reader sees the
|
||||
foreground series move *inside* the historical envelope. Render **before**
|
||||
foreground polylines/markers so it sits visually behind.
|
||||
|
||||
Fill with `[accent]` at low opacity (`fill-opacity="0.15"`) — a faint accent envelope, no extra token to bind.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Range band at x_center for one time-period. Range [min=130, max=320].
|
||||
Scale 1 px/unit, zero at y=460 → y_top = 460−320 = 140, y_bottom = 460−130 = 330.
|
||||
Replace x_center / range values per your data. -->
|
||||
<rect x="206" y="140" width="20" height="190" fill="[accent]" fill-opacity="0.15" stroke="none"/>
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
# secondary-metric-overlay
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, diverging-bar, clustered-column
|
||||
## Content
|
||||
---
|
||||
title: Secondary-metric overlay
|
||||
categories: [hero-reference-marker]
|
||||
chart_types: [column, stacked-column, hundred-percent-stacked-column, diverging-bar, clustered-column]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A dashed line + square markers at each category position, overlaid on the
|
||||
primary columns to express a **secondary metric** (e.g., "% pts vs previous
|
||||
survey"). Use when one chart needs to carry two metrics: the columns carry the
|
||||
primary metric, the overlay is a per-category index/delta.
|
||||
|
||||
Markers are filled `#ffffff` with a 1px `[ink]` outline so they read on top of
|
||||
the columns behind.
|
||||
|
||||
For a single average / target instead of a per-category secondary metric, use
|
||||
**Value line**.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Overlay across 4 category centers x = 160, 280, 400, 520.
|
||||
Secondary axis scale puts the four values at y = 236, 254, 310, 346.
|
||||
Replace the per-marker positions and labels per your data. -->
|
||||
<polyline fill="none" stroke="[ink]" stroke-width="1" stroke-dasharray="3,3"
|
||||
points="160,236 280,254 400,310 520,346"/>
|
||||
<rect x="153" y="229" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
|
||||
<rect x="273" y="247" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
|
||||
<rect x="393" y="303" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
|
||||
<rect x="513" y="339" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
|
||||
<text x="160" y="222" font-size="[fs-label]" fill="[ink]" text-anchor="middle">+9pts</text>
|
||||
<text x="280" y="240" font-size="[fs-label]" fill="[ink]" text-anchor="middle">+5pts</text>
|
||||
<text x="400" y="296" font-size="[fs-label]" fill="[ink]" text-anchor="middle">−1pts</text>
|
||||
<text x="520" y="332" font-size="[fs-label]" fill="[ink]" text-anchor="middle">+2pts</text>
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# series-connector
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: stacked-column, hundred-percent-stacked-column, stacked-bar, marimekko, combination
|
||||
## Content
|
||||
---
|
||||
title: Series connector
|
||||
categories: [structural-connector]
|
||||
chart_types: [stacked-column, hundred-percent-stacked-column, stacked-bar, marimekko, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Thin gray line between the **tops of the same-series segment** in adjacent stacked
|
||||
columns. Use when the chart has 3+ adjacent stacks and the reader needs to track
|
||||
one series' segment-height change across them (e.g., "the green segment shrinks
|
||||
from Q1 to Q3").
|
||||
|
||||
Skip on charts with only 2 stacks (the eye can pair tops without help). On
|
||||
`stacked-column` / `hundred-percent-stacked-column` / `stacked-bar`, if the
|
||||
hero is a per-stack TOTAL (not a per-segment trend across stacks), use
|
||||
**Total difference arrow** instead.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Connector from segment top at (240, 300) to next stack's segment top at (320, 280).
|
||||
Draw before the bar rects so they sit visually on top. -->
|
||||
<line x1="240" y1="300" x2="320" y2="280"
|
||||
stroke="[context]" stroke-width="1"/>
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# slice-breakout
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: pie, doughnut
|
||||
## Content
|
||||
---
|
||||
title: Slice breakout
|
||||
categories: [hero-call-out]
|
||||
chart_types: [pie, doughnut]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Pull one pie / doughnut slice radially outward from the center to spotlight it.
|
||||
Use exactly once per chart, on the hero slice the takeaway names. For
|
||||
non-protagonist accent on a slice, use **Ellipse callout** instead.
|
||||
|
||||
## Geometry
|
||||
|
||||
Offset the slice 12-20px along its centroid angle γ. Compute the translation:
|
||||
- `Δx = offset × cos((γ − 90) × π/180)`
|
||||
- `Δy = offset × sin((γ − 90) × π/180)`
|
||||
|
||||
Wrap the slice path in `<g transform="translate(Δx, Δy)">`.
|
||||
|
||||
```xml
|
||||
<!-- Pie slice (centroid γ=198°), broken out 16 px. Canonical pie cx=480 cy=282 r=236.
|
||||
Δx = 16 × cos(108°) = -4.94 → -5
|
||||
Δy = 16 × sin(108°) = 15.22 → 15
|
||||
Replace the path's M/L/A coordinates with your slice's geometry. -->
|
||||
<g transform="translate(-5 15)">
|
||||
<path d="M 480,282 L 619,473 A 236,236 0 0,1 256,355 Z"
|
||||
fill="[accent]" stroke="[bg]" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Doughnut slice variant (annulus segment, r=236 r_inner=130): wrap the full
|
||||
annulus path the same way. The translated path inherits the original arc
|
||||
geometry; only the screen position shifts. -->
|
||||
<g transform="translate(-5 15)">
|
||||
<path d="M 619,473 A 236,236 0 0,1 256,355 L 356,322 A 130,130 0 0,0 556,387 Z"
|
||||
fill="[accent]" stroke="[bg]" stroke-width="2"/>
|
||||
</g>
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# total-difference-arrow
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: stacked-column, hundred-percent-stacked-column, stacked-area, stacked-bar
|
||||
## Content
|
||||
---
|
||||
title: Total difference arrow
|
||||
categories: [hero-numeric-claim]
|
||||
chart_types: [stacked-column, hundred-percent-stacked-column, stacked-area, stacked-bar]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A curved arrow spanning **between two stacked-column tops**, with a Δ% label
|
||||
above the arc. Use when the takeaway is the change in the *aggregate total*
|
||||
(e.g., "Revenue Q1 → Q3 +25%", "Headcount Before → After −18%"). The reader's
|
||||
eye sweeps from earlier column to later, lands on the percentage.
|
||||
|
||||
Distinct from **Endpoint difference bracket** — that one is a side bracket
|
||||
measuring two columns' span; this one floats *above* and connects their tops,
|
||||
framing the total walk. Use **Endpoint difference bracket** when the takeaway
|
||||
is "EU vs APAC" (pairwise endpoint comparison); use this snippet when the
|
||||
takeaway is "Q1 → Q3" (total trajectory).
|
||||
|
||||
On `hundred-percent-stacked-column` the Δ measures the absolute totals shown
|
||||
by the **100% indicator** band (not the 100% labels themselves — those are
|
||||
trivially equal across columns).
|
||||
|
||||
For horizontal stacks (`stacked-bar`), transpose the primitive 90°: the arrow
|
||||
spans rightward between two row ends instead of upward between two column tops.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Total Δ arrow connecting two stacked-column tops. Example uses:
|
||||
- Column 1: center x=240, top y=228 (total height 320 above baseline y=548)
|
||||
- Column 2: center x=520, top y=148 (total height 400; col 2 is taller → ascending Δ)
|
||||
- Arrow anchors at +20px above each top: start (240, 208), end (520, 128)
|
||||
- Control point (380, 128) = midpoint x at end y → flat end tangent for clean arrowhead
|
||||
- Curve is monotonically ascending (in screen sense): y goes 208 → 128 with no wiggle
|
||||
- Label at x=380 (curve midpoint), y=118 (30px above curve midpoint y=148)
|
||||
Replace endpoints, control x, and label per your column tops + delta value.
|
||||
For a descending Δ (col 2 < col 1), mirror y's (start lower, end higher) — keep [accent]
|
||||
(or [ink]); switch to [warm] ONLY when the decline itself is the takeaway's negative claim
|
||||
(loss / shrinkage emphasized), per envelope §C. -->
|
||||
<path d="M 240 208 Q 380 128 520 128"
|
||||
fill="none" stroke="[accent]" stroke-width="1.5"/>
|
||||
<polygon points="528,128 520,124 520,132" fill="[accent]"/>
|
||||
<text x="380" y="118" font-size="[fs-label]" font-weight="bold" fill="[accent]" text-anchor="middle">+25%</text>
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# triangle-pointer
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, bar, line, stacked-area, bubble, waterfall, combination
|
||||
## Content
|
||||
---
|
||||
title: Triangle pointer
|
||||
categories: [hero-call-out]
|
||||
chart_types: [column, bar, line, stacked-area, bubble, waterfall, combination]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A small filled triangle marking the **endpoint** of a value / target line, or
|
||||
the tip of an annotation leader. Cheaper visual weight than an arrowhead-on-line;
|
||||
use as a "pin" rather than as a directional flow.
|
||||
|
||||
Direction is set via polygon vertex order — left, right, up, down — pick the
|
||||
side that **faces the chart it's pointing into** (e.g., a left-pointing pin sits
|
||||
to the right of the chart's right edge).
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Pointing LEFT at (900, 320). Right-end pin of a horizontal target line. -->
|
||||
<polygon points="900,320 910,312 910,328" fill="[ink]"/>
|
||||
|
||||
<!-- Pointing UP at (480, 250). Pairs with an Ellipse callout above a line point. -->
|
||||
<polygon points="480,250 472,258 488,258" fill="[accent]"/>
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# value-line
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, diverging-bar, small-multiples, combination, clustered-column
|
||||
## Content
|
||||
---
|
||||
title: Value line
|
||||
categories: [hero-reference-marker]
|
||||
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, diverging-bar, small-multiples, combination, clustered-column]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Horizontal reference line (average, target, prior period, breakeven) with a moveable
|
||||
label at the right. Use when the takeaway compares the data **against a single
|
||||
constant baseline** the reader doesn't see in the bars themselves ("X is above the
|
||||
industry average", "3 of 5 missed target").
|
||||
|
||||
On column-class charts (`column` / `stacked-column` /
|
||||
`hundred-percent-stacked-column` / `clustered-column` / `diverging-bar`), if
|
||||
the secondary metric varies **per category** (different value at each x
|
||||
position, not a flat line), use **Secondary-metric overlay** instead. For
|
||||
non-column-class charts (`line` / `scatter` / etc.), a per-category secondary
|
||||
metric is just a second series — draw another polyline / point set, no
|
||||
separate snippet needed.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Reference at y=320 across plot width 28..900. Label right of plot end.
|
||||
Replace "Avg 4.2" with your baseline name + value. -->
|
||||
<line x1="28" y1="320" x2="900" y2="320"
|
||||
stroke="[context]" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="904" y="324" font-size="[fs-meta]" fill="[context]">Avg 4.2</text>
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# vertical-bracket-measurement
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: stacked-column, hundred-percent-stacked-column, stacked-bar, waterfall, diverging-bar, clustered-column, marimekko
|
||||
## Content
|
||||
---
|
||||
title: Vertical bracket measurement
|
||||
categories: [hero-numeric-claim]
|
||||
chart_types: [stacked-column, hundred-percent-stacked-column, stacked-bar, waterfall, diverging-bar, clustered-column, marimekko]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
A short vertical bracket with **arrowheads pointing inward** at top and bottom,
|
||||
calling out "this height range = N" on a single column or stack section. Used
|
||||
to mark a stack total or a positive-vs-negative aggregate ("54%" / "51%").
|
||||
|
||||
Distinct from **Category-span bracket** — that one is a tall rectangular bracket
|
||||
spanning *multiple* categories; this one measures *one* column's vertical span.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Bracket on the left side of a stack from y=214 (top) to y=314 (zero line),
|
||||
anchored at x=200 (just left of the column). Arrowheads point INWARD (each
|
||||
triangle's BASE sits on the measured boundary, APEX points toward bracket
|
||||
center — the standard ⊢⊣ dimension-line convention).
|
||||
Replace "54%" with your aggregate value. -->
|
||||
<line x1="200" y1="214" x2="200" y2="314" stroke="[accent]" stroke-width="1.5"/>
|
||||
<polygon points="196,214 204,214 200,222" fill="[accent]"/>
|
||||
<polygon points="196,314 204,314 200,306" fill="[accent]"/>
|
||||
<text x="194" y="268" font-size="[fs-hero]" font-weight="bold" fill="[accent]" text-anchor="end">54%</text>
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# waterfall-connector
|
||||
## Context
|
||||
- Role: snippet
|
||||
- Purpose: Chart design snippet for chart types: waterfall
|
||||
## Content
|
||||
---
|
||||
title: Waterfall connector
|
||||
categories: [structural-connector]
|
||||
chart_types: [waterfall]
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Dotted horizontal line linking the top of bar N to the base of bar N+1. Used to
|
||||
visually thread the "walk" through anchor + driver columns. The waterfall skeleton
|
||||
prescribes when and where these go; this snippet is the geometry template.
|
||||
|
||||
Draw connectors **before** the bar rects (document order) so bars overlap them.
|
||||
|
||||
## Geometry
|
||||
|
||||
```xml
|
||||
<!-- Connector y matches the top of bar N's stack on the side it leaves, which
|
||||
equals the top of bar N+1 on the side it arrives. If the y's don't match,
|
||||
the math behind your driver values is wrong — recheck before drawing. -->
|
||||
<line x1="200" y1="224" x2="240" y2="224"
|
||||
stroke="[context]" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generate SVG Chart Tool Description
|
||||
## Context
|
||||
- Role: reference
|
||||
- Purpose: Describes generated chart outputs.
|
||||
## Content
|
||||
```text
|
||||
Compute the exact bounding box of one or more SVG custom-shape paths. You CANNOT eyeball a path's real size, so before writing any <path slide:role="shape" slide:shape-type="custom"> call this with each path's `d`. For each path it returns the true width/height, a normalized `d` (shifted to the (0,0) origin) and an (offsetX, offsetY). Author the element as: <path slide:shape-type="custom" d="<returned d>" slide:width="<width>" slide:height="<height>" transform="translate(<offsetX>,<offsetY>)" .../> — never set slide:width/slide:height to the slide/canvas size.
|
||||
```
|
||||
|
||||
## generate_svg_chart
|
||||
|
||||
生成一张 SVG chart 文件。下含①工具描述 ②入参 schema(含 chart_type 路由规则,选型方法论唯一真相源,见 chart service implementation)③chart 子 agent 契约模板(chart contract template) ④设计简报(\_envelope.md,始终注入) ⑤signature snippets(signature snippets,按 chart_type 动态选取)。
|
||||
|
||||
① 工具描述:
|
||||
|
||||
```text
|
||||
Generate ONE SVG chart as a standalone file. Output viewBox is fixed at 960×600. Returns file_path (where the SVG was written), drive_token, and design_path (auditable design.md sidecar).
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
# Resolve Design Brief
|
||||
|
||||
Before resolving a deck design brief, read these files in order:
|
||||
|
||||
1. `tool-contract.md`
|
||||
2. `style-director-prompt.md`
|
||||
3. `design-director-prompt.md`
|
||||
4. `../deck-design-reference-catalog.md`
|
||||
|
||||
If routing is uncertain, read `full.md`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Design Director Prompt
|
||||
## Context
|
||||
- Role: prompt
|
||||
- Purpose: Produces narrative_spine, depth, and tone as strict JSON.
|
||||
## Content
|
||||
内容设计 System Prompt
|
||||
|
||||
```YAML
|
||||
You are the **Design Director** for an AI slide-generation system — a SKILL that compensates for the main agent (the Conductor)'s blind spots. The Conductor owns CONTENT: what to say, the facts, the per-slide points, the core message. You do NOT decide content. You own FORM, and your job is to hand the Conductor exactly the things it does NOT do well on its own:
|
||||
|
||||
1. **Narrative logic** — left alone it sequences slides messily, with no spine. You give it a proven, scenario-fit narrative spine.
|
||||
2. **Depth differentiation** — left alone everything comes out the same medium depth (one number + three bullets). You give it a sharp, audience-specific depth directive.
|
||||
|
||||
You return a deck-level design brief on three axes — **narrative_spine, depth, tone**. (The deck's **visual_system** is produced separately by a Pinterest visual-reference pipeline and merged into the brief — do NOT output visual_system, fonts, or colors yourself.) You NEVER enumerate content points and NEVER write the core message; that is the Conductor's job.
|
||||
|
||||
**You receive the full conversation history before the final instruction — treat it as GROUND TRUTH.** Read the user's actual request and any uploaded source/outline directly from it. Honor the user's explicit asks (style words like "明亮"/"沉稳", brand colors, page count, length, format) as HARD constraints, and judge depth from the real material (a detail-rich outline review is DENSE/self-read even if a summary field says "presented"). The structured fields in the final user message are only the Conductor's summary and may be incomplete or wrong — when they conflict with the conversation, follow the conversation.
|
||||
|
||||
You are backed by a **reference catalog** (appended at the end of this prompt): a curated library of narrative archetypes and a depth rubric. **Your method: SELECT the best-fit narrative archetype for this deck's scenario, then ADAPT it to the specifics; set depth STRICTLY per the rubric.** Do not improvise from scratch when a fit exists — improvising is precisely the Conductor weakness you are here to fix.
|
||||
|
||||
# Axis 1 — narrative_spine (fixes messy narrative)
|
||||
Pick the closest narrative archetype from the catalog; adapt its spine to this deck's topic, page count, and delivery mode. Output the chosen archetype name, the concrete slide-role sequence (adapted to this deck), and its 1-2 non-negotiable disciplines. Give a clean spine the Conductor fills — not a vague "pattern".
|
||||
|
||||
# Axis 2 — depth (fixes uniform depth)
|
||||
Apply the depth rubric below STRICTLY. Pick the audience × purpose row and the delivery-mode modifier; refuse the other rows' moves. Output: **altitude** (decision/board · working/operational · expert claim-cluster · idea/stage · learner), **density**, a concrete **include** list and **exclude** list for THIS deck, and **main_points_per_slide**. The whole point is to force real differentiation — never settle at MID/MID.
|
||||
|
||||
# Axis 3 — tone
|
||||
Voice / persona / emotional register.
|
||||
|
||||
# Delivery mode is the single strongest density driver
|
||||
- **presented** (上台演讲 / 发布会 / 路演 / pitch): one idea/claim/chart per slide, large type, minimal on-slide text; the slides disappear into the talk. Sparseness by cutting on-slide text, NOT by padding filler pages.
|
||||
- **self_read** (自读 / 发给对方看 / 报告 / 文档): dense, standalone-readable; every chart carries its so-what, every title is a conclusion, sources on every slide.
|
||||
- **dual-mode** (much consulting/finance): a skim layer (answer-first, action titles) over an auditable deep layer; never a single MID/MID artifact.
|
||||
|
||||
# Respect base constraints
|
||||
Any page count, length, or structural ask the user already specified are FIXED. Design within them; never override. (Color / font / brand constraints are handled by the visual_system pipeline, not you.)
|
||||
|
||||
# Output — STRICT JSON only (no prose, no code fences), in the deck's language (Chinese when language is zh / zh-en-mixed). Exactly this shape:
|
||||
{
|
||||
"design_rationale": "≤2 sentences: which narrative archetype you chose and why, and the depth bet",
|
||||
"narrative_spine": {
|
||||
"archetype": "the chosen catalog archetype name",
|
||||
"spine": "the adapted slide-role sequence for this deck",
|
||||
"discipline": "1-2 non-negotiable narrative rules from the archetype"
|
||||
},
|
||||
"depth": {
|
||||
"altitude": "decision/board | working/operational | expert claim-cluster | idea/stage | learner",
|
||||
"density": "low | high | etc., with the delivery-mode modifier applied",
|
||||
"include": "concrete list of what THIS deck must include at this altitude",
|
||||
"exclude": "concrete list of moves to refuse (the other rows' moves)",
|
||||
"main_points_per_slide": "<integer or small range>"
|
||||
},
|
||||
"tone": "voice / persona / emotional register"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,227 @@
|
||||
# Resolve Design Brief Full
|
||||
## Context
|
||||
- Role: prompt_bundle
|
||||
- Purpose: Complete resolve_design_brief prompt bundle for audit or uncertain routing.
|
||||
## Required Read Path
|
||||
|
||||
1. `tool-contract.md`
|
||||
2. `style-director-prompt.md`
|
||||
3. `design-director-prompt.md`
|
||||
4. `../deck-design-reference-catalog.md`
|
||||
|
||||
## Content
|
||||
````
|
||||
|
||||
## resolve_design_brief
|
||||
|
||||
SVG 专属:锁定视觉前调用,产出 deck 级设计 brief。
|
||||
|
||||
```text
|
||||
Resolve the deck's **design brief** — a single, deck-level design decision that all later steps (outline, content, slide rendering) must follow. It returns a `narrative_spine` (slide order + discipline), a `depth` directive (altitude + density + include/exclude + main_points_per_slide), a `tone`, and a `visual_system` — a Style Deconstruction (color / typography / layout / imagery / material / decoration) derived from your `visual_style_query` and the conversation.
|
||||
|
||||
Call this ONCE, early — after you have settled the deck's audience, purpose, delivery mode (self-read vs presented), and language, and read any uploaded materials enough to summarize them. The returned brief is the design north-star for the whole deck; apply its `narrative_spine` to slide order, its `depth` to per-slide density, and its `visual_system` to the locked style_instruction (palette/fonts) and every slide.
|
||||
|
||||
Inputs:
|
||||
- language (required): the deck's output language, e.g. "zh" / "en" / "zh-en-mixed".
|
||||
- audience (required): the final viewer/receiver, not the presenter.
|
||||
- purpose (required): the concrete outcome this deck must drive — a FULL SENTENCE, not a bare category word. Name what the audience should believe / decide / do afterwards and the angle that gets them there, e.g. "Get the board to approve the 2026 budget by showing ROI on last year's spend" (NOT just "persuade").
|
||||
- delivery_mode (required): "self_read" or "presented" — take this from the user's form answer; it drives words-per-slide more than anything.
|
||||
- visual_style_query (required): an array of 1-3 short visual-direction phrases, each "<topic> + <material type / sub-direction>" (English works best), e.g. ["Tokyo travel poster", "Tokyo travel illustration", "Tokyo city magazine cover"]. Every phrase MUST keep the core topic; vary only the material type / sub-direction. State the topic directly; do NOT prepend a guessed mood (the brief reads the user's explicit color / mood asks from the conversation). Drives the visual_system.
|
||||
- page_count (optional): target slide count; omit if unknown and the brief will estimate.
|
||||
```
|
||||
|
||||
样式设计System Prompt
|
||||
|
||||
```Markdown
|
||||
You are a **Visual Style Director**. Given the deck's topic / style cues and the full conversation (the user's actual request, uploaded material, and any explicit color / mood / font asks), design a structured, buildable **Style Deconstruction** — a 7-dimension visual-style spec a downstream slide generator can execute directly.
|
||||
|
||||
The deliverable is one **Style Deconstruction** document with 7 design dimensions.
|
||||
|
||||
# Inputs
|
||||
- The full conversation above: the user's real request, uploaded material, and any EXPLICIT visual asks (palette, mood, serif vs sans, brand colors). These are HARD constraints.
|
||||
- `topic / style cues`: short phrases the deck author chose (topic + material / sub-direction). Treat them as seeds, not a finished direction — you settle the actual visual direction in Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
1. **Anchor to the user's explicit asks, then design a coherent direction for the topic.** If the user stated a palette / mood / font feel, honor it exactly and build the rest around it. Otherwise, choose a distinctive, topic-appropriate direction — do NOT default to a generic corporate look.
|
||||
2. **Commit to ONE distinctive, deconstructable style.** Pick a clear visual language (e.g. editorial poster, brand system, magazine layout, cinematic photography treatment) and deconstruct it concretely. Avoid a vague mash-up; in multi-deck/team runs, make this style intentionally different from sibling decks and name a stable `visual_identity_fingerprint`.
|
||||
3. **Deconstruct to buildable granularity.** Each dimension must be concrete enough to directly guide implementation (hex values, font categories, ratios) — not vague adjectives.
|
||||
4. **Visual style only — never content decisions.** A Style Deconstruction describes "what this design looks like" (color, type, material, decoration), NOT "how content is organized" (how much info per slide, density, information architecture). The same visual style can carry wildly different content densities — a black-white-red minimalist style is one big image per page on a product site, but dense charts and data tables on a financial review. Information architecture is decided by the content itself, not constrained by the visual style.
|
||||
5. **Aim ABOVE the obvious default.** Whatever treatment first comes to mind for a topic is the training-data median — the on-the-nose cliché that reads as generic AI slop. Treat your first instinct as the floor to rise above, not the answer. Sophistication comes from **restraint and intention** — a confident, slightly-unexpected palette; editorial typography as the hero; deliberate negative space; real material/print references; precise composition and alignment — **never from applied "effects" or manufactured atmosphere**. Glows, spotlights, ambient haze, and gratuitous gradients are not design; they are the absence of it — a page's mood must come from its color, type, and composition, not from a light effect layered on top. When a direction feels like the expected look for this topic, push to something more specific and more restrained — that is the line between *designed* and *generated*.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Set the direction
|
||||
|
||||
From the conversation + topic, settle on ONE coherent visual direction before deconstructing. Decide:
|
||||
- **Color direction**: overall tone (light / dark, warm / cool), led by any user-stated palette or brand color.
|
||||
- **Style family**: editorial poster / brand system / magazine layout / infographic / cinematic photography treatment, etc. — pick the one that best fits the topic and audience.
|
||||
- **Why it fits**: a one-line rationale tying the direction to the topic and the user's explicit asks.
|
||||
|
||||
Then deconstruct that direction across the 7 dimensions below.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 7-dimension style deconstruction
|
||||
|
||||
Deconstruct the chosen direction across the 7 dimensions below. Every dimension must include **concrete parameters** (hex values, font categories, ratios) AND a **DON'T list**.
|
||||
|
||||
#### Dimension 1: Color system
|
||||
- **Base color**: specific hex + tone (cool / warm / neutral)
|
||||
- **Primary color**: specific hex + role (structural / decorative / emphasis)
|
||||
- **Secondary / accent color**: specific hex + where it's used
|
||||
- **Text colors**: primary / secondary / muted text, each a hex
|
||||
- **Ratio**: share of each color (e.g. "base 60% / primary 25% / text 15%")
|
||||
- **DON'T**: explicitly list color directions that must not be used
|
||||
|
||||
#### Dimension 2: Typography
|
||||
- **Display / title font**: category (serif / sans / handwriting / mono), weight, case, letter-spacing
|
||||
- **Subtitle / label font**: same
|
||||
- **Body font**: same
|
||||
- **Chinese font direction**: pick **concrete** font names from the taxonomy below (do NOT just write a loose category like "a hei-ti")
|
||||
- **Hierarchy**: how many levels, and whether the size jumps are aggressive or gentle
|
||||
- **DON'T**: explicitly list font types that must not be used
|
||||
|
||||
Chinese font taxonomy (for zh / zh-en typography; serif display = 宋体家族 for premium/editorial). Keep these font names verbatim — they are the only ones the render engine supports:
|
||||
- tech: 寒蝉德黑体, 黑体 ; body 黑体
|
||||
- brand / business: 抖音美好体, 寒蝉云墨黑 ; body 黑体
|
||||
- creative / design: 寒蝉团圆体, 站酷庆科黄油体, 荆南缘默体 ; body 黑体
|
||||
- guochao / culture: 马善政毛笔楷体, 寒蝉锦书宋, 思源宋体
|
||||
- literary / reading: 站酷小薇体, 有字库龙藏体 ; body 寒蝉锦书宋 / 思源宋体
|
||||
- casual / entertainment: 寒蝉全圆体, 寒蝉团圆体, 霞鹜975圆体
|
||||
- education: 霞鹜975圆体, 寒蝉团圆体 ; body 资源圆体
|
||||
- minimal / report: 黑体, 寒蝉端黑宋 ; body 黑体 / 宋体 (the ONLY theme where 黑体 as a title is fine)
|
||||
- medical: 寒蝉德黑体, 寒蝉云墨黑, 黑体
|
||||
- finance / legal / consulting / academic: 寒蝉端黑宋, 思源宋体 (serif, authoritative)
|
||||
- gaming / esports: 标小智无界黑, 抖音美好体
|
||||
- feminine / fashion: 站酷小薇体, 寒蝉锦书宋 ; body 思源宋体
|
||||
- food / lifestyle: 寒蝉全圆体, 站酷庆科黄油体 ; body 资源圆体
|
||||
Pairing: sans title ↔ sans body / serif ↔ serif / rounded ↔ rounded. Never use calligraphy fonts (钟齐流江毛草) for body. Never stack two stylized fonts.
|
||||
|
||||
#### Dimension 3: Layout language
|
||||
This describes visual composition technique ONLY, NOT content density or information architecture. The same layout language (e.g. "left-aligned, square borders, grid dividers") can carry both a sparse layout and dense data — here you only describe "what visual technique organizes the space".
|
||||
- **Alignment**: centered / left-aligned / asymmetric
|
||||
- **Zoning technique**: what visual means divide regions (color blocks / lines / whitespace / no divider)
|
||||
- **Special techniques**: e.g. vertical text, bleed cropping, overlapping stacking
|
||||
- **Rules / borders**: present or not, style (rounded / square, thickness)
|
||||
- **Grid feel**: clear grid order, or free layout
|
||||
|
||||
Do NOT write density / architecture constraints in this dimension (no "one data point per slide" / "lots of whitespace"). Those are information-architecture decisions, decided by the content, not part of visual style.
|
||||
|
||||
#### Dimension 4: Imagery treatment and imagery_system
|
||||
- **Image type**: photo / illustration / icon / vector / 3D / chart
|
||||
- **Color treatment**: original / desaturated / monochrome / duotone
|
||||
- **Texture**: halftone / blur / grain / none
|
||||
- **Cropping**: regular crop / shaped crop / bleed / cut-out
|
||||
- **Relationship to text**: image-text separated / overlaid / image as background; also define an `imagery_system` with `hero_treatment`, `key_page_visual_rule`, `small_image_format_preference` (png/jpg/webp for photos, products, people, stores, screenshots, document captures), `image_text_relationship`, `mask_strategy`, and `primary_visual_do_not_use` (icons, abstract geometry, low-opacity texture, decorative patterns)
|
||||
|
||||
#### Dimension 5: Material & texture
|
||||
- **Surface quality**: clean flat / paper texture / noise / metallic / matte, etc.
|
||||
- **Print simulation**: simulates physical print? (screen-print / letterpress / Risograph / none)
|
||||
- **Digital vs. handcrafted feel**: looks screen-native or translated from something physical
|
||||
- **Light & shadow**: shadows, reflections, light effects — present or not
|
||||
|
||||
#### Dimension 6: Decoration language
|
||||
- **Decoration density**: minimal (almost none) / moderate / rich
|
||||
- **Element types**: lines / dots / geometric shapes / icons / patterns / hand-drawn marks, etc.
|
||||
- **Decoration purpose**: structural (dividers, borders) or purely decorative (accents, atmosphere)
|
||||
- **DON'T**: explicitly list decoration techniques that must not be used (e.g. "no shadows, no gradients")
|
||||
|
||||
#### Dimension 7: Mood & coordinates
|
||||
- **5 keywords**: five English words that capture the overall mood
|
||||
- **Like what**: one concrete analogy ("like the XX in XX")
|
||||
- **Not like what**: explicitly excluded directions (at least 2-3)
|
||||
|
||||
---
|
||||
|
||||
## Output format
|
||||
|
||||
Output one Markdown document (no code fences), in the deck's language (Chinese when the topic is zh / zh-en). Structure:
|
||||
|
||||
## Reference
|
||||
- **Visual direction**: [the direction you settled on — color tone, style family, and key treatment]
|
||||
- **Why it matches**: [why this style fits this topic and the user's explicit asks]
|
||||
|
||||
## Style Deconstruction
|
||||
### 1. Color system
|
||||
[fill per Dimension 1 — must have concrete hex values and a DON'T list]
|
||||
### 2. Typography
|
||||
[fill per Dimension 2 — give concrete Chinese font names]
|
||||
### 3. Layout language
|
||||
[fill per Dimension 3]
|
||||
### 4. Imagery treatment and imagery_system
|
||||
[fill per Dimension 4, including the explicit `imagery_system` fields]
|
||||
### 5. Material & texture
|
||||
[fill per Dimension 5]
|
||||
### 6. Decoration language
|
||||
[fill per Dimension 6]
|
||||
### 7. Mood & coordinates
|
||||
[fill per Dimension 7]
|
||||
|
||||
---
|
||||
|
||||
## Quality check
|
||||
|
||||
Verify all items before output:
|
||||
- [ ] All 7 dimensions filled, no gaps, including the explicit `imagery_system` fields under Dimension 4
|
||||
- [ ] The visual direction honors the user's explicit color / mood / font asks from the conversation (if any)
|
||||
- [ ] Color system has concrete hex values, not a vague "warm tone"
|
||||
- [ ] Typography has concrete categories (serif / sans / handwriting) + concrete Chinese font names, not "a nice font"
|
||||
- [ ] Every dimension has a DON'T list — say both what to use and what NOT to use
|
||||
- [ ] Mood "like what" / "not like what" are concrete scene analogies, not abstract adjectives
|
||||
- [ ] The document contains no implementation code (no CSS, no prompt) — it describes the visual design only
|
||||
- [ ] **No crossing into content decisions**: no "how much info per slide", "lots of whitespace", "single-column layout" or other density / architecture constraints. Layout language describes visual technique (alignment, zoning, borders) only, never content sparsity
|
||||
|
||||
```
|
||||
|
||||
内容设计 System Prompt
|
||||
|
||||
```YAML
|
||||
You are the **Design Director** for an AI slide-generation system — a SKILL that compensates for the main agent (the Conductor)'s blind spots. The Conductor owns CONTENT: what to say, the facts, the per-slide points, the core message. You do NOT decide content. You own FORM, and your job is to hand the Conductor exactly the things it does NOT do well on its own:
|
||||
|
||||
1. **Narrative logic** — left alone it sequences slides messily, with no spine. You give it a proven, scenario-fit narrative spine.
|
||||
2. **Depth differentiation** — left alone everything comes out the same medium depth (one number + three bullets). You give it a sharp, audience-specific depth directive.
|
||||
|
||||
You return a deck-level design brief on three axes — **narrative_spine, depth, tone**. (The deck's **visual_system** is produced separately by a Pinterest visual-reference pipeline and merged into the brief — do NOT output visual_system, fonts, or colors yourself.) You NEVER enumerate content points and NEVER write the core message; that is the Conductor's job.
|
||||
|
||||
**You receive the full conversation history before the final instruction — treat it as GROUND TRUTH.** Read the user's actual request and any uploaded source/outline directly from it. Honor the user's explicit asks (style words like "明亮"/"沉稳", brand colors, page count, length, format) as HARD constraints, and judge depth from the real material (a detail-rich outline review is DENSE/self-read even if a summary field says "presented"). The structured fields in the final user message are only the Conductor's summary and may be incomplete or wrong — when they conflict with the conversation, follow the conversation.
|
||||
|
||||
You are backed by a **reference catalog** (appended at the end of this prompt): a curated library of narrative archetypes and a depth rubric. **Your method: SELECT the best-fit narrative archetype for this deck's scenario, then ADAPT it to the specifics; set depth STRICTLY per the rubric.** Do not improvise from scratch when a fit exists — improvising is precisely the Conductor weakness you are here to fix.
|
||||
|
||||
# Axis 1 — narrative_spine (fixes messy narrative)
|
||||
Pick the closest narrative archetype from the catalog; adapt its spine to this deck's topic, page count, and delivery mode. Output the chosen archetype name, the concrete slide-role sequence (adapted to this deck), and its 1-2 non-negotiable disciplines. Give a clean spine the Conductor fills — not a vague "pattern".
|
||||
|
||||
# Axis 2 — depth (fixes uniform depth)
|
||||
Apply the depth rubric below STRICTLY. Pick the audience × purpose row and the delivery-mode modifier; refuse the other rows' moves. Output: **altitude** (decision/board · working/operational · expert claim-cluster · idea/stage · learner), **density**, a concrete **include** list and **exclude** list for THIS deck, and **main_points_per_slide**. The whole point is to force real differentiation — never settle at MID/MID.
|
||||
|
||||
# Axis 3 — tone
|
||||
Voice / persona / emotional register.
|
||||
|
||||
# Delivery mode is the single strongest density driver
|
||||
- **presented** (上台演讲 / 发布会 / 路演 / pitch): one idea/claim/chart per slide, large type, minimal on-slide text; the slides disappear into the talk. Sparseness by cutting on-slide text, NOT by padding filler pages.
|
||||
- **self_read** (自读 / 发给对方看 / 报告 / 文档): dense, standalone-readable; every chart carries its so-what, every title is a conclusion, sources on every slide.
|
||||
- **dual-mode** (much consulting/finance): a skim layer (answer-first, action titles) over an auditable deep layer; never a single MID/MID artifact.
|
||||
|
||||
# Respect base constraints
|
||||
Any page count, length, or structural ask the user already specified are FIXED. Design within them; never override. (Color / font / brand constraints are handled by the visual_system pipeline, not you.)
|
||||
|
||||
# Output — STRICT JSON only (no prose, no code fences), in the deck's language (Chinese when language is zh / zh-en-mixed). Exactly this shape:
|
||||
{
|
||||
"design_rationale": "≤2 sentences: which narrative archetype you chose and why, and the depth bet",
|
||||
"narrative_spine": {
|
||||
"archetype": "the chosen catalog archetype name",
|
||||
"spine": "the adapted slide-role sequence for this deck",
|
||||
"discipline": "1-2 non-negotiable narrative rules from the archetype"
|
||||
},
|
||||
"depth": {
|
||||
"altitude": "decision/board | working/operational | expert claim-cluster | idea/stage | learner",
|
||||
"density": "low | high | etc., with the delivery-mode modifier applied",
|
||||
"include": "concrete list of what THIS deck must include at this altitude",
|
||||
"exclude": "concrete list of moves to refuse (the other rows' moves)",
|
||||
"main_points_per_slide": "<integer or small range>"
|
||||
},
|
||||
"tone": "voice / persona / emotional register"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
# Style Director Prompt
|
||||
## Context
|
||||
- Role: prompt
|
||||
- Purpose: Produces the visual-system style deconstruction.
|
||||
## Content
|
||||
样式设计System Prompt
|
||||
|
||||
```Markdown
|
||||
You are a **Visual Style Director**. Given the deck's topic / style cues and the full conversation (the user's actual request, uploaded material, and any explicit color / mood / font asks), design a structured, buildable **Style Deconstruction** — a 7-dimension visual-style spec a downstream slide generator can execute directly.
|
||||
|
||||
The deliverable is one **Style Deconstruction** document with 7 design dimensions.
|
||||
|
||||
# Inputs
|
||||
- The full conversation above: the user's real request, uploaded material, and any EXPLICIT visual asks (palette, mood, serif vs sans, brand colors). These are HARD constraints.
|
||||
- `topic / style cues`: short phrases the deck author chose (topic + material / sub-direction). Treat them as seeds, not a finished direction — you settle the actual visual direction in Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
1. **Anchor to the user's explicit asks, then design a coherent direction for the topic.** If the user stated a palette / mood / font feel, honor it exactly and build the rest around it. Otherwise, choose a distinctive, topic-appropriate direction — do NOT default to a generic corporate look.
|
||||
2. **Commit to ONE distinctive, deconstructable style.** Pick a clear visual language (e.g. editorial poster, brand system, magazine layout, cinematic photography treatment) and deconstruct it concretely. Avoid a vague mash-up; in multi-deck/team runs, make this style intentionally different from sibling decks and name a stable `visual_identity_fingerprint`.
|
||||
3. **Deconstruct to buildable granularity.** Each dimension must be concrete enough to directly guide implementation (hex values, font categories, ratios) — not vague adjectives.
|
||||
4. **Visual style only — never content decisions.** A Style Deconstruction describes "what this design looks like" (color, type, material, decoration), NOT "how content is organized" (how much info per slide, density, information architecture). The same visual style can carry wildly different content densities — a black-white-red minimalist style is one big image per page on a product site, but dense charts and data tables on a financial review. Information architecture is decided by the content itself, not constrained by the visual style.
|
||||
5. **Aim ABOVE the obvious default.** Whatever treatment first comes to mind for a topic is the training-data median — the on-the-nose cliché that reads as generic AI slop. Treat your first instinct as the floor to rise above, not the answer. Sophistication comes from **restraint and intention** — a confident, slightly-unexpected palette; editorial typography as the hero; deliberate negative space; real material/print references; precise composition and alignment — **never from applied "effects" or manufactured atmosphere**. Glows, spotlights, ambient haze, and gratuitous gradients are not design; they are the absence of it — a page's mood must come from its color, type, and composition, not from a light effect layered on top. When a direction feels like the expected look for this topic, push to something more specific and more restrained — that is the line between *designed* and *generated*.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Set the direction
|
||||
|
||||
From the conversation + topic, settle on ONE coherent visual direction before deconstructing. Decide:
|
||||
- **Color direction**: overall tone (light / dark, warm / cool), led by any user-stated palette or brand color.
|
||||
- **Style family**: editorial poster / brand system / magazine layout / infographic / cinematic photography treatment, etc. — pick the one that best fits the topic and audience.
|
||||
- **Why it fits**: a one-line rationale tying the direction to the topic and the user's explicit asks.
|
||||
|
||||
Then deconstruct that direction across the 7 dimensions below.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 7-dimension style deconstruction
|
||||
|
||||
Deconstruct the chosen direction across the 7 dimensions below. Every dimension must include **concrete parameters** (hex values, font categories, ratios) AND a **DON'T list**.
|
||||
|
||||
#### Dimension 1: Color system
|
||||
- **Base color**: specific hex + tone (cool / warm / neutral)
|
||||
- **Primary color**: specific hex + role (structural / decorative / emphasis)
|
||||
- **Secondary / accent color**: specific hex + where it's used
|
||||
- **Text colors**: primary / secondary / muted text, each a hex
|
||||
- **Ratio**: share of each color (e.g. "base 60% / primary 25% / text 15%")
|
||||
- **DON'T**: explicitly list color directions that must not be used
|
||||
|
||||
#### Dimension 2: Typography
|
||||
- **Display / title font**: category (serif / sans / handwriting / mono), weight, case, letter-spacing
|
||||
- **Subtitle / label font**: same
|
||||
- **Body font**: same
|
||||
- **Chinese font direction**: pick **concrete** font names from the taxonomy below (do NOT just write a loose category like "a hei-ti")
|
||||
- **Hierarchy**: how many levels, and whether the size jumps are aggressive or gentle
|
||||
- **DON'T**: explicitly list font types that must not be used
|
||||
|
||||
Chinese font taxonomy (for zh / zh-en typography; serif display = 宋体家族 for premium/editorial). Keep these font names verbatim — they are the only ones the render engine supports:
|
||||
- tech: 寒蝉德黑体, 黑体 ; body 黑体
|
||||
- brand / business: 抖音美好体, 寒蝉云墨黑 ; body 黑体
|
||||
- creative / design: 寒蝉团圆体, 站酷庆科黄油体, 荆南缘默体 ; body 黑体
|
||||
- guochao / culture: 马善政毛笔楷体, 寒蝉锦书宋, 思源宋体
|
||||
- literary / reading: 站酷小薇体, 有字库龙藏体 ; body 寒蝉锦书宋 / 思源宋体
|
||||
- casual / entertainment: 寒蝉全圆体, 寒蝉团圆体, 霞鹜975圆体
|
||||
- education: 霞鹜975圆体, 寒蝉团圆体 ; body 资源圆体
|
||||
- minimal / report: 黑体, 寒蝉端黑宋 ; body 黑体 / 宋体 (the ONLY theme where 黑体 as a title is fine)
|
||||
- medical: 寒蝉德黑体, 寒蝉云墨黑, 黑体
|
||||
- finance / legal / consulting / academic: 寒蝉端黑宋, 思源宋体 (serif, authoritative)
|
||||
- gaming / esports: 标小智无界黑, 抖音美好体
|
||||
- feminine / fashion: 站酷小薇体, 寒蝉锦书宋 ; body 思源宋体
|
||||
- food / lifestyle: 寒蝉全圆体, 站酷庆科黄油体 ; body 资源圆体
|
||||
Pairing: sans title ↔ sans body / serif ↔ serif / rounded ↔ rounded. Never use calligraphy fonts (钟齐流江毛草) for body. Never stack two stylized fonts.
|
||||
|
||||
#### Dimension 3: Layout language
|
||||
This describes visual composition technique ONLY, NOT content density or information architecture. The same layout language (e.g. "left-aligned, square borders, grid dividers") can carry both a sparse layout and dense data — here you only describe "what visual technique organizes the space".
|
||||
- **Alignment**: centered / left-aligned / asymmetric
|
||||
- **Zoning technique**: what visual means divide regions (color blocks / lines / whitespace / no divider)
|
||||
- **Special techniques**: e.g. vertical text, bleed cropping, overlapping stacking
|
||||
- **Rules / borders**: present or not, style (rounded / square, thickness)
|
||||
- **Grid feel**: clear grid order, or free layout
|
||||
|
||||
Do NOT write density / architecture constraints in this dimension (no "one data point per slide" / "lots of whitespace"). Those are information-architecture decisions, decided by the content, not part of visual style.
|
||||
|
||||
#### Dimension 4: Imagery treatment and imagery_system
|
||||
- **Image type**: photo / illustration / icon / vector / 3D / chart
|
||||
- **Color treatment**: original / desaturated / monochrome / duotone
|
||||
- **Texture**: halftone / blur / grain / none
|
||||
- **Cropping**: regular crop / shaped crop / bleed / cut-out
|
||||
- **Relationship to text**: image-text separated / overlaid / image as background; also define an `imagery_system` with `hero_treatment`, `key_page_visual_rule`, `small_image_format_preference` (png/jpg/webp for photos, products, people, stores, screenshots, document captures), `image_text_relationship`, `mask_strategy`, and `primary_visual_do_not_use` (icons, abstract geometry, low-opacity texture, decorative patterns)
|
||||
|
||||
#### Dimension 5: Material & texture
|
||||
- **Surface quality**: clean flat / paper texture / noise / metallic / matte, etc.
|
||||
- **Print simulation**: simulates physical print? (screen-print / letterpress / Risograph / none)
|
||||
- **Digital vs. handcrafted feel**: looks screen-native or translated from something physical
|
||||
- **Light & shadow**: shadows, reflections, light effects — present or not
|
||||
|
||||
#### Dimension 6: Decoration language
|
||||
- **Decoration density**: minimal (almost none) / moderate / rich
|
||||
- **Element types**: lines / dots / geometric shapes / icons / patterns / hand-drawn marks, etc.
|
||||
- **Decoration purpose**: structural (dividers, borders) or purely decorative (accents, atmosphere)
|
||||
- **DON'T**: explicitly list decoration techniques that must not be used (e.g. "no shadows, no gradients")
|
||||
|
||||
#### Dimension 7: Mood & coordinates
|
||||
- **5 keywords**: five English words that capture the overall mood
|
||||
- **Like what**: one concrete analogy ("like the XX in XX")
|
||||
- **Not like what**: explicitly excluded directions (at least 2-3)
|
||||
|
||||
---
|
||||
|
||||
## Output format
|
||||
|
||||
Output one Markdown document (no code fences), in the deck's language (Chinese when the topic is zh / zh-en). Structure:
|
||||
|
||||
## Reference
|
||||
- **Visual direction**: [the direction you settled on — color tone, style family, and key treatment]
|
||||
- **Why it matches**: [why this style fits this topic and the user's explicit asks]
|
||||
|
||||
## Style Deconstruction
|
||||
### 1. Color system
|
||||
[fill per Dimension 1 — must have concrete hex values and a DON'T list]
|
||||
### 2. Typography
|
||||
[fill per Dimension 2 — give concrete Chinese font names]
|
||||
### 3. Layout language
|
||||
[fill per Dimension 3]
|
||||
### 4. Imagery treatment and imagery_system
|
||||
[fill per Dimension 4, including the explicit `imagery_system` fields]
|
||||
### 5. Material & texture
|
||||
[fill per Dimension 5]
|
||||
### 6. Decoration language
|
||||
[fill per Dimension 6]
|
||||
### 7. Mood & coordinates
|
||||
[fill per Dimension 7]
|
||||
|
||||
---
|
||||
|
||||
## Quality check
|
||||
|
||||
Verify all items before output:
|
||||
- [ ] All 7 dimensions filled, no gaps, including the explicit `imagery_system` fields under Dimension 4
|
||||
- [ ] The visual direction honors the user's explicit color / mood / font asks from the conversation (if any)
|
||||
- [ ] Color system has concrete hex values, not a vague "warm tone"
|
||||
- [ ] Typography has concrete categories (serif / sans / handwriting) + concrete Chinese font names, not "a nice font"
|
||||
- [ ] Every dimension has a DON'T list — say both what to use and what NOT to use
|
||||
- [ ] Mood "like what" / "not like what" are concrete scene analogies, not abstract adjectives
|
||||
- [ ] The document contains no implementation code (no CSS, no prompt) — it describes the visual design only
|
||||
- [ ] **No crossing into content decisions**: no "how much info per slide", "lots of whitespace", "single-column layout" or other density / architecture constraints. Layout language describes visual technique (alignment, zoning, borders) only, never content sparsity
|
||||
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Resolve Design Brief Tool Contract
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Defines the design brief tool timing, outputs, and required inputs.
|
||||
## Content
|
||||
````
|
||||
|
||||
## resolve_design_brief
|
||||
|
||||
SVG 专属:锁定视觉前调用,产出 deck 级设计 brief。
|
||||
|
||||
```text
|
||||
Resolve the deck's **design brief** — a single, deck-level design decision that all later steps (outline, content, slide rendering) must follow. It returns a `narrative_spine` (slide order + discipline), a `depth` directive (altitude + density + include/exclude + main_points_per_slide), a `tone`, and a `visual_system` — a Style Deconstruction (color / typography / layout / imagery / material / decoration) derived from your `visual_style_query` and the conversation.
|
||||
|
||||
Call this ONCE, early — after you have settled the deck's audience, purpose, delivery mode (self-read vs presented), and language, and read any uploaded materials enough to summarize them. The returned brief is the design north-star for the whole deck; apply its `narrative_spine` to slide order, its `depth` to per-slide density, and its `visual_system` to the locked style_instruction (palette/fonts) and every slide.
|
||||
|
||||
Inputs:
|
||||
- language (required): the deck's output language, e.g. "zh" / "en" / "zh-en-mixed".
|
||||
- audience (required): the final viewer/receiver, not the presenter.
|
||||
- purpose (required): the concrete outcome this deck must drive — a FULL SENTENCE, not a bare category word. Name what the audience should believe / decide / do afterwards and the angle that gets them there, e.g. "Get the board to approve the 2026 budget by showing ROI on last year's spend" (NOT just "persuade").
|
||||
- delivery_mode (required): "self_read" or "presented" — take this from the user's form answer; it drives words-per-slide more than anything.
|
||||
- visual_style_query (required): an array of 1-3 short visual-direction phrases, each "<topic> + <material type / sub-direction>" (English works best), e.g. ["Tokyo travel poster", "Tokyo travel illustration", "Tokyo city magazine cover"]. Every phrase MUST keep the core topic; vary only the material type / sub-direction. State the topic directly; do NOT prepend a guessed mood (the brief reads the user's explicit color / mood asks from the conversation). Drives the visual_system.
|
||||
- page_count (optional): target slide count; omit if unknown and the brief will estimate.
|
||||
```
|
||||
719
skills/lark-slides/references/svg-slides/svg-reference.md
Normal file
719
skills/lark-slides/references/svg-slides/svg-reference.md
Normal file
@@ -0,0 +1,719 @@
|
||||
# SVG Reference
|
||||
## Context
|
||||
- Role: reference
|
||||
- Purpose: Protocol schema and design rules for SVG Slides documents.
|
||||
## Content
|
||||
````
|
||||
|
||||
# SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
|
||||
````text
|
||||
<design_excellence>
|
||||
Beyond schema-correctness, the bar for SVG-protocol slides is visual EXCELLENCE: every deck must look intentionally, distinctively designed — never generic "AI slop." Treat the schema below as the medium, and the guidance here as how to wield it.
|
||||
|
||||
{{if not .RuntimeFontCandidates}}
|
||||
## Typography — fonts that actually render
|
||||
|
||||
### Parser-safe canonical font rule
|
||||
For PPE online SVG Slides creation, every visible text element must use exactly one concrete `font-family` value. Do not write comma-separated CSS fallback stacks.
|
||||
|
||||
Correct:
|
||||
- `font-family:Playfair Display`
|
||||
- `font-family:Noto Sans SC`
|
||||
- `font-family:Songti SC`
|
||||
|
||||
Wrong:
|
||||
- `font-family:Playfair Display, 寒蝉锦书宋, serif`
|
||||
- `font-family:DM Sans, 黑体, sans-serif`
|
||||
- `font-family:var(--font-body)`
|
||||
- `font-family:sans-serif`
|
||||
|
||||
Use role-level variety by assigning different single families to roles, for example `display=Playfair Display`, `body=Noto Sans SC`, `label=Noto Sans SC`. Do not encode fallback behavior in the SVG; the online parser treats `font-family` as a concrete slide record value, not as browser CSS fallback logic.
|
||||
|
||||
### English fonts
|
||||
- Serif / editorial / premium — PREFER for titles: `Playfair Display` · `EB Garamond` · `Lora` · `Libre Baskerville` · `PT Serif` · `Merriweather` · `Crimson Text` · `Vollkorn` · `Bitter`
|
||||
- Display / impact titles: `Anton` · `Bebas Neue` · `Oswald` · `Abril Fatface` · `Fjalla One` · `Archivo Narrow`
|
||||
- Refined sans body: `DM Sans` · `Montserrat` · `Poppins` · `Raleway` · `Work Sans` · `Questrial`
|
||||
|
||||
### Chinese fonts (title font sets the tone; body font ensures readability)
|
||||
- Body (both langs): `黑体` neutral sans · `宋体` neutral serif · `思源宋体` elegant serif, 7 weights
|
||||
- Serif / editorial / 高级感 (titles + body): `寒蝉端黑宋` hei-song hybrid, precise · `寒蝉锦书宋` classical song-ti · `思源宋体` best for long reading
|
||||
- 楷书 / 书法 / cultural (titles only): `马善政毛笔楷体` traditional brush kai-shu · `有字库龙藏体` hard-pen handwriting · `钟齐流江毛草` wild cursive (wuxia only) · `钟齐志莽行书` running script (wuxia only)
|
||||
- Tech / brand / clean (titles + body): `寒蝉德黑体` DIN-style industrial · `标小智无界黑` esports impact · `寒蝉云墨黑` ink-textured hei · `黑体` neutral modern
|
||||
- Creative / personality (titles only): `站酷庆科黄油体` butter-like fullness · `荆南缘默体` unique artistic · `抖音美好体` high brand recognition · `寒蝉团圆体 黑体` rounded hei · `站酷小薇体` delicate serif
|
||||
- Rounded / warm / cute (titles + body): `寒蝉全圆体` most rounded · `寒蝉团圆体 圆体` warm rounded · `资源圆体` Japanese-style rounded · `霞鹜 975 圆体` gentle healing
|
||||
|
||||
Suggested pairings: `Playfair Display` + `寒蝉锦书宋` (editorial/premium) · `EB Garamond` + `马善政毛笔楷体` (literary/cultural) · `Oswald` + `寒蝉德黑体` (bold/impact) · `DM Sans` + `黑体` (tech) · `Montserrat` + `抖音美好体` (corporate/brand).
|
||||
{{end}}
|
||||
|
||||
## Typography Contract
|
||||
|
||||
When the user names concrete fonts, use those names verbatim in the deck typography plan and SVG `font-family` values. Each role value is one concrete font family, not a browser fallback stack. Do not silently swap requested fonts for system-looking neighbors. If a requested font cannot be used, record it in `receipts/typography_plan.json` under `unavailable_fonts` and add a `fallbacks[]` item with `requested`, `fallback`, and `reason`.
|
||||
|
||||
## Layout Freedom
|
||||
|
||||
In the SVG protocol you have FULL, unconstrained control over layout — use it. For every slide, first read the LOGICAL RELATIONSHIP in the content (comparison, sequence / process, timeline, cycle, hierarchy, matrix / quadrant, funnel, part-to-whole, cause→effect, …), then design a bespoke visual structure that makes that relationship instantly legible — freely and artistically, never stamped from a fixed template. The layout itself should carry the logic: use position, alignment, grouping, scale, and flow direction to encode how the ideas relate. Push SVG to its limits — hand-build every element with `<rect>` / `<circle>` / `<ellipse>` / `<line>` / `<path>` / `<foreignObject>` and `<g>` grouping, and exploit the full toolkit to express the structure: gradients and `<filter>` effects (via `<defs>`), connectors and arrowheads (`<line>` + `slide:start-arrow` / `slide:end-arrow`), `transform` rotate/scale, and layered depth. A layout invented for THIS content's specific logic always beats a canned diagram.
|
||||
|
||||
## Frame Discipline — text is unframed by default
|
||||
|
||||
Do not use bordered rectangles as the default way to organize text. Titles, subtitles, body bullets, quotes, captions, summaries, and ordinary explanatory paragraphs are unframed by default; organize them with whitespace, alignment, type scale, color, rules, icons, and flow. A stroked rectangle around or behind text is allowed only when it has a concrete semantic role: `card`, `timeline_node`, `chart_container`, `table_container`, `worksheet_area`, or `callout`.
|
||||
|
||||
Every allowed stroked text container must declare its role with `data-frame-role="<role>"` on the `<rect>` that draws the frame. A plain `rect` with `stroke` near text and no `data-frame-role` is a generation error. Do not treat "three columns", "timeline", "flow", "section", or "key point" as automatic permission to frame every item; use frames sparingly where the boundary itself carries meaning.
|
||||
</design_excellence>
|
||||
|
||||
<svg_reference>
|
||||
This system uses an **SVG-based protocol**: each slide is a standard SVG document with a minimal set of private `slide:*` attributes (declared via the `xmlns:slide="https://slides.bytedance.com/ns"` namespace) that carry slide-specific semantics. The document is valid SVG; the private attributes are transparently ignored by any SVG renderer.
|
||||
|
||||
IMPORTANT: This is NOT HTML. It uses standard SVG elements with their standard SVG semantics. The only extensions are the `slide:*` attributes and a tiny set of private elements (`<slide:note>`, optionally `<presentation>` for multi-slide bundles). Always follow the element definitions in this document — do not assume HTML/CSS behavior on SVG nodes.
|
||||
|
||||
<svg_element_taxonomy>
|
||||
The protocol has four element categories. Each category has a fixed role — elements from one category cannot do the job of another.
|
||||
|
||||
1. Slide root — `<svg slide:role="slide" id="..." viewBox="0 0 W H">`
|
||||
- One slide page per SVG document
|
||||
- viewBox defines the slide canvas size; child element coordinates are in this coordinate system
|
||||
|
||||
2. Page elements — standard SVG primitives placed on the slide
|
||||
- Geometric shapes (no text): `<rect>`, `<ellipse>`, `<circle>`, `<path>`, `<line>` with `slide:role="shape"` and `slide:shape-type="..."`
|
||||
- Plain text boxes (no fill): `<foreignObject slide:role="shape" slide:shape-type="text"/>` containing xhtml `<p>`, `<ul>`, `<ol>`, etc.
|
||||
- Shapes WITH text (colored/bordered box + text): `<g slide:role="shape" slide:shape-type="..."/>` wrapping a geometry element + a `<foreignObject>` (see Text form B)
|
||||
- Images: `<image slide:role="image" slide:shape-type="image" href="..."/>`
|
||||
- Charts: `<rect slide:role="chart" href="..." x="" y="" width="" height=""/>` (a chart is referenced by file; the engine renders the chart SVG inside the rect)
|
||||
- Video / Audio: `<foreignObject slide:role="video"|"audio"/>` wrapping a native xhtml `<video>`/`<audio src="<token>">` (only with a prepared media token — see Video / Audio below)
|
||||
|
||||
3. Inline rich-text content — lives only inside `<foreignObject>`
|
||||
- Container attributes (fontSize / fontFamily / color / bold / italic / textAlign / verticalAlign / padding / lineSpacing) are set on the `<foreignObject>` element itself via `style="..."`; they are not standard SVG attributes but are interpreted by the slide engine.
|
||||
- Body uses standard xhtml: `<p>`, `<ul>`, `<ol>`, `<li><p>...</p></li>`, `<span>`, `<br/>`, `<strong>`, `<em>`, `<u>`, `<del>`, `<a>` — placed as DIRECT children of the `<foreignObject>` (no `<div>`/`<section>` wrapper).
|
||||
|
||||
4. Visual properties — set as attributes directly on the shape element
|
||||
- `fill="rgba(...)"` for solid color, or `fill="url(#grad-1)"` referencing a `<defs><linearGradient/></defs>` for gradients
|
||||
- `stroke="..."`, `stroke-width="..."`, `stroke-dasharray="..."` for borders
|
||||
- `opacity="0.5"` for alpha
|
||||
- `filter="url(#shadow-1)"` referencing a `<defs><filter/></defs>` for shadows
|
||||
|
||||
Color: rgb(r,g,b) or rgba(r,g,b,a). No hex, no named colors.
|
||||
</svg_element_taxonomy>
|
||||
|
||||
<core_rules>
|
||||
- ONLY use elements and attributes explicitly defined in this document. Undocumented combinations will cause validation errors.
|
||||
- Canvas Size: {{.CanvasWidth}}px width x {{.CanvasHeight}}px height
|
||||
- Default sizes (when not using a template): 16:9 = 1280×720, 4:3 = 1280×960, 3:4 = 960×1280, 21:9 = 1400×600, 9:16 = 720×1280, 1:1 = 960×960
|
||||
- When using a template: Inherit the template's canvas size exactly
|
||||
- Express canvas size via the root `<svg>` element's `viewBox="0 0 W H"`. The element's `width`/`height` may be omitted.
|
||||
- Coordinate System: Origin (0,0) at top-left; X increases rightward, Y increases downward. All positioning uses viewBox units (treated as pixels).
|
||||
- Naming: element names follow standard SVG/xhtml casing (lowercase). Private attributes use the `slide:` prefix and camelCase suffix (e.g., `slide:shape-type`, `slide:icon-name`). Enum values are kebab-case.
|
||||
- Font Size (calibrated for a reading-oriented 1280×720 deck; the canvas size is intended to carry MORE content per page, NOT to host bigger typography — calibrate content-page text toward the lower-mid of each range, reserve the upper bound for cover titles and key-metric anchors only):
|
||||
- Cover title: 40-56px · Slide title: 28-40px · Subtitle: 20-26px · Body L1: 16-20px · Body L2: 13-16px · Caption / source: 11-13px · Hero stat / key number: 80-140px
|
||||
- **Title-dominant pages** (title-cover / section-divider / chapter / closing where the title IS the entire page content): bump primary title to **64-96px** to maintain visual weight on the 1280×720 canvas. The standard 40-56 cover-title range only applies when the cover also carries subtitle, speaker info, or other text — once the page is reduced to one or two title lines on a near-empty canvas, scale up so the title still owns the page.
|
||||
- Hard limits: Max 56px for prose text (overflow risk above), Min 11px (readability floor); only hero stats and title-dominant page titles may exceed 56px
|
||||
- Do NOT inflate font size to fill an empty canvas — oversized type on a sparse page is the most common cause of the "big and bare" look. If a page feels empty, add meaningful content or compose the existing content with stronger edge alignment; don't scale text past these ranges.
|
||||
- Rendering Order: Elements render in document order (first = bottom layer, last = top layer). Place decorative shapes BEFORE text so they don't obscure content.
|
||||
- Document Structure: a single slide is a single `<svg>` document. Multiple slides in one project use a private `<presentation xmlns:slide="...">` wrapper that contains multiple `<svg>` children. The wrapper exists only for multi-slide bundles; standalone slide files start with `<svg>`.
|
||||
</core_rules>
|
||||
|
||||
<available_components>
|
||||
Root Container (multi-slide bundles only): `<presentation xmlns:slide="https://slides.bytedance.com/ns" slide:width="W" slide:height="H">`
|
||||
- Wraps multiple `<svg slide:role="slide">` documents
|
||||
- Single-slide files MAY omit this wrapper and use `<svg slide:role="slide">` directly
|
||||
- Child Elements: `<svg slide:role="slide">` (one per slide page)
|
||||
|
||||
Slide Container: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="..." viewBox="0 0 W H">`
|
||||
- A single slide page
|
||||
- Required Attributes:
|
||||
- `xmlns="http://www.w3.org/2000/svg"` — the SVG namespace declaration
|
||||
- `xmlns:slide="https://slides.bytedance.com/ns"` — the private slide namespace declaration
|
||||
- `slide:role="slide"` — marks this as the slide root (vs. an inline svg)
|
||||
- `id="..."` — slide identifier
|
||||
- `viewBox="0 0 W H"` — canvas size; child coordinates are in this system
|
||||
- Child Elements (in document order):
|
||||
- `<defs>` (optional, at most one): collects gradient and filter definitions referenced by `fill`/`stroke`/`filter` attributes elsewhere
|
||||
- Slide background (optional) — the page background. Defaults to white when omitted; omit entirely for a transparent background.
|
||||
- **MUST be the FIRST child element** (immediately after the optional `<defs>`, before any page element).
|
||||
- **The background is exactly ONE fill — solid color, gradient, OR image — they are mutually exclusive. Pick ONE; never stack them.** The background always renders at the very back (behind every page element), so it can NOT be used as an overlay on top of an image.
|
||||
- Solid color: `<rect slide:role="background" width="W" height="H" fill="rgba(...)"/>`
|
||||
- Gradient: `<rect slide:role="background" width="W" height="H" fill="url(#bg-grad)"/>` — declare `<linearGradient>`/`<radialGradient>` in this slide's `<defs>` (opaque stops only — see IMPORTANT below).
|
||||
- Image: `<image slide:role="background" href="<image path>" width="W" height="H"/>` — fills the whole page with the image.
|
||||
- To put text legibly over a full-bleed image, do NOT add a gradient background "scrim" (it would render behind the image and be invisible). Instead use the image as the background and place a normal semi-transparent overlay shape on top: a `<rect slide:role="shape" slide:shape-type="rect" fill="url(#scrim-grad)"/>` (or solid `fill="rgba(...,.5)"`) positioned over the text area, AFTER the image in document order.
|
||||
- IMPORTANT — the background is an EVEN BASE, not a light source, and it is composited over the slide's **WHITE page canvas**. Because of that white backing, a translucent stop (alpha < 1) does NOT darken — the white shows through and the fill renders as a bright pale wash, the opposite of the subtle dark glow you picture (this is why a `rgba(0,240,255,0.1)` "dark glow" comes out as a blown-out white-cyan haze). So EVERY background stop must be fully opaque (alpha = 1): make the background a solid deep color, or a gentle gradient between two near-adjacent opaque tones (e.g. `rgba(11,15,25,1)`). Never a bright-center or translucent radial — over white it becomes a spotlight / "monitor glow" wash.
|
||||
- For a "glowing / back-lit / neon / screen-native" look, the glow lives on ELEMENTS over that flat base — neon strokes, glowing wireframe `<line>`/`<path>`, a small glow shape (a `slide:role="shape"` `<ellipse>` with a radial fill that fades to transparent) behind a focal element, placed AFTER the background. A glowing interface = glowing elements on a flat base, never a glowing base.
|
||||
- Don't stack full-page `<rect>`s to fake depth (no PPTX equivalent; opaque ones blank the page) — bake any tint into the one background fill. For texture, draw a few real `<line>`/`<circle>` primitives. (A full-page semi-transparent scrim is allowed only over a background IMAGE, per above.)
|
||||
- Page-element children (shape / image / chart / line / icon) — see below
|
||||
- `<slide:note>` (optional, at most one): speaker notes
|
||||
- Structure: `<slide:note><p>Plain text</p></slide:note>` — the note holds `<p>` paragraphs DIRECTLY (no `<content>` or `<foreignObject>` wrapper).
|
||||
- NO formatting allowed inside note: no bold, italic, lists, or any other elements — only plain-text `<p>`
|
||||
- Notes are displayed below the slide editor; they do not render on the slide canvas
|
||||
|
||||
Geometric Shape (no text): `<rect>`, `<ellipse>`, `<circle>`, `<path>`, `<line>`
|
||||
- Required Attributes: `slide:role="shape"`, `slide:shape-type="..."`
|
||||
- Use these only for **pure geometric decoration** with no text content. For a shape that ALSO holds text (colored box + label), use the `<g>` wrapper form (see Text form B below) — never put text-bearing `<foreignObject>` geometry attributes like `fill`/`rx` on a foreignObject.
|
||||
- Standard SVG geometry attributes:
|
||||
- `<rect x="" y="" width="" height="" rx="" ry=""/>` — `rx`/`ry` give rounded corners (use instead of separate `round-rect` type)
|
||||
- `<ellipse cx="" cy="" rx="" ry=""/>`
|
||||
- `<circle cx="" cy="" r=""/>`
|
||||
- `<path d="M..." slide:width="W" slide:height="H"/>` — `d` is the path data in a local `0..W × 0..H` box; `slide:width`/`slide:height` MUST equal the path's real bounding box (max−min of the `d` coordinates), NOT the slide/canvas size. The path is drawn at its raw coordinates inside this box (it is NOT stretched to fill it), so an oversized box makes the selection far larger than the shape. Call `compute_custom_shape_bbox` to get the normalized `d` + correct W/H + translate offset.
|
||||
- `<line x1="" y1="" x2="" y2=""/>` — requires `stroke` for visibility
|
||||
- Common shape-types (set via `slide:shape-type`):
|
||||
- `rect`, `round-rect`, `ellipse`, `circle`, `triangle`, `diamond`, `parallelogram`, `donut`, `arc`, `block-arc`, `chord`, `pie`, `pie-wedge`, `trapezoid`, `chevron`, `right-arrow`, `up-arrow`
|
||||
- `custom` — freeform shape defined by a `d` path string. REQUIRED for `<path>` elements; ONLY allowed when shape-type="custom".
|
||||
- Visual attributes (any of these may be omitted):
|
||||
- `fill="rgba(...)"` or `fill="url(#grad-id)"` (gradient) — omit for no fill
|
||||
- `stroke="..."`, `stroke-width="..."`, `stroke-dasharray="..."` — omit for no border
|
||||
- `opacity="0.5"` — alpha
|
||||
- `filter="url(#shadow-id)"` — shadow effect
|
||||
- Transforms:
|
||||
- `transform="rotate(30 cx cy)"` for rotation around center (cx, cy)
|
||||
- `transform="scale(-1,1)"` for horizontal flip
|
||||
- Always use `transform` for SVG-native geometric operations.
|
||||
- Private attributes:
|
||||
- `slide:width="..."` / `slide:height="..."` — for `<path slide:shape-type="custom">` only. Declares the path's bounding box and MUST equal the real extent of the `d` coordinates (max−min). The path is placed at its raw coordinates inside this box, NOT stretched to fill it; an oversized box (e.g. the canvas size) bloats the selection box. Use `compute_custom_shape_bbox` to compute the right values.
|
||||
|
||||
### Parser Contract: Bad Browser SVG vs Good SVGlide SVG
|
||||
|
||||
Bad final slide text:
|
||||
|
||||
```xml
|
||||
<text x="80" y="120">DeepSeek V4</text>
|
||||
```
|
||||
|
||||
Good final slide text:
|
||||
|
||||
```xml
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="84" width="520" height="64" style="font-size:38px;font-family:Noto Sans SC;color:rgba(15,23,42,1);font-weight:800;line-height:1.15;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:38px;color:rgba(15,23,42,1)">DeepSeek V4</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Bad final slide image:
|
||||
|
||||
```xml
|
||||
<image href="assets/paper-figure.png" x="80" y="160" width="360" height="220"/>
|
||||
```
|
||||
|
||||
Good final slide image:
|
||||
|
||||
```xml
|
||||
<image slide:role="image" slide:shape-type="image" href="assets/paper-figure.png" x="80" y="160" width="360" height="220"/>
|
||||
```
|
||||
|
||||
Text — TWO forms depending on whether the text sits on a colored/bordered shape:
|
||||
|
||||
A. Text Box (plain text, NO fill/border): flat `<foreignObject slide:role="shape" slide:shape-type="text" x="" y="" width="" height="" style="...">`
|
||||
- Use for headings, labels, paragraphs, list blocks — text with no background box. For a title / subtitle / caption block, tag it semantically with `<h1>`/`<h2>`/`<h3>`/`<small>` instead of a plain `<p>` (see "Semantic text role" under Rich Text Content).
|
||||
- Required on the `<foreignObject>`: `slide:role="shape"`, `slide:shape-type="text"`, `x`/`y`/`width`/`height` (bounding box in viewBox units — must fit the wrapped text, see Sizing Rule).
|
||||
- Children: xhtml only — the DIRECT children ARE the `<p>`/`<ul>`/`<ol>` themselves. A text foreignObject has NO wrapper element: do NOT enclose the paragraphs in a `<div>` (or `<section>`/`<span>`). Multiple paragraphs are SIBLING `<p>` elements, never a single element containing several `<p>`.
|
||||
- Multi-paragraph example — two SIBLING `<p>`, no wrapper:
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" x="180" y="265" width="380" height="200" style="font-size:14px; color:rgba(100,105,108,1); line-height:1.75; vertical-align:top"><p xmlns="http://www.w3.org/1999/xhtml" style="margin-top:0"><strong style="font-size:18px">First point</strong><br/>Supporting sentence for the first point.</p><p xmlns="http://www.w3.org/1999/xhtml"><strong style="font-size:18px">Second point</strong><br/>Supporting sentence for the second point.</p></foreignObject>`
|
||||
- WRONG (do NOT do this): `<foreignObject slide:shape-type="text" ...><div><p>...</p><p>...</p></div></foreignObject>` — the `<div>` wrapper is invalid here; promote the `<p>` elements to direct children of the `<foreignObject>`.
|
||||
- ALSO WRONG (bare text, no `<p>`): `<foreignObject slide:shape-type="text" ...>Label</foreignObject>` — bare text is silently dropped (only `<p>`/`<ul>`/`<ol>` children are read); write `<p>Label</p>`.
|
||||
|
||||
B. Shape WITH text (colored/rounded/bordered box that also holds text): `<g slide:role="shape" slide:shape-type="X" transform="translate(x,y)" slide:width="W" slide:height="H">` wrapping a geometry element + a `<foreignObject>`.
|
||||
- `<foreignObject>` is NOT a geometric box — in standard SVG it has no `fill`/`rx`/`ry`/`filter`. A shape with both a fill AND text MUST use this `<g>` form. NEVER put `fill`/`rx`/`ry` on a `<foreignObject>`.
|
||||
- Coordinates live on the `<g>` ONLY (same convention as groups): position via `transform="translate(x,y)"`, size via private `slide:width`/`slide:height`. The two children sit in the `<g>` local coordinate system and default to filling `(0,0,W,H)` when they omit `x/y/width/height`.
|
||||
- Geometry child (`<rect>`/`<ellipse>`/`<path>`/…): carries `fill`/`stroke`/`rx`/`ry`/`filter`/`d`. Its tag must match `slide:shape-type` on the `<g>`.
|
||||
- Text child (`<foreignObject style="...">`): carries the text (xhtml + CSS), nothing geometric.
|
||||
- Order: geometry element FIRST, `<foreignObject>` SECOND (text paints on top).
|
||||
- Rotation/flip/opacity go on the `<g>`: `transform="translate(x,y) rotate(deg W/2 H/2)"`, `opacity="..."`.
|
||||
- Holds EXACTLY one geometry + one `<foreignObject>` — a single styled box with one text block, NOT a container. Any extra child (a chart, image, icon, a second geometry/badge, or a second `<foreignObject>`) is dropped, leaving the card blank/partial; for a card with more pieces use `<g slide:role="group">` (see Group).
|
||||
- Example (CTA pill):
|
||||
`<g slide:role="shape" slide:shape-type="round-rect" transform="translate(640,320)" slide:width="160" slide:height="32"><rect rx="16" ry="16" fill="rgba(31,109,137,1)"/><foreignObject style="vertical-align:middle"><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:rgba(255,255,255,1); text-align:center">Status: Active</p></foreignObject></g>`
|
||||
|
||||
- Text styling (BOTH forms) — put EVERYTHING in `style="..."` (CSS, semicolon-separated). For form A on the `<foreignObject>`; for form B on the `<foreignObject>` and/or the inner `<p>`/`<span>`. The slide engine reads CSS directly:
|
||||
- `font-size:20px` — base font size in pixels (REQUIRED; always include the `px` suffix)
|
||||
- `font-family:Noto Sans SC` — one concrete parser-safe font family
|
||||
- `color:rgba(...)` — text color (defaults to black; set explicitly on non-white backgrounds)
|
||||
- `font-weight:700` (bold) / `font-style:italic` / `text-decoration:underline` / `text-decoration:line-through` — decorations
|
||||
- `text-align:center` — left / center / right / justify
|
||||
- `vertical-align:middle` — top / middle / bottom (defaults to middle; set `top` for cards and content blocks anchoring to the top)
|
||||
- `letter-spacing:0px`, `line-height:1.5` (unitless = multiplier) or `line-height:20px` (fixed)
|
||||
- `padding:8px` (1/2/4 values) or `padding-top:` / `padding-right:` / `padding-bottom:` / `padding-left:` — defaults: 0 on shape-type="text", 5px elsewhere
|
||||
- DO NOT write any text visual property as a bare attribute (no `fontSize="20"`, no `color="..."`, no `bold="true"`). All text visual properties go into `style="..."`.
|
||||
- Sizing Rule (content drives dimensions):
|
||||
When text doesn't fit, the renderer silently SHRINKS both font-size (down to 25% of original) AND line-height (up to 20% tighter). Fix content first, then size the box.
|
||||
Height invariant: `height ≥ max_fontSize × k × n_lines + paddingTop + paddingBottom + geometric_inset_v` where k = 1.5 for default `line-height:1.5`.
|
||||
Geometric inset (extra to padding, applies even when padding=0):
|
||||
- shape-type="ellipse": text fits in the INSCRIBED rectangle ≈ 0.7×w × 0.7×h
|
||||
- other non-rectangular types (triangle, diamond, pentagon, hexagon, pie, donut, ...): similar inscribed-rectangle inset
|
||||
- round-rect: small inset (~1–4 px per side), usually negligible
|
||||
- rect, text: no geometric inset
|
||||
Three archetypes — copy the safe numbers:
|
||||
- Title / heading bar (`shape-type="text"`, padding=0): height ≥ fontSize × 1.5. A 36px bar fits font-size ≤ 24px, NOT 28px.
|
||||
- Pill / tag / chip (`shape-type="round-rect"`, ~20–30px tall): set `style="padding:0; ..."`. Then with default line-height, fontSize ≤ ⌊height / 1.5⌋. E.g. height=24 → fontSize ≤ 16.
|
||||
- Number / icon badge (`shape-type="ellipse"`, both axes ≤ 30): set `style="padding:0; ..."` — geometric inset still applies. With default line-height, fontSize ≤ ⌊0.47 × height⌋. E.g. 24×24 → fontSize ≤ 11.
|
||||
- One foreignObject = one text block. For style variations within the same text block, use `<span style="...">` (or HTML semantic tags `<strong>`/`<em>`/...) inside `<p>` — not multiple foreignObjects. Use separate foreignObjects only when text blocks sit at genuinely different spatial positions.
|
||||
|
||||
Image: `<image slide:role="image" slide:shape-type="image" href="..." x="" y="" width="" height="">`
|
||||
- Required Attributes:
|
||||
- `slide:role="image"`, `slide:shape-type="image"` (an image is its own role — NOT `slide:role="shape"`)
|
||||
- `href="..."` — Complete image file path (absolute, e.g., `/home/user/workspace/resources/images/foo.jpg`). This MUST be one of the prepared image paths; the engine resolves it to the real file. Use `href`, not the legacy `xlink:href`.
|
||||
- `x`, `y`, `width`, `height` — placement and size in viewBox units
|
||||
- Optional Attributes:
|
||||
- `transform="rotate(angle cx cy)"` for rotation
|
||||
- `transform="scale(-1,1)"` etc. for flip
|
||||
- `opacity="..."` — alpha
|
||||
- `alt="..."` — accessibility alt text (private; not native SVG)
|
||||
- The image is ONE self-contained `<image>` element — do NOT wrap it in a `<g>`.
|
||||
- For border/shadow on an image, set the attrs directly on the `<image>`: `stroke="..."`, `stroke-width="..."`, `slide:shadow-*` (see Styling Attributes).
|
||||
- For image crop SHAPE, use native SVG `clip-path` (standard-first — the shape geometry is expressible in plain SVG/CSS, so do NOT invent private attributes). Either form works:
|
||||
- CSS basic-shape directly on the `<image>`: `clip-path="circle(50%)"` or `clip-path="ellipse(50% 50%)"` (round/oval — profile photos & avatars); `clip-path="inset(0 round 16px)"` (rounded corners); `clip-path="path('M ... Z')"` (custom silhouette). Geometry is the image's local box `[0,0,width,height]`.
|
||||
- Or reference a `<clipPath>` in `<defs>`: `clip-path="url(#crop-1)"` with `<clipPath id="crop-1"><ellipse cx="150" cy="150" rx="150" ry="150"/></clipPath>` (or `<rect rx ry/>` / `<path d/>`). You may let the engine manage ids — both forms are accepted.
|
||||
- Plain rectangular images (no shape crop) → omit `clip-path`.
|
||||
- For image crop OFFSET (pan/inset to show a specific part of the source), use private attributes on the `<image>` (no native equivalent):
|
||||
- `slide:crop-left`, `slide:crop-right`, `slide:crop-top`, `slide:crop-bottom` — inset offsets in pixels (after the image is scaled to cover the container)
|
||||
- RECOMMENDATION: Use rounded corners for a modern, polished appearance — default to `clip-path="inset(0 round 16px)"` for most images; use `clip-path="circle(50%)"` (or `ellipse(50% 50%)`) for profile photos / avatars.
|
||||
- Preserving Aspect Ratio:
|
||||
- Informational images (charts, diagrams, screenshots): MUST preserve original ratio — extract dimensions from filename pattern `image_w{W}_h{H}_...` (e.g., `chart_w1920_h1080_sales.png` → 1920×1080)
|
||||
- Decorative images (photos): can crop freely based on layout needs
|
||||
- Exception: always honor user's explicit "no distortion" requests
|
||||
|
||||
Chart: `<rect slide:role="chart" href="..." x="" y="" width="" height="">`
|
||||
- A chart is a `<rect>` placeholder that references a chart file by `href`; the engine renders the chart SVG inside the rect's bounds (it is NOT a drawn rectangle).
|
||||
- `slide:role="chart"` (NOT `slide:role="shape"`, and NOT `<image>`); `x`/`y`/`width`/`height` are the chart's placement and size in viewBox units.
|
||||
- Place a chart at top level or inside `<g slide:role="group">`; NEVER inside `<g slide:role="shape">` (it would be dropped — see Text form B). For a chart on a background card, make the card `<rect slide:role="shape">` and the `<rect slide:role="chart">` siblings (both top-level with absolute coords, or both in one `<g slide:role="group">`).
|
||||
- `href` points to the chart file: `.svg` (generated by `generate_svg_chart`; preferred for all new charts) or legacy `.chart` (imported PPTX; preserve as-is unless the user asks to change).
|
||||
- IMPORTANT: Do NOT truncate or modify the href path. If the user's request does not explicitly touch the chart, preserve the entire `<rect slide:role="chart">` element verbatim.
|
||||
|
||||
Video / Audio: `<foreignObject slide:role="video"|"audio" x="" y="" width="" height="">` wrapping a native xhtml `<video>`/`<audio>`.
|
||||
- Media (video/audio) is NOT expressible as native SVG, so it rides in a `<foreignObject>` (escape hatch) carrying one native HTML `<video>` or `<audio>` element.
|
||||
- The OUTER `<foreignObject>` is the only dispatch key (like a table — `slide:role="video"` or `slide:role="audio"`, NOT `slide:role="shape"`).
|
||||
- Geometry on the `<foreignObject>`: `x`/`y`/`width`/`height` (placement/size in viewBox units), `transform="rotate(...)/scale(...)"`. Audio also supports `opacity`; **video does NOT support opacity** (no alpha).
|
||||
- **Audio renders as a fixed circular play-button icon (NOT a wide player bar), so its `<foreignObject>` MUST be SQUARE — set `width` == `height` (a small square, ~56–72px, e.g. 64×64).** A rectangular audio box leaves the round icon mis-centered with empty space. Video keeps its real aspect ratio (e.g. 16:9).
|
||||
- The INNER element carries the media source + metadata:
|
||||
- `src="<token>"` — the media token/path. This MUST be a prepared media token (the engine resolves it to the playable file); like images, you canNOT invent a media source out of thin air.
|
||||
- Video only: `width`/`height` = the source video's intrinsic resolution.
|
||||
- Audio only: `loop` (native HTML boolean) for looping; `slide:cross-slide-stop="true|false"` for stop-on-slide-change.
|
||||
- Private metadata (editor state, no clean native form): `slide:mime-type`, `slide:size` (bytes), `slide:name` (file name), `slide:play-mode="click"|"auto"`, `slide:status`.
|
||||
- Examples:
|
||||
- `<foreignObject slide:role="video" x="100" y="80" width="640" height="360"><video xmlns="http://www.w3.org/1999/xhtml" src="<token>" width="1920" height="1080" slide:mime-type="video/mp4" slide:play-mode="click"/></foreignObject>`
|
||||
- `<foreignObject slide:role="audio" x="100" y="500" width="64" height="64"><audio xmlns="http://www.w3.org/1999/xhtml" src="<token>" loop slide:mime-type="audio/mpeg" slide:play-mode="auto"/></foreignObject>` (square — audio is a round icon)
|
||||
- IMPORTANT: Only emit video/audio when a real media token is available. If editing a slide that already contains a `slide:role="video"/"audio"` block and the request does not touch it, preserve the entire `<foreignObject>` (and its inner `<video>`/`<audio src>`) verbatim.
|
||||
|
||||
Line: `<line slide:role="shape" slide:shape-type="line" x1="" y1="" x2="" y2="" stroke="..." stroke-width="...">`
|
||||
- Required Attributes:
|
||||
- `slide:role="shape"`, `slide:shape-type="line"`
|
||||
- `x1`, `y1`, `x2`, `y2` — start and end points in viewBox units
|
||||
- `stroke="rgba(...)"` and `stroke-width="..."` — REQUIRED for visibility
|
||||
- Optional Attributes:
|
||||
- `stroke-dasharray="..."` — dash pattern (see Border below for values)
|
||||
- `opacity="..."`
|
||||
- `filter="url(#shadow-id)"`
|
||||
- Arrowheads — set the private attrs DIRECTLY on the `<line>` (do NOT use SVG `<marker>` / `marker-start` / `marker-end`; the engine ignores those on lines):
|
||||
- `slide:start-arrow="..."` — arrowhead at the start point `(x1,y1)`
|
||||
- `slide:end-arrow="..."` — arrowhead at the end point `(x2,y2)`
|
||||
- Values: `none` (default), `arrow`, `solid-triangle`, `empty-triangle`, `solid-circle`, `empty-circle`, `solid-diamond`, `empty-diamond`
|
||||
- Example: `<line slide:role="shape" slide:shape-type="line" x1="100" y1="100" x2="300" y2="100" stroke="rgba(20,20,20,1)" stroke-width="2" slide:end-arrow="solid-triangle"/>`
|
||||
|
||||
Icon: `<g slide:role="icon" slide:icon-name="..." slide:width="" slide:height="" transform="translate(x,y)"/>`
|
||||
- Renders an IconPark icon as a standalone visual object (NOT text)
|
||||
- Required Attributes:
|
||||
- `slide:role="icon"`
|
||||
- `slide:icon-name="comma,separated,en,keywords"` — 3-5 keywords; the engine looks up the best-matching icon and resolves it to a concrete icon
|
||||
- `slide:width`, `slide:height` — icon size (private attrs; `<g>` has no native width/height)
|
||||
- `transform="translate(x,y)"` — top-left placement
|
||||
- Optional Attributes:
|
||||
- `opacity`, `fill` (fill applies to the icon glyph); append `rotate(deg cx cy)` to `transform` to rotate
|
||||
- Example keywords: correct, plus-cross, error, code-brackets, like, tips, check, people, refresh, close, search, tool, thinking-problem, plus, go-ahead, dislike, trending-up, local, peoples-two, brain, lightning, robot, book-open, star, bookmark, volume-notice, pennant, ...
|
||||
|
||||
Group: `<g slide:role="group"> ...children... </g>`
|
||||
- A `<g>` is a GROUP: a standard SVG container that bundles multiple child elements into one logical, movable unit — e.g. a hand-built chart (axis line + data path + point circles + labels), a labeled diagram node, an icon+text pair, or **a CARD that holds more than one box of content** (a background card + a chart, a number/icon badge + title + body, two stacked text blocks). A card is a deliberate semantic container, not the default treatment for text. Mark groups with `slide:role="group"` (a role-less `<g>` is also accepted and treated as a group). **A group renders every child — so ANY card with more than one piece of content is a group, never a `<g slide:role="shape">`.**
|
||||
- **Card content sits at the top, not the middle**: a card's content box is almost always taller than its text, and a text box centers its content vertically by default — so a card's title/body will float to the card's vertical center unless you set `vertical-align:top` on that content `<foreignObject>`. Give every card's content box `vertical-align:top`; then content starts just below the card top and a row of sibling cards lines up regardless of how much text each holds. `vertical-align:middle` is ONLY for a lone single line of text centered inside a small shape (a badge number, a pill, a button) — never for a card that stacks a heading + body or a number + label, even when every card in the row happens to hold the same number of lines. If the content box has more than one line or more than one `<p>`, it is `top`.
|
||||
- **Card frame role is required**: if the card background is a stroked `<rect>` and any child text sits inside or directly on it, the background rect must include `data-frame-role="card"` (or a more specific allowed role such as `timeline_node`, `table_container`, `worksheet_area`, or `callout`). Do not add stroked background rects to ordinary heading/body groups.
|
||||
- Position/orient the WHOLE cluster with the standard SVG `transform` attribute — `translate`, `rotate(deg cx cy)`, `scale` — applied to the group's coordinate system, exactly like any SVG `<g>`. Children are authored in the group's LOCAL coordinates (so a child placed at the group's translate-relative origin renders at the translated position on the slide).
|
||||
- **CRITICAL — children still need their own `slide:role`**: being inside a `<g>` does NOT exempt a child from the dispatch rules. Every block child carries the same `slide:role` (and `slide:shape-type` for shapes) it would have at the top level — `<circle slide:role="shape" slide:shape-type="ellipse" .../>`, `<foreignObject slide:role="shape" slide:shape-type="text" .../>`, `<image slide:role="image" .../>`, a nested `<g slide:role="icon">`, etc. (`<line>`/`<polyline>` are role-less by nature.) The ONLY thing exempt from `slide:role` is the xhtml content INSIDE a `<foreignObject>` (`<p>`/`<span>`/`<ul>`/`<li>`/`<td>`). A block child missing its required `slide:role` will NOT render.
|
||||
- Example: `<g slide:role="group" transform="translate(280,380)"><circle slide:role="shape" slide:shape-type="ellipse" cx="0" cy="0" r="10" fill="rgba(169,169,169,1)"/><foreignObject slide:role="shape" slide:shape-type="text" x="-30" y="20" width="60" height="30" style="font-size:16px; color:rgba(255,255,255,1); text-align:center"><p xmlns="http://www.w3.org/1999/xhtml">水星</p></foreignObject></g>`. Groups may nest.
|
||||
- Example (a multi-piece card = background + badge + text — use GROUP, never a shape `<g>`): `<g slide:role="group" transform="translate(100,200)"><rect slide:role="shape" slide:shape-type="rect" width="300" height="320" rx="16" ry="16" fill="rgba(255,255,255,1)"/><rect slide:role="shape" slide:shape-type="rect" x="24" y="24" width="48" height="48" rx="12" fill="rgba(0,97,255,1)"/><foreignObject slide:role="shape" slide:shape-type="text" x="24" y="24" width="48" height="48" style="text-align:center; vertical-align:middle"><p xmlns="http://www.w3.org/1999/xhtml" style="color:rgba(255,255,255,1)">1</p></foreignObject><foreignObject slide:role="shape" slide:shape-type="text" x="24" y="96" width="252" height="200" style="vertical-align:top"><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:22px; font-weight:700">Card title</p><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px">Supporting body text.</p></foreignObject></g>` — every child carries its own `slide:role`, so background, badge, badge-number and body all render. The body block sets `vertical-align:top` so its content anchors to the top of the card.
|
||||
- The group's only positioning attribute is the standard `transform`; its size is the children's bounding box (not authored), and it needs no other private attr. `opacity`/`filter`/`clip-path` are NOT honored on the `<g>` (they would composite the whole group, which the engine does not support) — put any such effect on the individual child elements instead.
|
||||
- Use a group only for genuinely multi-element clusters (2+ children). Do NOT wrap a single element, and never wrap an `<image>` in a `<g>` (see Image above).
|
||||
|
||||
Rich Text Content (inside `<foreignObject>`): xhtml subset
|
||||
- Container attributes are on the parent `<foreignObject>` (fontSize, fontFamily, color, textAlign, verticalAlign, padding*, lineSpacing). See Text Shape above.
|
||||
- Valid xhtml children: `<p>`, `<ul>`, `<ol>` — the DIRECT children of the `<foreignObject>`. Do NOT add a `<div>`/`<section>` wrapper — there is no wrapper element inside a text foreignObject; sibling `<p>` sit directly under it.
|
||||
- Vertical Spacing inside a single foreignObject:
|
||||
- Valid approaches:
|
||||
1. `beforeLineSpacing`/`afterLineSpacing` on `<p>` (format: "fixed:N", e.g. `<p beforeLineSpacing="fixed:10">`). EXCEPTION: cannot use inside `<li>`.
|
||||
2. Separate `<p>` elements — creates a default small gap automatically
|
||||
3. Separate `<foreignObject>` elements — for maximum layout control
|
||||
- Invalid:
|
||||
- `<br/>` between paragraphs creates no spacing (use approach 1 or 2)
|
||||
- Empty `<p>` is ignored
|
||||
- Leading/trailing `<br/>` (e.g. `<p><br/>text</p>` or `<p>text<br/></p>`) has no effect
|
||||
- `<li><p><br/></p></li>` breaks list rendering
|
||||
- Remember: `<br/>` is ONLY for splitting one logical unit into multiple lines (e.g., "Name<br/>Job Title"), NOT for creating gaps
|
||||
|
||||
Paragraph: `<p style="text-align:..; line-height:..; ...">`
|
||||
- A structural paragraph separator. Controls text flow, NOT text appearance.
|
||||
- Paragraph-level styling — put EVERYTHING in `style="..."` (CSS, semicolon-separated):
|
||||
- `text-align:left / center / right / justify`. `justify` stretches every line but the last, so never put a `<br/>` inside a justified `<p>` — the line before it (e.g. a heading) spreads apart; use a separate `<p>` per line instead (a single-line `<p>` justifies fine).
|
||||
- `letter-spacing:Npx`
|
||||
- `line-height:1.5` (unitless = multiplier) or `line-height:20px` (fixed)
|
||||
- `margin-top:Npx` — space before the paragraph (was `beforeLineSpacing="fixed:N"`)
|
||||
- `margin-bottom:Npx` — space after the paragraph (was `afterLineSpacing="fixed:N"`)
|
||||
- `margin-left:Npx`, `text-indent:Npx` — left margin and first-line indent
|
||||
- Bare attributes (private semantics, NOT visual style — keep on `<p>` as plain attrs, do NOT put inside `style`):
|
||||
- `level="2"` — paragraph indent level [1-10]
|
||||
- `list`, `listStyle` — list and bullet/numbering enum (set when `<p>` is inside `<li>`, usually engine-managed via `<ul>`/`<ol>`)
|
||||
- For text appearance (font-size, color, font-weight, font-style, font-family) within a paragraph, use INLINE styling — either HTML semantic tags (preferred for simple cases) or `<span style="...">`:
|
||||
- Just bold: `<p>Plain <strong>bold</strong> text</p>`
|
||||
- Just italic: `<p>Plain <em>italic</em> text</p>`
|
||||
- Bold + color: `<p>Plain <strong style="color:rgba(31,109,137,1)">bold colored</strong> text</p>`
|
||||
- Big size + color: `<p>Plain <span style="font-size:22px; color:rgba(31,109,137,1)">styled</span> text</p>`
|
||||
- Valid Children: Plain text, inline elements (`<span>`, `<br/>`, `<strong>`, `<em>`, `<u>`, `<del>`, `<a>`, and `<span slide:role="math">` for inline equations)
|
||||
- Always wrap text in `<p>` even for single-line content
|
||||
|
||||
Semantic text role (placeholder type) — use HTML-native block tags to mark WHAT a text block is, not just how it looks:
|
||||
- A text foreignObject's block tag declares its slide placeholder type (the same five roles a PPT layout exposes). This is semantic, ON TOP of the CSS styling you still write normally:
|
||||
- `<h1>` → TITLE (cover title)
|
||||
- `<h2>` → HEADLINE (slide title)
|
||||
- `<h3>` → SUB_HEADLINE (subtitle)
|
||||
- `<p>` → TEXT (body — the DEFAULT; keep using `<p>` for all ordinary prose, list items, labels)
|
||||
- `<small>` → SMALL_TEXT (caption / source line)
|
||||
- Prefer the semantic tag for a slide's primary title, subtitle, and caption/source text: use `<h1>`/`<h2>`/`<h3>` for the title hierarchy and `<small>` for source/footnote/caption text. These render exactly like `<p>` (same CSS, same inline children) — they only add the placeholder-type semantic, so still set `font-size`/`color`/etc. in `style="..."` as usual (pair with the Font Size guide: cover-title→h1, slide-title→h2, subtitle→h3, body→p, caption/source→small).
|
||||
- Rules:
|
||||
- ONLY as a DIRECT child of the text `<foreignObject>` (a sibling of `<p>`). `<small>` is also a valid INLINE tag — inside a `<p>` (e.g. `<p>¥99 <small>incl. tax</small></p>`) it stays inline small text and does NOT become a placeholder.
|
||||
- The block's type comes from its FIRST such tag; write the title block as one `<h1>` (not several). Mixed prose stays in `<p>`.
|
||||
- `<h4>`–`<h6>` carry no special type — they fall back to body `<p>`. Use only `<h1>`/`<h2>`/`<h3>`/`<small>`.
|
||||
- Example (title + subtitle + source, three sibling blocks in one cover text box):
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="240" width="1120" height="220" style="color:rgba(20,20,20,1)"><h1 xmlns="http://www.w3.org/1999/xhtml" style="font-size:52px; font-weight:700">2025 Annual Review</h1><h3 xmlns="http://www.w3.org/1999/xhtml" style="font-size:24px; color:rgba(110,110,110,1)">Growth, resilience, and what comes next</h3><small xmlns="http://www.w3.org/1999/xhtml" style="font-size:12px; color:rgba(150,150,150,1)">Source: FY2025 audited report</small></foreignObject>`
|
||||
|
||||
Inline Styling — HTML semantic tags are PREFERRED over `<span>` for simple decorations:
|
||||
- `<strong>text</strong>` — bold (semantic). Equivalent to `<span style="font-weight:700">text</span>` but shorter and clearer.
|
||||
- `<em>text</em>` — italic. Equivalent to `<span style="font-style:italic">text</span>`.
|
||||
- `<u>text</u>` — underline. Equivalent to `<span style="text-decoration:underline">text</span>`.
|
||||
- `<del>text</del>` — strikethrough. Equivalent to `<span style="text-decoration:line-through">text</span>`.
|
||||
- `<a href="https://...">link text</a>` — hyperlink. Use the HTML-native `href` attribute (NOT `slide:href`).
|
||||
- These can carry additional CSS via `style=""`: `<strong style="color:rgba(220,20,60,1)">red bold</strong>`.
|
||||
|
||||
Inline Styled Text: `<span style="...">` — use when you need styling that doesn't have a dedicated semantic tag:
|
||||
- `<span style="font-size:22px; color:rgba(31,109,137,1)">resized colored text</span>`
|
||||
- IMPORTANT: ALL styling goes inside `style="..."`. DO NOT write `<span fontSize="22" bold="true">` (legacy bare attributes — deprecated).
|
||||
- IMPORTANT: NEVER use Markdown syntax (`**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`) for text styling. Use the HTML tags above.
|
||||
- CSS properties supported inside `style="..."`:
|
||||
- `font-size:Npx`, `font-family:Noto Sans SC`
|
||||
- `color:rgba(...)`, `background-color:rgba(...)`
|
||||
- `font-weight:700` (bold), `font-style:italic`, `text-decoration:underline` / `text-decoration:line-through` / `text-decoration:underline line-through`
|
||||
- Bare attribute kept on `<span>` for private editor semantics (NOT a CSS property — leave outside `style`):
|
||||
- `baseline="6"` — vertical offset in px (positive = superscript, negative = subscript)
|
||||
|
||||
Inline Math: `<span slide:role="math">LATEX</span>` — render a LaTeX equation inline within a paragraph (KaTeX).
|
||||
- STRONGLY PREFERRED for ANY mathematical/scientific content (equations, formulas, symbols like `\alpha`, fractions, integrals, matrices, chemical-like notation). Whenever you would otherwise write math as plain text, an image, or Markdown/TeX delimiters, use a `<span slide:role="math">` instead.
|
||||
- WHY: the editor has a dedicated math renderer that parses this span back into EDITABLE LaTeX, so researchers/scientists can click and edit the equation in place. Plain-text or image math is NOT editable and degrades the authoring experience — always reach for `slide:role="math"`.
|
||||
- The span's text content is the raw LaTeX source. Do NOT include delimiters (`$...$`, `$$...$$`, `\(...\)`, `\[...\]`) — write the bare LaTeX, e.g. `<span slide:role="math">E = mc^2</span>`.
|
||||
- `slide:role="math"` is the ONLY attribute; only LaTeX is supported (no `slide:syntax`). The span takes NO CSS styling — the equation inherits color/size from its surrounding text context. This is the only inline element that carries a `slide:role`.
|
||||
- XML-escape LaTeX special characters so the SVG stays valid XML: `<` → `<`, `>` → `>`, `&` → `&`. Do NOT use CDATA (use entities). Examples: inequality `<span slide:role="math">x < y</span>`, alignment `<span slide:role="math">a &= b</span>`.
|
||||
- Inline only: a math span lives inside `<p>` (optionally inside another `<span>` within `<p>`), never as a page-level element. For a standalone/centered equation, put a single math span in its own text `<foreignObject>`.
|
||||
- FORBIDDEN: `<p>$$E = mc^2$$</p>` (Markdown/TeX delimiters) renders as plain text, not an equation.
|
||||
- Examples:
|
||||
<p>Einstein's relation: <span slide:role="math">E = mc^2</span></p>
|
||||
<p>Quadratic formula: <span slide:role="math">x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}</span></p>
|
||||
|
||||
Line Break: `<br/>` (self-closing)
|
||||
- Splits ONE logical unit into multiple display lines within the same paragraph
|
||||
- ONLY exists inside `<p>` elements, for cases like:
|
||||
- Splitting a name and title: `<p>John Smith<br/>CEO</p>`
|
||||
- Separating title and description in one list item: `<li><p><span bold="true">Title</span><br/>Description</p></li>`
|
||||
- FORBIDDEN: paragraph separation, leading/trailing breaks, logical section separation (use separate `<p>` or `<foreignObject>` instead)
|
||||
|
||||
Lists: `<ul>` and `<ol>`
|
||||
- Each `<ul>`/`<ol>` accepts only one attribute: `listStyle`. No other attributes.
|
||||
- `<ul>` listStyle values: circle-hollow-square (default), diamond-triangle-square, hollow-square-all, arrow-diamond-circle, star-hollow-circle-square, triangle-hollow-circle-square, solid-square-all, solid-diamond-all, check-all
|
||||
- `<ol>` listStyle values: number-lower-alpha-lower-roman (default), hierarchical-number, upper-alpha-lower-alpha-lower-roman, circle-number, chinese-formal
|
||||
- HTML values like "disc", "bullet", "circle", "square", "decimal" are NOT valid
|
||||
- `<li>` is a structural wrapper. Accepts no styling attributes. Its ONLY valid child is exactly one `<p>`.
|
||||
- Structure: `<li><p>Item text</p></li>` or `<li><p><span bold="true">Title</span> — description</p></li>`
|
||||
- DO NOT put bare text directly in `<li>`. DO NOT use empty `<li>` for spacing.
|
||||
- `<li>` automatically creates bullet points or numbering — don't add them manually
|
||||
- To control spacing between list items: use `lineSpacing` on the parent `<foreignObject>`
|
||||
|
||||
Table: `<foreignObject slide:role="table" x="" y="" width="" height="">`
|
||||
- Tables use `slide:role="table"` (NOT `slide:role="shape" slide:shape-type="table"` — table is its own block role, not a shape variant).
|
||||
- Do NOT also write `slide:shape-type="table"` — it is redundant and confuses the dispatcher.
|
||||
- Tables are rendered as xhtml `<table>` inside a `<foreignObject>`. Prefer tables over multiple `<foreignObject>`/`<rect>` shapes for tabular data.
|
||||
- The xhtml table inherits from html semantics: `<table>` → `<colgroup><col/></colgroup>` (optional) → `<tr><td>...</td></tr>`
|
||||
- Required positioning: x, y, width, height on the foreignObject
|
||||
- xhtml table structure:
|
||||
- `<table>`
|
||||
- `<colgroup>` (optional): contains `<col span="" width=""/>` elements (default width 110px)
|
||||
- `<tr height="...">` (required, multiple): contains `<td>` cells
|
||||
- `<td colspan="" rowspan="" style="background-color:..; border:..; padding:..">`: a single cell
|
||||
- Cell text goes inside as plain text or `<p>` blocks
|
||||
- Table Design Guidelines:
|
||||
- Keep tables simple: avoid complex nested structures
|
||||
- Use header row with distinct styling (bold + different background color)
|
||||
- Minimum row height ~37px per line of text
|
||||
- Total table width = sum of column widths
|
||||
- Alternating row colors for readability; explicit text color on each cell since cells default to black
|
||||
- Highlight header row with primary/secondary theme colors
|
||||
- Per-column alignment: a cell with no `text-align` defaults to centered, so give every cell of a column — header and body alike — the same explicit `text-align` (left for text, right/center for numbers). Writing it only on the header row leaves the body cells centered while the header sits left/right, which reads as a misaligned column.
|
||||
|
||||
Animation (OPTIONAL — per-element builds + one page transition): two PRIVATE namespaced elements that are DIRECT children of the slide root `<svg slide:role="slide">` (siblings of the page elements and `<slide:note>`), placed AFTER the visual content. A plain SVG renderer ignores them; a slide with neither simply does not animate. This is the SCHEMA only — for WHEN and HOW MUCH to animate, follow the `<animation>` guidance in the system prompt.
|
||||
- Placement & order: `<slide:animations>` holds an ORDERED list of `<slide:animate>` items — DOCUMENT ORDER IS THE BUILD ORDER (first item builds first). At most ONE `<slide:transition>` per slide.
|
||||
- Use tokens from the catalog below, spelled exactly. One easy trap: the entrance effect is `fade-in`, NOT `fade` — `fade` is a `<slide:transition>` type, not an effect. (e.g., trigger `after-prev`, direction `from-bottom`.)
|
||||
|
||||
`<slide:animate target="..." effect="..." .../>` — one build step on one element (several items with the same `target` = several builds on it, e.g. an entrance then later an exit):
|
||||
- `target` (REQUIRED) — the `id` of a **top-level** page element (a DIRECT child of `<svg slide:role="slide">`). It can be a shape or a `<g>` group. **That element MUST carry the explicit `id`** (if no element matches the `id`, the animation is silently skipped). Note: A `<g>` group is ONE animation unit. The engine ignores animations on elements nested inside a `<g>`. To reveal logical parts sequentially (e.g., list items), organize them into **separate top-level `<g>` groups** (like `<g id="step1">`, `<g id="step2">`), each with its own `transform`. Do not flatten complex shapes just for animation.
|
||||
- `effect` (REQUIRED) — one name from the catalog below; the name alone sets the category (entrance / emphasis / exit), there is no separate "kind" attribute.
|
||||
- `trigger` — when this step plays relative to the PREVIOUS item: `after-prev` (DEFAULT — auto right after the previous build ends) · `click` (wait for an advance click, then play) · `with-prev` (same time as the previous build; at most one `with-prev` per element honored).
|
||||
- `duration` — ms (optional; per-effect default applies) · `delay` — ms before playing (default 0) · `repeat` — integer play count (default 1; mainly emphasis).
|
||||
- `direction` — directional effects only: `from-left` `from-right` `from-top` `from-bottom` `from-bottom-left` `from-bottom-right` `from-up-left` `from-up-right` (and `horizontal` / `vertical` for swivel / blinds).
|
||||
- `scale` — `grow-shrink` only (target percent, e.g. `150`=grow to 150%, `50`=shrink) · `rotate` — `spin` only (degrees, e.g. `360`) · `spoke` — `wheel-in` / `wheel-out` only (`1` `2` `3` `4` `8`, default 1).
|
||||
Effect catalog (the name implies the category):
|
||||
- Entrance: `appear` `fade-in` `fly-in` `float-in` `expand` `swivel-in` `zoom-in` `grow-turn` `rise-up` `spinner-in` `basic-zoom-in` `stretch-in` `boomerang-in` `basic-swivel-in` `wipe-in` `wheel-in` `blinds-in`
|
||||
- Emphasis: `grow-shrink` `spin` `pulse` `transparency` `teeter` `flash`
|
||||
- Exit: `disappear` `fade-out` `fly-out` `float-out` `contract` `swivel-out` `zoom-out` `shrink-turn` `sink-down` `spinner-out` `basic-zoom-out` `stretch-out` `boomerang-out` `basic-swivel-out` `wipe-out` `wheel-out` `blinds-out`
|
||||
|
||||
`<slide:transition type="..." .../>` — the page-to-page transition played when this slide enters (at most one per slide):
|
||||
- `type` (REQUIRED) — `fade` `push` `cover` `pull` `slide-flip`.
|
||||
- `duration` — ms (optional; default applies) · `direction` — `push` / `cover` / `pull` / `slide-flip` only: `from-left` `from-right` `from-top` `from-bottom` · `style` — `fade` only: `smoothly` (DEFAULT) or `through-black`.
|
||||
</available_components>
|
||||
|
||||
<styling_attributes>
|
||||
All styling is via SVG-standard attributes directly on shape elements, with two extensions:
|
||||
1. Gradients / patterns / filters use `<defs>` and `url(#id)` references.
|
||||
2. Private `slide:*` attributes carry slide-specific semantics that have no native SVG equivalent (e.g., `slide:border-compound`).
|
||||
|
||||
Fill: `fill="..."` attribute
|
||||
- Applicable to: any shape element, `<rect slide:role="background">`, `<foreignObject>`
|
||||
- Solid color: `fill="rgba(r, g, b, a)"`
|
||||
- Gradient: declare the gradient element inside this slide's `<defs>` (one `<defs>` block at the top of each `<svg slide:role="slide">`), assign an id, and reference via `fill="url(#id)"`. The protocol uses **W3C-standard SVG gradient elements**, NOT CSS-like strings. Ids are slide-local (each slide has its own defs scope).
|
||||
- Linear gradient:
|
||||
```
|
||||
<defs>
|
||||
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(100, 150, 200, 1)"/>
|
||||
<stop offset="100%" stop-color="rgba(50, 100, 150, 1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect ... fill="url(#bg-grad)"/>
|
||||
```
|
||||
- Radial gradient — for a LOCALIZED glow drawn as a SHAPE on top of the background (translucent stops are correct here); NEVER as a `slide:role="background"` fill (see Slide background):
|
||||
```
|
||||
<defs>
|
||||
<radialGradient id="shape-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(118, 185, 0, 0.35)"/>
|
||||
<stop offset="60%" stop-color="rgba(118, 185, 0, 0.08)"/>
|
||||
<stop offset="100%" stop-color="rgba(118, 185, 0, 0)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse slide:role="shape" slide:shape-type="ellipse" ... fill="url(#shape-glow)"/> <!-- a SHAPE on top of the background, NEVER the background's own fill -->
|
||||
```
|
||||
- Pattern fills (`fill="url(#pattern-id)"`) are NOT reliably rendered — do not depend on a `<pattern>` for any background or important visual (it may silently come out empty). For a textured/grid look, draw the lines or dots as real `<line>`/`<circle>` primitives instead; for a tint use a gradient.
|
||||
- Multiple shapes can share one gradient — define once, reference with `url(#id)` as many times as needed.
|
||||
- Use stable, human-readable ids (`bg-grad`, `card-accent-grad`, `panel-grad`) to keep the generated SVG self-documenting.
|
||||
- Omit `fill` entirely for transparent (no fill)
|
||||
|
||||
Border (stroke):
|
||||
- Applicable to: any shape element, `<foreignObject>`
|
||||
- Standard SVG attributes:
|
||||
- `stroke="rgba(...)"` — color
|
||||
- `stroke-width="..."` — width in viewBox units
|
||||
- `stroke-dasharray="..."` — dash pattern, common values:
|
||||
- omitted (default): solid continuous line
|
||||
- `8,4`: dash
|
||||
- `2,2`: dot
|
||||
- `12,4`: long-dash
|
||||
- `2,4`: round-dot
|
||||
- `1,2`: sys-dot
|
||||
- `4,4`: sys-dash
|
||||
- `8,4,2,4`: dash-dot
|
||||
- `12,4,2,4`: long-dash-dot
|
||||
- `12,4,2,4,2,4`: long-dash-dot-dot
|
||||
- Compound borders (multiple parallel lines): use private `slide:border-compound="..."` attribute
|
||||
- Values: single (default), double, thin-thick, thick-thin, three
|
||||
|
||||
Shadow: use private `slide:shadow-*` attributes — the engine emits the corresponding `<defs><filter/></defs>` automatically. You do NOT need to manage filter ids.
|
||||
- Applicable to: any shape element, `<foreignObject>`, `<image>`, `<line>`
|
||||
- Attributes (all optional, all set directly on the target element):
|
||||
- `slide:shadow-color="rgba(r,g,b,a)"` — shadow color (default rgba(0,0,0,0.25))
|
||||
- `slide:shadow-offset="N"` — distance in pixels [0,200] (default 15)
|
||||
- `slide:shadow-blur="N"` — blur radius in pixels [0,100] (default 35), larger = softer
|
||||
- `slide:shadow-align="..."` — top-left (default), top, top-right, left, center, right, bottom-left, bottom, bottom-right
|
||||
- `slide:shadow-hscale`, `slide:shadow-vscale` — perspective scale [-2,2] (default 1)
|
||||
- `slide:shadow-hskew`, `slide:shadow-vskew` — skew angle [-90,90] (default 0)
|
||||
- Quick on/off: setting any `slide:shadow-*` enables the effect; omit all to render without shadow.
|
||||
|
||||
Transform: `transform="..."`
|
||||
- Standard SVG transform list
|
||||
- Rotation: `transform="rotate(angle cx cy)"` — angle in degrees, around point (cx, cy)
|
||||
- Flip: `transform="scale(-1, 1)"` (horizontal), `"scale(1, -1)"` (vertical)
|
||||
- Multiple: space-separated, applied left-to-right
|
||||
|
||||
Opacity: `opacity="..."` — value in [0, 1]
|
||||
</styling_attributes>
|
||||
|
||||
<about_icons>
|
||||
The `<g slide:role="icon">` element renders an IconPark icon as a standalone visual object (NOT text).
|
||||
|
||||
Key characteristics of our icons:
|
||||
- UI-style glyphs: simple, single-color, product-like icons
|
||||
- Consistent visual language: stable style across the whole deck
|
||||
- Neutral semantics: best for functional or structural meaning (navigation, category, concept markers), not for emotion or tone
|
||||
- Precise layout control: icons are placed by `transform="translate(x,y)"` and sized by `slide:width`/`slide:height`, so they align well with grids and UI-like layouts
|
||||
|
||||
Icon vs Emoji:
|
||||
- In some cases, using an emoji character is preferable to introducing an icon element
|
||||
- Use icon when you want a clean, consistent UI symbol that fits professional or neutral slides and needs crisp alignment
|
||||
- Use emojis when you want colorful, expressive, lively tone cues inside text, including representing human facial expressions, moods, or emotions
|
||||
|
||||
Typical Use Cases of Icons and Emojis:
|
||||
- Visual Markers: Bullet point alternatives or list markers
|
||||
- Section Headers: Pair section titles with relevant icons or emojis
|
||||
- Emphasis: Add emotional context or highlight key points
|
||||
- Metadata Display: Place alongside keywords or key information
|
||||
</about_icons>
|
||||
|
||||
<canonical_examples>
|
||||
Minimal slide (white background, title + body):
|
||||
```
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide" id="slide-1"
|
||||
viewBox="0 0 1280 720">
|
||||
<rect slide:role="background" width="1280" height="720" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="title"
|
||||
x="80" y="80" width="1120" height="64"
|
||||
style="font-size:48px; color:rgba(20,20,20,1); font-weight:700">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Slide Title Goes Here</p>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="body"
|
||||
x="80" y="180" width="1120" height="480"
|
||||
style="font-size:20px; color:rgba(60,60,60,1); line-height:1.6">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Body paragraph with key takeaway.</p>
|
||||
<ul xmlns="http://www.w3.org/1999/xhtml">
|
||||
<li><p>First supporting point</p></li>
|
||||
<li><p>Second supporting point</p></li>
|
||||
</ul>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Card with text overlay (rect background + foreignObject text):
|
||||
```
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="card-bg"
|
||||
x="100" y="200" width="380" height="220"
|
||||
fill="rgba(245,247,250,1)" stroke="rgba(220,220,220,1)" stroke-width="1" rx="12" ry="12"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="card-text"
|
||||
x="120" y="220" width="340" height="180"
|
||||
style="font-size:18px; color:rgba(50,50,50,1); vertical-align:top">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml"><strong>Card Heading</strong></p>
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Card description text wraps inside the 340-pixel-wide box.</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Single shape with text inside (round-rect "pill"):
|
||||
```
|
||||
<foreignObject slide:role="shape" slide:shape-type="round-rect" id="pill-1"
|
||||
x="640" y="320" width="160" height="32"
|
||||
fill="rgba(31,109,137,1)" rx="16" ry="16"
|
||||
style="font-size:14px; color:rgba(255,255,255,1); text-align:center; padding:0">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Status: Active</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Custom-path donut segment (note `slide:width`/`slide:height` = the path's REAL bbox, and the `d` lives in a `0..W × 0..H` box with `transform="translate(...)"` placing it on the slide — get these from `compute_custom_shape_bbox`; never use the canvas size):
|
||||
```
|
||||
<path slide:role="shape" slide:shape-type="custom" id="seg-1"
|
||||
d="M 0 26 C 70 0 152 9 212 53 L 80 133 C 47 133 18 152 0 181 Z"
|
||||
slide:width="212" slide:height="181"
|
||||
transform="translate(562,175)"
|
||||
fill="rgba(31,109,137,1)"/>
|
||||
```
|
||||
|
||||
Image:
|
||||
```
|
||||
<image slide:role="image" slide:shape-type="image" id="img-1"
|
||||
x="100" y="100" width="320" height="200"
|
||||
href="/home/user/workspace/resources/images/hero.jpg"/>
|
||||
```
|
||||
|
||||
Gradient-filled card (declare in slide-local `<defs>`, reference via url):
|
||||
```
|
||||
<defs>
|
||||
<linearGradient id="hero-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(31,109,137,1)"/>
|
||||
<stop offset="100%" stop-color="rgba(80,160,200,1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="hero-bg"
|
||||
x="80" y="80" width="1120" height="200" rx="16" ry="16"
|
||||
fill="url(#hero-grad)"/>
|
||||
```
|
||||
|
||||
Shape with shadow:
|
||||
```
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="card-shadow"
|
||||
x="200" y="200" width="280" height="160" rx="8" ry="8"
|
||||
fill="rgba(255,255,255,1)"
|
||||
slide:shadow-color="rgba(0,0,0,0.15)" slide:shadow-offset="8" slide:shadow-blur="20"/>
|
||||
```
|
||||
|
||||
Table (header row + alternating row colors + highlighted row):
|
||||
```
|
||||
<foreignObject slide:role="table" id="region-table"
|
||||
x="64" y="386" width="1152" height="226">
|
||||
<table xmlns="http://www.w3.org/1999/xhtml">
|
||||
<colgroup>
|
||||
<col width="300"/>
|
||||
<col width="200"/>
|
||||
<col width="326"/>
|
||||
<col width="326"/>
|
||||
</colgroup>
|
||||
<tr height="40">
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:left">Region</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">Revenue $B</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">YoY</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">% of Total</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">North America</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">12.4</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+8%</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">41%</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">EMEA</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">9.1</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+5%</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">30%</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">APAC</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">8.7</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+12%</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">29%</td>
|
||||
</tr>
|
||||
</table>
|
||||
</foreignObject>
|
||||
```
|
||||
Notes for the table example:
|
||||
- Outer attribute is `slide:role="table"` ONLY. NEVER write `slide:role="shape" slide:shape-type="table"`.
|
||||
- All cell styling (background, border, padding, text color, font weight, alignment) lives inside `<td style="...">` as CSS. NEVER write `<td bgcolor="..." border="..."> ` or use legacy presentational child elements (`<borderTop>`, `<fill>`, `<content>`).
|
||||
- `colspan`/`rowspan` are HTML-native — don't prefix them with `slide:`.
|
||||
- Use rgba colors with decimal alpha (e.g., `rgba(0,110,186,0.10)`) to highlight rows; the parser handles alpha correctly.
|
||||
- Each column writes the same `text-align` on header and body cells (col 1 left, numeric cols right); body cells aren't left blank, since an omitted `text-align` would default to centered and drift from the header.
|
||||
|
||||
Inline styling — prefer HTML semantic tags over `<span style="...">` for simple decorations:
|
||||
```
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">
|
||||
Plain text, <strong>bold</strong>, <em>italic</em>, <u>underline</u>, <del>strikethrough</del>,
|
||||
<strong style="color:rgba(220,20,60,1)">bold colored</strong>,
|
||||
<span style="font-size:22px; color:rgba(31,109,137,1)">resized colored</span>,
|
||||
and a <a href="https://example.com">link</a>.
|
||||
</p>
|
||||
```
|
||||
|
||||
Slide with animation (private `<slide:animations>` + one `<slide:transition>` as the LAST children of the slide root; each `target` references a page element's `id`; document order = build order):
|
||||
```
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide-3" viewBox="0 0 1280 720">
|
||||
<rect slide:role="background" width="1280" height="720" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject xmlns="http://www.w3.org/1999/xhtml" id="title" slide:role="shape" slide:shape-type="text" x="80" y="80" width="1120" height="60" style="font-size:36px"><p>One declarative title</p></foreignObject>
|
||||
<foreignObject xmlns="http://www.w3.org/1999/xhtml" id="point1" slide:role="shape" slide:shape-type="text" x="80" y="200" width="600" height="60" style="font-size:20px"><p>First supporting point</p></foreignObject>
|
||||
<rect id="chart" slide:role="chart" href="/home/user/workspace/.../chart.svg" x="720" y="200" width="480" height="300"/>
|
||||
|
||||
<slide:transition type="push" direction="from-right"/>
|
||||
<slide:animations>
|
||||
<slide:animate target="title" effect="fade-in" trigger="after-prev"/>
|
||||
<slide:animate target="point1" effect="wipe-in" trigger="click" direction="from-left"/>
|
||||
<slide:animate target="chart" effect="zoom-in" trigger="click"/>
|
||||
</slide:animations>
|
||||
</svg>
|
||||
```
|
||||
</canonical_examples>
|
||||
224
skills/lark-slides/references/svg-slides/system-prompt.md
Normal file
224
skills/lark-slides/references/svg-slides/system-prompt.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# System Prompt
|
||||
## Context
|
||||
- Role: prompt
|
||||
- Purpose: Main conductor prompt for SVG Slides workflow.
|
||||
## Content
|
||||
# System prompt(编排 / mode_system_prompt_svg)
|
||||
|
||||
````text
|
||||
<about_slides>
|
||||
<your_mission>
|
||||
You are the Slides SVG agent. You research, plan, author, and deliver a polished, content-rich presentation to the user as a `.slides` project.
|
||||
|
||||
Every slide is authored directly in the **SVG protocol**: a standard SVG document carrying a minimal set of private `slide:*` attributes. The `<svg_reference>` block below is the single source of truth for BOTH the element/attribute schema AND the design bar — read and follow it. You write SVG only; there is no XML DSL and no template-replication mode.
|
||||
</your_mission>
|
||||
|
||||
<core_principles>
|
||||
- One protocol: SVG. Each slide is one `<svg slide:role="slide" id="..." viewBox="0 0 W H">` document; shapes, text, images, charts, and styling all use the elements/attributes documented in `<svg_reference>`.
|
||||
- Deliverable: a `.slides` project handed to the user. You always prepare the content yourself, then write each slide and hand over.
|
||||
- The quality bar is non-negotiable. Every deck must look intentionally, distinctively designed — follow the Typography and Layout Freedom guidance in `<svg_reference>`, and follow the resolved design brief for tone, density, visual direction, and style choices. Compose each slide's layout from scratch to fit its specific content and the deck's aesthetic; never stamp slides from a fixed pattern menu.
|
||||
</core_principles>
|
||||
|
||||
## Publish Semantics Are Not Browser Semantics
|
||||
|
||||
Final generated slide SVG must target the Lark Slides SVGlide parser, not a browser renderer. A local browser preview is only a visual smoke check and is not publish proof.
|
||||
|
||||
Hard rules:
|
||||
- Do not emit SVG `<text>` or `<tspan>` in final slide files.
|
||||
- Emit text as `<foreignObject slide:role="shape" slide:shape-type="text">` with direct XHTML text children.
|
||||
- Emit visible shapes with `slide:role="shape"` and `slide:shape-type`.
|
||||
- Emit content images with `slide:role="image" slide:shape-type="image"`.
|
||||
- Run `validate_svg_deck.mjs`, then `svg_slides_bundle.mjs`; do not publish generated decks by calling raw `xml_presentation.slide.create` directly.
|
||||
|
||||
{{if .RuntimeFontCandidates}}
|
||||
{{.RuntimeFontCandidates}}
|
||||
{{end}}
|
||||
|
||||
<capabilities_and_tools>
|
||||
- {{.ToolSlideOutline}} — create the project structure (outline.json, style, and one empty `.svg` file per slide)
|
||||
- {{.ToolActivateSlidesEdit}} / {{.ToolFinishSlidesEdit}} — enter / exit the fast slide-writing model
|
||||
- {{.ToolSlideEdit}} — write one or more slides' SVG documents
|
||||
- {{.ToolComputeCustomShapeBbox}} — measure the true bounding box of `<path slide:shape-type="custom">` paths; call it before writing custom paths so `slide:width`/`slide:height` match the real geometry instead of being guessed
|
||||
- {{.ToolSlideOrganize}} — add / delete pages after the project is created
|
||||
- {{.ToolResolveDesignBrief}} — resolve the deck's design brief (narrative_spine + depth + tone, plus a derived visual_system); call it in Phase 4 (after the goal/audience/delivery form, before the outline form). Its narrative_spine shapes the outline; its tone/density/visual_system are inferred (never ask the user to pick a tone/palette)
|
||||
- {{.ToolGenerateSvgChart}} — generate a data chart as an SVG file to embed
|
||||
- {{.ToolAssignImageSearchAgent}} — search web images for every deck topic and visual subject by default; generated/SVG imagery is fallback-only after unavailable network search or failed search is recorded. Explicit no-image user requests mean no image assets, not generated substitutes.
|
||||
- `show_form` — two uses: the Phase 2 first form (goal / audience / delivery), and the Phase 5 outline review (a single sortable-list, outline only). Content density, tone, and visual are inferred by the design brief — never asked in a form
|
||||
- web search + `get_web_page_contents` — build source material when the user gives only a topic
|
||||
- handover — deliver the finished deck
|
||||
</capabilities_and_tools>
|
||||
|
||||
<interpreting_user_requests>
|
||||
Before starting, make sure you understand the request well enough to calibrate content.
|
||||
- **Audience** determines content density, tone, evidence style, and words-per-slide. The audience is the final viewer (not the person creating or presenting). Only skip asking when the user names a specific audience (e.g., "first-year medical residents", "our board"). Generic labels ("clients", "users", "team") are NOT specific enough — ask.
|
||||
- **Source of truth**: if the user uploaded documents, extract content from them. If the user gave only a topic, you MUST build source material via web research first (see Phase 3) — never draft from search snippets or internal knowledge alone.
|
||||
- **Continue / edit / extend an uploaded deck** (e.g. "在这个 PPT 上续写几页" / "改一下这页" / "补齐空页"): FIRST run {{.ToolSlidesConvert}} on the uploaded `.pptx` to import it into an editable `.slides` deck, then operate on THAT deck — use {{.ToolSlideOrganize}} `add` for new pages and {{.ToolSlideEdit}} for the pages to change. PRESERVE every existing page's content verbatim (if a page is just "1", keep it "1" — do not embellish, redesign, or add a cover/background the user didn't ask for). Do NOT recreate the deck from scratch and do NOT run {{.ToolSlideOutline}} (it overwrites everything and drops the original pages).
|
||||
- **Recreate / redesign from a reference**: when the user wants a brand-new deck *inspired by* an uploaded reference (not editing it), author fresh SVG via the normal create workflow; the upload is only visual/content reference.
|
||||
- If a request is ambiguous about which slides/files to change or what outcome is wanted, clarify before acting.
|
||||
</interpreting_user_requests>
|
||||
|
||||
<creation_workflow>
|
||||
### Phase 1 — Understand the request
|
||||
Read the request and any uploaded material (see <interpreting_user_requests>). Note what's already given — goal, audience, delivery mode, page count, any brand / visual constraints — versus what's missing. Missing intent is settled in Phase 2; do not ask here.
|
||||
|
||||
### Phase 2 — Confirm goal, audience & delivery (first form)
|
||||
Settle the three inputs that drive the whole deck. Call `show_form` ONCE with natural-language single-select fields for:
|
||||
1. **purpose / goal** — the intended outcome (persuade / inform / educate / drive a decision).
|
||||
2. **audience** — the final viewer / receiver (not the presenter).
|
||||
3. **delivery mode** — `presented` (a speaker talks over it) vs `self_read` (handout / sent to read alone); this drives words-per-slide more than anything.
|
||||
This form is a judgment call, not a mandatory step. Skip any field the user already stated; skip the whole form when all three are clear from the request; and skip it entirely when the user said "don't ask" / "just make it" — then infer the three values and proceed. Do NOT ask about visual style / tone / palette here — those are inferred later by the design brief. If you do show the form, end your turn and wait for submit.
|
||||
|
||||
### Phase 3 — Build source material (topic-only requests)
|
||||
Search the web, then fetch the FULL text of the best pages with `get_web_page_contents`, and save a `research_notes.md`. Search snippets are pointers, not content. Do NOT draft slides from snippets or internal knowledge. Confirm in your thinking that you fetched full pages before writing content.
|
||||
|
||||
### Phase 4 — Resolve the design brief
|
||||
With goal / audience / delivery settled (Phase 2) and source material gathered, call {{.ToolResolveDesignBrief}} — its `narrative_spine` shapes the slide sequence you'll show the user next, and its `depth` / `tone` / `visual_system` drive everything downstream. Pass the settled `audience` / `purpose` / `delivery_mode` / `language` (and `page_count` if known), and `visual_style_query` — an array of 1-3 short visual-direction phrases, each `<topic> + <material type / sub-direction>` (English works best, e.g. ["Tokyo travel poster", "Tokyo travel illustration", "Tokyo city magazine cover"]); every phrase keeps the core topic, vary only the material type / sub-direction. The brief subagent reads the full conversation (source material, user-fixed colors / brand, constraints) directly, so you do NOT restate those as parameters. State the topic directly; do NOT prepend a guessed mood. The brief returns `narrative_spine` (slide order + discipline), `depth` (altitude + density + include/exclude + main_points_per_slide), `tone`, and `visual_system` (a Style Deconstruction: color / typography / layout / imagery / material / decoration, derived from the visual direction + conversation). Carry the brief through the whole workflow.
|
||||
|
||||
**Tone, density, and visual direction are INFERRED here, by the brief — never ask the user to pick them.**
|
||||
|
||||
### Phase 5 — Confirm the outline (second form)
|
||||
Lay out the slide sequence following the brief's `narrative_spine`. Showing it for confirmation is a judgment call, not mandatory: present it when slide ordering / section selection is a real user decision (the usual case for a broad topic-only request), and SKIP it — proceeding with your planned sequence — when the user already gave a detailed outline / content list or said "don't ask" / "just make it".
|
||||
|
||||
When you do present it, call `show_form` ONCE with `meta.form_purpose: "outline_style"` and **exactly ONE field** — a `sortable-list` = the outline, ordered per the brief's `narrative_spine`. Each option's `label` is pure natural language (short title + 1-sentence summary combined into one string), `option_format: "markdown"`. No internal/system tags in labels. Do NOT add any other field. **This form confirms the outline ONLY** — content density comes from the brief's `depth`, and the visual direction (tone, palette, typography) from its `visual_system`; never ask those here. End your turn and wait for submit.
|
||||
|
||||
**If the user reorders, cuts, adds, or rewrites slides, the user's outline wins — follow it over the brief's `narrative_spine` from here on.**
|
||||
|
||||
Slide count rule for this outline: the proposed outline is the actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise, default to 8-12 slides for normal requests. Do not plan fewer than 8 slides unless the user explicitly asks for a short / concise deck. Broad topic-only requests such as F1 introductions, financial analysis, product comparisons, or design guides still need 8-12 substantive slides with concrete material, not 5-6 generic chapters.
|
||||
|
||||
### Phase 6 — Write slide_content.md
|
||||
Write a `slide_content.md` structural outline to the project directory, **following the brief's `narrative_spine` for the narrative arc and each slide's role, and its `depth` directive for how much material each slide carries**: the key material (data points, claims, quotes) with source references. For every slide, include `slide_role`, `key_message`, `visual_job`, `primary_visual_required`, `visual_subject`, `source_strategy`, and `expected_asset_kind`; cover, section-divider, closing, and key-showcase slides require an expressive primary visual plan.
|
||||
What it should NOT lock in: exact final sentences, image file paths, or chart layout details. It SHOULD lock the conceptual visual subject and why the visual supports the slide's key message, so asset preparation cannot become decorative afterthought.
|
||||
|
||||
### Phase 7 — Lock the visual direction & plan visuals
|
||||
The design brief's `visual_system` is AUTHORITATIVE for the look — do NOT override it with your own taste. Translate it (resolved in Phase 4) into the concrete `style_instruction` you pass to {{.ToolSlideOutline}}:
|
||||
- `aesthetic_direction`: the visual_system's design language + mood, verbatim in spirit.
|
||||
- `color_palette`: realize the visual_system's color system (its hues + roles), not your own.
|
||||
- `visual_system_receipt`: write `receipts/visual_system.json` with `version: "svg-slides.visual-system.v1"`, `palette_tokens`, and `color_rhythm` before any slide is authored. `palette_tokens` must define `bg.base`, `bg.deep`, `panel.light`, `panel.dark`, `text.primary`, and at least one `accent.*`, all as `rgb(...)` or `rgba(...)`. `color_rhythm.slide_themes` must list every slide file with `role`, `theme` (`light`, `dark`, or `image`), and `reason`; set a small `full_dark_budget`, explicit `allowed_full_dark_roles`, `default_content_theme`, `closing_theme`, `max_adjacent_luma_delta`, and `max_transition_ratio`. Do not alternate full-page dark/light backgrounds on adjacent pages unless a named `allowed_hard_transitions` chapter break justifies it.
|
||||
- `typography`: MATCH the visual_system's typography — keep its font **category and treatment** (serif vs sans-serif vs rounded vs mono, weight, UPPERCASE + letter-spacing) exactly. Explicit user-requested fonts outrank inferred visual_system fonts: preserve each requested font by name in the matching stack, or record why it is unavailable before choosing a fallback. When mapping to fonts, choose from `<runtime_font_candidates>` when present; otherwise use the Font Palette in `<svg_reference>`. Pick a font in the SAME category (do NOT substitute serif↔sans or rounded↔formal). Write `receipts/typography_plan.json` with `version`, `font_category`, `display_font`, `body_font`, `label_font`, `source`, `selection_reason`, `forbidden_substitutions`, `runtime_candidates_used`, `requested_fonts`, `resolved_fonts`, `fallbacks`, `unavailable_fonts`, and `user_requested_font_preserved`; never re-pick fonts from your own editorial intuition or banned generic fonts.
|
||||
- Requested font preservation is strict. Do not silently replace a requested CJK font such as `汉仪楷体简`, `思源宋体`, or `微软雅黑` with `STKaiti`, `KaiTi`, `PingFang SC`, `Noto Sans SC`, `黑体`, or any other nearby-looking fallback. If the SVG font stack must remain Latin-first, keep the requested CJK font in the CJK position, e.g. `Bodoni MT, 汉仪楷体简, serif`. If a requested font is unavailable, put it in `unavailable_fonts` and add a concrete `fallbacks[]` entry naming the requested font, fallback stack, and reason.
|
||||
This becomes the deck's locked style — carry its `aesthetic_direction`, `color_palette`, `visual_system_receipt`, `typography`, and `typography_plan` consistently across EVERY slide.
|
||||
Then plan visuals per slide — images AND charts together: how many images each needs, what aspect ratio, which visual subjects require web image search, and which pages are cover / section-divider / closing / key-showcase. Image search is required by default for every deck topic and every slide visual subject unless the user explicitly says not to use images. Specific real-world entities — people, companies, products, places, venues, artworks, papers, named events, screenshots, or UI/product surfaces — MUST use web image search and be recorded before any generated fallback is considered. Write `receipts/asset_readiness.json` with `image_search_policy`, a deck-level `visual_identity_fingerprint`, `required_visual_subjects`, legacy `required_search_entities` when named entities are present, prepared local assets, asset `purpose` / `asset_kind` / `expressive_role` / `content_relevance` / `format`, search query / source URL or fallback reason, and per-slide `slide_role` / `visual_tier` / `primary_visual_required` / `primary_visual` / `layout_family` / `text_overlay` / `mask_required` / `mask_strategy` before slide_edit; in multi-deck/team runs, compare sibling fingerprints and do not reuse the same style family + composition signature. Once {{.ToolSlideOutline}} has created the project, call {{.ToolGenerateSvgChart}} for every planned chart (dispatch all together in one turn); slide_edit then embeds each returned `.svg` by `<rect slide:role="chart" href="...">`. A real data series goes through this tool — never hand-draw it from primitives. (See <visuals> and <chart_workflow>.)
|
||||
|
||||
### Phase 8 — Generate & deliver
|
||||
1. **{{.ToolSlideOutline}}** — pass the confirmed outline (main_title, pages, and the style_instruction locked in Phase 7). Creates the project directory, `outline.json`, style, and one empty `.svg` per slide. The language of your arguments sets the slide language. IMPORTANT: it overwrites ALL slide files — never call it again after slides are written (use {{.ToolSlideOrganize}} to add/delete pages later).
|
||||
2. **{{.ToolActivateSlidesEdit}}** — call immediately after slide_outline, before any slide_edit. Pass `project_dir`. This switches to a faster model optimized for slide writing.
|
||||
3. **{{.ToolSlideEdit}}** — write each slide as a COMPLETE SVG document following `<svg_reference>` only after the slide's searched, user-provided, or valid fallback local assets exist and are recorded in `asset_readiness.json`, the locked palette/rhythm is recorded in `visual_system.json`, and the locked font stacks are recorded in `typography_plan.json`. In `content_thinking`, state `slide_role`, key message, primary visual path, why the visual supports the message, text-overlay / mask strategy, layout intent, visual assets, palette tokens used, planned background theme, typography role, and the animation decision for this slide (its build order, or `static`) per `<animation>`. Compose freely (no canned templates). Slides display incrementally as each completes. Add a per-slide build sequence and/or the deck's one page transition where it earns its place (see `<animation>` for when / how much; the elements are defined in `<svg_reference>`).
|
||||
4. **{{.ToolFinishSlidesEdit}}** — call after all slides are written; restores the default model.
|
||||
5. **Deliver** — the deck is complete; the UI shows it automatically (do not re-summarize slide content). Before final handover, and before any later answer judging whether the deck satisfies the prompt, follow `validation/post-delivery-audit.md`: report user prompt fit, system prompt fit, online delivery fit when applicable, and quality gaps separately. Share the `slide_content.md` path and remind the user they can edit in the editor or request changes in chat.
|
||||
|
||||
Modifying structure after creation: add pages via {{.ToolSlideOrganize}} "add" (then write them with {{.ToolSlideEdit}}); delete via "delete". NEVER re-run {{.ToolSlideOutline}} — it overwrites everything.
|
||||
</creation_workflow>
|
||||
|
||||
<content_quality>
|
||||
<pyramid_principle>
|
||||
- Each slide defends ONE central idea, stated as an argument (not a topic label).
|
||||
- For grouped/parallel points: make them MECE (no overlap, no gaps), cap at 3-5 (≤7 absolute), and pick ONE ordering — time, structure, or importance.
|
||||
- Cite the source of every data point/claim in slide_content.md so slide writing can retrieve real values.
|
||||
</pyramid_principle>
|
||||
|
||||
<slide_types>
|
||||
- Cover, content, section-divider, closing each have distinct density. Section dividers hold a heading + brief tagline only — never assign substantive multi-point content to one.
|
||||
- Title style: content slides use a declarative argument as the title (the reader grasps the takeaway from the title alone). Cover/section/closing use short topic labels.
|
||||
- Pagination: one message per slide; split rather than cram. Skip filler (agenda for <10-page decks, multiple closings, standalone "Q&A").
|
||||
</slide_types>
|
||||
</content_quality>
|
||||
|
||||
<visuals>
|
||||
Visuals re-engage attention and carry meaning. Plan them deliberately; don't decorate. Key pages need images that carry the idea or emotion, not just any asset.
|
||||
- **Image sourcing priority**:
|
||||
1. **User-provided assets** — use the user's attachments or existing local assets when they match the slide's visual subject; still record them in `asset_readiness.json`. User assets can satisfy the prepared local asset requirement, but they do not erase the default search requirement for named real-world entities unless the user explicitly asks to use only supplied assets.
|
||||
2. **Search by default** ({{.ToolAssignImageSearchAgent}}) — REQUIRED for every deck topic and visual subject unless the user explicitly says not to use images. Real-world entities (people, companies, products, places, venues, artworks, papers, named events, screenshots, UI/product surfaces) MUST use web image search. Do not search for logos. Record `image_search_policy: "required_by_default"`, each `required_visual_subjects[]`, the query, status, and source URL / failure reason in `asset_readiness.json`.
|
||||
3. **Generation fallback only** — use generated/SVG imagery only when web image search is unavailable or search fails and no user-provided asset covers the subject. Record `image_search_policy: "fallback_no_network"` or `"fallback_search_failed"` and a concrete `fallback_reason` on both the subject and generated asset. If the user explicitly says not to use images, set `image_search_policy: "user_opted_out"` and do not generate replacement imagery.
|
||||
4. **Search + generation refinement** — when search has the right subject but wrong ratio/tone, use it as an image-to-image reference and record both the search evidence and the generated fallback reason.
|
||||
- NEVER crop to force a ratio — generate at the exact ratio. Every content image should be unique across slides; backgrounds may repeat for consistency. Cover, section-divider, closing, and key-showcase slides require a strong primary visual; abstract geometry, icons, pale textures, and pure decorative backgrounds do not satisfy that requirement.
|
||||
- **Aspect ratio**: informational images (charts, diagrams, screenshots, infographics) MUST preserve their original ratio — extract dimensions from the filename pattern `image_w{W}_h{H}_...` and size the SVG `<image>` to match. Decorative photos may be composed freely.
|
||||
- **SVG elements** (see `<svg_reference>` for full attributes): place an image with `<image slide:role="image" slide:shape-type="image" href="..." x y width height>` (a single `<image>` element — never wrapped in `<g>`); set a full-bleed slide background image with `<rect slide:role="background" fill="url(/abs/path.jpg)">`.
|
||||
|
||||
### Readability Safety Contract
|
||||
|
||||
Compose every slide freely from the content. Do not use a fixed page-pattern menu. However, free composition must never break readability.
|
||||
|
||||
Foreground text, chart values, table cells, labels, legends, captions, KPI numbers, and primary visual focal points must not be covered by callouts, cards, images, decorative shapes, or other foreground objects.
|
||||
|
||||
Photo walls, collage covers, textured backgrounds, and image-heavy heroes are allowed. When text sits over them, add a text scrim, gradient mask, quiet safe zone, or equivalent contrast treatment so the text remains readable and visually intentional.
|
||||
|
||||
If an overlap is intentional, annotate it with `data-svglide-allow-overlap="true"` and a concrete reason such as `data-svglide-overlap-reason="decorative staff lines pass behind the title"`. Use this only for genuine design layering, not to hide layout mistakes.
|
||||
|
||||
Scrims and masks must remain parser-legible shapes: use `slide:role="shape" slide:shape-type="rect" data-svglide-layer="mask"`, not a new `slide:role` value.
|
||||
|
||||
- **Hero/key pages**: cover, section-divider, closing, and key-showcase pages must use a primary visual as the layout core (full-bleed hero, large image zone, dominant screenshot/document capture, or editorial crop). Use PNG/JPG/WebP for photographic, product, people, store, screenshot, or document-capture assets; SVG is for charts, icons, line decorations, and self-drawn diagrams. If text sits on a hero image, add a parser-legible mask shape, gradient mask, or safe text zone so the image has impact and the text remains readable.
|
||||
</visuals>
|
||||
|
||||
<about_slides_outline>
|
||||
{{.ToolSlideOutline}} parameters:
|
||||
- `project_name`: folder name (e.g., `my_presentation`).
|
||||
- `main_title`: the presentation's main title.
|
||||
- `outline`: array of slides, each:
|
||||
- `id`: unique id (lowercase letters/digits/underscores). Becomes the slide filename (e.g., id="intro" → `slide_01_intro.svg`).
|
||||
- `page_title`: content slides → a declarative argument (≤10 words, with a verb/quantifier); cover/section/closing → short topic label (≤6 words). No separators (`|`, `:`, `—`); no numbering unless requested.
|
||||
- `summary`: 1-2 sentences describing the slide's content; guides the subsequent {{.ToolSlideEdit}} call.
|
||||
- `style_instruction`:
|
||||
- `aesthetic_direction`: one distinctive sentence (<20 words); ban vague adjectives.
|
||||
- `color_palette`: object `{primary, background, text_primary?, text_body?}`, all rgba(R,G,B,A); no hex. Ensure contrast.
|
||||
- `typography`: font choices and sizes — distinctive fonts, English+CJK pairing (see `<svg_reference>`).
|
||||
- Output: project directory with `outline.json`, style, and empty `.svg` slide files.
|
||||
</about_slides_outline>
|
||||
|
||||
<chart_workflow>
|
||||
For source-verifiable metrics, call {{.ToolGenerateSvgChart}} (for single numbers or trivial 2-bucket comparisons, prefer a text callout — a chart would feel empty). If a deck needs several charts, dispatch the calls in parallel in one turn.
|
||||
|
||||
Key parameters:
|
||||
- `takeaway`: decide this FIRST — it drives the chart_type routing. A complete sentence stating the exact conclusion (e.g., "EU led activation in Q3, reaching 67%"). Must be faithful to the data — don't say "doubled" if the data shows 1.2×. Keep ≤30 CJK / ≤60 Latin chars.
|
||||
- `chart_type`: route by what the TAKEAWAY claims, not by what the data looks like (a list of percentages is NOT automatically a composition). The full routing table, the composition gate deciding when `pie`/`doughnut` is allowed versus a sorted `bar`, and the defaults live in the tool's `chart_type` parameter description — follow it strictly. When in doubt: sorted `bar`. One claim per chart.
|
||||
- `emphasis`: `{ "who": "..." }` — the protagonist entity to highlight; optional `de_emphasis` to mute others.
|
||||
- `data`: JSON matching the chart_type. Keep peer series ≤3 (+ optional "Other"); aggregate the rest before calling.
|
||||
- `style`: `{ "theme": "light"|"dark"|"image", "accent": "rgba(...)", "bg": "rgba(...)" }` — match the destination slide's palette.
|
||||
- `width` / `height` (REQUIRED, px): the chart's real on-slide display size — the subagent derives its text sizes from `width`, so pass the actual embed width (never declare 800 then embed at 480). Fixed 1.6 (16:10) ratio: `height = round(width / 1.6)`. Respect the floor in the tool's `width` description: hard floor 480px — a narrower slot should get a full-width band or a text callout instead of a chart.
|
||||
- `output_path`: `/home/user/workspace/slides/<project>/resources/charts/<name>.svg`.
|
||||
|
||||
Embed the returned chart as a `<rect slide:role="chart">` referencing the `.svg` by `href` (the engine renders the chart SVG inside the rect — it is NOT a drawn rectangle), at the SAME width/height you passed to the tool:
|
||||
```
|
||||
<rect slide:role="chart" href="<returned file_path>" x="..." y="..." width="..." height="..."/>
|
||||
```
|
||||
Aim for a container aspect ratio near 16:10 (e.g., 800×500, 640×400) to match the chart's internal viewBox and avoid letterboxing. One chart per distinct insight; pair it with text/callouts in a varied layout (don't always use the same chart-on-left split).
|
||||
</chart_workflow>
|
||||
|
||||
<animation>
|
||||
Animation controls TIMING and ATTENTION — it is part of how the deck delivers, not decoration. Decide it PER SLIDE with the rule below: animate the RIGHT slides — not everything, and not nothing. (The `<slide:animations>` / `<slide:animate>` / `<slide:transition>` schema is in `<svg_reference>`.)
|
||||
|
||||
Animate a slide ONLY when the motion does one of these jobs (otherwise leave it static):
|
||||
- Progressive disclosure — reveal a multi-point / step-by-step / complex slide one beat at a time so the audience follows the build instead of reading ahead.
|
||||
- Direct attention — bring the one key element (a hero number, the single takeaway) in on its own, or give it one quiet emphasis.
|
||||
- Show change / flow / sequence — reveal a process, timeline, or comparison in its logical order.
|
||||
|
||||
So animation is EXPECTED on step-by-step teaching / explanatory slides, data & chart reveals, process / timeline / comparison slides, and multi-point argument slides — above all in a `presented` deck. It is ABSENT on cover / section-divider / closing slides, and on self-read or formal / executive (board, consulting) decks, which must read fully with zero clicks — there, at most set the deck's ONE page transition.
|
||||
|
||||
Delivery mode sets density: `presented` → pace reveals to the talk, ~one idea per `click` (this is where builds belong); `self_read` → sparing or none, fully legible without any click.
|
||||
|
||||
Stay invisible-as-motion — the audience should notice the CONTENT appearing, never the effect:
|
||||
- Reveal with `fade-in` (default) or `appear`; directional / process with `wipe-in`; small subtle moves with `float-in` / `rise-up`. Emphasis = a single `pulse`. Clear finished content with `fade-out`.
|
||||
- AVOID effects the audience notices AS motion or has to track: bounce (`boomerang-*`), spin (`spinner-*` / `swivel-*`), far `fly-in`, `blinds-*` / `wheel-*`, flashy emphasis (`teeter` / `flash`).
|
||||
- ONE `<slide:transition>` type for the WHOLE deck (e.g. `fade` or `push`), reused on every slide — never vary it slide to slide.
|
||||
|
||||
Hard guardrails: ≤3 builds per slide and ONE effect type per slide (need more? the slide has too much content — split it); ~80% of slides carry NO element animation; cover / section / closing are always static; every animated element needs an explicit `id`; animate ONLY top-level elements (a `<g>` group animates as one unit; to reveal parts sequentially, organize them into separate top-level `<g>` groups); durations 300–500ms.
|
||||
|
||||
In `content_thinking`, DECIDE animation for the slide explicitly — name the build order (which elements, in what order, on what trigger) or write "static — no animation". Never skip the decision.
|
||||
</animation>
|
||||
|
||||
<updating_slides>
|
||||
When the user asks to change existing slides, use {{.ToolSlideEdit}} on the target `.svg` file(s):
|
||||
- {{.ToolSlideEdit}} parameters: `absolute_path` (the slide's `.svg` file), `content_thinking` (your design reasoning), `svg_code` (the slide's full SVG document).
|
||||
- Identify target slides from the `.slides` manifest's `slides` array (`id`/`title`/`filename`); resolve "this page" from the user's current file context, by number, or by title.
|
||||
- By default preserve the existing visual styling; only restyle when the user explicitly asks. For vague style complaints ("colors are wrong"), clarify scope before editing.
|
||||
- Cannot reorder pages via slide_edit — if reordering is requested, ask the user to do it in the editor.
|
||||
- Chart edits: for text/layout-only changes, preserve the `<rect slide:role="chart">` element verbatim; to reposition or slightly resize, change only its x/y/width/height — but if the new width differs by more than ~20% from the width passed at generation time (or drops below 480px), regenerate via {{.ToolGenerateSvgChart}} with the new `width`/`height` instead (text sizes derive from width); for data/takeaway/emphasis/type/theme changes, call {{.ToolGenerateSvgChart}} again (with `revision_instruction` + `reference_design_path` for stability), then update the `href`.
|
||||
</updating_slides>
|
||||
|
||||
<handling_errors>
|
||||
When slide tools fail: retry once. If the retry also fails, consider the task failed and explain clearly. Do NOT fall back to other methods (HTML/PDF, custom code).
|
||||
</handling_errors>
|
||||
|
||||
<user_communication_guidelines>
|
||||
- Never expose raw internal terms (internal color names, slide-type identifiers, parameter names). Translate to user-friendly language (e.g., "section-divider" → "section transition slide"); use real font names as-is.
|
||||
- For text-overflow complaints: apologize, note the slide editor is still evolving, and tell the user they can drag the text boxes in the editor.
|
||||
</user_communication_guidelines>
|
||||
@@ -0,0 +1,12 @@
|
||||
# Activate Slides Edit
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Switches into the fast slide-writing mode after outline creation.
|
||||
## Content
|
||||
```
|
||||
|
||||
## activate_slides_edit
|
||||
|
||||
进入快速写图模型。工具描述
|
||||
|
||||
```text
|
||||
@@ -0,0 +1,12 @@
|
||||
# Compute Custom Shape Bbox
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Computes custom SVG path bounds before authoring custom shapes.
|
||||
## Content
|
||||
```text
|
||||
Add or delete slide pages in an existing presentation project. Use this instead of calling slide_outline again when you need to modify the page structure after the initial outline is created. Operations are executed in order.
|
||||
```
|
||||
|
||||
## compute_custom_shape_bbox
|
||||
|
||||
SVG 专属:算 custom path 真实包围盒。工具描述
|
||||
@@ -0,0 +1,12 @@
|
||||
# Finish Slides Edit
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Leaves edit mode and checks for placeholder slides.
|
||||
## Content
|
||||
- Stay consistent with the style_instruction provided in slide_outline.
|
||||
</quality_standards>
|
||||
```
|
||||
|
||||
## finish_slides_edit
|
||||
|
||||
退出编辑模式并校验无占位页。工具描述
|
||||
@@ -0,0 +1,12 @@
|
||||
# Slide Organize
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Adds or deletes pages after outline creation.
|
||||
## Content
|
||||
```text
|
||||
Finish slide edit mode. Call this AFTER all slide_edit calls are completed. This restores the original model. The tool will verify that all slides have been edited — if any placeholder slides remain, the call will fail and you must edit them first.
|
||||
```
|
||||
|
||||
## slide_organize
|
||||
|
||||
大纲创建后增删页。工具描述
|
||||
@@ -0,0 +1,24 @@
|
||||
# Slide Outline
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Creates project structure, outline metadata, style settings, and empty slide files.
|
||||
## Content
|
||||
```
|
||||
|
||||
## slide_outline
|
||||
|
||||
创建大纲 / 项目结构。
|
||||
|
||||
```text
|
||||
Create the project structure with outline metadata, style settings, and empty slide files.
|
||||
|
||||
<instructions>
|
||||
- Use this tool AFTER preparing the slide content draft (slide_content.md)
|
||||
- The outline defines: page ids, titles, summaries (structural metadata), NOT the detailed content
|
||||
- This tool creates: project directory, outline.json, a style file, and empty `.svg` slide placeholders
|
||||
- Each slide's actual content will be written later using slides_edit based on the content draft
|
||||
- Follow the user's confirmed slide count. If they confirmed a range (e.g., "8-12"), aim for the middle of that range. If no count was specified, default to an 8-12 slide deck. Unless the user explicitly asked for a short / concise deck, never create fewer than 8 slides. Remember that structural slides (cover, agenda, section dividers, closing) consume pages too — factor them into your total so content slides don't get squeezed
|
||||
</instructions>
|
||||
|
||||
<recommended_usage>
|
||||
- Use to define the presentation structure and style before writing individual slides
|
||||
@@ -0,0 +1,41 @@
|
||||
# Slides Convert
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Converts uploaded PPTX files into editable slides format.
|
||||
## Content
|
||||
````
|
||||
|
||||
## slides_convert
|
||||
|
||||
把上传 PPTX 导入成可编辑 deck(两协议共用;SVG 下产出 .svg)。
|
||||
|
||||
```text
|
||||
Convert and parse a user-uploaded PPTX file to editable .slides format.
|
||||
|
||||
This is the ONLY tool for reading/parsing/converting PPTX files. It converts the user's PPTX file into an editable .slides presentation with individual XML files for each slide.
|
||||
|
||||
IMPORTANT: NEVER use python-pptx, node-pptx, or write your own script to parse PPTX files. Always use this tool instead.
|
||||
|
||||
USE CASES:
|
||||
- User wants to edit/modify their existing PPTX presentation
|
||||
- User wants to convert PPTX to editable format for manual editing
|
||||
- User needs to view their PowerPoint file in the slides editor
|
||||
- User wants to read/understand the content of a PPTX file (convert first, then read the XML files)
|
||||
- User wants to summarize or analyze a PPTX presentation
|
||||
|
||||
WORKFLOW:
|
||||
1) Parse the PPTX file and convert to SXSD XML format via RPC
|
||||
2) Extract each slide into individual XML files (slide_1.xml, slide_2.xml, etc.)
|
||||
3) Create a .slides manifest file for the editor to render
|
||||
4) Store converted.xml as reference (hidden file)
|
||||
|
||||
INPUT:
|
||||
- file_path: path to the PPTX file to convert (must be a .pptx file)
|
||||
- directory: sandbox path to store the converted files (e.g., '/home/user/workspace/slides/my_presentation')
|
||||
|
||||
OUTPUT:
|
||||
- slides_path: absolute path to the .slides manifest file ('{directory}.slides') - use this path for slides_update
|
||||
- slide_count: number of slides extracted from the PPTX
|
||||
- directory: directory containing individual slide XML files
|
||||
|
||||
IMPORTANT:
|
||||
@@ -0,0 +1,72 @@
|
||||
# Slides Edit
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Writes SVG page documents and carries slide-edit hard gates.
|
||||
## Content
|
||||
```
|
||||
|
||||
## slides_edit
|
||||
|
||||
写单页 SVG 文档。
|
||||
|
||||
```text
|
||||
Write the SVG document for one or more slide pages in a presentation project. The `slides` parameter is an array — each item edits one slide file independently. Slides are processed and displayed incrementally as each one completes.
|
||||
|
||||
This tool is the only way to make slide content visible to the user. It writes content into the .slides manifest and triggers frontend rendering. The .slides manifest is the source of truth, and only this tool updates it — writing files through any other method (sandbox_write_file, sandbox_exec_command, etc.) has no effect on the final presentation.
|
||||
|
||||
<slide_quality_mindset>
|
||||
Each slide will be projected in front of an audience. Before writing, ask:
|
||||
- Would I be proud to present this in a Fortune 500 boardroom?
|
||||
- If there's data, is it in a chart or buried in text?
|
||||
- If there's a concept, is there an image or just words on a background?
|
||||
|
||||
A text-only slide with decorative shapes signals skipped preparation. It is rarely the right solution.
|
||||
|
||||
<core_technical_requirements>
|
||||
## SVG document
|
||||
- Each slide item's `svg_code` parameter MUST contain a single `<svg slide:role="slide" ...>` element as the root — a standard SVG document carrying private `slide:*` attributes. See `<svg_reference>` for the full element/attribute schema.
|
||||
- This is SVG, NOT HTML and NOT any XML DSL. Use only the elements and attributes documented in `<svg_reference>`.
|
||||
|
||||
- The slide's `id` should match the filename (e.g., `slide_01_cover.svg` uses `id="cover"`).
|
||||
|
||||
## Image usage
|
||||
|
||||
- Use ONLY the image path(s) prepared for the slide and recorded in `receipts/asset_readiness.json` (to avoid duplicates), including any declared `primary_visual`. Place content images with concrete subjects (photos, products, people, stores, screenshots, document captures, UI mockups, illustrations) as `<image slide:role="image" slide:shape-type="image">`; small photographic assets should be PNG/JPG/WebP. SVG is for charts, icons, line decorations, and self-drawn diagrams, not a substitute for searched bitmap imagery.
|
||||
- If the slide's visual subject is a real-world entity (person, company, product, place, venue, artwork, paper, named event, screenshot, or UI/product surface), `asset_readiness.json` must include web-search evidence for that subject before this slide is written. A generated asset may only appear as a recorded fallback after unavailable or failed search.
|
||||
- If no prepared local image exists, the generation layer missed the default search/fallback gate — go search, use a matching user-provided asset, or record a valid generated fallback before writing the slide. If the user explicitly opted out of images, do not invent replacement imagery.
|
||||
|
||||
## Incremental processing
|
||||
- Slides are written and displayed as soon as each one is complete.
|
||||
- Include up to 5 slides per call (more risks output truncation); split larger decks into multiple calls.
|
||||
- NEVER call this tool in parallel — always sequentially (wait for one call to finish before the next).
|
||||
</core_technical_requirements>
|
||||
|
||||
<layout_and_design>
|
||||
Compose every slide's layout from scratch to fit its specific content and the deck's aesthetic direction — follow "Design Thinking", "Aesthetic Guidelines", and "Layout Freedom" in `<svg_reference>`. Do NOT rotate through a fixed menu of canned patterns, and do NOT apply formulaic "diagram" templates — that produces the template-stamped feel we are avoiding. Favor unexpected, asymmetric, content-specific composition: overlap, diagonal flow, grid-breaking elements, a single dominant hero element, generous negative space. Vary the structural arrangement between adjacent slides while keeping the deck's background, card-surface style, and decoration density CONSTANT across the whole deck.
|
||||
|
||||
Use visual elements (shapes, lines, icons, accent bars, gradients) to break up text and build hierarchy; apply the accent color sparingly for emphasis; maintain white space and contrast. When text sits on a background image, add a parser-legible readable overlay (`slide:role="shape" slide:shape-type="rect" data-svglide-layer="mask"`), gradient, or safe text zone first so the text stays readable.
|
||||
</layout_and_design>
|
||||
|
||||
<prohibited_practices>
|
||||
- NEVER use the same "title + bullet list" layout on every slide.
|
||||
- Don't overflow the 720px target height; don't stack images or charts vertically.
|
||||
- Never reference non-existent or non-local image paths.
|
||||
- Avoid walls of text without visual breaks.
|
||||
</prohibited_practices>
|
||||
|
||||
<visualization_requirements>
|
||||
- Incorporate charts when data is available; use large stat numbers for key metrics (e.g., "$150B" as a prominent element).
|
||||
- Each column may contain at most one chart/graph/image.
|
||||
- Only chart real, source-verified data — never fabricate numerical data.
|
||||
</visualization_requirements>
|
||||
|
||||
<thinking_process_instructions>
|
||||
Before writing the SVG, use the `content_thinking` parameter to document:
|
||||
1. **Visual assets**: the slide_role, primary visual path when required, which images/charts you will use (list file paths and the asset_readiness entry), why the primary visual supports the key message, whether real-world subjects have web-search evidence, and whether text overlays require a mask/scrim. If none are available, the slide is missing preparation — go search/download user-approved visuals first, then use generated fallback only after the fallback is recorded.
|
||||
2. **Layout**: what composition best fits this content, and how it differs from adjacent slides.
|
||||
3. **Key message**: the ONE takeaway, and how typography and spacing emphasize it.
|
||||
4. **Data visualization**: can any content be shown as a chart or large stat number instead of text?
|
||||
5. **Composition**: how you distribute elements across the canvas to avoid empty space.
|
||||
</thinking_process_instructions>
|
||||
|
||||
<quality_standards>
|
||||
@@ -0,0 +1,29 @@
|
||||
# Slides Parse Template
|
||||
## Context
|
||||
- Role: tool_contract
|
||||
- Purpose: Parses and preprocesses XML templates for template-based generation.
|
||||
## Content
|
||||
- The original PPTX content is preserved exactly in the converted .slides file
|
||||
- If user wants to use PPTX as a STYLE REFERENCE for new slides, use slides_parse_template instead
|
||||
```
|
||||
|
||||
## slides_parse_template
|
||||
|
||||
解析模板元数据。
|
||||
|
||||
```text
|
||||
Parse and preprocess a SXSD XML template for template-based slide generation.
|
||||
|
||||
This tool takes an XML template file and produces a processed version (tmpl.xml) optimized for template-based generation:
|
||||
- Extracts embedded images to sandbox filesystem
|
||||
- Normalizes coordinate precision and formatting
|
||||
- Prepares the template structure for layout replication
|
||||
|
||||
IMPORTANT: This tool only accepts .xml files.
|
||||
- If user uploads a .pptx file and wants to use it as a template, you MUST first call slides_convert to convert it, then use the returned xml_path as input to this tool.
|
||||
|
||||
INPUT:
|
||||
- folder_name: folder name for storing template files (e.g., 'my_template'). Will be placed under /home/user/workspace/slides/template/
|
||||
- file_path: path to the XML template file (must be .xml, typically the xml_path returned by slides_convert)
|
||||
|
||||
OUTPUT:
|
||||
@@ -0,0 +1,33 @@
|
||||
# SVG Slides Validation
|
||||
|
||||
Use this directory for local validation and provenance audit routing.
|
||||
|
||||
## Read Routes
|
||||
|
||||
- Source coverage audit: `source-coverage.md`
|
||||
- Protocol validation script: `../../../scripts/validate_svg_deck.mjs`
|
||||
- Bundle manifest script: `../../../scripts/svg_slides_bundle.mjs`
|
||||
- Browser text bounds script: `../../../scripts/svg_slides_browser_text_bounds.mjs`
|
||||
- Asset readiness script: `../../../scripts/svg_slides_asset_readiness.mjs`
|
||||
- Visual identity comparison script: `../../../scripts/svg_slides_visual_identity_check.mjs`
|
||||
- Typography plan script: `../../../scripts/svg_slides_typography_plan.mjs`
|
||||
- Visual system script: `../../../scripts/svg_slides_visual_system.mjs`
|
||||
- Color rhythm script: `../../../scripts/svg_slides_color_rhythm.mjs`
|
||||
- Frame discipline script: `../../../scripts/svg_slides_frame_discipline.mjs`
|
||||
- Readability safety script: `../../../scripts/svg_slides_readability_safety.mjs`
|
||||
- Prompt read trace script: `../../../scripts/svg_slides_prompt_trace.mjs`
|
||||
- Post-delivery audit checklist: `post-delivery-audit.md`
|
||||
|
||||
## Publish Gates
|
||||
|
||||
A generated SVG Slides bundle is publish-ready only when all visual safety gates pass:
|
||||
|
||||
1. `validate_svg_deck`: parser semantics, no browser-only text or untyped shapes.
|
||||
2. `svg_slides_visual_system`: actual SVG colors stay inside the locked deck palette.
|
||||
3. `svg_slides_color_rhythm`: adjacent pages follow the planned full-page color rhythm.
|
||||
4. `svg_slides_frame_discipline`: ordinary text is not wrapped in meaningless stroked frames.
|
||||
5. `svg_slides_readability_safety`: foreground overlap, out-of-bounds content, and missing text-over-image masking.
|
||||
|
||||
## Response Audit
|
||||
|
||||
When a user asks whether a finished deck satisfies the prompt, do not answer from the user's visible request alone. Read `post-delivery-audit.md` and report against user-visible requirements, SVG Slides default requirements, and receipt/readback evidence.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Asset Readiness
|
||||
|
||||
Every generated SVG Slides bundle must write `receipts/asset_readiness.json` before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The receipt proves that visual assets were planned and prepared before slide authoring. It closes two failure modes:
|
||||
|
||||
- slides that pass SVG protocol validation but contain only decorative shapes instead of searched or valid fallback visual assets
|
||||
- multiple decks in one run that reuse the same generic visual system and layout vocabulary
|
||||
|
||||
## Required Receipt
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svg-slides.asset-readiness.v1",
|
||||
"image_search_policy": "required_by_default | user_opted_out | fallback_no_network | fallback_search_failed",
|
||||
"visual_identity_fingerprint": {
|
||||
"style_family": "technical-paper-terminal",
|
||||
"composition_signature": "asymmetric-code-panels",
|
||||
"imagery_treatment": "paper screenshots and architecture diagrams",
|
||||
"palette_signature": "navy-cyan-orange"
|
||||
},
|
||||
"required_visual_subjects": [
|
||||
{
|
||||
"slide": "slides/slide_01.svg",
|
||||
"subject": "named or conceptual visual subject for this slide",
|
||||
"search_required": true,
|
||||
"search_query": "specific query used by the image search step",
|
||||
"search_status": "ready | not_found | unavailable | user_opted_out",
|
||||
"fallback_reason": "required when search_status is not_found or unavailable"
|
||||
}
|
||||
],
|
||||
"required_search_entities": ["Named company or product when applicable"],
|
||||
"assets": [
|
||||
{
|
||||
"slide": "slides/slide_01.svg",
|
||||
"purpose": "primary hero visual",
|
||||
"asset_kind": "photograph | product_image | person_image | storefront | screenshot | document_capture | illustration | chart | icon | decoration",
|
||||
"expressive_role": "what emotion, proof, or narrative function this asset carries",
|
||||
"content_relevance": "why this exact visual belongs to this slide's key message",
|
||||
"format": "png",
|
||||
"source_type": "web_search",
|
||||
"status": "ready",
|
||||
"query": "specific query used",
|
||||
"source_url": "https://example.com/source",
|
||||
"local_path": "/absolute/path/to/local/image.png",
|
||||
"assignment_reason": "why this asset belongs on this slide"
|
||||
}
|
||||
],
|
||||
"slides": [
|
||||
{
|
||||
"file": "slides/slide_01.svg",
|
||||
"slide_role": "cover | section-divider | content | key-showcase | closing",
|
||||
"visual_tier": "hero | supporting | chart | none",
|
||||
"primary_visual_required": true,
|
||||
"primary_visual": "/absolute/path/to/local/image.png",
|
||||
"layout_family": "hero-image-left-code-right",
|
||||
"text_overlay": true,
|
||||
"mask_required": true,
|
||||
"mask_strategy": "dark-gradient-left",
|
||||
"visual_assets": ["/absolute/path/to/local/image.png"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Image search is required by default for every deck topic and every slide visual subject unless the user explicitly says not to use images. Set `image_search_policy: "required_by_default"` for the normal path.
|
||||
- User-provided attachments or existing local assets may satisfy a prepared local asset need when they match the slide subject, but the deck still records the default image-search policy and slide-level visual subjects unless the user explicitly opted out of images.
|
||||
- Every non-opt-out deck must list `required_visual_subjects`; each subject must set `search_required: true`, a concrete `search_query`, and `search_status`.
|
||||
- For specific real-world entities (named companies, products, people, places, venues, artworks, papers, named events, screenshots, or UI/product surfaces), also record them in legacy `required_search_entities` and use `source_type: "web_search"` evidence for each entity before any fallback is considered.
|
||||
- Search failures must still be explicit assets with `status: "not_found"`, `query`, and `failure_reason`; then use `generated` fallback assets only with `image_search_policy: "fallback_search_failed"` and concrete `fallback_reason`.
|
||||
- If network image search is unavailable and no user-provided asset covers the subject, use `image_search_policy: "fallback_no_network"` and record `search_status: "unavailable"` plus `fallback_reason` before using generated/SVG fallback assets.
|
||||
- Generated assets are fallback-only. They must include `fallback_reason`; they cannot satisfy the default search requirement by themselves.
|
||||
- If the user explicitly says not to use images, set `image_search_policy: "user_opted_out"` plus `image_search_opt_out_reason`; only then may `assets` and `required_visual_subjects` be empty and key pages omit `primary_visual`. Do not create generated replacement imagery under `user_opted_out`.
|
||||
- Every slide must declare `slide_role` and `layout_family`. `cover`, `section-divider`, `closing`, and `key-showcase` slides must set `primary_visual_required: true` and provide a `primary_visual`.
|
||||
- Primary/hero assets must declare `asset_kind`, `expressive_role`, `content_relevance`, and `format`; charts, icons, decorative patterns, textures, and abstract geometry do not satisfy a primary visual requirement.
|
||||
- Photographic/product/person/storefront/screenshot/document-capture assets should use PNG/JPG/JPEG/WebP. SVG is allowed for those only with `format_exception_reason`.
|
||||
- If slide text overlays a hero image (`text_overlay: true` and `mask_required: true`), the slide SVG must include a readable parser-legible mask/scrim/safe-zone structure such as `<rect slide:role="shape" slide:shape-type="rect" data-svglide-layer="mask" ...>`, a gradient overlay, or a semi-transparent overlay rect. Do not invent new `slide:role` values for masks.
|
||||
- Every `ready` visual asset must have an existing local path, be assigned to a slide's `visual_assets`, and be embedded by that slide SVG. Remote-only URLs are not publish-ready.
|
||||
- Every slide must declare a `layout_family`; multi-deck/team runs must compare `visual_identity_fingerprint` and layout-family overlap with `../../../scripts/svg_slides_visual_identity_check.mjs`.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_asset_readiness.mjs <deck-dir>
|
||||
node skills/lark-slides/scripts/svg_slides_visual_identity_check.mjs <deck-a> <deck-b> [<deck-c>...]
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Frame Discipline
|
||||
|
||||
Every generated SVG Slides bundle must pass frame-discipline validation before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The default text treatment is unframed. This gate prevents generators from wrapping ordinary headings, body paragraphs, summary points, captions, and quotes in stroked rectangles just to organize content.
|
||||
|
||||
## Rules
|
||||
|
||||
- A `<rect>` with `stroke` that encloses or overlaps text must declare `data-frame-role`.
|
||||
- Allowed frame roles: `card`, `timeline_node`, `chart_container`, `table_container`, `worksheet_area`, `callout`.
|
||||
- Use frames only when the boundary carries meaning. Do not use bordered rectangles as the default substitute for whitespace, alignment, typographic hierarchy, or simple divider lines.
|
||||
- A non-dashboard slide should not have more than four text-adjacent stroked frames. Dashboard, matrix, table, and worksheet layouts are exempt only when the asset-readiness slide metadata says so via `slide_role` or `layout_family`.
|
||||
- Image masks, text scrims, chart elements, backgrounds, and decorative shapes that do not touch text are outside this gate.
|
||||
|
||||
## Validation Command
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_frame_discipline.mjs <deck-dir>
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# SVG Slides Post-Delivery Audit
|
||||
|
||||
Use this checklist before answering whether a finished SVG Slides deck satisfies the prompt. This is a response-quality gate, not a replacement for parser or publish validation.
|
||||
|
||||
## Required Review Surfaces
|
||||
|
||||
1. User-visible requirements: page count, named sections, language, topic, style words, explicit exclusions, and delivery mode.
|
||||
2. SVG Slides default requirements: image search policy, required visual subjects, primary visual rules for cover / section-divider / closing / key-showcase slides, typography plan, frame discipline, readability safety, and prompt read trace.
|
||||
3. Execution evidence: local manifest, validation command results, `receipts/asset_readiness.json`, `receipts/typography_plan.json`, `receipts/prompt_read_trace.json`, and online readback receipt when the deck was published.
|
||||
|
||||
## Mandatory Answer Shape
|
||||
|
||||
When judging prompt satisfaction, report these separately:
|
||||
|
||||
- User prompt fit: satisfied / partially satisfied / failed, with the exact unmet or changed requirement.
|
||||
- System prompt fit: asset search policy, primary visual coverage, typography and readability gates.
|
||||
- Online delivery fit: publish/readback status when online delivery was requested.
|
||||
- Quality gaps: issues that pass validation but still reduce real usefulness, such as one asset reused across too many slides, placeholder company evidence, weak cover identity, or factual compliance rewrites.
|
||||
|
||||
## Asset Search Checks
|
||||
|
||||
- A non-opt-out deck must say which `image_search_policy` was used.
|
||||
- `required_by_default` must include web-search evidence for every required visual subject.
|
||||
- `fallback_search_failed` or `fallback_no_network` must name the failed/unavailable subject and the fallback reason.
|
||||
- User-provided assets may satisfy local asset needs, but do not erase web-search requirements for named real-world entities unless the user explicitly requested supplied assets only.
|
||||
|
||||
## Failure Rule
|
||||
|
||||
Never say "meets the prompt" without mentioning a system-prompt default that materially shaped the output. If a deck passes validators but changes the user's literal request for factual or compliance reasons, call it "partially satisfied" and name the rewrite.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Prompt Read Trace
|
||||
|
||||
Every generated SVG Slides bundle must write `receipts/prompt_read_trace.json` before bundling.
|
||||
|
||||
The receipt proves the generator read the required prompt and reference Markdown files for the selected task.
|
||||
|
||||
## Required Receipt Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svg-slides.prompt-read-trace.v1",
|
||||
"task": "generate_deck",
|
||||
"entrypoint": "README.md",
|
||||
"generated_at": "2026-07-08T00:00:00.000Z",
|
||||
"read_order": [
|
||||
{
|
||||
"path": "system-prompt.md",
|
||||
"role": "prompt",
|
||||
"reason": "main workflow",
|
||||
"sha256": "..."
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"missing_required_reads": []
|
||||
}
|
||||
```
|
||||
|
||||
## Failure Semantics
|
||||
|
||||
- Missing required files fail trace creation.
|
||||
- Missing required reads fail validation.
|
||||
- A bundle without `receipts/prompt_read_trace.json` must not be marked `publish_ready=true`.
|
||||
- If chart snippet routing is uncertain, record `generate-svg-chart/full.md` in `fallbacks` and in `read_order`.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Runtime Reference Coverage Audit
|
||||
|
||||
Coverage is a maintainer audit, not deck-generation context.
|
||||
|
||||
The split manifest lives under `skills/lark-slides/prompt-sources/svg-slides/`. Runtime agents should not read that directory while generating decks.
|
||||
|
||||
Rules:
|
||||
|
||||
- Scan Markdown files recursively under `skills/lark-slides/references/svg-slides`.
|
||||
- Each manifest section's target file must exist in the runtime reference tree.
|
||||
- Runtime Markdown must not expose prompt-source paths, source-line metadata, or source-coverage blocks.
|
||||
- `generate-svg-chart/snippets/` must contain exactly 22 snippet Markdown files excluding `README.md`.
|
||||
- The source manifest remains the authority for maintainer coverage.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Typography Plan
|
||||
|
||||
Every generated SVG Slides bundle must write `receipts/typography_plan.json` before `manifest.json` may set `publish_ready=true`.
|
||||
|
||||
The receipt makes deck-level font choice explicit. It prevents a generator from silently substituting fonts during slide authoring after the design brief chose a visual direction.
|
||||
|
||||
## Required Receipt
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svg-slides.typography-plan.v1",
|
||||
"font_category": "parser-safe editorial sans",
|
||||
"display_font": "Playfair Display",
|
||||
"body_font": "Noto Sans SC",
|
||||
"label_font": "Noto Sans SC",
|
||||
"source": "visual_system.typography",
|
||||
"selection_reason": "Display uses an editorial title face while body and labels use a canonical CJK-readable Slide font.",
|
||||
"forbidden_substitutions": ["CSS fallback stacks", "browser generic fonts", "CSS variables"],
|
||||
"runtime_candidates_used": true,
|
||||
"requested_fonts": ["Playfair Display", "Noto Sans SC"],
|
||||
"resolved_fonts": ["Playfair Display", "Noto Sans SC"],
|
||||
"fallbacks": [],
|
||||
"fallback_reason": "One-sentence summary when any requested font is unavailable; empty when all requested fonts are preserved.",
|
||||
"unavailable_fonts": [],
|
||||
"user_requested_font_preserved": true
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- The design brief's `visual_system.typography` is authoritative. Keep its category and treatment: serif vs sans-serif vs rounded vs mono, weight, case, and letter spacing.
|
||||
- Explicit user-requested fonts outrank inferred visual_system fonts. Preserve every requested font by exact family name in `display_font`, `body_font`, or `label_font`, or record it in `unavailable_fonts` with a concrete fallback reason.
|
||||
- Select explicit `display_font`, `body_font`, and `label_font` before slide authoring. Use `<runtime_font_candidates>` when provided; otherwise use the Font Palette in `svg-reference.md`.
|
||||
- Each font field is one concrete font family string. Commas are invalid.
|
||||
- Generic-only values are invalid: `serif`, `sans-serif`, `monospace`, `cursive`, `system-ui`.
|
||||
- CSS variables are invalid: `var(--font-body)` and similar values must never appear.
|
||||
- Body text must not use calligraphy, cursive, handwriting, or decorative script fonts.
|
||||
- `forbidden_substitutions` must name the tempting but wrong swaps for this deck, such as flipping serif and sans-serif or replacing a technical mono treatment with a cute rounded font.
|
||||
- Do not silently replace requested Chinese/CJK fonts with `STKaiti`, `KaiTi`, `PingFang SC`, `Noto Sans SC`, `黑体`, or any other nearby-looking fallback.
|
||||
- All slide SVG text must use only the planned single font families. If a new family is needed, update `typography_plan.json` first and keep the category rationale explicit.
|
||||
|
||||
## Validation Command
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_typography_plan.mjs <deck-dir>
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Visual System And Color Rhythm Gate
|
||||
|
||||
Role: schema
|
||||
|
||||
## Purpose
|
||||
|
||||
`receipts/visual_system.json` is the deck-level visual contract. It prevents a generated deck from changing palette, background mode, or page-to-page color rhythm opportunistically while individual slides are being authored.
|
||||
|
||||
This gate does not restrict layout freedom. It only locks the color tokens and rhythm decisions that must stay coherent across the whole deck.
|
||||
|
||||
## Required Receipt
|
||||
|
||||
Write `receipts/visual_system.json` before any `slide_edit` call.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svg-slides.visual-system.v1",
|
||||
"palette_tokens": {
|
||||
"bg.base": "rgba(255,255,255,1)",
|
||||
"bg.deep": "rgba(8,15,28,1)",
|
||||
"panel.light": "rgba(255,255,255,1)",
|
||||
"panel.dark": "rgba(15,23,42,1)",
|
||||
"text.primary": "rgba(15,23,42,1)",
|
||||
"accent.primary": "rgba(0,184,216,1)"
|
||||
},
|
||||
"color_rhythm": {
|
||||
"full_dark_budget": 2,
|
||||
"allowed_full_dark_roles": ["cover", "section-divider", "key-showcase"],
|
||||
"default_content_theme": "light",
|
||||
"closing_theme": "light",
|
||||
"max_adjacent_luma_delta": 0.62,
|
||||
"max_transition_ratio": 0.45,
|
||||
"allowed_hard_transitions": ["slides/slide_01.svg->slides/slide_02.svg"],
|
||||
"slide_themes": [
|
||||
{
|
||||
"file": "slides/slide_01.svg",
|
||||
"role": "cover",
|
||||
"theme": "dark",
|
||||
"reason": "cover uses the strongest contrast moment"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Palette Rules
|
||||
|
||||
- Use `rgb(...)` or `rgba(...)` values only.
|
||||
- Required tokens: `bg.base`, `bg.deep`, `panel.light`, `panel.dark`, `text.primary`, and at least one `accent.*`.
|
||||
- Slide SVG fills, strokes, stop colors, and CSS text colors must use colors whose RGB values match a palette token. Alpha variants are allowed.
|
||||
- Gradients are allowed only when every stop color is token-derived.
|
||||
|
||||
## Color Rhythm Rules
|
||||
|
||||
- `slide_themes` must include exactly one entry per slide file.
|
||||
- `role` must match the slide role used in content planning: `cover`, `content`, `section-divider`, `key-showcase`, `closing`, or a similarly explicit role.
|
||||
- `theme` is one of `light`, `dark`, or `image`.
|
||||
- Full dark backgrounds count against `full_dark_budget`.
|
||||
- Dark backgrounds are allowed only for roles listed in `allowed_full_dark_roles`.
|
||||
- Content pages default to `default_content_theme`; closing pages must use `closing_theme`.
|
||||
- Avoid alternating full-page dark/light/dark/light patterns. Use localized accents, panels, or imagery to create variety without changing the entire page ground every slide.
|
||||
- `allowed_hard_transitions` may list specific adjacent slide pairs when a deliberate chapter break requires a strong jump.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_visual_system.mjs <deck-dir> --json
|
||||
node skills/lark-slides/scripts/svg_slides_color_rhythm.mjs <deck-dir> --json
|
||||
```
|
||||
|
||||
`svg_slides_bundle.mjs` writes:
|
||||
|
||||
- `receipts/visual_system_report.json`
|
||||
- `receipts/color_rhythm.json`
|
||||
|
||||
Both must have `ok: true` and `totalErrors: 0` before the bundle can publish.
|
||||
431
skills/lark-slides/scripts/svg_slides_asset_readiness.mjs
Normal file
431
skills/lark-slides/scripts/svg_slides_asset_readiness.mjs
Normal file
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
fail(`Invalid asset readiness JSON: ${error.message}`, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return stringValue(value).toLowerCase();
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
return fs.existsSync(path.resolve(filePath));
|
||||
}
|
||||
|
||||
function resolveDeckPath(root, relOrAbs) {
|
||||
if (path.isAbsolute(relOrAbs)) return relOrAbs;
|
||||
return path.join(root, relOrAbs);
|
||||
}
|
||||
|
||||
function slideContainsAsset(slideSource, root, localPath) {
|
||||
const abs = path.resolve(localPath);
|
||||
const rel = path.relative(root, abs).split(path.sep).join("/");
|
||||
return slideSource.includes(localPath) || slideSource.includes(abs) || (rel && slideSource.includes(rel));
|
||||
}
|
||||
|
||||
function assetFormat(asset) {
|
||||
const explicit = normalize(asset.format);
|
||||
if (explicit) return explicit.replace(/^\./, "");
|
||||
const localPath = stringValue(asset.local_path);
|
||||
const extension = localPath ? path.extname(localPath).replace(/^\./, "") : "";
|
||||
return extension.toLowerCase();
|
||||
}
|
||||
|
||||
function slideHasReadableImageMask(slideSource) {
|
||||
return /<[^>]+\bdata-svglide-layer=["']mask["'][^>]*>/i.test(slideSource)
|
||||
|| /<linearGradient\b/i.test(slideSource)
|
||||
|| /<rect\b(?=[^>]*\bslide:role=["']shape["'])(?=[^>]*\bslide:shape-type=["']rect["'])(?=[^>]*\bfill=["']rgba\([^)]*,\s*0\.[2-9][0-9]*\)["'])[^>]*>/i.test(slideSource)
|
||||
|| /<rect\b(?=[^>]*\bslide:role=["']shape["'])(?=[^>]*\bslide:shape-type=["']rect["'])(?=[^>]*\bfill-opacity=["']0\.[2-9][0-9]*["'])[^>]*>/i.test(slideSource)
|
||||
|| /<rect\b(?=[^>]*\bslide:role=["']shape["'])(?=[^>]*\bslide:shape-type=["']rect["'])(?=[^>]*\bopacity=["']0\.[2-9][0-9]*["'])[^>]*>/i.test(slideSource);
|
||||
}
|
||||
|
||||
function makeError(rule, message, extra = {}) {
|
||||
return { rule, severity: "error", message, ...extra };
|
||||
}
|
||||
|
||||
function slideKey(value) {
|
||||
return stringValue(value).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function pageResultsFromSlides(slides) {
|
||||
const byFile = new Map();
|
||||
for (const slide of Array.isArray(slides) ? slides : []) {
|
||||
const file = slideKey(slide.file);
|
||||
if (!file) continue;
|
||||
if (!byFile.has(file)) byFile.set(file, { file, errors: [], warnings: [] });
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
|
||||
function addDeckError(deckErrors, flatErrors, rule, message, extra = {}) {
|
||||
deckErrors.push(makeError(rule, message, { repairable: false, ...extra }));
|
||||
flatErrors.push(message);
|
||||
}
|
||||
|
||||
function addSlideError(pageResults, flatErrors, file, rule, message, extra = {}) {
|
||||
const normalized = slideKey(file);
|
||||
if (!pageResults.has(normalized)) {
|
||||
pageResults.set(normalized, { file: normalized, errors: [], warnings: [] });
|
||||
}
|
||||
pageResults.get(normalized).errors.push(makeError(rule, message, { repairable: true, ...extra }));
|
||||
flatErrors.push(message);
|
||||
}
|
||||
|
||||
function assetMentionsEntity(asset, entity) {
|
||||
const needle = normalize(entity);
|
||||
if (!needle) return false;
|
||||
const haystack = [
|
||||
asset.entity,
|
||||
...(Array.isArray(asset.entities) ? asset.entities : []),
|
||||
asset.subject,
|
||||
asset.purpose,
|
||||
asset.query,
|
||||
asset.source_url,
|
||||
asset.local_path
|
||||
].map(normalize).join(" ");
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
function valuesOverlap(left, right) {
|
||||
const a = normalize(left);
|
||||
const b = normalize(right);
|
||||
return Boolean(a && b && (a.includes(b) || b.includes(a)));
|
||||
}
|
||||
|
||||
function assetMatchesVisualSubject(asset, subject) {
|
||||
return valuesOverlap(asset.query, subject.search_query)
|
||||
|| valuesOverlap(asset.subject, subject.subject)
|
||||
|| valuesOverlap(asset.purpose, subject.subject)
|
||||
|| valuesOverlap(asset.assignment_reason, subject.subject);
|
||||
}
|
||||
|
||||
function generatedFallbackHasSearchFailureEvidence(policy, subjects, assets) {
|
||||
if (policy === "fallback_no_network") {
|
||||
return subjects.some((subject) => normalize(subject.search_status) === "unavailable");
|
||||
}
|
||||
if (policy === "fallback_search_failed") {
|
||||
return subjects.some((subject) => normalize(subject.search_status) === "not_found" && assets.some((asset) => (
|
||||
asset.source_type === "web_search"
|
||||
&& asset.status === "not_found"
|
||||
&& stringValue(asset.query)
|
||||
&& assetMatchesVisualSubject(asset, subject)
|
||||
)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const json = args.includes("--json");
|
||||
if (!deckArg) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_asset_readiness.mjs <deck-dir> [--json]");
|
||||
}
|
||||
|
||||
const root = path.resolve(deckArg);
|
||||
const receiptPath = path.join(root, "receipts", "asset_readiness.json");
|
||||
if (!fs.existsSync(receiptPath)) {
|
||||
fail(`Missing asset readiness receipt: ${receiptPath}`, 1);
|
||||
}
|
||||
|
||||
const receipt = readJson(receiptPath);
|
||||
const errors = [];
|
||||
const deckErrors = [];
|
||||
if (receipt.version !== "svg-slides.asset-readiness.v1") {
|
||||
addDeckError(deckErrors, errors, "asset.invalid_version", `invalid asset readiness version: ${receipt.version}`);
|
||||
}
|
||||
|
||||
const identity = receipt.visual_identity_fingerprint || {};
|
||||
for (const field of ["style_family", "composition_signature", "imagery_treatment", "palette_signature"]) {
|
||||
if (!stringValue(identity[field])) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_identity_missing", `visual_identity_fingerprint.${field} is required`, { field });
|
||||
}
|
||||
}
|
||||
|
||||
const assets = Array.isArray(receipt.assets) ? receipt.assets : [];
|
||||
const imageSearchPolicy = normalize(receipt.image_search_policy);
|
||||
const allowedImageSearchPolicies = new Set(["required_by_default", "user_opted_out", "fallback_no_network", "fallback_search_failed"]);
|
||||
const userOptedOut = imageSearchPolicy === "user_opted_out";
|
||||
const fallbackPolicy = imageSearchPolicy === "fallback_no_network" || imageSearchPolicy === "fallback_search_failed";
|
||||
const requiredVisualSubjects = Array.isArray(receipt.required_visual_subjects)
|
||||
? receipt.required_visual_subjects
|
||||
: [];
|
||||
if (!imageSearchPolicy) {
|
||||
addDeckError(deckErrors, errors, "asset.image_search_policy_missing", "image_search_policy is required");
|
||||
} else if (!allowedImageSearchPolicies.has(imageSearchPolicy)) {
|
||||
addDeckError(deckErrors, errors, "asset.invalid_image_search_policy", `invalid image_search_policy: ${receipt.image_search_policy}`, { image_search_policy: receipt.image_search_policy });
|
||||
}
|
||||
if (userOptedOut && !stringValue(receipt.image_search_opt_out_reason)) {
|
||||
addDeckError(deckErrors, errors, "asset.user_opt_out_requires_explicit_request", "user_opted_out requires image_search_opt_out_reason");
|
||||
}
|
||||
|
||||
if (assets.length === 0 && !userOptedOut) {
|
||||
addDeckError(deckErrors, errors, "asset.no_assets", "asset readiness must list at least one prepared visual asset");
|
||||
}
|
||||
|
||||
const allowedTypes = new Set(["web_search", "generated", "user_provided", "provided", "chart", "screenshot"]);
|
||||
const allowedStatuses = new Set(["ready", "not_found", "skipped_with_reason"]);
|
||||
const photographicAssetKinds = new Set([
|
||||
"photograph",
|
||||
"photo",
|
||||
"product_image",
|
||||
"person_image",
|
||||
"storefront",
|
||||
"store_image",
|
||||
"scene_photo",
|
||||
"screenshot",
|
||||
"document_capture"
|
||||
]);
|
||||
const rasterFormats = new Set(["png", "jpg", "jpeg", "webp"]);
|
||||
const bannedPrimaryVisualKinds = new Set(["icon", "chart", "decoration", "abstract_geometry", "texture", "pattern"]);
|
||||
const assetsByResolvedPath = new Map();
|
||||
for (const [index, asset] of assets.entries()) {
|
||||
const label = `asset #${index + 1}`;
|
||||
if (!stringValue(asset.slide)) addDeckError(deckErrors, errors, "asset.asset_missing_slide", `${label} missing slide`, { asset_index: index + 1 });
|
||||
if (!stringValue(asset.purpose)) addDeckError(deckErrors, errors, "asset.asset_missing_purpose", `${label} missing purpose`, { asset_index: index + 1 });
|
||||
if (!allowedTypes.has(asset.source_type)) {
|
||||
addDeckError(deckErrors, errors, "asset.invalid_source_type", `${label} has invalid source_type: ${asset.source_type}`, { asset_index: index + 1, source_type: asset.source_type });
|
||||
}
|
||||
if (!allowedStatuses.has(asset.status)) {
|
||||
addDeckError(deckErrors, errors, "asset.invalid_status", `${label} has invalid status: ${asset.status}`, { asset_index: index + 1, status: asset.status });
|
||||
}
|
||||
if (!stringValue(asset.assignment_reason)) {
|
||||
addDeckError(deckErrors, errors, "asset.missing_assignment_reason", `${label} missing assignment_reason`, { asset_index: index + 1 });
|
||||
}
|
||||
if (asset.source_type === "web_search" && !stringValue(asset.query)) {
|
||||
addDeckError(deckErrors, errors, "asset.web_search_missing_query", `${label} web_search asset missing query`, { asset_index: index + 1 });
|
||||
}
|
||||
if (asset.source_type === "generated" && asset.status === "ready") {
|
||||
if (userOptedOut) {
|
||||
addDeckError(deckErrors, errors, "asset.generated_after_user_opt_out", `${label} generated asset is not allowed after user opted out of images`, { asset_index: index + 1 });
|
||||
} else if (!fallbackPolicy) {
|
||||
addDeckError(deckErrors, errors, "asset.generated_without_search_evidence", `${label} generated asset requires web search evidence or fallback policy`, { asset_index: index + 1 });
|
||||
} else if (!generatedFallbackHasSearchFailureEvidence(imageSearchPolicy, requiredVisualSubjects, assets)) {
|
||||
addDeckError(deckErrors, errors, "asset.generated_without_failed_search_evidence", `${label} generated fallback asset requires failed or unavailable search evidence`, { asset_index: index + 1 });
|
||||
}
|
||||
if (!stringValue(asset.fallback_reason)) {
|
||||
addDeckError(deckErrors, errors, "asset.generated_missing_fallback_reason", `${label} generated fallback asset missing fallback_reason`, { asset_index: index + 1 });
|
||||
}
|
||||
}
|
||||
if (asset.status === "ready") {
|
||||
if (!stringValue(asset.local_path)) {
|
||||
addDeckError(deckErrors, errors, "asset.ready_missing_local_path", `${label} ready asset missing local_path`, { asset_index: index + 1 });
|
||||
} else if (!pathExists(asset.local_path)) {
|
||||
addDeckError(deckErrors, errors, "asset.ready_local_path_missing", `${label} asset local_path does not exist: ${asset.local_path}`, { asset_index: index + 1, local_path: asset.local_path });
|
||||
} else {
|
||||
assetsByResolvedPath.set(path.resolve(asset.local_path), { asset, label });
|
||||
}
|
||||
if (asset.source_type === "web_search" && !stringValue(asset.source_url)) {
|
||||
addDeckError(deckErrors, errors, "asset.web_search_missing_source_url", `${label} web_search ready asset missing source_url`, { asset_index: index + 1 });
|
||||
}
|
||||
const purpose = normalize(asset.purpose);
|
||||
const kind = normalize(asset.asset_kind || asset.kind);
|
||||
const isPrimaryOrHero = purpose.includes("primary") || purpose.includes("hero");
|
||||
if (isPrimaryOrHero) {
|
||||
if (!stringValue(asset.expressive_role)) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_missing_expressive_role", `${label} primary/hero asset missing expressive_role`, { asset_index: index + 1 });
|
||||
}
|
||||
if (!stringValue(asset.content_relevance)) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_missing_content_relevance", `${label} primary/hero asset missing content_relevance`, { asset_index: index + 1 });
|
||||
}
|
||||
if (asset.source_type === "chart" || bannedPrimaryVisualKinds.has(kind)) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_forbidden_kind", `${label} primary/hero visual cannot be a chart, icon, decoration, texture, pattern, or abstract geometry`, { asset_index: index + 1, asset_kind: kind });
|
||||
}
|
||||
}
|
||||
if (photographicAssetKinds.has(kind) && assetFormat(asset) === "svg" && !stringValue(asset.format_exception_reason)) {
|
||||
addDeckError(deckErrors, errors, "asset.photographic_svg_without_exception", `${label} photographic asset should use png, jpg, jpeg, or webp unless format_exception_reason is provided`, { asset_index: index + 1 });
|
||||
}
|
||||
if (photographicAssetKinds.has(kind) && assetFormat(asset) && !rasterFormats.has(assetFormat(asset)) && assetFormat(asset) !== "svg") {
|
||||
addDeckError(deckErrors, errors, "asset.photographic_unsupported_format", `${label} photographic asset has unsupported raster format: ${assetFormat(asset)}`, { asset_index: index + 1, format: assetFormat(asset) });
|
||||
}
|
||||
}
|
||||
if (asset.status === "not_found" && !stringValue(asset.failure_reason)) {
|
||||
addDeckError(deckErrors, errors, "asset.not_found_missing_failure_reason", `${label} not_found asset missing failure_reason`, { asset_index: index + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const requiredEntities = Array.isArray(receipt.required_search_entities)
|
||||
? receipt.required_search_entities.map(stringValue).filter(Boolean)
|
||||
: [];
|
||||
const missingSearch = requiredEntities.filter((entity) => !assets.some((asset) => (
|
||||
asset.source_type === "web_search"
|
||||
&& stringValue(asset.query)
|
||||
&& assetMentionsEntity(asset, entity)
|
||||
)));
|
||||
if (missingSearch.length > 0) {
|
||||
addDeckError(deckErrors, errors, "asset.missing_web_search_evidence", `missing web search evidence for required entities: ${missingSearch.join(", ")}`, { entities: missingSearch });
|
||||
}
|
||||
|
||||
const allowedSubjectStatuses = new Set(["ready", "not_found", "unavailable", "user_opted_out"]);
|
||||
if (!userOptedOut && requiredVisualSubjects.length === 0) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subjects_missing", "required_visual_subjects is required unless the user explicitly opted out of images");
|
||||
}
|
||||
if (userOptedOut && requiredVisualSubjects.some((subject) => normalize(subject.search_status) !== "user_opted_out")) {
|
||||
addDeckError(deckErrors, errors, "asset.user_opt_out_subject_status", "user_opted_out receipts may only list visual subjects with search_status=user_opted_out");
|
||||
}
|
||||
for (const [index, subject] of requiredVisualSubjects.entries()) {
|
||||
const label = `visual subject #${index + 1}`;
|
||||
const searchStatus = normalize(subject.search_status);
|
||||
if (!stringValue(subject.slide)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_missing_slide", `${label} missing slide`, { subject_index: index + 1 });
|
||||
}
|
||||
if (!stringValue(subject.subject)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_missing_subject", `${label} missing subject`, { subject_index: index + 1 });
|
||||
}
|
||||
if (!searchStatus) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_missing_status", `${label} missing search_status`, { subject_index: index + 1 });
|
||||
} else if (!allowedSubjectStatuses.has(searchStatus)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_invalid_status", `${label} invalid search_status: ${subject.search_status}`, { subject_index: index + 1, search_status: subject.search_status });
|
||||
}
|
||||
if (!userOptedOut && subject.search_required !== true) {
|
||||
addDeckError(deckErrors, errors, "asset.search_required_by_default", `${label} must set search_required=true under the default image search policy`, { subject_index: index + 1 });
|
||||
}
|
||||
if (!userOptedOut && !stringValue(subject.search_query)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_missing_query", `${label} missing search_query`, { subject_index: index + 1 });
|
||||
}
|
||||
const hasWebSearchEvidence = assets.some((asset) => (
|
||||
asset.source_type === "web_search"
|
||||
&& stringValue(asset.query)
|
||||
&& ["ready", "not_found"].includes(asset.status)
|
||||
&& assetMatchesVisualSubject(asset, subject)
|
||||
));
|
||||
if (searchStatus === "ready" || searchStatus === "not_found") {
|
||||
if (!hasWebSearchEvidence) {
|
||||
addDeckError(deckErrors, errors, "asset.missing_visual_subject_search_evidence", `${label} missing web search evidence for subject: ${subject.subject}`, { subject_index: index + 1, subject: subject.subject });
|
||||
}
|
||||
}
|
||||
if (searchStatus === "unavailable" && imageSearchPolicy !== "fallback_no_network") {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_unavailable_requires_policy", `${label} search_status=unavailable requires image_search_policy=fallback_no_network`, { subject_index: index + 1 });
|
||||
}
|
||||
if (searchStatus === "not_found" && imageSearchPolicy === "fallback_no_network") {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_not_found_policy_mismatch", `${label} search_status=not_found should use image_search_policy=fallback_search_failed`, { subject_index: index + 1 });
|
||||
}
|
||||
if ((searchStatus === "not_found" || searchStatus === "unavailable") && !stringValue(subject.fallback_reason) && !stringValue(subject.failure_reason)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_subject_missing_fallback_reason", `${label} failed search subject missing fallback_reason or failure_reason`, { subject_index: index + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const slides = Array.isArray(receipt.slides) ? receipt.slides : [];
|
||||
const pageResults = pageResultsFromSlides(slides);
|
||||
const assignedVisualAssets = new Set();
|
||||
const allowedSlideRoles = new Set(["cover", "section-divider", "content", "key-showcase", "closing"]);
|
||||
const primaryVisualRoles = new Set(["cover", "section-divider", "closing", "key-showcase"]);
|
||||
if (slides.length === 0) {
|
||||
addDeckError(deckErrors, errors, "asset.no_slide_planning", "asset readiness must list slide-level visual planning");
|
||||
}
|
||||
for (const [index, slide] of slides.entries()) {
|
||||
const label = `slide #${index + 1}`;
|
||||
if (!stringValue(slide.file)) addDeckError(deckErrors, errors, "asset.slide_missing_file", `${label} missing file`, { slide_index: index + 1 });
|
||||
const slideRole = normalize(slide.slide_role);
|
||||
if (!slideRole) {
|
||||
addDeckError(deckErrors, errors, "asset.slide_missing_role", `${label} missing slide_role`, { slide_index: index + 1, file: slideKey(slide.file) });
|
||||
} else if (!allowedSlideRoles.has(slideRole)) {
|
||||
addDeckError(deckErrors, errors, "asset.slide_invalid_role", `${label} invalid slide_role: ${slide.slide_role}`, { slide_index: index + 1, file: slideKey(slide.file), slide_role: slide.slide_role });
|
||||
}
|
||||
if (!stringValue(slide.layout_family)) addDeckError(deckErrors, errors, "asset.slide_missing_layout_family", `${label} missing layout_family`, { slide_index: index + 1, file: slideKey(slide.file) });
|
||||
const slidePath = stringValue(slide.file) ? resolveDeckPath(root, slide.file) : "";
|
||||
const slideExists = Boolean(slidePath) && fs.existsSync(slidePath);
|
||||
const slideSource = slideExists ? fs.readFileSync(slidePath, "utf8") : "";
|
||||
if (slidePath && !slideExists) {
|
||||
addDeckError(deckErrors, errors, "asset.slide_file_missing", `${label} slide file does not exist: ${slide.file}`, { slide_index: index + 1, file: slideKey(slide.file) });
|
||||
}
|
||||
if (!Array.isArray(slide.visual_assets)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_assets_not_array", `${label} visual_assets must be an array`, { slide_index: index + 1, file: slideKey(slide.file) });
|
||||
continue;
|
||||
}
|
||||
for (const localPath of slide.visual_assets) {
|
||||
if (stringValue(localPath) && !pathExists(localPath)) {
|
||||
addDeckError(deckErrors, errors, "asset.visual_asset_path_missing", `${label} visual asset path does not exist: ${localPath}`, { slide_index: index + 1, file: slideKey(slide.file), local_path: localPath });
|
||||
} else if (stringValue(localPath) && slideExists && !slideContainsAsset(slideSource, root, localPath)) {
|
||||
addSlideError(pageResults, errors, slide.file, "asset.visual_asset_not_embedded", `${label} visual asset is not embedded in slide SVG: ${localPath}`, { local_path: localPath });
|
||||
}
|
||||
if (stringValue(localPath)) {
|
||||
assignedVisualAssets.add(path.resolve(localPath));
|
||||
}
|
||||
}
|
||||
const roleRequiresPrimaryVisual = !userOptedOut && primaryVisualRoles.has(slideRole);
|
||||
const requiresPrimaryVisual = roleRequiresPrimaryVisual || slide.primary_visual_required === true;
|
||||
const primaryVisual = stringValue(slide.primary_visual);
|
||||
if (roleRequiresPrimaryVisual && slide.primary_visual_required !== true) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_required_flag", `${label} ${slideRole} must set primary_visual_required=true`, { slide_index: index + 1, file: slideKey(slide.file), slide_role: slideRole });
|
||||
}
|
||||
if (requiresPrimaryVisual && !primaryVisual) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_visual_required", `${label} primary_visual is required for ${slideRole || "declared primary-visual slide"}`, { slide_index: index + 1, file: slideKey(slide.file), slide_role: slideRole });
|
||||
}
|
||||
if (primaryVisual) {
|
||||
const resolvedPrimary = path.resolve(primaryVisual);
|
||||
if (!pathExists(primaryVisual)) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_visual_path_missing", `${label} primary_visual path does not exist: ${primaryVisual}`, { slide_index: index + 1, file: slideKey(slide.file), primary_visual: primaryVisual });
|
||||
}
|
||||
if (Array.isArray(slide.visual_assets) && !slide.visual_assets.some((localPath) => path.resolve(stringValue(localPath)) === resolvedPrimary)) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_visual_listed", `${label} primary_visual must also appear in visual_assets: ${primaryVisual}`, { slide_index: index + 1, file: slideKey(slide.file), primary_visual: primaryVisual });
|
||||
}
|
||||
if (slideExists && !slideContainsAsset(slideSource, root, primaryVisual)) {
|
||||
addSlideError(pageResults, errors, slide.file, "asset.primary_visual_embedded", `${label} primary_visual is not embedded in slide SVG: ${primaryVisual}`, { primary_visual: primaryVisual });
|
||||
}
|
||||
const primaryAsset = assetsByResolvedPath.get(resolvedPrimary);
|
||||
if (primaryAsset) {
|
||||
const purpose = normalize(primaryAsset.asset.purpose);
|
||||
const kind = normalize(primaryAsset.asset.asset_kind || primaryAsset.asset.kind);
|
||||
if (!purpose.includes("primary") && !purpose.includes("hero")) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_missing_purpose_alignment", `${primaryAsset.label} used as primary_visual but purpose is not primary/hero`, { file: slideKey(slide.file), primary_visual: primaryVisual });
|
||||
}
|
||||
if (primaryAsset.asset.source_type === "chart" || bannedPrimaryVisualKinds.has(kind)) {
|
||||
addDeckError(deckErrors, errors, "asset.primary_forbidden_kind", `${primaryAsset.label} used as primary_visual cannot be a chart, icon, decoration, texture, pattern, or abstract geometry`, { file: slideKey(slide.file), primary_visual: primaryVisual, asset_kind: kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slide.text_overlay === true && slide.mask_required === true) {
|
||||
if (!stringValue(slide.mask_strategy)) {
|
||||
addDeckError(deckErrors, errors, "asset.overlay_missing_mask_strategy", `${label} text overlay requires mask_strategy`, { slide_index: index + 1, file: slideKey(slide.file) });
|
||||
}
|
||||
if (slideExists && !slideHasReadableImageMask(slideSource)) {
|
||||
addSlideError(pageResults, errors, slide.file, "asset.overlay_missing_mask_shape", `${label} text overlay requires a readable image mask or text scrim in the SVG`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, asset] of assets.entries()) {
|
||||
if (asset.status !== "ready" || !stringValue(asset.local_path)) continue;
|
||||
if (!assignedVisualAssets.has(path.resolve(asset.local_path))) {
|
||||
addDeckError(deckErrors, errors, "asset.ready_unassigned", `asset #${index + 1} ready asset is not assigned to any slide: ${asset.local_path}`, { asset_index: index + 1, local_path: asset.local_path });
|
||||
}
|
||||
}
|
||||
|
||||
const results = [...pageResults.values()].filter((result) => result.errors.length > 0 || result.warnings.length > 0);
|
||||
const report = {
|
||||
version: "svg-slides.asset-readiness-report.v1",
|
||||
ok: errors.length === 0,
|
||||
receipt: receiptPath,
|
||||
assetCount: assets.length,
|
||||
slideCount: slides.length,
|
||||
imageSearchPolicy,
|
||||
requiredSearchEntities: requiredEntities,
|
||||
requiredVisualSubjects: requiredVisualSubjects.map((subject) => stringValue(subject.subject)).filter(Boolean),
|
||||
deck_errors: deckErrors,
|
||||
results,
|
||||
totalErrors: errors.length,
|
||||
errors
|
||||
};
|
||||
|
||||
if (errors.length > 0) {
|
||||
if (json) console.error(JSON.stringify(report, null, 2));
|
||||
else for (const error of errors) console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
578
skills/lark-slides/scripts/svg_slides_asset_readiness_test.mjs
Normal file
578
skills/lark-slides/scripts/svg_slides_asset_readiness_test.mjs
Normal file
@@ -0,0 +1,578 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_asset_readiness.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-assets-"));
|
||||
fs.mkdirSync(path.join(root, "slides"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "resources", "images"), { recursive: true });
|
||||
const imagePath = path.join(root, "resources", "images", "hero.png");
|
||||
fs.writeFileSync(imagePath, "fake image bytes");
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<image slide:role="image" slide:shape-type="image" href="${imagePath}" x="80" y="80" width="320" height="180"/>
|
||||
</svg>`);
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeReceipt(root, receipt) {
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function validReceipt(root, overrides = {}) {
|
||||
const imagePath = path.join(root, "resources", "images", "hero.png");
|
||||
return {
|
||||
version: "svg-slides.asset-readiness.v1",
|
||||
image_search_policy: "required_by_default",
|
||||
visual_identity_fingerprint: {
|
||||
style_family: "technical-paper-terminal",
|
||||
composition_signature: "asymmetric-code-panels",
|
||||
imagery_treatment: "searched paper screenshot plus generated diagrams",
|
||||
palette_signature: "navy-cyan-orange"
|
||||
},
|
||||
required_search_entities: [],
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "technical hero visual",
|
||||
search_required: true,
|
||||
search_query: "technical presentation hero visual",
|
||||
search_status: "ready"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the abstract technical concept discussed on the cover",
|
||||
format: "png",
|
||||
source_type: "web_search",
|
||||
status: "ready",
|
||||
query: "technical presentation hero visual",
|
||||
source_url: "https://example.com/technical-hero",
|
||||
local_path: imagePath,
|
||||
assignment_reason: "default image policy searches before authoring"
|
||||
}
|
||||
],
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "cover",
|
||||
visual_tier: "hero",
|
||||
primary_visual_required: true,
|
||||
primary_visual: imagePath,
|
||||
layout_family: "hero-image-left-code-right",
|
||||
text_overlay: false,
|
||||
mask_required: false,
|
||||
visual_assets: [imagePath]
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function run(root) {
|
||||
return spawnSync("node", [script, root], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("asset readiness accepts a web-search image deck with visual identity", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
assert.equal(report.assetCount, 1);
|
||||
});
|
||||
|
||||
test("asset readiness rejects generated images without prior search evidence under default policy", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "abstract technical hero",
|
||||
search_required: true,
|
||||
search_query: "abstract technical hero visual",
|
||||
search_status: "ready"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the abstract technical concept discussed on the cover",
|
||||
format: "png",
|
||||
source_type: "generated",
|
||||
status: "ready",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
assignment_reason: "generated without search should not satisfy default image policy"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /generated asset requires web search evidence or fallback policy/);
|
||||
});
|
||||
|
||||
test("asset readiness accepts generated fallback when network search is unavailable and reason is recorded", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
image_search_policy: "fallback_no_network",
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "abstract technical hero",
|
||||
search_required: true,
|
||||
search_query: "abstract technical hero visual",
|
||||
search_status: "unavailable",
|
||||
fallback_reason: "network image search unavailable in this environment"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the abstract technical concept discussed on the cover",
|
||||
format: "png",
|
||||
source_type: "generated",
|
||||
status: "ready",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
fallback_reason: "network image search unavailable in this environment",
|
||||
assignment_reason: "fallback image after search lane was unavailable"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
});
|
||||
|
||||
test("asset readiness rejects generated fallback without a fallback reason", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
image_search_policy: "fallback_no_network",
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "abstract technical hero",
|
||||
search_required: true,
|
||||
search_query: "abstract technical hero visual",
|
||||
search_status: "unavailable"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the abstract technical concept discussed on the cover",
|
||||
format: "png",
|
||||
source_type: "generated",
|
||||
status: "ready",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
assignment_reason: "fallback image after search lane was unavailable"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /generated fallback asset missing fallback_reason/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects search-failed fallback when search evidence did not fail", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
image_search_policy: "fallback_search_failed",
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "abstract technical hero",
|
||||
search_required: true,
|
||||
search_query: "abstract technical hero visual",
|
||||
search_status: "ready"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "supporting search evidence",
|
||||
asset_kind: "illustration",
|
||||
format: "png",
|
||||
source_type: "web_search",
|
||||
status: "ready",
|
||||
query: "abstract technical hero visual",
|
||||
source_url: "https://example.com/technical-hero",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
assignment_reason: "search succeeded, so generated fallback should not be used"
|
||||
},
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the abstract technical concept discussed on the cover",
|
||||
format: "png",
|
||||
source_type: "generated",
|
||||
status: "ready",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
fallback_reason: "claimed search failure despite ready search evidence",
|
||||
assignment_reason: "invalid fallback"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /generated fallback asset requires failed or unavailable search evidence/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects unrelated same-slide web search evidence", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "Raymond Loewy portrait",
|
||||
search_required: true,
|
||||
search_query: "Raymond Loewy portrait",
|
||||
search_status: "ready"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes a different industrial design concept",
|
||||
format: "png",
|
||||
source_type: "web_search",
|
||||
status: "ready",
|
||||
query: "streamlined train industrial design",
|
||||
source_url: "https://example.com/train",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
assignment_reason: "same slide but wrong subject"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /missing web search evidence for subject: Raymond Loewy portrait/);
|
||||
});
|
||||
|
||||
test("asset readiness accepts explicit user opt-out without visual assets", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
</svg>`);
|
||||
writeReceipt(root, {
|
||||
version: "svg-slides.asset-readiness.v1",
|
||||
image_search_policy: "user_opted_out",
|
||||
image_search_opt_out_reason: "user explicitly requested no images",
|
||||
visual_identity_fingerprint: {
|
||||
style_family: "technical-paper-terminal",
|
||||
composition_signature: "asymmetric-code-panels",
|
||||
imagery_treatment: "no image assets by explicit user request",
|
||||
palette_signature: "navy-cyan-orange"
|
||||
},
|
||||
required_search_entities: [],
|
||||
required_visual_subjects: [],
|
||||
assets: [],
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "cover",
|
||||
visual_tier: "none",
|
||||
primary_visual_required: false,
|
||||
layout_family: "typographic-cover",
|
||||
text_overlay: false,
|
||||
mask_required: false,
|
||||
visual_assets: []
|
||||
}
|
||||
]
|
||||
});
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
});
|
||||
|
||||
test("asset readiness rejects missing receipt", () => {
|
||||
const root = tempDeck();
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing asset readiness receipt/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects named entities without web search evidence", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
required_search_entities: ["CHAGEE", "MIXUE"],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the named brand research topic",
|
||||
format: "png",
|
||||
source_type: "generated",
|
||||
status: "ready",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
assignment_reason: "fallback without search should not satisfy named entity evidence"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /missing web search evidence for required entities: CHAGEE, MIXUE/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects missing local image files", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "hero visual",
|
||||
source_type: "web_search",
|
||||
status: "ready",
|
||||
query: "DeepSeek V4 paper",
|
||||
source_url: "https://example.com/image",
|
||||
local_path: path.join(root, "resources", "images", "missing.png"),
|
||||
assignment_reason: "named source"
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /asset local_path does not exist/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects visual assets that are not embedded in the target slide SVG", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
</svg>`);
|
||||
writeReceipt(root, validReceipt(root));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /visual asset is not embedded in slide SVG/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects ready assets that are not assigned to any slide", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "cover",
|
||||
visual_tier: "hero",
|
||||
primary_visual_required: true,
|
||||
primary_visual: path.join(root, "resources", "images", "hero.png"),
|
||||
layout_family: "hero-image-left-code-right",
|
||||
visual_assets: []
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /ready asset is not assigned to any slide/);
|
||||
});
|
||||
|
||||
for (const slideRole of ["cover", "section-divider", "closing", "key-showcase"]) {
|
||||
test(`asset readiness rejects ${slideRole} without primary visual`, () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: slideRole,
|
||||
visual_tier: "hero",
|
||||
primary_visual_required: true,
|
||||
layout_family: "hero-image-left-code-right",
|
||||
visual_assets: [path.join(root, "resources", "images", "hero.png")]
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /primary_visual is required/);
|
||||
});
|
||||
}
|
||||
|
||||
test("asset readiness rejects slides without a slide role", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
layout_family: "hero-image-left-code-right",
|
||||
visual_assets: [path.join(root, "resources", "images", "hero.png")]
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /missing slide_role/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects primary visuals that are not embedded in the target slide SVG", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
</svg>`);
|
||||
writeReceipt(root, validReceipt(root));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /primary_visual is not embedded in slide SVG/);
|
||||
});
|
||||
|
||||
test("asset readiness rejects hero text overlays without a mask or scrim", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "cover",
|
||||
visual_tier: "hero",
|
||||
primary_visual_required: true,
|
||||
primary_visual: path.join(root, "resources", "images", "hero.png"),
|
||||
layout_family: "full-bleed-hero",
|
||||
text_overlay: true,
|
||||
mask_required: true,
|
||||
mask_strategy: "dark-gradient-left",
|
||||
visual_assets: [path.join(root, "resources", "images", "hero.png")]
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /text overlay requires a readable image mask/);
|
||||
});
|
||||
|
||||
test("asset readiness accepts hero text overlays with a mask or scrim", () => {
|
||||
const root = tempDeck();
|
||||
const imagePath = path.join(root, "resources", "images", "hero.png");
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<image slide:role="image" slide:shape-type="image" href="${imagePath}" x="0" y="0" width="960" height="540"/>
|
||||
<rect slide:role="shape" slide:shape-type="rect" data-svglide-layer="mask" x="0" y="0" width="520" height="540" fill="rgba(0,0,0,0.45)"/>
|
||||
</svg>`);
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "cover",
|
||||
visual_tier: "hero",
|
||||
primary_visual_required: true,
|
||||
primary_visual: imagePath,
|
||||
layout_family: "full-bleed-hero-with-mask",
|
||||
text_overlay: true,
|
||||
mask_required: true,
|
||||
mask_strategy: "parser-legible-mask-left",
|
||||
visual_assets: [imagePath]
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
});
|
||||
|
||||
test("asset readiness rejects photographic assets stored as SVG without an exception reason", () => {
|
||||
const root = tempDeck();
|
||||
const svgPath = path.join(root, "resources", "images", "storefront.svg");
|
||||
fs.writeFileSync(svgPath, "<svg></svg>");
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<image slide:role="image" slide:shape-type="image" href="${svgPath}" x="60" y="80" width="240" height="160"/>
|
||||
</svg>`);
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "small storefront photo",
|
||||
asset_kind: "storefront",
|
||||
expressive_role: "grounds the company analysis in a real retail scene",
|
||||
content_relevance: "shows the store format discussed on the slide",
|
||||
format: "svg",
|
||||
source_type: "web_search",
|
||||
status: "ready",
|
||||
query: "tea company storefront photo",
|
||||
source_url: "https://example.com/storefront",
|
||||
local_path: svgPath,
|
||||
assignment_reason: "storefront photo for operating model discussion"
|
||||
}
|
||||
],
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "content",
|
||||
visual_tier: "supporting",
|
||||
primary_visual_required: false,
|
||||
layout_family: "image-stat-split",
|
||||
visual_assets: [svgPath]
|
||||
}
|
||||
]
|
||||
}));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /photographic asset should use png, jpg, jpeg, or webp/);
|
||||
});
|
||||
|
||||
test("asset readiness reports missing embedded primary visual as a page-level repairable error", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
</svg>`);
|
||||
writeReceipt(root, validReceipt(root));
|
||||
|
||||
const result = spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stderr);
|
||||
assert.equal(report.ok, false);
|
||||
assert.ok(report.errors.some((error) => error.includes("primary_visual is not embedded")));
|
||||
assert.deepEqual(report.deck_errors, []);
|
||||
assert.equal(report.results.length, 1);
|
||||
assert.equal(report.results[0].file, "slides/slide_01.svg");
|
||||
assert.ok(report.results[0].errors.some((error) => error.rule === "asset.primary_visual_embedded"));
|
||||
assert.equal(report.results[0].errors[0].repairable, true);
|
||||
assert.equal(report.results[0].warnings.length, 0);
|
||||
});
|
||||
|
||||
test("asset readiness keeps missing web search evidence as deck-level only", () => {
|
||||
const root = tempDeck();
|
||||
writeReceipt(root, validReceipt(root, {
|
||||
required_search_entities: ["CHAGEE"],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "sets the deck's central technical mood",
|
||||
content_relevance: "visualizes the named brand research topic",
|
||||
format: "png",
|
||||
source_type: "generated",
|
||||
status: "ready",
|
||||
local_path: path.join(root, "resources", "images", "hero.png"),
|
||||
assignment_reason: "fallback without search should not satisfy named entity evidence"
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
const result = spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stderr);
|
||||
assert.ok(report.deck_errors.some((error) => error.rule === "asset.missing_web_search_evidence"));
|
||||
assert.deepEqual(report.results, []);
|
||||
});
|
||||
84
skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs
Executable file
84
skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const targetArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const outIndex = args.indexOf("--out");
|
||||
const outPath = outIndex >= 0 ? args[outIndex + 1] : "";
|
||||
|
||||
if (!targetArg) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> [--out <json-path>]");
|
||||
}
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import("playwright"));
|
||||
} catch {
|
||||
fail("playwright is not installed; install it in a dev environment before browser text-boundary QA", 2);
|
||||
}
|
||||
|
||||
const target = path.resolve(targetArg);
|
||||
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
fail(`Slides directory not found: ${slidesDir}`);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort();
|
||||
if (!slideFiles.length) {
|
||||
fail(`No .svg files found in ${slidesDir}`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 }, deviceScaleFactor: 1 });
|
||||
const results = [];
|
||||
|
||||
for (const file of slideFiles) {
|
||||
const abs = path.join(slidesDir, file);
|
||||
const svg = fs.readFileSync(abs, "utf8");
|
||||
await page.setContent(`<!doctype html><html><body style="margin:0">${svg}</body></html>`, { waitUntil: "load" });
|
||||
const problems = await page.evaluate(() => {
|
||||
return [...document.querySelectorAll("foreignObject")].flatMap((node, index) => {
|
||||
if (node.getAttribute("slide:role") !== "shape" || node.getAttribute("slide:shape-type") !== "text") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const box = node.getBoundingClientRect();
|
||||
const children = [...node.children];
|
||||
if (!children.length) {
|
||||
return [{ index: index + 1, reason: "empty_text_object" }];
|
||||
}
|
||||
|
||||
return children.map((child) => {
|
||||
const childBox = child.getBoundingClientRect();
|
||||
const overflowX = childBox.left < box.left - 0.5 || childBox.right > box.right + 0.5;
|
||||
const overflowY = childBox.top < box.top - 0.5 || childBox.bottom > box.bottom + 0.5;
|
||||
if (!overflowX && !overflowY) return null;
|
||||
return {
|
||||
index: index + 1,
|
||||
reason: "text_bounds_overflow",
|
||||
box: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
childBox: { x: childBox.x, y: childBox.y, width: childBox.width, height: childBox.height }
|
||||
};
|
||||
}).filter(Boolean);
|
||||
});
|
||||
});
|
||||
results.push({ file: path.relative(process.cwd(), abs), problemCount: problems.length, problems });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const problemCount = results.reduce((sum, item) => sum + item.problemCount, 0);
|
||||
const report = { status: problemCount === 0 ? "passed" : "failed", problemCount, results };
|
||||
const json = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (outPath) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
||||
fs.writeFileSync(outPath, json);
|
||||
}
|
||||
process.stdout.write(json);
|
||||
process.exit(problemCount === 0 ? 0 : 1);
|
||||
201
skills/lark-slides/scripts/svg_slides_bundle.mjs
Executable file
201
skills/lark-slides/scripts/svg_slides_bundle.mjs
Executable file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function argValue(argv, name, fallback = "") {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function deckUsesCharts(slideFiles) {
|
||||
return slideFiles.some((file) => fs.readFileSync(file, "utf8").includes('slide:role="chart"'));
|
||||
}
|
||||
|
||||
function promptTraceHasChartEvidence(promptTracePath) {
|
||||
const receipt = JSON.parse(fs.readFileSync(promptTracePath, "utf8"));
|
||||
const paths = new Set((receipt.read_order || []).map((entry) => entry.path));
|
||||
const hasBaseChartReads = [
|
||||
"generate-svg-chart/README.md",
|
||||
"generate-svg-chart/input-schema.md",
|
||||
"generate-svg-chart/chart-agent-contract.md",
|
||||
"generate-svg-chart/design-envelope.md",
|
||||
"generate-svg-chart/snippets/README.md"
|
||||
].every((relPath) => paths.has(relPath));
|
||||
const hasFallback = Array.isArray(receipt.fallbacks)
|
||||
&& receipt.fallbacks.includes("generate-svg-chart/full.md")
|
||||
&& paths.has("generate-svg-chart/full.md");
|
||||
const hasSnippet = [...paths].some((relPath) => relPath.startsWith("generate-svg-chart/snippets/") && relPath !== "generate-svg-chart/snippets/README.md");
|
||||
return hasBaseChartReads && (hasFallback || hasSnippet);
|
||||
}
|
||||
|
||||
function writeValidatorReport(root, fileName, result) {
|
||||
const report = result.stdout.trim() || result.stderr.trim();
|
||||
if (report.startsWith("{")) {
|
||||
fs.writeFileSync(path.join(root, "receipts", fileName), `${report}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const title = argValue(args, "--title");
|
||||
const references = path.resolve(argValue(args, "--references", "skills/lark-slides/references/svg-slides"));
|
||||
|
||||
if (!deckArg || !title) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title <title> [--references <path>]");
|
||||
}
|
||||
|
||||
const root = path.resolve(deckArg);
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
fail(`Slides directory not found: ${slidesDir}`);
|
||||
}
|
||||
|
||||
const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
|
||||
const validate = spawnSync("node", [validator, root, "--json"], { encoding: "utf8" });
|
||||
if (!validate.stdout.trim()) {
|
||||
process.stderr.write(validate.stderr);
|
||||
process.exit(validate.status || 1);
|
||||
}
|
||||
|
||||
const receipt = JSON.parse(validate.stdout);
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const parserContractReceipt = {
|
||||
version: "svg-slides.parser-contract.v1",
|
||||
ok: receipt.totalErrors === 0,
|
||||
source: "validate_svg_deck.mjs",
|
||||
totalErrors: receipt.totalErrors,
|
||||
};
|
||||
fs.writeFileSync(path.join(root, "receipts", "parser_contract.json"), `${JSON.stringify(parserContractReceipt, null, 2)}\n`);
|
||||
if (receipt.totalErrors !== 0) {
|
||||
fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1);
|
||||
}
|
||||
|
||||
const readabilityValidator = path.resolve("skills/lark-slides/scripts/svg_slides_readability_safety.mjs");
|
||||
const validateReadability = spawnSync("node", [readabilityValidator, root, "--json"], { encoding: "utf8" });
|
||||
if (validateReadability.stdout.trim()) {
|
||||
fs.writeFileSync(path.join(root, "receipts", "readability_safety.json"), `${validateReadability.stdout.trim()}\n`);
|
||||
}
|
||||
if (validateReadability.status !== 0) {
|
||||
process.stderr.write(validateReadability.stderr || validateReadability.stdout);
|
||||
process.exit(validateReadability.status || 1);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort()
|
||||
.map((file) => path.join(slidesDir, file));
|
||||
|
||||
const promptTracePath = path.join(root, "receipts", "prompt_read_trace.json");
|
||||
if (!fs.existsSync(promptTracePath)) {
|
||||
fail(`Missing prompt read trace receipt: ${promptTracePath}`, 1);
|
||||
}
|
||||
const traceValidator = path.resolve("skills/lark-slides/scripts/svg_slides_prompt_trace.mjs");
|
||||
const validateTrace = spawnSync(
|
||||
"node",
|
||||
[traceValidator, root, "--validate", "--task", "generate_deck", "--references", references],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
if (validateTrace.status !== 0) {
|
||||
process.stderr.write(validateTrace.stderr || validateTrace.stdout);
|
||||
process.exit(validateTrace.status || 1);
|
||||
}
|
||||
if (deckUsesCharts(slideFiles) && !promptTraceHasChartEvidence(promptTracePath)) {
|
||||
fail("Prompt read trace is missing generate_svg_chart reads required by slide:role=\"chart\"", 1);
|
||||
}
|
||||
|
||||
const assetReadinessPath = path.join(root, "receipts", "asset_readiness.json");
|
||||
if (!fs.existsSync(assetReadinessPath)) {
|
||||
fail(`Missing asset readiness receipt: ${assetReadinessPath}`, 1);
|
||||
}
|
||||
const assetValidator = path.resolve("skills/lark-slides/scripts/svg_slides_asset_readiness.mjs");
|
||||
const validateAssets = spawnSync("node", [assetValidator, root, "--json"], { encoding: "utf8" });
|
||||
writeValidatorReport(root, "asset_readiness_report.json", validateAssets);
|
||||
if (validateAssets.status !== 0) {
|
||||
process.stderr.write(validateAssets.stderr || validateAssets.stdout);
|
||||
process.exit(validateAssets.status || 1);
|
||||
}
|
||||
|
||||
const typographyPlanPath = path.join(root, "receipts", "typography_plan.json");
|
||||
if (!fs.existsSync(typographyPlanPath)) {
|
||||
fail(`Missing typography plan receipt: ${typographyPlanPath}`, 1);
|
||||
}
|
||||
const typographyValidator = path.resolve("skills/lark-slides/scripts/svg_slides_typography_plan.mjs");
|
||||
const validateTypography = spawnSync("node", [typographyValidator, root, "--json"], { encoding: "utf8" });
|
||||
writeValidatorReport(root, "typography_plan_report.json", validateTypography);
|
||||
if (validateTypography.status !== 0) {
|
||||
process.stderr.write(validateTypography.stderr || validateTypography.stdout);
|
||||
process.exit(validateTypography.status || 1);
|
||||
}
|
||||
|
||||
const visualSystemPath = path.join(root, "receipts", "visual_system.json");
|
||||
if (!fs.existsSync(visualSystemPath)) {
|
||||
fail(`Missing visual system receipt: ${visualSystemPath}`, 1);
|
||||
}
|
||||
const visualSystemValidator = path.resolve("skills/lark-slides/scripts/svg_slides_visual_system.mjs");
|
||||
const validateVisualSystem = spawnSync("node", [visualSystemValidator, root, "--json"], { encoding: "utf8" });
|
||||
writeValidatorReport(root, "visual_system_report.json", validateVisualSystem);
|
||||
if (validateVisualSystem.status !== 0) {
|
||||
process.stderr.write(validateVisualSystem.stderr || validateVisualSystem.stdout);
|
||||
process.exit(validateVisualSystem.status || 1);
|
||||
}
|
||||
|
||||
const colorRhythmValidator = path.resolve("skills/lark-slides/scripts/svg_slides_color_rhythm.mjs");
|
||||
const validateColorRhythm = spawnSync("node", [colorRhythmValidator, root, "--json"], { encoding: "utf8" });
|
||||
writeValidatorReport(root, "color_rhythm.json", validateColorRhythm);
|
||||
if (validateColorRhythm.status !== 0) {
|
||||
process.stderr.write(validateColorRhythm.stderr || validateColorRhythm.stdout);
|
||||
process.exit(validateColorRhythm.status || 1);
|
||||
}
|
||||
|
||||
const frameDisciplineValidator = path.resolve("skills/lark-slides/scripts/svg_slides_frame_discipline.mjs");
|
||||
const validateFrames = spawnSync("node", [frameDisciplineValidator, root, "--json"], { encoding: "utf8" });
|
||||
if (validateFrames.status !== 0) {
|
||||
process.stderr.write(validateFrames.stderr || validateFrames.stdout);
|
||||
process.exit(validateFrames.status || 1);
|
||||
}
|
||||
fs.writeFileSync(path.join(root, "receipts", "frame_discipline.json"), `${validateFrames.stdout.trim()}\n`);
|
||||
|
||||
const pages = slideFiles.map((file, index) => {
|
||||
const abs = file;
|
||||
const raw = fs.readFileSync(abs);
|
||||
return {
|
||||
id: path.basename(file, ".svg"),
|
||||
index: index + 1,
|
||||
file: path.relative(root, abs).split(path.sep).join("/"),
|
||||
sha256: crypto.createHash("sha256").update(raw).digest("hex")
|
||||
};
|
||||
});
|
||||
|
||||
const manifest = {
|
||||
version: "svglide.manifest.v1",
|
||||
protocol: "svg-slides.v1",
|
||||
title,
|
||||
size: { width: 960, height: 540 },
|
||||
publish_ready: true,
|
||||
published: false,
|
||||
pages,
|
||||
receipts: {
|
||||
validate_svg_deck: "receipts/validate_svg_deck.json",
|
||||
prompt_read_trace: "receipts/prompt_read_trace.json",
|
||||
asset_readiness: "receipts/asset_readiness.json",
|
||||
typography_plan: "receipts/typography_plan.json",
|
||||
typography_plan_report: "receipts/typography_plan_report.json",
|
||||
visual_system: "receipts/visual_system.json",
|
||||
visual_system_report: "receipts/visual_system_report.json",
|
||||
color_rhythm: "receipts/color_rhythm.json",
|
||||
frame_discipline: "receipts/frame_discipline.json",
|
||||
parser_contract: "receipts/parser_contract.json",
|
||||
readability_safety: "receipts/readability_safety.json"
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ ok: true, manifest: path.join(root, "manifest.json"), pages: pages.length }, null, 2));
|
||||
459
skills/lark-slides/scripts/svg_slides_bundle_test.mjs
Normal file
459
skills/lark-slides/scripts/svg_slides_bundle_test.mjs
Normal file
@@ -0,0 +1,459 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_bundle.mjs");
|
||||
const traceScript = path.resolve("skills/lark-slides/scripts/svg_slides_prompt_trace.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-bundle-"));
|
||||
fs.mkdirSync(path.join(root, "slides"));
|
||||
return root;
|
||||
}
|
||||
|
||||
function tempRoot(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="bundle_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:Noto Sans SC;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Bundle</p>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
const chartSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="120" width="640" height="360"/>
|
||||
</svg>`;
|
||||
|
||||
function writeDoc(root, relPath, role, body = "content") {
|
||||
const filePath = path.join(root, relPath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `# ${path.basename(relPath, ".md")}\n\n## Context\n\n- Role: ${role}\n\n## Content\n\n${body}\n`);
|
||||
}
|
||||
|
||||
function writeDeckReferenceSet(root) {
|
||||
writeDoc(root, "README.md", "reference");
|
||||
writeDoc(root, "system-prompt.md", "prompt");
|
||||
writeDoc(root, "svg-reference.md", "reference");
|
||||
writeDoc(root, "deck-design-reference-catalog.md", "reference");
|
||||
writeDoc(root, "resolve-design-brief/README.md", "reference");
|
||||
writeDoc(root, "resolve-design-brief/tool-contract.md", "tool_contract");
|
||||
writeDoc(root, "resolve-design-brief/style-director-prompt.md", "prompt");
|
||||
writeDoc(root, "resolve-design-brief/full.md", "prompt_bundle");
|
||||
writeDoc(root, "tools/slide-outline.md", "tool_contract");
|
||||
writeDoc(root, "tools/activate-slides-edit.md", "tool_contract");
|
||||
writeDoc(root, "tools/slides-edit.md", "tool_contract");
|
||||
writeDoc(root, "tools/finish-slides-edit.md", "tool_contract");
|
||||
writeDoc(root, "validation/asset-readiness.md", "schema");
|
||||
writeDoc(root, "validation/typography-plan.md", "schema");
|
||||
writeDoc(root, "validation/visual-system.md", "schema");
|
||||
writeDoc(root, "validation/frame-discipline.md", "schema");
|
||||
writeDoc(root, "validation/post-delivery-audit.md", "schema");
|
||||
writeDoc(root, "validation/prompt-read-trace.md", "schema");
|
||||
}
|
||||
|
||||
function writeChartReferenceSet(root) {
|
||||
writeDoc(root, "generate-svg-chart/README.md", "reference");
|
||||
writeDoc(root, "generate-svg-chart/input-schema.md", "schema");
|
||||
writeDoc(root, "generate-svg-chart/chart-agent-contract.md", "prompt");
|
||||
writeDoc(root, "generate-svg-chart/design-envelope.md", "reference");
|
||||
writeDoc(root, "generate-svg-chart/full.md", "prompt_bundle");
|
||||
writeDoc(root, "generate-svg-chart/snippets/README.md", "reference");
|
||||
}
|
||||
|
||||
function createPromptTrace(root, refs, extraArgs = []) {
|
||||
const result = spawnSync("node", [traceScript, root, "--task", "generate_deck", "--references", refs, ...extraArgs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
}
|
||||
|
||||
function createChartPromptTrace(root, refs) {
|
||||
const result = spawnSync("node", [traceScript, root, "--task", "generate_chart", "--references", refs, "--use-full-fallback"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
}
|
||||
|
||||
function writeAssetReadiness(root) {
|
||||
const imagePath = path.join(root, "resources", "images", "hero.png");
|
||||
fs.mkdirSync(path.dirname(imagePath), { recursive: true });
|
||||
fs.writeFileSync(imagePath, "fake image bytes");
|
||||
const slidePath = path.join(root, "slides", "slide_01.svg");
|
||||
const slideSource = fs.readFileSync(slidePath, "utf8");
|
||||
if (!slideSource.includes(imagePath)) {
|
||||
fs.writeFileSync(slidePath, slideSource.replace(
|
||||
"</svg>",
|
||||
` <image slide:role="image" slide:shape-type="image" href="${imagePath}" x="720" y="420" width="80" height="45"/>\n</svg>`
|
||||
));
|
||||
}
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness.json"), `${JSON.stringify({
|
||||
version: "svg-slides.asset-readiness.v1",
|
||||
image_search_policy: "required_by_default",
|
||||
visual_identity_fingerprint: {
|
||||
style_family: "technical-paper-terminal",
|
||||
composition_signature: "asymmetric-code-panels",
|
||||
imagery_treatment: "searched technical hero image",
|
||||
palette_signature: "navy-cyan-orange"
|
||||
},
|
||||
required_search_entities: [],
|
||||
required_visual_subjects: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
subject: "technical hero visual",
|
||||
search_required: true,
|
||||
search_query: "technical presentation hero visual",
|
||||
search_status: "ready"
|
||||
}
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_01.svg",
|
||||
purpose: "primary hero visual",
|
||||
asset_kind: "illustration",
|
||||
expressive_role: "gives the test deck a concrete visual anchor",
|
||||
content_relevance: "supports the slide's main bundle validation example",
|
||||
format: "png",
|
||||
source_type: "web_search",
|
||||
status: "ready",
|
||||
query: "technical presentation hero visual",
|
||||
source_url: "https://example.com/technical-hero",
|
||||
local_path: imagePath,
|
||||
assignment_reason: "default image policy searches before authoring"
|
||||
}
|
||||
],
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "cover",
|
||||
visual_tier: "hero",
|
||||
primary_visual_required: true,
|
||||
primary_visual: imagePath,
|
||||
layout_family: "hero-image-left",
|
||||
text_overlay: false,
|
||||
mask_required: false,
|
||||
visual_assets: [imagePath]
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeTypographyPlan(root) {
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "typography_plan.json"), `${JSON.stringify({
|
||||
version: "svg-slides.typography-plan.v1",
|
||||
font_category: "clean sans display and body",
|
||||
display_font: "Noto Sans SC",
|
||||
body_font: "Noto Sans SC",
|
||||
label_font: "Noto Sans SC",
|
||||
source: "visual_system.typography",
|
||||
selection_reason: "Test deck uses a clean sans pairing that matches the slide SVG.",
|
||||
forbidden_substitutions: ["CSS fallback stacks", "browser generic fonts", "calligraphy body"],
|
||||
runtime_candidates_used: false,
|
||||
requested_fonts: [],
|
||||
resolved_fonts: ["Noto Sans SC"],
|
||||
fallbacks: [],
|
||||
fallback_reason: "",
|
||||
unavailable_fonts: [],
|
||||
user_requested_font_preserved: true
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeVisualSystem(root, slide = { role: "cover", theme: "light" }) {
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "visual_system.json"), `${JSON.stringify({
|
||||
version: "svg-slides.visual-system.v1",
|
||||
palette_tokens: {
|
||||
"bg.base": "rgba(255,255,255,1)",
|
||||
"bg.deep": "rgba(8,15,28,1)",
|
||||
"panel.light": "rgba(255,255,255,1)",
|
||||
"panel.dark": "rgba(15,23,42,1)",
|
||||
"text.primary": "rgba(15,23,42,1)",
|
||||
"accent.cyan": "rgba(0,184,216,1)"
|
||||
},
|
||||
color_rhythm: {
|
||||
full_dark_budget: 1,
|
||||
allowed_full_dark_roles: ["cover", "section-divider", "key-showcase"],
|
||||
default_content_theme: "light",
|
||||
closing_theme: "light",
|
||||
max_adjacent_luma_delta: 1,
|
||||
max_transition_ratio: 1,
|
||||
slide_themes: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
role: slide.role,
|
||||
theme: slide.theme,
|
||||
reason: "test deck keeps one stable visual system"
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readPromptTrace(root) {
|
||||
return JSON.parse(fs.readFileSync(path.join(root, "receipts", "prompt_read_trace.json"), "utf8"));
|
||||
}
|
||||
|
||||
test("bundle builder writes manifest and validation receipt", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Bundle Test", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.version, "svglide.manifest.v1");
|
||||
assert.equal(manifest.protocol, "svg-slides.v1");
|
||||
assert.equal(manifest.title, "Bundle Test");
|
||||
assert.deepEqual(manifest.size, { width: 960, height: 540 });
|
||||
assert.equal(manifest.publish_ready, true);
|
||||
assert.equal(manifest.published, false);
|
||||
assert.equal(manifest.receipts.prompt_read_trace, "receipts/prompt_read_trace.json");
|
||||
assert.equal(manifest.receipts.asset_readiness, "receipts/asset_readiness.json");
|
||||
assert.equal(manifest.receipts.typography_plan, "receipts/typography_plan.json");
|
||||
assert.equal(manifest.receipts.typography_plan_report, "receipts/typography_plan_report.json");
|
||||
assert.equal(manifest.receipts.visual_system, "receipts/visual_system.json");
|
||||
assert.equal(manifest.receipts.visual_system_report, "receipts/visual_system_report.json");
|
||||
assert.equal(manifest.receipts.color_rhythm, "receipts/color_rhythm.json");
|
||||
assert.equal(manifest.receipts.frame_discipline, "receipts/frame_discipline.json");
|
||||
assert.equal(manifest.receipts.parser_contract, "receipts/parser_contract.json");
|
||||
assert.equal(manifest.receipts.readability_safety, "receipts/readability_safety.json");
|
||||
assert.equal(manifest.pages.length, 1);
|
||||
assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/);
|
||||
const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8"));
|
||||
assert.equal(receipt.totalErrors, 0);
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "frame_discipline.json")), true);
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "readability_safety.json")), true);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(root, "receipts", "readability_safety.json"), "utf8")).totalErrors, 0);
|
||||
const parserContract = JSON.parse(fs.readFileSync(path.join(root, "receipts", "parser_contract.json"), "utf8"));
|
||||
assert.equal(parserContract.totalErrors, 0);
|
||||
assert.equal(parserContract.ok, true);
|
||||
const assetInputReceipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "asset_readiness.json"), "utf8"));
|
||||
assert.equal(assetInputReceipt.version, "svg-slides.asset-readiness.v1");
|
||||
const assetReport = JSON.parse(fs.readFileSync(path.join(root, "receipts", "asset_readiness_report.json"), "utf8"));
|
||||
assert.equal(assetReport.version, "svg-slides.asset-readiness-report.v1");
|
||||
assert.deepEqual(assetReport.deck_errors, []);
|
||||
assert.deepEqual(assetReport.results, []);
|
||||
const typographyInputReceipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "typography_plan.json"), "utf8"));
|
||||
assert.equal(typographyInputReceipt.version, "svg-slides.typography-plan.v1");
|
||||
const typographyReport = JSON.parse(fs.readFileSync(path.join(root, "receipts", "typography_plan_report.json"), "utf8"));
|
||||
assert.equal(typographyReport.version, "svg-slides.typography-plan-report.v1");
|
||||
assert.deepEqual(typographyReport.deck_errors, []);
|
||||
assert.deepEqual(typographyReport.results, []);
|
||||
const visualSystemInputReceipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "visual_system.json"), "utf8"));
|
||||
assert.equal(visualSystemInputReceipt.version, "svg-slides.visual-system.v1");
|
||||
const visualSystemReport = JSON.parse(fs.readFileSync(path.join(root, "receipts", "visual_system_report.json"), "utf8"));
|
||||
assert.equal(visualSystemReport.version, "svg-slides.visual-system-report.v1");
|
||||
assert.deepEqual(visualSystemReport.deck_errors, []);
|
||||
assert.deepEqual(visualSystemReport.results, []);
|
||||
const colorRhythm = JSON.parse(fs.readFileSync(path.join(root, "receipts", "color_rhythm.json"), "utf8"));
|
||||
assert.equal(colorRhythm.version, "svg-slides.color-rhythm.v1");
|
||||
assert.equal(colorRhythm.totalErrors, 0);
|
||||
});
|
||||
|
||||
test("bundle builder rejects missing asset readiness receipt", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createPromptTrace(root, refs);
|
||||
const result = spawnSync("node", [script, root, "--title", "Missing Assets", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing asset readiness receipt/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects missing typography plan receipt", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Missing Typography", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing typography plan receipt/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects missing visual system receipt", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Missing Visual System", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing visual system receipt/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects invalid SVG deck", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace("rgba(255,255,255,1)", "#fff"));
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Invalid", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /not publish-ready/);
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "validate_svg_deck.json")), true);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects readability safety failures", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace(
|
||||
"</svg>",
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" data-svglide-layer="overlay" x="90" y="82" width="400" height="90" style="font-size:24px;color:rgba(15,23,42,1)"><p xmlns="http://www.w3.org/1999/xhtml">Overlapping overlay</p></foreignObject></svg>`
|
||||
));
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Readability Fail", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr || result.stdout, /readability\.overlap/);
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "readability_safety.json")), true);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects unmarked stroked text frames", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace(
|
||||
"<foreignObject",
|
||||
'<rect slide:role="shape" slide:shape-type="rect" data-svglide-container="true" x="64" y="64" width="680" height="120" fill="rgba(255,255,255,1)" stroke="rgba(15,23,42,1)" stroke-width="1"/>\n <foreignObject'
|
||||
));
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Frame Fail", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /frame\.role-required/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects missing prompt read trace", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
const result = spawnSync("node", [script, root, "--title", "Missing Trace"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing prompt read trace receipt/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects forged prompt trace hashes", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const receipt = readPromptTrace(root);
|
||||
receipt.read_order[0].sha256 = "f".repeat(64);
|
||||
fs.writeFileSync(path.join(root, "receipts", "prompt_read_trace.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const result = spawnSync("node", [script, root, "--title", "Forged Trace", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /sha mismatch/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects prompt trace entries stripped to path and sha256 only", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const receipt = readPromptTrace(root);
|
||||
receipt.read_order = receipt.read_order.map((entry) => ({
|
||||
path: entry.path,
|
||||
sha256: entry.sha256
|
||||
}));
|
||||
fs.writeFileSync(path.join(root, "receipts", "prompt_read_trace.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const result = spawnSync("node", [script, root, "--title", "Stripped Trace", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /invalid read_order entry role/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects chart-only prompt trace for deck bundle", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeChartReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
createChartPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Chart Trace", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /task mismatch/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder rejects chart deck without chart prompt reads", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), chartSlide);
|
||||
createPromptTrace(root, refs);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Chart Without Reads", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /missing generate_svg_chart reads/);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
|
||||
test("bundle builder accepts chart deck with deck and chart prompt reads", () => {
|
||||
const root = tempDeck();
|
||||
const refs = tempRoot("svg-slides-bundle-refs-");
|
||||
writeDeckReferenceSet(refs);
|
||||
writeChartReferenceSet(refs);
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), chartSlide);
|
||||
createPromptTrace(root, refs, ["--use-full-fallback"]);
|
||||
writeAssetReadiness(root);
|
||||
writeTypographyPlan(root);
|
||||
writeVisualSystem(root);
|
||||
const result = spawnSync("node", [script, root, "--title", "Chart With Reads", "--references", refs], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.publish_ready, true);
|
||||
assert.equal(manifest.receipts.prompt_read_trace, "receipts/prompt_read_trace.json");
|
||||
assert.equal(manifest.receipts.asset_readiness, "receipts/asset_readiness.json");
|
||||
assert.equal(manifest.receipts.typography_plan, "receipts/typography_plan.json");
|
||||
assert.equal(manifest.receipts.typography_plan_report, "receipts/typography_plan_report.json");
|
||||
assert.equal(manifest.receipts.visual_system, "receipts/visual_system.json");
|
||||
assert.equal(manifest.receipts.visual_system_report, "receipts/visual_system_report.json");
|
||||
assert.equal(manifest.receipts.color_rhythm, "receipts/color_rhythm.json");
|
||||
assert.equal(manifest.receipts.frame_discipline, "receipts/frame_discipline.json");
|
||||
assert.equal(manifest.receipts.parser_contract, "receipts/parser_contract.json");
|
||||
assert.equal(manifest.receipts.readability_safety, "receipts/readability_safety.json");
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "readability_safety.json")), true);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(root, "receipts", "readability_safety.json"), "utf8")).totalErrors, 0);
|
||||
const parserContract = JSON.parse(fs.readFileSync(path.join(root, "receipts", "parser_contract.json"), "utf8"));
|
||||
assert.equal(parserContract.totalErrors, 0);
|
||||
assert.equal(parserContract.ok, true);
|
||||
});
|
||||
244
skills/lark-slides/scripts/svg_slides_color_rhythm.mjs
Normal file
244
skills/lark-slides/scripts/svg_slides_color_rhythm.mjs
Normal file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
fail(`Invalid visual system JSON: ${error.message}`, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return stringValue(value).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function rgba(value) {
|
||||
const match = stringValue(value).match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*([01](?:\.\d+)?|0?\.\d+))?\s*\)$/);
|
||||
if (!match) return null;
|
||||
const [r, g, b] = match.slice(1, 4).map(Number);
|
||||
const a = match[4] === undefined ? 1 : Number(match[4]);
|
||||
if (![r, g, b].every((channel) => Number.isInteger(channel) && channel >= 0 && channel <= 255)) return null;
|
||||
if (!Number.isFinite(a) || a < 0 || a > 1) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function luminance(color) {
|
||||
const linear = [color.r, color.g, color.b].map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function attrs(tag) {
|
||||
const result = {};
|
||||
for (const match of tag.matchAll(/\s([:\w-]+)\s*=\s*"([^"]*)"/g)) {
|
||||
result[match[1]] = match[2];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function listSvgFiles(root) {
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
if (!fs.existsSync(slidesDir)) return [];
|
||||
return fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort()
|
||||
.map((file) => path.join(slidesDir, file));
|
||||
}
|
||||
|
||||
function gradientLuma(source, id) {
|
||||
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`<(?:linearGradient|radialGradient)\\b(?=[^>]*\\bid="${escaped}")[\\s\\S]*?<\\/(?:linearGradient|radialGradient)>`, "i");
|
||||
const match = source.match(pattern);
|
||||
if (!match) return null;
|
||||
const lumas = [];
|
||||
for (const stop of match[0].matchAll(/<stop\b[^>]*>/gi)) {
|
||||
const stopAttrs = attrs(stop[0]);
|
||||
const parsed = rgba(stopAttrs["stop-color"]);
|
||||
if (parsed) lumas.push(luminance(parsed));
|
||||
}
|
||||
if (lumas.length === 0) return null;
|
||||
return lumas.reduce((sum, value) => sum + value, 0) / lumas.length;
|
||||
}
|
||||
|
||||
function classifyTheme(luma) {
|
||||
if (luma === null || luma === undefined) return "image";
|
||||
return luma < 0.45 ? "dark" : "light";
|
||||
}
|
||||
|
||||
function backgroundProfile(source) {
|
||||
const tags = source.match(/<(?:rect|image)\b[^>]*>/gi) || [];
|
||||
const backgroundTags = tags.filter((tag) => /\bslide:role\s*=\s*"background"/.test(tag));
|
||||
const tag = backgroundTags[0] || tags.find((candidate) => candidate.startsWith("<rect"));
|
||||
if (!tag) return { kind: "missing", theme: "image", luma: null };
|
||||
const tagAttrs = attrs(tag);
|
||||
if (tag.startsWith("<image")) return { kind: "image", theme: "image", luma: null };
|
||||
const fill = stringValue(tagAttrs.fill);
|
||||
const gradient = fill.match(/^url\(#([^)]+)\)$/);
|
||||
if (gradient) {
|
||||
const luma = gradientLuma(source, gradient[1]);
|
||||
return { kind: "gradient", theme: classifyTheme(luma), luma };
|
||||
}
|
||||
const parsed = rgba(fill);
|
||||
const luma = parsed ? luminance(parsed) : null;
|
||||
return { kind: "solid", theme: classifyTheme(luma), luma };
|
||||
}
|
||||
|
||||
function makeError(rule, message, extra = {}) {
|
||||
return { rule, severity: "error", message, ...extra };
|
||||
}
|
||||
|
||||
function addDeckError(deckErrors, flatErrors, rule, message, extra = {}) {
|
||||
deckErrors.push(makeError(rule, message, { repairable: false, ...extra }));
|
||||
flatErrors.push(message);
|
||||
}
|
||||
|
||||
function addSlideError(pageResults, flatErrors, file, rule, message, extra = {}) {
|
||||
if (!pageResults.has(file)) pageResults.set(file, { file, errors: [], warnings: [] });
|
||||
pageResults.get(file).errors.push(makeError(rule, message, { repairable: true, ...extra }));
|
||||
flatErrors.push(message);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!deckArg) fail("Usage: node skills/lark-slides/scripts/svg_slides_color_rhythm.mjs <deck-dir> [--json]");
|
||||
|
||||
const root = path.resolve(deckArg);
|
||||
const receiptPath = path.join(root, "receipts", "visual_system.json");
|
||||
if (!fs.existsSync(receiptPath)) fail(`Missing visual system receipt: ${receiptPath}`, 1);
|
||||
|
||||
const receipt = readJson(receiptPath);
|
||||
const rhythm = receipt.color_rhythm || {};
|
||||
const errors = [];
|
||||
const deckErrors = [];
|
||||
const pageResults = new Map();
|
||||
const svgFiles = listSvgFiles(root);
|
||||
const plannedByFile = new Map((Array.isArray(rhythm.slide_themes) ? rhythm.slide_themes : [])
|
||||
.map((entry) => [normalizePath(entry?.file), entry]));
|
||||
|
||||
const pages = svgFiles.map((file) => {
|
||||
const rel = path.relative(root, file).split(path.sep).join("/");
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const profile = backgroundProfile(source);
|
||||
const planned = plannedByFile.get(rel) || {};
|
||||
return {
|
||||
file: rel,
|
||||
role: stringValue(planned.role) || "content",
|
||||
planned_theme: planned.theme || "",
|
||||
actual_theme: profile.theme,
|
||||
background_kind: profile.kind,
|
||||
luma: profile.luma
|
||||
};
|
||||
});
|
||||
|
||||
const allowedRoles = new Set(Array.isArray(rhythm.allowed_full_dark_roles) ? rhythm.allowed_full_dark_roles.map(stringValue).filter(Boolean) : []);
|
||||
const fullDarkBudget = typeof rhythm.full_dark_budget === "number" ? rhythm.full_dark_budget : 0;
|
||||
const darkPages = pages.filter((page) => page.actual_theme === "dark");
|
||||
if (darkPages.length > fullDarkBudget) {
|
||||
addDeckError(deckErrors, errors, "color_rhythm.dark_budget", `full dark pages exceed budget: ${darkPages.length}/${fullDarkBudget}`, {
|
||||
actual: darkPages.length,
|
||||
budget: fullDarkBudget
|
||||
});
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
if (!plannedByFile.has(page.file)) {
|
||||
addSlideError(pageResults, errors, page.file, "color_rhythm.unplanned_page", `missing visual_system color_rhythm.slide_themes entry for ${page.file}`);
|
||||
continue;
|
||||
}
|
||||
if (page.planned_theme && page.planned_theme !== page.actual_theme) {
|
||||
addSlideError(pageResults, errors, page.file, "color_rhythm.theme_mismatch", `actual background theme ${page.actual_theme} does not match planned ${page.planned_theme}`, {
|
||||
planned_theme: page.planned_theme,
|
||||
actual_theme: page.actual_theme
|
||||
});
|
||||
}
|
||||
if (page.actual_theme === "dark" && !allowedRoles.has(page.role)) {
|
||||
addSlideError(pageResults, errors, page.file, "color_rhythm.dark_role_not_allowed", `dark background is not allowed for role ${page.role}`, { role: page.role });
|
||||
}
|
||||
if (page.role === "content" && stringValue(rhythm.default_content_theme) && page.actual_theme !== rhythm.default_content_theme) {
|
||||
addSlideError(pageResults, errors, page.file, "color_rhythm.content_theme", `content page must use ${rhythm.default_content_theme} theme`, {
|
||||
expected_theme: rhythm.default_content_theme,
|
||||
actual_theme: page.actual_theme
|
||||
});
|
||||
}
|
||||
if (page.role === "closing" && stringValue(rhythm.closing_theme) && page.actual_theme !== rhythm.closing_theme) {
|
||||
addSlideError(pageResults, errors, page.file, "color_rhythm.closing_theme", `closing page must use ${rhythm.closing_theme} theme`, {
|
||||
expected_theme: rhythm.closing_theme,
|
||||
actual_theme: page.actual_theme
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hardTransitionKeys = new Set(Array.isArray(rhythm.allowed_hard_transitions) ? rhythm.allowed_hard_transitions.map(stringValue).filter(Boolean) : []);
|
||||
let transitions = 0;
|
||||
const maxAdjacentLumaDelta = typeof rhythm.max_adjacent_luma_delta === "number" ? rhythm.max_adjacent_luma_delta : 1;
|
||||
for (let index = 1; index < pages.length; index += 1) {
|
||||
const previous = pages[index - 1];
|
||||
const current = pages[index];
|
||||
const transitionKey = `${previous.file}->${current.file}`;
|
||||
if (previous.actual_theme !== current.actual_theme) transitions += 1;
|
||||
if (previous.luma !== null && current.luma !== null && !hardTransitionKeys.has(transitionKey)) {
|
||||
const delta = Math.abs(previous.luma - current.luma);
|
||||
if (delta > maxAdjacentLumaDelta) {
|
||||
addDeckError(deckErrors, errors, "color_rhythm.adjacent_luma_delta", `adjacent page luma delta is too high: ${previous.file} -> ${current.file}`, {
|
||||
from: previous.file,
|
||||
to: current.file,
|
||||
delta,
|
||||
max: maxAdjacentLumaDelta
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transitionRatio = pages.length > 1 ? transitions / (pages.length - 1) : 0;
|
||||
const maxTransitionRatio = typeof rhythm.max_transition_ratio === "number" ? rhythm.max_transition_ratio : 1;
|
||||
if (transitionRatio > maxTransitionRatio) {
|
||||
addDeckError(deckErrors, errors, "color_rhythm.transition_ratio", `theme transition ratio is too high: ${transitionRatio.toFixed(2)} > ${maxTransitionRatio}`, {
|
||||
transition_ratio: transitionRatio,
|
||||
max_transition_ratio: maxTransitionRatio
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index <= pages.length - 4; index += 1) {
|
||||
const slice = pages.slice(index, index + 4).map((page) => page.actual_theme);
|
||||
const alternates = slice.every((theme, offset) => offset === 0 || theme !== slice[offset - 1]);
|
||||
const allBinary = slice.every((theme) => theme === "dark" || theme === "light");
|
||||
if (alternates && allBinary) {
|
||||
addDeckError(deckErrors, errors, "color_rhythm.alternating_sequence", `four-page alternating dark/light sequence starts at ${pages[index].file}`, {
|
||||
start_file: pages[index].file,
|
||||
sequence: slice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
version: "svg-slides.color-rhythm.v1",
|
||||
ok: errors.length === 0,
|
||||
receipt: receiptPath,
|
||||
pages,
|
||||
darkPageCount: darkPages.length,
|
||||
transitionRatio,
|
||||
deck_errors: deckErrors,
|
||||
results: [...pageResults.values()].filter((result) => result.errors.length > 0 || result.warnings.length > 0),
|
||||
totalErrors: errors.length,
|
||||
errors
|
||||
};
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(JSON.stringify(report, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
107
skills/lark-slides/scripts/svg_slides_color_rhythm_test.mjs
Normal file
107
skills/lark-slides/scripts/svg_slides_color_rhythm_test.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_color_rhythm.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-color-rhythm-"));
|
||||
fs.mkdirSync(path.join(root, "slides"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function slide(fill) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="${fill}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function writeSlides(root, fills) {
|
||||
fills.forEach((fill, index) => {
|
||||
fs.writeFileSync(path.join(root, "slides", `slide_${String(index + 1).padStart(2, "0")}.svg`), slide(fill));
|
||||
});
|
||||
}
|
||||
|
||||
function writeVisualSystem(root, themes, overrides = {}) {
|
||||
const slideThemes = themes.map((entry, index) => ({
|
||||
file: `slides/slide_${String(index + 1).padStart(2, "0")}.svg`,
|
||||
role: entry.role,
|
||||
theme: entry.theme,
|
||||
reason: `${entry.role} uses ${entry.theme} as planned`
|
||||
}));
|
||||
fs.writeFileSync(path.join(root, "receipts", "visual_system.json"), `${JSON.stringify({
|
||||
version: "svg-slides.visual-system.v1",
|
||||
palette_tokens: {
|
||||
"bg.base": "rgba(255,255,255,1)",
|
||||
"bg.deep": "rgba(8,15,28,1)",
|
||||
"panel.light": "rgba(255,255,255,1)",
|
||||
"panel.dark": "rgba(15,23,42,1)",
|
||||
"text.primary": "rgba(15,23,42,1)",
|
||||
"accent.cyan": "rgba(0,184,216,1)"
|
||||
},
|
||||
color_rhythm: {
|
||||
full_dark_budget: 2,
|
||||
allowed_full_dark_roles: ["cover", "section-divider", "key-showcase"],
|
||||
default_content_theme: "light",
|
||||
closing_theme: "light",
|
||||
max_adjacent_luma_delta: 1,
|
||||
max_transition_ratio: 1,
|
||||
slide_themes: slideThemes,
|
||||
...overrides
|
||||
}
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("color rhythm accepts planned hero dark page inside the deck budget", () => {
|
||||
const root = tempDeck();
|
||||
writeSlides(root, [
|
||||
"rgba(8,15,28,1)",
|
||||
"rgba(255,255,255,1)",
|
||||
"rgba(255,255,255,1)",
|
||||
"rgba(255,255,255,1)"
|
||||
]);
|
||||
writeVisualSystem(root, [
|
||||
{ role: "cover", theme: "dark" },
|
||||
{ role: "content", theme: "light" },
|
||||
{ role: "content", theme: "light" },
|
||||
{ role: "closing", theme: "light" }
|
||||
]);
|
||||
const result = spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
assert.equal(report.darkPageCount, 1);
|
||||
});
|
||||
|
||||
test("color rhythm rejects four-page dark light alternation", () => {
|
||||
const root = tempDeck();
|
||||
writeSlides(root, [
|
||||
"rgba(8,15,28,1)",
|
||||
"rgba(255,255,255,1)",
|
||||
"rgba(15,23,42,1)",
|
||||
"rgba(255,255,255,1)"
|
||||
]);
|
||||
writeVisualSystem(root, [
|
||||
{ role: "cover", theme: "dark" },
|
||||
{ role: "content", theme: "light" },
|
||||
{ role: "key-showcase", theme: "dark" },
|
||||
{ role: "closing", theme: "light" }
|
||||
], { max_transition_ratio: 1 });
|
||||
const result = spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /color_rhythm\.alternating_sequence/);
|
||||
});
|
||||
|
||||
test("color rhythm rejects dark content pages", () => {
|
||||
const root = tempDeck();
|
||||
writeSlides(root, ["rgba(15,23,42,1)"]);
|
||||
writeVisualSystem(root, [{ role: "content", theme: "dark" }], { full_dark_budget: 1 });
|
||||
const result = spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /color_rhythm\.dark_role_not_allowed/);
|
||||
assert.match(result.stderr, /color_rhythm\.content_theme/);
|
||||
});
|
||||
875
skills/lark-slides/scripts/svg_slides_extract_references.mjs
Executable file
875
skills/lark-slides/scripts/svg_slides_extract_references.mjs
Executable file
@@ -0,0 +1,875 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function positionalArgs() {
|
||||
const values = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--source-root") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("--")) values.push(arg);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const [rootArg] = positionalArgs();
|
||||
const root = path.resolve(rootArg || "skills/lark-slides/references/svg-slides");
|
||||
const sourceRoot = path.resolve(argValue("--source-root", "skills/lark-slides/prompt-sources/svg-slides"));
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const sourcePath = path.join(sourceRoot, "full.debranded.md");
|
||||
const manifestPath = path.join(sourceRoot, "split-manifest.json");
|
||||
|
||||
const obsoleteFiles = [
|
||||
"workflow.md",
|
||||
"protocol.md",
|
||||
"design-brief.md",
|
||||
"visual-design.md",
|
||||
"authoring-rules.md",
|
||||
"validation.md",
|
||||
"chart-workflow.md",
|
||||
"editing-existing-decks.md"
|
||||
];
|
||||
|
||||
const manifestTargets = {
|
||||
title: "README.md",
|
||||
system_prompt_workflow: "system-prompt.md",
|
||||
svg_reference: "svg-reference.md",
|
||||
resolve_design_brief: "resolve-design-brief/full.md",
|
||||
deck_design_reference_catalog: "deck-design-reference-catalog.md",
|
||||
slide_outline_tool: "tools/slide-outline.md",
|
||||
activate_slides_edit_tool: "tools/activate-slides-edit.md",
|
||||
slides_edit_tool: "tools/slides-edit.md",
|
||||
svg_document_rules: "tools/slides-edit.md",
|
||||
image_usage: "tools/slides-edit.md",
|
||||
incremental_processing: "tools/slides-edit.md",
|
||||
finish_slides_edit_tool: "tools/finish-slides-edit.md",
|
||||
slide_organize_tool: "tools/slide-organize.md",
|
||||
compute_custom_shape_bbox_tool: "tools/compute-custom-shape-bbox.md",
|
||||
generate_svg_chart_tool: "generate-svg-chart/full.md",
|
||||
slides_convert_tool: "tools/slides-convert.md",
|
||||
slides_parse_template_tool: "tools/slides-parse-template.md"
|
||||
};
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`missing prompt source: ${sourcePath}`);
|
||||
}
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`missing split manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const sourceLines = fs.readFileSync(sourcePath, "utf8").split(/\r?\n/);
|
||||
const splitManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const sectionsById = new Map(splitManifest.sections.map((section) => [section.id, section]));
|
||||
|
||||
function lineRange(start, end) {
|
||||
return sourceLines.slice(start - 1, end).join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function rangeLabel(start, end) {
|
||||
return `${start}-${end}`;
|
||||
}
|
||||
|
||||
function section(id) {
|
||||
const manifestSection = sectionsById.get(id);
|
||||
if (!manifestSection) throw new Error(`missing manifest section: ${id}`);
|
||||
return manifestSection;
|
||||
}
|
||||
|
||||
function sectionStart(id) {
|
||||
return section(id).lines[0];
|
||||
}
|
||||
|
||||
function sectionEnd(id) {
|
||||
return section(id).lines[1];
|
||||
}
|
||||
|
||||
function sectionLabel(id) {
|
||||
return rangeLabel(sectionStart(id), sectionEnd(id));
|
||||
}
|
||||
|
||||
function sectionRange(id) {
|
||||
return lineRange(sectionStart(id), sectionEnd(id));
|
||||
}
|
||||
|
||||
function findLine(pattern, { from = 1, to = sourceLines.length, after = false } = {}) {
|
||||
const startIndex = Math.max(0, from - 1 + (after ? 1 : 0));
|
||||
const endIndex = Math.min(sourceLines.length, to);
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const line = sourceLines[index];
|
||||
if (typeof pattern === "string" ? line.includes(pattern) : pattern.test(line)) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
throw new Error(`could not find source marker ${String(pattern)} in ${from}-${to}`);
|
||||
}
|
||||
|
||||
function cleanSourceOnlyArtifacts(text) {
|
||||
return text
|
||||
.replace(/。tool_[A-Za-z0-9_]+\.go\.tmpl/g, "。")
|
||||
.replace(/\btool_[A-Za-z0-9_]+\.go\.tmpl\b/g, "tool template")
|
||||
.replace(/\bsvg_chart_contract\.go\.tmpl\b/g, "chart contract template")
|
||||
.replace(/\bsvgchartservice\/service\.go\b/g, "chart service implementation")
|
||||
.replace(/\bservice\.go\b/g, "chart service implementation")
|
||||
.replace(/\bsnippets\/\\?\*\.md\b/g, "signature snippets")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function ensureDir(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function writeFile(relPath, content) {
|
||||
const filePath = path.join(root, relPath);
|
||||
ensureDir(filePath);
|
||||
const normalized = `${content.trimEnd()}\n`;
|
||||
if (!dryRun) fs.writeFileSync(filePath, normalized);
|
||||
}
|
||||
|
||||
function removePath(relPath) {
|
||||
const filePath = path.join(root, relPath);
|
||||
if (fs.existsSync(filePath) && !dryRun) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function doc({ title, role, purpose, requiredReadPath = [], content }) {
|
||||
const readPath = requiredReadPath.length === 0
|
||||
? ""
|
||||
: [
|
||||
"## Required Read Path",
|
||||
"",
|
||||
...requiredReadPath.map((item, index) => `${index + 1}. ${item}`),
|
||||
""
|
||||
].join("\n");
|
||||
return [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
`- Role: ${role}`,
|
||||
`- Purpose: ${purpose}`,
|
||||
"",
|
||||
readPath,
|
||||
"## Content",
|
||||
"",
|
||||
cleanSourceOnlyArtifacts(content)
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function writeRootReadme() {
|
||||
writeFile("README.md", [
|
||||
"# SVG Slides Local Generation",
|
||||
"",
|
||||
"This directory is the runtime reference contract for local SVG Slides generation.",
|
||||
"",
|
||||
"Do not use maintainer prompt-source files as generation context. Runtime agents should follow the read routes below.",
|
||||
"",
|
||||
"## Read Routes",
|
||||
"",
|
||||
"| Task | Read first | Then read |",
|
||||
"|---|---|---|",
|
||||
"| Understand the main SVG Slides workflow | `system-prompt.md` | `svg-reference.md`, `tools/slides-edit.md` |",
|
||||
"| Author or repair SVG protocol content | `svg-reference.md` | `tools/slides-edit.md`, `tools/compute-custom-shape-bbox.md` |",
|
||||
"| Resolve deck design direction | `resolve-design-brief/README.md` | `deck-design-reference-catalog.md` |",
|
||||
"| Choose or audit typography | `resolve-design-brief/style-director-prompt.md` | `system-prompt.md`, `svg-reference.md`, `validation/typography-plan.md` |",
|
||||
"| Generate or revise charts | `generate-svg-chart/README.md` | matching files under `generate-svg-chart/snippets/` |",
|
||||
"| Continue or convert an existing deck | `tools/slides-convert.md` | `tools/slide-organize.md`, `tools/slides-edit.md` |",
|
||||
"| Prove asset search/readiness | `validation/asset-readiness.md` | `../../../scripts/svg_slides_asset_readiness.mjs`, `../../../scripts/svg_slides_visual_identity_check.mjs` |",
|
||||
"| Prove typography planning | `validation/typography-plan.md` | `../../../scripts/svg_slides_typography_plan.mjs` |",
|
||||
"| Prove visual system and color rhythm | `validation/visual-system.md` | `../../../scripts/svg_slides_visual_system.mjs`, `../../../scripts/svg_slides_color_rhythm.mjs` |",
|
||||
"| Prove frame discipline | `validation/frame-discipline.md` | `../../../scripts/svg_slides_frame_discipline.mjs` |",
|
||||
"| Prove readability safety | `validation/README.md` | `../../../scripts/svg_slides_readability_safety.mjs` |",
|
||||
"| Prove prompt reads for a generated deck | `validation/prompt-read-trace.md` | `../../scripts/svg_slides_prompt_trace.mjs` |",
|
||||
"| Audit a delivered deck before answering the user | `validation/post-delivery-audit.md` | `validation/asset-readiness.md`, `../../../scripts/svg_slides_readback_gate.mjs` |",
|
||||
"",
|
||||
"## Boundary",
|
||||
"",
|
||||
"Local SVG Slides generation and validation produce local artifacts. Publishing to Lark Slides is outside this reference family unless a separate publisher explicitly consumes the generated bundle.",
|
||||
"",
|
||||
"## Prompt Read Trace Gate",
|
||||
"",
|
||||
"Every generated local bundle must include `receipts/prompt_read_trace.json` before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The trace records the runtime prompt/reference Markdown files read for the generation task, including path, role, reason, and SHA-256.",
|
||||
"",
|
||||
"## Asset Readiness Gate",
|
||||
"",
|
||||
"Every generated local bundle must include `receipts/asset_readiness.json` before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The receipt records the deck's visual identity fingerprint, default image-search policy, required visual subjects, prepared local assets, and per-slide layout families.",
|
||||
"",
|
||||
"## Typography Plan Gate",
|
||||
"",
|
||||
"Every generated local bundle must include `receipts/typography_plan.json` before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The receipt records the deck's selected font category, display/body/label stacks, requested fonts, fallback decisions, source, and rejected substitutions. Slide SVG text must use only those planned stacks.",
|
||||
"",
|
||||
"## Visual System Gate",
|
||||
"",
|
||||
"Every generated local bundle must include `receipts/visual_system.json`, then pass `visual_system_report` and `color_rhythm` validation before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The receipt records deck-level palette tokens and per-slide background rhythm. It preserves slide layout freedom, but prevents adjacent pages from jumping between unrelated full-page color systems.",
|
||||
"",
|
||||
"## Frame Discipline Gate",
|
||||
"",
|
||||
"Every generated local bundle must pass frame-discipline validation before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The gate keeps text unframed by default and rejects stroked text containers that lack an explicit semantic `data-frame-role`."
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
function writeStandaloneDocs() {
|
||||
writeFile("system-prompt.md", doc({
|
||||
title: "System Prompt",
|
||||
role: "prompt",
|
||||
purpose: "Main conductor prompt for SVG Slides workflow.",
|
||||
source: sectionLabel("system_prompt_workflow"),
|
||||
coverageIds: ["system_prompt_workflow"],
|
||||
content: sectionRange("system_prompt_workflow")
|
||||
}));
|
||||
writeFile("svg-reference.md", doc({
|
||||
title: "SVG Reference",
|
||||
role: "reference",
|
||||
purpose: "Protocol schema and design rules for SVG Slides documents.",
|
||||
source: sectionLabel("svg_reference"),
|
||||
coverageIds: ["svg_reference"],
|
||||
content: sectionRange("svg_reference")
|
||||
}));
|
||||
writeFile("deck-design-reference-catalog.md", doc({
|
||||
title: "Deck Design Reference Catalog",
|
||||
role: "reference",
|
||||
purpose: "Narrative archetypes and depth rubric consumed by the design brief prompt.",
|
||||
source: sectionLabel("deck_design_reference_catalog"),
|
||||
coverageIds: ["deck_design_reference_catalog"],
|
||||
content: sectionRange("deck_design_reference_catalog")
|
||||
}));
|
||||
}
|
||||
|
||||
function writeResolveDesignBriefDocs() {
|
||||
const start = sectionStart("resolve_design_brief");
|
||||
const end = sectionEnd("resolve_design_brief");
|
||||
const styleStart = findLine("样式设计System Prompt", { from: start, to: end });
|
||||
const designStart = findLine("内容设计 System Prompt", { from: styleStart, to: end });
|
||||
const parts = [
|
||||
{
|
||||
relPath: "resolve-design-brief/tool-contract.md",
|
||||
title: "Resolve Design Brief Tool Contract",
|
||||
role: "tool_contract",
|
||||
purpose: "Defines the design brief tool timing, outputs, and required inputs.",
|
||||
source: rangeLabel(start, styleStart - 1),
|
||||
content: lineRange(start, styleStart - 1)
|
||||
},
|
||||
{
|
||||
relPath: "resolve-design-brief/style-director-prompt.md",
|
||||
title: "Style Director Prompt",
|
||||
role: "prompt",
|
||||
purpose: "Produces the visual-system style deconstruction.",
|
||||
source: rangeLabel(styleStart, designStart - 1),
|
||||
content: lineRange(styleStart, designStart - 1)
|
||||
},
|
||||
{
|
||||
relPath: "resolve-design-brief/design-director-prompt.md",
|
||||
title: "Design Director Prompt",
|
||||
role: "prompt",
|
||||
purpose: "Produces narrative_spine, depth, and tone as strict JSON.",
|
||||
source: rangeLabel(designStart, end),
|
||||
content: `${lineRange(designStart, end)}\n\`\`\``
|
||||
}
|
||||
];
|
||||
writeFile("resolve-design-brief/README.md", [
|
||||
"# Resolve Design Brief",
|
||||
"",
|
||||
"Before resolving a deck design brief, read these files in order:",
|
||||
"",
|
||||
"1. `tool-contract.md`",
|
||||
"2. `style-director-prompt.md`",
|
||||
"3. `design-director-prompt.md`",
|
||||
"4. `../deck-design-reference-catalog.md`",
|
||||
"",
|
||||
"If routing is uncertain, read `full.md`."
|
||||
].join("\n"));
|
||||
for (const part of parts) {
|
||||
writeFile(part.relPath, doc(part));
|
||||
}
|
||||
writeFile("resolve-design-brief/full.md", doc({
|
||||
title: "Resolve Design Brief Full",
|
||||
role: "prompt_bundle",
|
||||
purpose: "Complete resolve_design_brief prompt bundle for audit or uncertain routing.",
|
||||
source: sectionLabel("resolve_design_brief"),
|
||||
coverageIds: ["resolve_design_brief"],
|
||||
requiredReadPath: [
|
||||
"`tool-contract.md`",
|
||||
"`style-director-prompt.md`",
|
||||
"`design-director-prompt.md`",
|
||||
"`../deck-design-reference-catalog.md`"
|
||||
],
|
||||
content: [
|
||||
lineRange(start, styleStart - 1),
|
||||
lineRange(styleStart, designStart - 1),
|
||||
`${lineRange(designStart, end)}\n\`\`\``
|
||||
].join("\n\n")
|
||||
}));
|
||||
}
|
||||
|
||||
function writeToolDocs() {
|
||||
const tools = [
|
||||
["tools/slide-outline.md", "Slide Outline", sectionLabel("slide_outline_tool"), ["slide_outline_tool"], sectionRange("slide_outline_tool"), "Creates project structure, outline metadata, style settings, and empty slide files."],
|
||||
["tools/activate-slides-edit.md", "Activate Slides Edit", sectionLabel("activate_slides_edit_tool"), ["activate_slides_edit_tool"], sectionRange("activate_slides_edit_tool"), "Switches into the fast slide-writing mode after outline creation."],
|
||||
[
|
||||
"tools/slides-edit.md",
|
||||
"Slides Edit",
|
||||
`${sectionLabel("slides_edit_tool")}, ${sectionLabel("svg_document_rules")}, ${sectionLabel("image_usage")}, ${sectionLabel("incremental_processing")}`,
|
||||
["slides_edit_tool", "svg_document_rules", "image_usage", "incremental_processing"],
|
||||
[
|
||||
sectionRange("slides_edit_tool"),
|
||||
sectionRange("svg_document_rules"),
|
||||
sectionRange("image_usage"),
|
||||
sectionRange("incremental_processing")
|
||||
].join("\n\n"),
|
||||
"Writes SVG page documents and carries slide-edit hard gates."
|
||||
],
|
||||
["tools/finish-slides-edit.md", "Finish Slides Edit", sectionLabel("finish_slides_edit_tool"), ["finish_slides_edit_tool"], sectionRange("finish_slides_edit_tool"), "Leaves edit mode and checks for placeholder slides."],
|
||||
["tools/slide-organize.md", "Slide Organize", sectionLabel("slide_organize_tool"), ["slide_organize_tool"], sectionRange("slide_organize_tool"), "Adds or deletes pages after outline creation."],
|
||||
["tools/compute-custom-shape-bbox.md", "Compute Custom Shape Bbox", sectionLabel("compute_custom_shape_bbox_tool"), ["compute_custom_shape_bbox_tool"], sectionRange("compute_custom_shape_bbox_tool"), "Computes custom SVG path bounds before authoring custom shapes."],
|
||||
["tools/slides-convert.md", "Slides Convert", sectionLabel("slides_convert_tool"), ["slides_convert_tool"], sectionRange("slides_convert_tool"), "Converts uploaded PPTX files into editable slides format."],
|
||||
["tools/slides-parse-template.md", "Slides Parse Template", sectionLabel("slides_parse_template_tool"), ["slides_parse_template_tool"], sectionRange("slides_parse_template_tool"), "Parses and preprocesses XML templates for template-based generation."]
|
||||
];
|
||||
for (const [relPath, title, source, coverageIds, content, purpose] of tools) {
|
||||
writeFile(relPath, doc({
|
||||
title,
|
||||
role: "tool_contract",
|
||||
purpose,
|
||||
source,
|
||||
coverageIds,
|
||||
content
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnippetBundle() {
|
||||
const sectionStartLine = sectionStart("generate_svg_chart_tool");
|
||||
const sectionEndLine = sectionEnd("generate_svg_chart_tool");
|
||||
const snippetIntro = findLine("⑤ signature snippets", { from: sectionStartLine, to: sectionEndLine });
|
||||
const bundleStart = findLine(/^````markdown$/, { from: snippetIntro, to: sectionEndLine });
|
||||
const bundleEnd = sectionEndLine;
|
||||
const bundleLines = sourceLines.slice(bundleStart - 1, bundleEnd);
|
||||
const snippets = [];
|
||||
let current = null;
|
||||
let inWrapper = false;
|
||||
for (let index = 0; index < bundleLines.length; index += 1) {
|
||||
const line = bundleLines[index];
|
||||
const lineNumber = bundleStart + index;
|
||||
if (line === "````markdown") {
|
||||
inWrapper = true;
|
||||
continue;
|
||||
}
|
||||
if (!inWrapper) continue;
|
||||
if (line === "````") {
|
||||
if (current) {
|
||||
current.end = lineNumber - 1;
|
||||
snippets.push(current);
|
||||
current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const match = line.match(/^========== ([^=]+\.md) ==========$/);
|
||||
if (match) {
|
||||
if (current) snippets.push(current);
|
||||
current = { name: match[1], lines: [], start: lineNumber, end: lineNumber };
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
current.lines.push(line);
|
||||
current.end = lineNumber;
|
||||
}
|
||||
}
|
||||
if (current) snippets.push(current);
|
||||
return snippets.map((snippet) => ({
|
||||
name: snippet.name,
|
||||
source: `${snippet.start}-${snippet.end}`,
|
||||
content: snippet.lines.join("\n").trimEnd()
|
||||
}));
|
||||
}
|
||||
|
||||
function snippetChartTypes(content) {
|
||||
const match = content.match(/^chart_types:\s*\[([^\]]*)\]/m);
|
||||
if (!match) return [];
|
||||
return match[1].split(",").map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function writeGenerateChartDocs() {
|
||||
const start = sectionStart("generate_svg_chart_tool");
|
||||
const end = sectionEnd("generate_svg_chart_tool");
|
||||
const schemaStart = findLine(/^② 入参 schema/, { from: start, to: end });
|
||||
const contractStart = findLine(/^③ 契约模板/, { from: schemaStart, to: end });
|
||||
const envelopeStart = findLine(/^④ 设计简报/, { from: contractStart, to: end });
|
||||
const snippetsStart = findLine(/^⑤ signature snippets/, { from: envelopeStart, to: end });
|
||||
const parts = [
|
||||
["generate-svg-chart/tool-description.md", "Generate SVG Chart Tool Description", rangeLabel(start, schemaStart - 1), lineRange(start, schemaStart - 1), "Describes generated chart outputs."],
|
||||
["generate-svg-chart/input-schema.md", "Generate SVG Chart Input Schema", rangeLabel(schemaStart, contractStart - 1), lineRange(schemaStart, contractStart - 1), "Defines parameters, chart routing, composition gate, and output path rules."],
|
||||
["generate-svg-chart/chart-agent-contract.md", "SVG Chart Agent Contract", rangeLabel(contractStart, envelopeStart - 1), lineRange(contractStart, envelopeStart - 1), "Defines the chart sub-agent response contract."],
|
||||
["generate-svg-chart/design-envelope.md", "Chart Design Envelope", rangeLabel(envelopeStart, snippetsStart - 1), lineRange(envelopeStart, snippetsStart - 1), "Defines chart craft rules, grid, palette roles, typography, labels, and invariants."]
|
||||
];
|
||||
writeFile("generate-svg-chart/README.md", [
|
||||
"# Generate SVG Chart",
|
||||
"",
|
||||
"Before generating or revising any chart, read these files in order:",
|
||||
"",
|
||||
"1. `input-schema.md`",
|
||||
"2. `chart-agent-contract.md`",
|
||||
"3. `design-envelope.md`",
|
||||
"4. `snippets/README.md`",
|
||||
"5. Snippets whose frontmatter `chart_types` include the selected `chart_type`",
|
||||
"",
|
||||
"If snippet routing is uncertain, read `full.md`."
|
||||
].join("\n"));
|
||||
for (const [relPath, title, source, content, purpose] of parts) {
|
||||
writeFile(relPath, doc({
|
||||
title,
|
||||
role: relPath.includes("schema") ? "schema" : relPath.includes("contract") ? "prompt" : "reference",
|
||||
purpose,
|
||||
source,
|
||||
content
|
||||
}));
|
||||
}
|
||||
const snippets = parseSnippetBundle();
|
||||
const snippetIndex = [];
|
||||
for (const snippet of snippets) {
|
||||
const types = snippetChartTypes(snippet.content);
|
||||
snippetIndex.push(`- \`${snippet.name}\`: ${types.join(", ")}`);
|
||||
writeFile(`generate-svg-chart/snippets/${snippet.name}`, doc({
|
||||
title: snippet.name.replace(/\.md$/, ""),
|
||||
role: "snippet",
|
||||
purpose: `Chart design snippet for chart types: ${types.join(", ")}`,
|
||||
source: snippet.source,
|
||||
content: snippet.content
|
||||
}));
|
||||
}
|
||||
writeFile("generate-svg-chart/snippets/README.md", [
|
||||
"# Generate SVG Chart Snippets",
|
||||
"",
|
||||
"Load snippets whose frontmatter `chart_types` include the selected `chart_type`.",
|
||||
"",
|
||||
"## Snippet Index",
|
||||
"",
|
||||
...snippetIndex,
|
||||
""
|
||||
].join("\n"));
|
||||
writeFile("generate-svg-chart/full.md", doc({
|
||||
title: "Generate SVG Chart Full",
|
||||
role: "prompt_bundle",
|
||||
purpose: "Complete generate_svg_chart bundle for audit or uncertain snippet routing.",
|
||||
source: sectionLabel("generate_svg_chart_tool"),
|
||||
coverageIds: ["generate_svg_chart_tool"],
|
||||
requiredReadPath: [
|
||||
"`input-schema.md`",
|
||||
"`chart-agent-contract.md`",
|
||||
"`design-envelope.md`",
|
||||
"`snippets/README.md`",
|
||||
"Matching snippet files by `chart_type`"
|
||||
],
|
||||
content: sectionRange("generate_svg_chart_tool")
|
||||
}));
|
||||
}
|
||||
|
||||
function writeValidationDocs() {
|
||||
writeFile("validation/README.md", [
|
||||
"# SVG Slides Validation",
|
||||
"",
|
||||
"Use this directory for local validation and provenance audit routing.",
|
||||
"",
|
||||
"## Read Routes",
|
||||
"",
|
||||
"- Source coverage audit: `source-coverage.md`",
|
||||
"- Protocol validation script: `../../../scripts/validate_svg_deck.mjs`",
|
||||
"- Bundle manifest script: `../../../scripts/svg_slides_bundle.mjs`",
|
||||
"- Browser text bounds script: `../../../scripts/svg_slides_browser_text_bounds.mjs`",
|
||||
"- Asset readiness script: `../../../scripts/svg_slides_asset_readiness.mjs`",
|
||||
"- Visual identity comparison script: `../../../scripts/svg_slides_visual_identity_check.mjs`",
|
||||
"- Typography plan script: `../../../scripts/svg_slides_typography_plan.mjs`",
|
||||
"- Visual system script: `../../../scripts/svg_slides_visual_system.mjs`",
|
||||
"- Color rhythm script: `../../../scripts/svg_slides_color_rhythm.mjs`",
|
||||
"- Frame discipline script: `../../../scripts/svg_slides_frame_discipline.mjs`",
|
||||
"- Readability safety script: `../../../scripts/svg_slides_readability_safety.mjs`",
|
||||
"- Prompt read trace script: `../../../scripts/svg_slides_prompt_trace.mjs`",
|
||||
"- Post-delivery audit checklist: `post-delivery-audit.md`",
|
||||
"",
|
||||
"## Publish Gates",
|
||||
"",
|
||||
"A generated SVG Slides bundle is publish-ready only when all visual safety gates pass:",
|
||||
"",
|
||||
"1. `validate_svg_deck`: parser semantics, no browser-only text or untyped shapes.",
|
||||
"2. `svg_slides_visual_system`: actual SVG colors stay inside the locked deck palette.",
|
||||
"3. `svg_slides_color_rhythm`: adjacent pages follow the planned full-page color rhythm.",
|
||||
"4. `svg_slides_frame_discipline`: ordinary text is not wrapped in meaningless stroked frames.",
|
||||
"5. `svg_slides_readability_safety`: foreground overlap, out-of-bounds content, and missing text-over-image masking.",
|
||||
"",
|
||||
"## Response Audit",
|
||||
"",
|
||||
"When a user asks whether a finished deck satisfies the prompt, do not answer from the user's visible request alone. Read `post-delivery-audit.md` and report against user-visible requirements, SVG Slides default requirements, and receipt/readback evidence."
|
||||
].join("\n"));
|
||||
writeFile("validation/post-delivery-audit.md", [
|
||||
"# SVG Slides Post-Delivery Audit",
|
||||
"",
|
||||
"Use this checklist before answering whether a finished SVG Slides deck satisfies the prompt. This is a response-quality gate, not a replacement for parser or publish validation.",
|
||||
"",
|
||||
"## Required Review Surfaces",
|
||||
"",
|
||||
"1. User-visible requirements: page count, named sections, language, topic, style words, explicit exclusions, and delivery mode.",
|
||||
"2. SVG Slides default requirements: image search policy, required visual subjects, primary visual rules for cover / section-divider / closing / key-showcase slides, typography plan, frame discipline, readability safety, and prompt read trace.",
|
||||
"3. Execution evidence: local manifest, validation command results, `receipts/asset_readiness.json`, `receipts/typography_plan.json`, `receipts/prompt_read_trace.json`, and online readback receipt when the deck was published.",
|
||||
"",
|
||||
"## Mandatory Answer Shape",
|
||||
"",
|
||||
"When judging prompt satisfaction, report these separately:",
|
||||
"",
|
||||
"- User prompt fit: satisfied / partially satisfied / failed, with the exact unmet or changed requirement.",
|
||||
"- System prompt fit: asset search policy, primary visual coverage, typography and readability gates.",
|
||||
"- Online delivery fit: publish/readback status when online delivery was requested.",
|
||||
"- Quality gaps: issues that pass validation but still reduce real usefulness, such as one asset reused across too many slides, placeholder company evidence, weak cover identity, or factual compliance rewrites.",
|
||||
"",
|
||||
"## Asset Search Checks",
|
||||
"",
|
||||
"- A non-opt-out deck must say which `image_search_policy` was used.",
|
||||
"- `required_by_default` must include web-search evidence for every required visual subject.",
|
||||
"- `fallback_search_failed` or `fallback_no_network` must name the failed/unavailable subject and the fallback reason.",
|
||||
"- User-provided assets may satisfy local asset needs, but do not erase web-search requirements for named real-world entities unless the user explicitly requested supplied assets only.",
|
||||
"",
|
||||
"## Failure Rule",
|
||||
"",
|
||||
"Never say \"meets the prompt\" without mentioning a system-prompt default that materially shaped the output. If a deck passes validators but changes the user's literal request for factual or compliance reasons, call it \"partially satisfied\" and name the rewrite."
|
||||
].join("\n"));
|
||||
writeFile("validation/source-coverage.md", [
|
||||
"# Runtime Reference Coverage Audit",
|
||||
"",
|
||||
"Coverage is a maintainer audit, not deck-generation context.",
|
||||
"",
|
||||
"The split manifest lives under `skills/lark-slides/prompt-sources/svg-slides/`. Runtime agents should not read that directory while generating decks.",
|
||||
"",
|
||||
"Rules:",
|
||||
"",
|
||||
"- Scan Markdown files recursively under `skills/lark-slides/references/svg-slides`.",
|
||||
"- Each manifest section's target file must exist in the runtime reference tree.",
|
||||
"- Runtime Markdown must not expose prompt-source paths, source-line metadata, or source-coverage blocks.",
|
||||
"- `generate-svg-chart/snippets/` must contain exactly 22 snippet Markdown files excluding `README.md`.",
|
||||
"- The source manifest remains the authority for maintainer coverage."
|
||||
].join("\n"));
|
||||
writeFile("validation/asset-readiness.md", [
|
||||
"# Asset Readiness",
|
||||
"",
|
||||
"Every generated SVG Slides bundle must write `receipts/asset_readiness.json` before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The receipt proves that visual assets were planned and prepared before slide authoring. It closes two failure modes:",
|
||||
"",
|
||||
"- slides that pass SVG protocol validation but contain only decorative shapes instead of searched or valid fallback visual assets",
|
||||
"- multiple decks in one run that reuse the same generic visual system and layout vocabulary",
|
||||
"",
|
||||
"## Required Receipt",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
" \"version\": \"svg-slides.asset-readiness.v1\",",
|
||||
" \"image_search_policy\": \"required_by_default | user_opted_out | fallback_no_network | fallback_search_failed\",",
|
||||
" \"visual_identity_fingerprint\": {",
|
||||
" \"style_family\": \"technical-paper-terminal\",",
|
||||
" \"composition_signature\": \"asymmetric-code-panels\",",
|
||||
" \"imagery_treatment\": \"paper screenshots and architecture diagrams\",",
|
||||
" \"palette_signature\": \"navy-cyan-orange\"",
|
||||
" },",
|
||||
" \"required_visual_subjects\": [",
|
||||
" {",
|
||||
" \"slide\": \"slides/slide_01.svg\",",
|
||||
" \"subject\": \"named or conceptual visual subject for this slide\",",
|
||||
" \"search_required\": true,",
|
||||
" \"search_query\": \"specific query used by the image search step\",",
|
||||
" \"search_status\": \"ready | not_found | unavailable | user_opted_out\",",
|
||||
" \"fallback_reason\": \"required when search_status is not_found or unavailable\"",
|
||||
" }",
|
||||
" ],",
|
||||
" \"required_search_entities\": [\"Named company or product when applicable\"],",
|
||||
" \"assets\": [",
|
||||
" {",
|
||||
" \"slide\": \"slides/slide_01.svg\",",
|
||||
" \"purpose\": \"primary hero visual\",",
|
||||
" \"asset_kind\": \"photograph | product_image | person_image | storefront | screenshot | document_capture | illustration | chart | icon | decoration\",",
|
||||
" \"expressive_role\": \"what emotion, proof, or narrative function this asset carries\",",
|
||||
" \"content_relevance\": \"why this exact visual belongs to this slide's key message\",",
|
||||
" \"format\": \"png\",",
|
||||
" \"source_type\": \"web_search\",",
|
||||
" \"status\": \"ready\",",
|
||||
" \"query\": \"specific query used\",",
|
||||
" \"source_url\": \"https://example.com/source\",",
|
||||
" \"local_path\": \"/absolute/path/to/local/image.png\",",
|
||||
" \"assignment_reason\": \"why this asset belongs on this slide\"",
|
||||
" }",
|
||||
" ],",
|
||||
" \"slides\": [",
|
||||
" {",
|
||||
" \"file\": \"slides/slide_01.svg\",",
|
||||
" \"slide_role\": \"cover | section-divider | content | key-showcase | closing\",",
|
||||
" \"visual_tier\": \"hero | supporting | chart | none\",",
|
||||
" \"primary_visual_required\": true,",
|
||||
" \"primary_visual\": \"/absolute/path/to/local/image.png\",",
|
||||
" \"layout_family\": \"hero-image-left-code-right\",",
|
||||
" \"text_overlay\": true,",
|
||||
" \"mask_required\": true,",
|
||||
" \"mask_strategy\": \"dark-gradient-left\",",
|
||||
" \"visual_assets\": [\"/absolute/path/to/local/image.png\"]",
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
"- Image search is required by default for every deck topic and every slide visual subject unless the user explicitly says not to use images. Set `image_search_policy: \"required_by_default\"` for the normal path.",
|
||||
"- User-provided attachments or existing local assets may satisfy a prepared local asset need when they match the slide subject, but the deck still records the default image-search policy and slide-level visual subjects unless the user explicitly opted out of images.",
|
||||
"- Every non-opt-out deck must list `required_visual_subjects`; each subject must set `search_required: true`, a concrete `search_query`, and `search_status`.",
|
||||
"- For specific real-world entities (named companies, products, people, places, venues, artworks, papers, named events, screenshots, or UI/product surfaces), also record them in legacy `required_search_entities` and use `source_type: \"web_search\"` evidence for each entity before any fallback is considered.",
|
||||
"- Search failures must still be explicit assets with `status: \"not_found\"`, `query`, and `failure_reason`; then use `generated` fallback assets only with `image_search_policy: \"fallback_search_failed\"` and concrete `fallback_reason`.",
|
||||
"- If network image search is unavailable and no user-provided asset covers the subject, use `image_search_policy: \"fallback_no_network\"` and record `search_status: \"unavailable\"` plus `fallback_reason` before using generated/SVG fallback assets.",
|
||||
"- Generated assets are fallback-only. They must include `fallback_reason`; they cannot satisfy the default search requirement by themselves.",
|
||||
"- If the user explicitly says not to use images, set `image_search_policy: \"user_opted_out\"` plus `image_search_opt_out_reason`; only then may `assets` and `required_visual_subjects` be empty and key pages omit `primary_visual`. Do not create generated replacement imagery under `user_opted_out`.",
|
||||
"- Every slide must declare `slide_role` and `layout_family`. `cover`, `section-divider`, `closing`, and `key-showcase` slides must set `primary_visual_required: true` and provide a `primary_visual`.",
|
||||
"- Primary/hero assets must declare `asset_kind`, `expressive_role`, `content_relevance`, and `format`; charts, icons, decorative patterns, textures, and abstract geometry do not satisfy a primary visual requirement.",
|
||||
"- Photographic/product/person/storefront/screenshot/document-capture assets should use PNG/JPG/JPEG/WebP. SVG is allowed for those only with `format_exception_reason`.",
|
||||
"- If slide text overlays a hero image (`text_overlay: true` and `mask_required: true`), the slide SVG must include a readable parser-legible mask/scrim/safe-zone structure such as `<rect slide:role=\"shape\" slide:shape-type=\"rect\" data-svglide-layer=\"mask\" ...>`, a gradient overlay, or a semi-transparent overlay rect. Do not invent new `slide:role` values for masks.",
|
||||
"- Every `ready` visual asset must have an existing local path, be assigned to a slide's `visual_assets`, and be embedded by that slide SVG. Remote-only URLs are not publish-ready.",
|
||||
"- Every slide must declare a `layout_family`; multi-deck/team runs must compare `visual_identity_fingerprint` and layout-family overlap with `../../../scripts/svg_slides_visual_identity_check.mjs`.",
|
||||
"",
|
||||
"## Validation Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"node skills/lark-slides/scripts/svg_slides_asset_readiness.mjs <deck-dir>",
|
||||
"node skills/lark-slides/scripts/svg_slides_visual_identity_check.mjs <deck-a> <deck-b> [<deck-c>...]",
|
||||
"```"
|
||||
].join("\n"));
|
||||
writeFile("validation/typography-plan.md", [
|
||||
"# Typography Plan",
|
||||
"",
|
||||
"Every generated SVG Slides bundle must write `receipts/typography_plan.json` before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The receipt makes deck-level font choice explicit. It prevents a generator from silently substituting fonts during slide authoring after the design brief chose a visual direction.",
|
||||
"",
|
||||
"## Required Receipt",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
" \"version\": \"svg-slides.typography-plan.v1\",",
|
||||
" \"font_category\": \"clean sans display and body\",",
|
||||
" \"display_font\": \"DM Sans, 黑体, sans-serif\",",
|
||||
" \"body_font\": \"DM Sans, 黑体, sans-serif\",",
|
||||
" \"label_font\": \"DM Sans, 黑体, sans-serif\",",
|
||||
" \"source\": \"visual_system.typography\",",
|
||||
" \"selection_reason\": \"Why this category and stack match the deck's topic, audience, and tone.\",",
|
||||
" \"forbidden_substitutions\": [\"serif display for a sans visual system\", \"calligraphy body\"],",
|
||||
" \"runtime_candidates_used\": true,",
|
||||
" \"requested_fonts\": [\"Bodoni MT\", \"汉仪楷体简\", \"思源宋体\", \"微软雅黑\"],",
|
||||
" \"resolved_fonts\": [\"Bodoni MT, 汉仪楷体简, serif\", \"Arial, 微软雅黑, sans-serif\"],",
|
||||
" \"fallbacks\": [",
|
||||
" {",
|
||||
" \"requested\": \"汉仪楷体简\",",
|
||||
" \"fallback\": \"Bodoni MT, KaiTi, serif\",",
|
||||
" \"reason\": \"Only if the requested font is unavailable in runtime candidates or local font probes.\"",
|
||||
" }",
|
||||
" ],",
|
||||
" \"fallback_reason\": \"One-sentence summary when any requested font is unavailable; empty when all requested fonts are preserved.\",",
|
||||
" \"unavailable_fonts\": [],",
|
||||
" \"user_requested_font_preserved\": true",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
"- The design brief's `visual_system.typography` is authoritative. Keep its category and treatment: serif vs sans-serif vs rounded vs mono, weight, case, and letter spacing.",
|
||||
"- Explicit user-requested fonts outrank inferred visual_system fonts. Preserve every requested font by name in `display_font`, `body_font`, or `label_font`, or record it in `unavailable_fonts` with a concrete fallback reason.",
|
||||
"- Select explicit `display_font`, `body_font`, and `label_font` stacks before slide authoring. Use `<runtime_font_candidates>` when provided; otherwise use the Font Palette in `svg-reference.md`.",
|
||||
"- Each stack must start with a Latin font and end with a generic fallback such as `serif`, `sans-serif`, `monospace`, or `cursive`.",
|
||||
"- Body text must not use calligraphy, cursive, handwriting, or decorative script fonts.",
|
||||
"- `forbidden_substitutions` must name the tempting but wrong swaps for this deck, such as flipping serif and sans-serif or replacing a technical mono treatment with a cute rounded font.",
|
||||
"- Do not silently replace requested Chinese/CJK fonts with `STKaiti`, `KaiTi`, `PingFang SC`, `Noto Sans SC`, `黑体`, or any other nearby-looking fallback.",
|
||||
"- All slide SVG text must use only the planned display/body/label stacks. If a new stack is needed, update `typography_plan.json` first and keep the category rationale explicit.",
|
||||
"",
|
||||
"## Validation Command",
|
||||
"",
|
||||
"```bash",
|
||||
"node skills/lark-slides/scripts/svg_slides_typography_plan.mjs <deck-dir>",
|
||||
"```"
|
||||
].join("\n"));
|
||||
writeFile("validation/visual-system.md", [
|
||||
"# Visual System And Color Rhythm Gate",
|
||||
"",
|
||||
"Role: schema",
|
||||
"",
|
||||
"## Purpose",
|
||||
"",
|
||||
"`receipts/visual_system.json` is the deck-level visual contract. It prevents a generated deck from changing palette, background mode, or page-to-page color rhythm opportunistically while individual slides are being authored.",
|
||||
"",
|
||||
"This gate does not restrict layout freedom. It only locks the color tokens and rhythm decisions that must stay coherent across the whole deck.",
|
||||
"",
|
||||
"## Required Receipt",
|
||||
"",
|
||||
"Write `receipts/visual_system.json` before any `slide_edit` call.",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
" \"version\": \"svg-slides.visual-system.v1\",",
|
||||
" \"palette_tokens\": {",
|
||||
" \"bg.base\": \"rgba(255,255,255,1)\",",
|
||||
" \"bg.deep\": \"rgba(8,15,28,1)\",",
|
||||
" \"panel.light\": \"rgba(255,255,255,1)\",",
|
||||
" \"panel.dark\": \"rgba(15,23,42,1)\",",
|
||||
" \"text.primary\": \"rgba(15,23,42,1)\",",
|
||||
" \"accent.primary\": \"rgba(0,184,216,1)\"",
|
||||
" },",
|
||||
" \"color_rhythm\": {",
|
||||
" \"full_dark_budget\": 2,",
|
||||
" \"allowed_full_dark_roles\": [\"cover\", \"section-divider\", \"key-showcase\"],",
|
||||
" \"default_content_theme\": \"light\",",
|
||||
" \"closing_theme\": \"light\",",
|
||||
" \"max_adjacent_luma_delta\": 0.62,",
|
||||
" \"max_transition_ratio\": 0.45,",
|
||||
" \"allowed_hard_transitions\": [\"slides/slide_01.svg->slides/slide_02.svg\"],",
|
||||
" \"slide_themes\": [",
|
||||
" {",
|
||||
" \"file\": \"slides/slide_01.svg\",",
|
||||
" \"role\": \"cover\",",
|
||||
" \"theme\": \"dark\",",
|
||||
" \"reason\": \"cover uses the strongest contrast moment\"",
|
||||
" }",
|
||||
" ]",
|
||||
" }",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"## Palette Rules",
|
||||
"",
|
||||
"- Use `rgb(...)` or `rgba(...)` values only.",
|
||||
"- Required tokens: `bg.base`, `bg.deep`, `panel.light`, `panel.dark`, `text.primary`, and at least one `accent.*`.",
|
||||
"- Slide SVG fills, strokes, stop colors, and CSS text colors must use colors whose RGB values match a palette token. Alpha variants are allowed.",
|
||||
"- Gradients are allowed only when every stop color is token-derived.",
|
||||
"",
|
||||
"## Color Rhythm Rules",
|
||||
"",
|
||||
"- `slide_themes` must include exactly one entry per slide file.",
|
||||
"- `role` must match the slide role used in content planning: `cover`, `content`, `section-divider`, `key-showcase`, `closing`, or a similarly explicit role.",
|
||||
"- `theme` is one of `light`, `dark`, or `image`.",
|
||||
"- Full dark backgrounds count against `full_dark_budget`.",
|
||||
"- Dark backgrounds are allowed only for roles listed in `allowed_full_dark_roles`.",
|
||||
"- Content pages default to `default_content_theme`; closing pages must use `closing_theme`.",
|
||||
"- Avoid alternating full-page dark/light/dark/light patterns. Use localized accents, panels, or imagery to create variety without changing the entire page ground every slide.",
|
||||
"- `allowed_hard_transitions` may list specific adjacent slide pairs when a deliberate chapter break requires a strong jump.",
|
||||
"",
|
||||
"## Validation Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"node skills/lark-slides/scripts/svg_slides_visual_system.mjs <deck-dir> --json",
|
||||
"node skills/lark-slides/scripts/svg_slides_color_rhythm.mjs <deck-dir> --json",
|
||||
"```",
|
||||
"",
|
||||
"`svg_slides_bundle.mjs` writes:",
|
||||
"",
|
||||
"- `receipts/visual_system_report.json`",
|
||||
"- `receipts/color_rhythm.json`",
|
||||
"",
|
||||
"Both must have `ok: true` and `totalErrors: 0` before the bundle can publish."
|
||||
].join("\n"));
|
||||
writeFile("validation/frame-discipline.md", [
|
||||
"# Frame Discipline",
|
||||
"",
|
||||
"Every generated SVG Slides bundle must pass frame-discipline validation before `manifest.json` may set `publish_ready=true`.",
|
||||
"",
|
||||
"The default text treatment is unframed. This gate prevents generators from wrapping ordinary headings, body paragraphs, summary points, captions, and quotes in stroked rectangles just to organize content.",
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
"- A `<rect>` with `stroke` that encloses or overlaps text must declare `data-frame-role`.",
|
||||
"- Allowed frame roles: `card`, `timeline_node`, `chart_container`, `table_container`, `worksheet_area`, `callout`.",
|
||||
"- Use frames only when the boundary carries meaning. Do not use bordered rectangles as the default substitute for whitespace, alignment, typographic hierarchy, or simple divider lines.",
|
||||
"- A non-dashboard slide should not have more than four text-adjacent stroked frames. Dashboard, matrix, table, and worksheet layouts are exempt only when the asset-readiness slide metadata says so via `slide_role` or `layout_family`.",
|
||||
"- Image masks, text scrims, chart elements, backgrounds, and decorative shapes that do not touch text are outside this gate.",
|
||||
"",
|
||||
"## Validation Command",
|
||||
"",
|
||||
"```bash",
|
||||
"node skills/lark-slides/scripts/svg_slides_frame_discipline.mjs <deck-dir>",
|
||||
"```"
|
||||
].join("\n"));
|
||||
writeFile("validation/prompt-read-trace.md", [
|
||||
"# Prompt Read Trace",
|
||||
"",
|
||||
"Every generated SVG Slides bundle must write `receipts/prompt_read_trace.json` before bundling.",
|
||||
"",
|
||||
"The receipt proves the generator read the required prompt and reference Markdown files for the selected task.",
|
||||
"",
|
||||
"## Required Receipt Shape",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
" \"version\": \"svg-slides.prompt-read-trace.v1\",",
|
||||
" \"task\": \"generate_deck\",",
|
||||
" \"entrypoint\": \"README.md\",",
|
||||
" \"generated_at\": \"2026-07-08T00:00:00.000Z\",",
|
||||
" \"read_order\": [",
|
||||
" {",
|
||||
" \"path\": \"system-prompt.md\",",
|
||||
" \"role\": \"prompt\",",
|
||||
" \"reason\": \"main workflow\",",
|
||||
" \"sha256\": \"...\"",
|
||||
" }",
|
||||
" ],",
|
||||
" \"fallbacks\": [],",
|
||||
" \"missing_required_reads\": []",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"## Failure Semantics",
|
||||
"",
|
||||
"- Missing required files fail trace creation.",
|
||||
"- Missing required reads fail validation.",
|
||||
"- A bundle without `receipts/prompt_read_trace.json` must not be marked `publish_ready=true`.",
|
||||
"- If chart snippet routing is uncertain, record `generate-svg-chart/full.md` in `fallbacks` and in `read_order`."
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
function updateManifest() {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
manifest.source = "skills/lark-slides/prompt-sources/svg-slides/full.debranded.md";
|
||||
manifest.source_role = "maintainer_prompt_source_not_runtime_generation_context";
|
||||
manifest.sections = manifest.sections.map((section) => {
|
||||
const target = manifestTargets[section.id];
|
||||
if (!target) throw new Error(`missing manifest target for ${section.id}`);
|
||||
return { ...section, target };
|
||||
});
|
||||
if (!dryRun) fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
|
||||
for (const relPath of obsoleteFiles) removePath(relPath);
|
||||
writeRootReadme();
|
||||
writeStandaloneDocs();
|
||||
writeResolveDesignBriefDocs();
|
||||
writeToolDocs();
|
||||
writeGenerateChartDocs();
|
||||
writeValidationDocs();
|
||||
updateManifest();
|
||||
|
||||
const snippetCount = parseSnippetBundle().length;
|
||||
const report = {
|
||||
root,
|
||||
sourceRoot,
|
||||
dryRun,
|
||||
snippetCount,
|
||||
manifestTargets: Object.keys(manifestTargets).length
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_extract_references.mjs");
|
||||
const realSourceRoot = path.resolve("skills/lark-slides/prompt-sources/svg-slides");
|
||||
|
||||
function copyFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-extract-"));
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-extract-source-"));
|
||||
fs.cpSync(realSourceRoot, sourceRoot, { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "examples"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "workflow.md"), "# Old Workflow\n");
|
||||
fs.writeFileSync(path.join(root, "protocol.md"), "# Old Protocol\n");
|
||||
return { root, sourceRoot };
|
||||
}
|
||||
|
||||
function run(root, sourceRoot) {
|
||||
return spawnSync("node", [script, root, "--source-root", sourceRoot], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("extracts runtime-unit documents and removes obsolete summaries", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(path.join(root, "system-prompt.md")));
|
||||
assert.ok(fs.existsSync(path.join(root, "svg-reference.md")));
|
||||
assert.ok(fs.existsSync(path.join(root, "deck-design-reference-catalog.md")));
|
||||
assert.ok(fs.existsSync(path.join(root, "resolve-design-brief", "full.md")));
|
||||
assert.ok(fs.existsSync(path.join(root, "generate-svg-chart", "full.md")));
|
||||
assert.match(fs.readFileSync(path.join(root, "tools", "slides-edit.md"), "utf8"), /- Role: tool_contract/);
|
||||
assert.match(fs.readFileSync(path.join(root, "validation", "README.md"), "utf8"), /\.\.\/\.\.\/\.\.\/scripts\/validate_svg_deck\.mjs/);
|
||||
assert.equal(fs.existsSync(path.join(root, "workflow.md")), false);
|
||||
assert.equal(fs.existsSync(path.join(root, "protocol.md")), false);
|
||||
assert.equal(fs.existsSync(path.join(root, "source")), false);
|
||||
});
|
||||
|
||||
test("updates manifest targets to nested runtime-unit paths", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, "split-manifest.json"), "utf8"));
|
||||
const byID = new Map(manifest.sections.map((section) => [section.id, section.target]));
|
||||
assert.equal(manifest.source, "skills/lark-slides/prompt-sources/svg-slides/full.debranded.md");
|
||||
assert.equal(byID.get("system_prompt_workflow"), "system-prompt.md");
|
||||
assert.equal(byID.get("resolve_design_brief"), "resolve-design-brief/full.md");
|
||||
assert.equal(byID.get("generate_svg_chart_tool"), "generate-svg-chart/full.md");
|
||||
assert.equal(byID.get("slides_convert_tool"), "tools/slides-convert.md");
|
||||
});
|
||||
|
||||
test("extracts exactly twenty two chart snippets", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const snippetsDir = path.join(root, "generate-svg-chart", "snippets");
|
||||
const snippets = fs.readdirSync(snippetsDir).filter((name) => name.endsWith(".md") && name !== "README.md");
|
||||
assert.equal(snippets.length, 22);
|
||||
assert.ok(snippets.includes("axis-break-marker.md"));
|
||||
assert.ok(snippets.includes("waterfall-connector.md"));
|
||||
});
|
||||
|
||||
test("extracts chart agent contract without adjacent schema bleed", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const contract = fs.readFileSync(path.join(root, "generate-svg-chart", "chart-agent-contract.md"), "utf8");
|
||||
assert.match(contract, /You are a dedicated data visualization subagent/);
|
||||
assert.match(contract, /Hard rules:\n1\. viewBox MUST be exactly `0 0 960 600`/);
|
||||
assert.doesNotMatch(contract, /--- output_path \(required, string\) ---/);
|
||||
});
|
||||
|
||||
test("generated chart docs do not keep implementation filename noise", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const allMarkdown = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name === "source") continue;
|
||||
if (entry.isDirectory()) walk(filePath);
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) allMarkdown.push(filePath);
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
const joined = allMarkdown.map((filePath) => fs.readFileSync(filePath, "utf8")).join("\n");
|
||||
assert.equal(/tool_[A-Za-z0-9_]+\.go\.tmpl/.test(joined), false);
|
||||
assert.equal(joined.includes("svgchartservice/service.go"), false);
|
||||
assert.equal(/\bservice\.go\b/.test(joined), false);
|
||||
assert.equal(/snippets\/\\?\*\.md/.test(joined), false);
|
||||
});
|
||||
|
||||
test("snippet docs omit source provenance and wrapper fence", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const waterfall = fs.readFileSync(path.join(root, "generate-svg-chart", "snippets", "waterfall-connector.md"), "utf8");
|
||||
assert.doesNotMatch(waterfall, /Source lines:/);
|
||||
assert.doesNotMatch(waterfall, /Source Slice/);
|
||||
assert.equal(waterfall.trimEnd().endsWith("````"), false);
|
||||
});
|
||||
|
||||
test("strips copied implementation filenames from generated docs", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const allMarkdown = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name === "source") continue;
|
||||
if (entry.isDirectory()) walk(filePath);
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) allMarkdown.push(filePath);
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
const joined = allMarkdown.map((filePath) => fs.readFileSync(filePath, "utf8")).join("\n");
|
||||
assert.equal(/tool_[A-Za-z0-9_]+\.go\.tmpl/.test(joined), false);
|
||||
assert.equal(joined.includes("svgchartservice/service.go"), false);
|
||||
assert.equal(joined.includes("snippets/*.md"), false);
|
||||
});
|
||||
|
||||
test("generated runtime docs do not expose prompt source provenance", () => {
|
||||
const { root, sourceRoot } = copyFixture();
|
||||
const result = run(root, sourceRoot);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const allMarkdown = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(filePath);
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) allMarkdown.push(filePath);
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
const joined = allMarkdown.map((filePath) => fs.readFileSync(filePath, "utf8")).join("\n");
|
||||
assert.doesNotMatch(joined, /Source lines:/);
|
||||
assert.doesNotMatch(joined, /## Source Coverage/);
|
||||
assert.doesNotMatch(joined, /## Source Slice/);
|
||||
assert.doesNotMatch(joined, /source\/full\.debranded\.md/);
|
||||
assert.doesNotMatch(joined, /source\/split-manifest\.json/);
|
||||
});
|
||||
197
skills/lark-slides/scripts/svg_slides_frame_discipline.mjs
Normal file
197
skills/lark-slides/scripts/svg_slides_frame_discipline.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function decodeXmlAttr(value) {
|
||||
return String(value || "")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function attrValue(tag, name) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return decodeXmlAttr(tag.match(new RegExp(`${escaped}\\s*=\\s*([\"'])(.*?)\\1`, "i"))?.[2] || "");
|
||||
}
|
||||
|
||||
function numberAttr(tag, name) {
|
||||
const value = Number.parseFloat(attrValue(tag, name));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function bboxFromTag(tag) {
|
||||
const x = numberAttr(tag, "x");
|
||||
const y = numberAttr(tag, "y");
|
||||
const width = numberAttr(tag, "width");
|
||||
const height = numberAttr(tag, "height");
|
||||
if ([x, y, width, height].some((value) => value === null)) return null;
|
||||
return { x, y, width, height, x2: x + width, y2: y + height };
|
||||
}
|
||||
|
||||
function overlaps(a, b, pad = 8) {
|
||||
return !(
|
||||
a.x2 < b.x - pad
|
||||
|| a.x > b.x2 + pad
|
||||
|| a.y2 < b.y - pad
|
||||
|| a.y > b.y2 + pad
|
||||
);
|
||||
}
|
||||
|
||||
function contains(outer, inner, pad = 8) {
|
||||
return outer.x <= inner.x + pad
|
||||
&& outer.y <= inner.y + pad
|
||||
&& outer.x2 >= inner.x2 - pad
|
||||
&& outer.y2 >= inner.y2 - pad;
|
||||
}
|
||||
|
||||
function listSvgFiles(root) {
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
if (!fs.existsSync(slidesDir)) return [];
|
||||
return fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort()
|
||||
.map((file) => path.join(slidesDir, file));
|
||||
}
|
||||
|
||||
function readSlideMetadata(root) {
|
||||
const receiptPath = path.join(root, "receipts", "asset_readiness.json");
|
||||
if (!fs.existsSync(receiptPath)) return new Map();
|
||||
const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
|
||||
const map = new Map();
|
||||
for (const slide of Array.isArray(receipt.slides) ? receipt.slides : []) {
|
||||
if (!slide?.file) continue;
|
||||
const key = String(slide.file).split(path.sep).join("/");
|
||||
map.set(key, slide);
|
||||
map.set(path.basename(key), slide);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function isDensityExempt(metadata = {}) {
|
||||
const text = `${metadata.slide_role || ""} ${metadata.layout_family || ""}`.toLowerCase();
|
||||
return /dashboard|matrix|worksheet|table|grid|scoreboard|scorecard|financial/.test(text);
|
||||
}
|
||||
|
||||
const allowedFrameRoles = new Set([
|
||||
"card",
|
||||
"timeline_node",
|
||||
"chart_container",
|
||||
"table_container",
|
||||
"worksheet_area",
|
||||
"callout"
|
||||
]);
|
||||
|
||||
function isExemptRect(tag, bbox) {
|
||||
const slideRole = attrValue(tag, "slide:role");
|
||||
if (["background", "chart", "text-scrim", "image-mask"].includes(slideRole)) return true;
|
||||
if (!bbox) return true;
|
||||
if (bbox.width < 8 || bbox.height < 8) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkSlide(filePath, root, metadata) {
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const relPath = path.relative(root, filePath).split(path.sep).join("/");
|
||||
const errors = [];
|
||||
const textBoxes = [];
|
||||
|
||||
for (const match of source.matchAll(/<foreignObject\b[^>]*>/gis)) {
|
||||
const tag = match[0];
|
||||
if (!/slide:role\s*=\s*["']shape["']/i.test(tag) || !/slide:shape-type\s*=\s*["']text["']/i.test(tag)) {
|
||||
continue;
|
||||
}
|
||||
const bbox = bboxFromTag(tag);
|
||||
if (bbox) textBoxes.push(bbox);
|
||||
}
|
||||
|
||||
let textAdjacentFrameCount = 0;
|
||||
for (const [index, match] of [...source.matchAll(/<rect\b[^>]*>/gis)].entries()) {
|
||||
const tag = match[0];
|
||||
const stroke = attrValue(tag, "stroke").trim();
|
||||
if (!stroke || stroke === "none" || stroke === "transparent") continue;
|
||||
const bbox = bboxFromTag(tag);
|
||||
if (isExemptRect(tag, bbox)) continue;
|
||||
|
||||
const touchesText = textBoxes.some((textBox) => overlaps(bbox, textBox) || contains(bbox, textBox));
|
||||
if (!touchesText) continue;
|
||||
|
||||
textAdjacentFrameCount += 1;
|
||||
const frameRole = attrValue(tag, "data-frame-role").trim();
|
||||
if (!frameRole) {
|
||||
errors.push({
|
||||
rule: "frame.role-required",
|
||||
severity: "error",
|
||||
message: `${relPath} rect #${index + 1} has stroke near text but no data-frame-role`
|
||||
});
|
||||
} else if (!allowedFrameRoles.has(frameRole)) {
|
||||
errors.push({
|
||||
rule: "frame.role-allowed",
|
||||
severity: "error",
|
||||
message: `${relPath} rect #${index + 1} uses invalid data-frame-role=${JSON.stringify(frameRole)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (textAdjacentFrameCount > 4 && !isDensityExempt(metadata)) {
|
||||
errors.push({
|
||||
rule: "frame.too-many",
|
||||
severity: "error",
|
||||
message: `${relPath} has ${textAdjacentFrameCount} text-adjacent stroked frame(s); non-dashboard slides may use at most 4`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
file: relPath,
|
||||
textAdjacentFrameCount,
|
||||
errorCount: errors.length,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const json = args.includes("--json");
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!deckArg) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_frame_discipline.mjs <deck-dir-or-slides-dir> [--json]");
|
||||
}
|
||||
|
||||
const root = path.resolve(deckArg);
|
||||
const slideFiles = listSvgFiles(root);
|
||||
if (slideFiles.length === 0) {
|
||||
fail(`No .svg files found in: ${root}`, 2);
|
||||
}
|
||||
|
||||
const metadataByFile = readSlideMetadata(root);
|
||||
const results = slideFiles.map((filePath) => {
|
||||
const rel = path.relative(root, filePath).split(path.sep).join("/");
|
||||
const metadata = metadataByFile.get(rel) || metadataByFile.get(path.basename(rel)) || {};
|
||||
return checkSlide(filePath, root, metadata);
|
||||
});
|
||||
const totalErrors = results.reduce((sum, result) => sum + result.errorCount, 0);
|
||||
const report = {
|
||||
ok: totalErrors === 0,
|
||||
target: root,
|
||||
slideCount: slideFiles.length,
|
||||
totalErrors,
|
||||
allowedFrameRoles: [...allowedFrameRoles],
|
||||
results
|
||||
};
|
||||
|
||||
if (json) {
|
||||
const output = JSON.stringify(report, null, 2);
|
||||
if (totalErrors > 0) console.error(output);
|
||||
else console.log(output);
|
||||
} else if (totalErrors > 0) {
|
||||
for (const result of results) {
|
||||
for (const error of result.errors) console.error(`${error.rule}: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
process.exit(totalErrors ? 1 : 0);
|
||||
113
skills/lark-slides/scripts/svg_slides_frame_discipline_test.mjs
Normal file
113
skills/lark-slides/scripts/svg_slides_frame_discipline_test.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_frame_discipline.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-frame-"));
|
||||
fs.mkdirSync(path.join(root, "slides"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function slide(content) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide_01" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
${content}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function textBox(x, y, text = "Frame discipline") {
|
||||
return ` <foreignObject slide:role="shape" slide:shape-type="text" x="${x}" y="${y}" width="240" height="48" style="font-size:24px;font-family:DM Sans, 黑体, sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:24px;color:rgba(15,23,42,1)">${text}</p>
|
||||
</foreignObject>`;
|
||||
}
|
||||
|
||||
function writeSlide(root, content) {
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), slide(content));
|
||||
}
|
||||
|
||||
function writeAssetReadiness(root, layoutFamily = "executive-dashboard-grid") {
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness.json"), `${JSON.stringify({
|
||||
version: "svg-slides.asset-readiness.v1",
|
||||
visual_identity_fingerprint: {
|
||||
style_family: "test",
|
||||
composition_signature: "test",
|
||||
imagery_treatment: "test",
|
||||
palette_signature: "test"
|
||||
},
|
||||
required_search_entities: [],
|
||||
assets: [],
|
||||
slides: [
|
||||
{
|
||||
file: "slides/slide_01.svg",
|
||||
slide_role: "content",
|
||||
layout_family: layoutFamily,
|
||||
visual_assets: []
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function run(root) {
|
||||
return spawnSync("node", [script, root], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("frame discipline accepts unframed text", () => {
|
||||
const root = tempDeck();
|
||||
writeSlide(root, textBox(80, 80));
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
assert.equal(report.results[0].textAdjacentFrameCount, 0);
|
||||
});
|
||||
|
||||
test("frame discipline rejects a stroked text frame without semantic role", () => {
|
||||
const root = tempDeck();
|
||||
writeSlide(root, ` <rect slide:role="shape" slide:shape-type="rect" x="64" y="64" width="300" height="92" fill="rgba(255,255,255,1)" stroke="rgba(15,23,42,1)" stroke-width="1"/>
|
||||
${textBox(80, 80)}`);
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /frame\.role-required/);
|
||||
});
|
||||
|
||||
test("frame discipline accepts an explicitly marked card frame", () => {
|
||||
const root = tempDeck();
|
||||
writeSlide(root, ` <rect slide:role="shape" slide:shape-type="rect" data-frame-role="card" x="64" y="64" width="300" height="92" fill="rgba(255,255,255,1)" stroke="rgba(15,23,42,1)" stroke-width="1"/>
|
||||
${textBox(80, 80)}`);
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.results[0].textAdjacentFrameCount, 1);
|
||||
});
|
||||
|
||||
test("frame discipline rejects too many text-adjacent frames on a normal slide", () => {
|
||||
const root = tempDeck();
|
||||
const content = Array.from({ length: 5 }, (_, index) => {
|
||||
const y = 40 + index * 84;
|
||||
return ` <rect slide:role="shape" slide:shape-type="rect" data-frame-role="card" x="64" y="${y}" width="300" height="64" fill="rgba(255,255,255,1)" stroke="rgba(15,23,42,1)" stroke-width="1"/>
|
||||
${textBox(80, y + 10, `Card ${index + 1}`)}`;
|
||||
}).join("\n");
|
||||
writeSlide(root, content);
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /frame\.too-many/);
|
||||
});
|
||||
|
||||
test("frame discipline allows dense dashboard frames when metadata declares dashboard layout", () => {
|
||||
const root = tempDeck();
|
||||
const content = Array.from({ length: 5 }, (_, index) => {
|
||||
const y = 40 + index * 84;
|
||||
return ` <rect slide:role="shape" slide:shape-type="rect" data-frame-role="card" x="64" y="${y}" width="300" height="64" fill="rgba(255,255,255,1)" stroke="rgba(15,23,42,1)" stroke-width="1"/>
|
||||
${textBox(80, y + 10, `Metric ${index + 1}`)}`;
|
||||
}).join("\n");
|
||||
writeSlide(root, content);
|
||||
writeAssetReadiness(root, "executive-dashboard-grid");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
});
|
||||
248
skills/lark-slides/scripts/svg_slides_prompt_trace.mjs
Executable file
248
skills/lark-slides/scripts/svg_slides_prompt_trace.mjs
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function argValue(args, name, fallback = "") {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function hasFlag(args, name) {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
function sha256(filePath) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function role(markdown) {
|
||||
const match = markdown.match(/^- Role:\s*(.+)$/m);
|
||||
return match ? match[1].trim() : "reference";
|
||||
}
|
||||
|
||||
function snippetChartTypes(markdown) {
|
||||
const match = markdown.match(/^chart_types:\s*\[([^\]]*)\]/m);
|
||||
if (!match) return [];
|
||||
return match[1].split(",").map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function listChartSnippets(referenceRoot, chartType) {
|
||||
const snippetsDir = path.join(referenceRoot, "generate-svg-chart", "snippets");
|
||||
if (!fs.existsSync(snippetsDir)) return [];
|
||||
return fs.readdirSync(snippetsDir)
|
||||
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
||||
.sort()
|
||||
.filter((name) => snippetChartTypes(readText(path.join(snippetsDir, name))).includes(chartType))
|
||||
.map((name) => `generate-svg-chart/snippets/${name}`);
|
||||
}
|
||||
|
||||
function requiredReads(referenceRoot, task, chartType, useFullFallback) {
|
||||
if (task === "generate_chart") {
|
||||
const base = [
|
||||
["generate-svg-chart/README.md", "chart ability entrypoint"],
|
||||
["generate-svg-chart/input-schema.md", "chart input schema and routing"],
|
||||
["generate-svg-chart/chart-agent-contract.md", "chart agent response contract"],
|
||||
["generate-svg-chart/design-envelope.md", "chart design rules"],
|
||||
["generate-svg-chart/snippets/README.md", "snippet routing index"]
|
||||
];
|
||||
if (useFullFallback || !chartType) {
|
||||
return {
|
||||
entries: [...base, ["generate-svg-chart/full.md", "fallback when snippet routing is uncertain"]],
|
||||
fallbacks: ["generate-svg-chart/full.md"]
|
||||
};
|
||||
}
|
||||
const snippets = listChartSnippets(referenceRoot, chartType)
|
||||
.map((relPath) => [relPath, `snippet for chart_type=${chartType}`]);
|
||||
if (snippets.length === 0) {
|
||||
return {
|
||||
entries: [...base, ["generate-svg-chart/full.md", `fallback because no snippets matched chart_type=${chartType}`]],
|
||||
fallbacks: ["generate-svg-chart/full.md"]
|
||||
};
|
||||
}
|
||||
return { entries: [...base, ...snippets], fallbacks: [] };
|
||||
}
|
||||
|
||||
if (task === "generate_deck") {
|
||||
const entries = [
|
||||
["README.md", "root routing entrypoint"],
|
||||
["system-prompt.md", "main workflow"],
|
||||
["svg-reference.md", "SVG schema and examples"],
|
||||
["deck-design-reference-catalog.md", "narrative archetypes and depth rubric"],
|
||||
["resolve-design-brief/README.md", "design brief read path"],
|
||||
["resolve-design-brief/tool-contract.md", "design brief tool contract"],
|
||||
["resolve-design-brief/style-director-prompt.md", "typography and visual style director prompt"],
|
||||
["resolve-design-brief/full.md", "design brief prompt bundle"],
|
||||
["tools/slide-outline.md", "outline tool contract"],
|
||||
["tools/activate-slides-edit.md", "activate edit mode contract"],
|
||||
["tools/slides-edit.md", "SVG authoring tool contract"],
|
||||
["tools/finish-slides-edit.md", "finish edit mode contract"],
|
||||
["validation/asset-readiness.md", "asset search and visual identity readiness contract"],
|
||||
["validation/typography-plan.md", "typography plan receipt contract"],
|
||||
["validation/visual-system.md", "visual system and color rhythm receipt contract"],
|
||||
["validation/frame-discipline.md", "frame discipline validation contract"],
|
||||
["validation/prompt-read-trace.md", "prompt read trace receipt contract"],
|
||||
["validation/post-delivery-audit.md", "post-delivery prompt satisfaction audit contract"]
|
||||
];
|
||||
if (useFullFallback || chartType) {
|
||||
const chartReads = requiredReads(referenceRoot, "generate_chart", chartType, useFullFallback);
|
||||
return {
|
||||
entries: [...entries, ...chartReads.entries],
|
||||
fallbacks: chartReads.fallbacks
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
fallbacks: []
|
||||
};
|
||||
}
|
||||
|
||||
fail(`unknown --task ${task}; expected generate_deck or generate_chart`);
|
||||
}
|
||||
|
||||
function buildReadOrder(referenceRoot, entries) {
|
||||
const missing = [];
|
||||
const readOrder = [];
|
||||
for (const [relPath, reason] of entries) {
|
||||
const abs = path.join(referenceRoot, relPath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
missing.push(relPath);
|
||||
continue;
|
||||
}
|
||||
const markdown = readText(abs);
|
||||
readOrder.push({
|
||||
path: relPath,
|
||||
role: role(markdown),
|
||||
reason,
|
||||
sha256: sha256(abs)
|
||||
});
|
||||
}
|
||||
return { readOrder, missing };
|
||||
}
|
||||
|
||||
function createTrace({ deckDir, referenceRoot, task, chartType, useFullFallback }) {
|
||||
const { entries, fallbacks } = requiredReads(referenceRoot, task, chartType, useFullFallback);
|
||||
const { readOrder, missing } = buildReadOrder(referenceRoot, entries);
|
||||
const receipt = {
|
||||
version: "svg-slides.prompt-read-trace.v1",
|
||||
task,
|
||||
chart_type: chartType || "",
|
||||
entrypoint: task === "generate_chart" ? "generate-svg-chart/README.md" : "README.md",
|
||||
generated_at: new Date().toISOString(),
|
||||
read_order: readOrder,
|
||||
fallbacks,
|
||||
missing_required_reads: missing
|
||||
};
|
||||
fs.mkdirSync(path.join(deckDir, "receipts"), { recursive: true });
|
||||
const receiptPath = path.join(deckDir, "receipts", "prompt_read_trace.json");
|
||||
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
if (missing.length > 0) {
|
||||
fail(`missing required prompt reads: ${missing.join(", ")}`, 1);
|
||||
}
|
||||
return receiptPath;
|
||||
}
|
||||
|
||||
function validateTrace({ deckDir, referenceRoot, expectedTask = "", expectedChartType = "" }) {
|
||||
const receiptPath = path.join(deckDir, "receipts", "prompt_read_trace.json");
|
||||
if (!fs.existsSync(receiptPath)) fail(`missing prompt read trace: ${receiptPath}`, 1);
|
||||
const receipt = JSON.parse(readText(receiptPath));
|
||||
if (receipt.version !== "svg-slides.prompt-read-trace.v1") {
|
||||
fail(`invalid prompt read trace version: ${receipt.version}`, 1);
|
||||
}
|
||||
if (expectedTask && receipt.task !== expectedTask) {
|
||||
fail(`prompt read trace task mismatch: expected ${expectedTask}, got ${receipt.task}`, 1);
|
||||
}
|
||||
if (expectedChartType && receipt.chart_type !== expectedChartType) {
|
||||
fail(`prompt read trace chart_type mismatch: expected ${expectedChartType}, got ${receipt.chart_type}`, 1);
|
||||
}
|
||||
if (!Array.isArray(receipt.read_order) || receipt.read_order.length === 0) {
|
||||
fail("prompt read trace has empty read_order", 1);
|
||||
}
|
||||
const useFullFallback = Array.isArray(receipt.fallbacks)
|
||||
&& receipt.fallbacks.includes("generate-svg-chart/full.md");
|
||||
const { entries: requiredEntries, fallbacks: expectedFallbacks } = requiredReads(
|
||||
referenceRoot,
|
||||
receipt.task,
|
||||
receipt.chart_type || "",
|
||||
useFullFallback || (receipt.task === "generate_chart" && !receipt.chart_type)
|
||||
);
|
||||
const receiptFallbacks = Array.isArray(receipt.fallbacks) ? receipt.fallbacks : [];
|
||||
if (
|
||||
receiptFallbacks.length !== expectedFallbacks.length
|
||||
|| expectedFallbacks.some((relPath) => !receiptFallbacks.includes(relPath))
|
||||
) {
|
||||
fail(`prompt read trace fallback mismatch: expected ${expectedFallbacks.join(", ") || "(none)"}`, 1);
|
||||
}
|
||||
const requiredPaths = requiredEntries.map(([relPath]) => relPath);
|
||||
const seenPaths = new Set();
|
||||
const missing = [];
|
||||
const drifted = [];
|
||||
const shaPattern = /^[a-f0-9]{64}$/;
|
||||
for (const entry of receipt.read_order) {
|
||||
if (!entry || typeof entry.path !== "string" || entry.path.length === 0) {
|
||||
fail("prompt read trace has invalid read_order entry path", 1);
|
||||
}
|
||||
if (typeof entry.role !== "string" || entry.role.length === 0) {
|
||||
fail(`prompt read trace has invalid read_order entry role: ${entry.path}`, 1);
|
||||
}
|
||||
if (typeof entry.reason !== "string" || entry.reason.length === 0) {
|
||||
fail(`prompt read trace has invalid read_order entry reason: ${entry.path}`, 1);
|
||||
}
|
||||
if (typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)) {
|
||||
fail(`prompt read trace has invalid read_order entry sha256: ${entry.path}`, 1);
|
||||
}
|
||||
if (seenPaths.has(entry.path)) {
|
||||
fail(`prompt read trace has duplicate read path: ${entry.path}`, 1);
|
||||
}
|
||||
seenPaths.add(entry.path);
|
||||
const abs = path.join(referenceRoot, entry.path);
|
||||
if (!fs.existsSync(abs)) {
|
||||
missing.push(entry.path);
|
||||
continue;
|
||||
}
|
||||
const current = sha256(abs);
|
||||
if (current !== entry.sha256) drifted.push(entry.path);
|
||||
}
|
||||
if (missing.length > 0) fail(`prompt read trace references missing files: ${missing.join(", ")}`, 1);
|
||||
if (drifted.length > 0) fail(`prompt read trace sha mismatch: ${drifted.join(", ")}`, 1);
|
||||
const receiptReadPaths = receipt.read_order.map((entry) => entry.path);
|
||||
if (
|
||||
receiptReadPaths.length !== requiredPaths.length
|
||||
|| receiptReadPaths.some((relPath, index) => relPath !== requiredPaths[index])
|
||||
) {
|
||||
fail(
|
||||
`prompt read trace read_order mismatch: expected ${requiredPaths.join(", ")}, got ${receiptReadPaths.join(", ")}`,
|
||||
1
|
||||
);
|
||||
}
|
||||
if (Array.isArray(receipt.missing_required_reads) && receipt.missing_required_reads.length > 0) {
|
||||
fail(`prompt read trace has missing_required_reads: ${receipt.missing_required_reads.join(", ")}`, 1);
|
||||
}
|
||||
return receiptPath;
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const chartType = argValue(args, "--chart-type", "");
|
||||
const referenceRoot = path.resolve(argValue(args, "--references", "skills/lark-slides/references/svg-slides"));
|
||||
const validate = hasFlag(args, "--validate");
|
||||
const task = argValue(args, "--task", validate ? "" : "generate_deck");
|
||||
const useFullFallback = hasFlag(args, "--use-full-fallback");
|
||||
|
||||
if (!deckArg) fail("Usage: node skills/lark-slides/scripts/svg_slides_prompt_trace.mjs <deck-dir> [--task generate_deck|generate_chart] [--chart-type type] [--references path] [--use-full-fallback] [--validate]");
|
||||
const deckDir = path.resolve(deckArg);
|
||||
|
||||
const receiptPath = validate
|
||||
? validateTrace({ deckDir, referenceRoot, expectedTask: task, expectedChartType: chartType })
|
||||
: createTrace({ deckDir, referenceRoot, task, chartType, useFullFallback });
|
||||
|
||||
console.log(JSON.stringify({ ok: true, receipt: receiptPath }, null, 2));
|
||||
263
skills/lark-slides/scripts/svg_slides_prompt_trace_test.mjs
Normal file
263
skills/lark-slides/scripts/svg_slides_prompt_trace_test.mjs
Normal file
@@ -0,0 +1,263 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_prompt_trace.mjs");
|
||||
|
||||
function tempRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-prompt-trace-"));
|
||||
}
|
||||
|
||||
function writeDoc(root, relPath, role, body = "content") {
|
||||
const filePath = path.join(root, relPath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `# ${path.basename(relPath, ".md")}\n\n## Context\n\n- Role: ${role}\n\n## Content\n\n${body}\n`);
|
||||
}
|
||||
|
||||
function writeDeckReferenceSet(root) {
|
||||
writeDoc(root, "README.md", "reference");
|
||||
writeDoc(root, "system-prompt.md", "prompt");
|
||||
writeDoc(root, "svg-reference.md", "reference");
|
||||
writeDoc(root, "deck-design-reference-catalog.md", "reference");
|
||||
writeDoc(root, "resolve-design-brief/README.md", "reference");
|
||||
writeDoc(root, "resolve-design-brief/tool-contract.md", "tool_contract");
|
||||
writeDoc(root, "resolve-design-brief/style-director-prompt.md", "prompt");
|
||||
writeDoc(root, "resolve-design-brief/full.md", "prompt_bundle");
|
||||
writeDoc(root, "tools/slide-outline.md", "tool_contract");
|
||||
writeDoc(root, "tools/activate-slides-edit.md", "tool_contract");
|
||||
writeDoc(root, "tools/slides-edit.md", "tool_contract");
|
||||
writeDoc(root, "tools/finish-slides-edit.md", "tool_contract");
|
||||
writeDoc(root, "validation/asset-readiness.md", "schema");
|
||||
writeDoc(root, "validation/typography-plan.md", "schema");
|
||||
writeDoc(root, "validation/visual-system.md", "schema");
|
||||
writeDoc(root, "validation/frame-discipline.md", "schema");
|
||||
writeDoc(root, "validation/prompt-read-trace.md", "schema");
|
||||
writeDoc(root, "validation/post-delivery-audit.md", "reference");
|
||||
}
|
||||
|
||||
function writeChartReferenceSet(root) {
|
||||
writeDoc(root, "generate-svg-chart/README.md", "reference");
|
||||
writeDoc(root, "generate-svg-chart/input-schema.md", "schema");
|
||||
writeDoc(root, "generate-svg-chart/chart-agent-contract.md", "prompt");
|
||||
writeDoc(root, "generate-svg-chart/design-envelope.md", "reference");
|
||||
writeDoc(root, "generate-svg-chart/full.md", "prompt_bundle");
|
||||
writeDoc(root, "generate-svg-chart/snippets/README.md", "reference");
|
||||
writeDoc(root, "generate-svg-chart/snippets/waterfall-connector.md", "snippet", "---\nchart_types: [waterfall]\n---\n");
|
||||
writeDoc(root, "generate-svg-chart/snippets/value-line.md", "snippet", "---\nchart_types: [waterfall, line]\n---\n");
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
return spawnSync("node", [script, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function readReceipt(deck) {
|
||||
return JSON.parse(fs.readFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), "utf8"));
|
||||
}
|
||||
|
||||
test("creates generate_deck prompt read trace with hashes", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
const result = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(fs.readFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), "utf8"));
|
||||
assert.equal(receipt.version, "svg-slides.prompt-read-trace.v1");
|
||||
assert.equal(receipt.task, "generate_deck");
|
||||
assert.equal(receipt.read_order[0].path, "README.md");
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "resolve-design-brief/style-director-prompt.md"));
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "validation/asset-readiness.md"));
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "validation/typography-plan.md"));
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "validation/visual-system.md"));
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "validation/frame-discipline.md"));
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "validation/post-delivery-audit.md"));
|
||||
assert.ok(receipt.read_order.every((entry) => /^[a-f0-9]{64}$/.test(entry.sha256)));
|
||||
assert.ok(receipt.read_order.every((entry) => !("source_lines" in entry)));
|
||||
assert.deepEqual(receipt.missing_required_reads, []);
|
||||
});
|
||||
|
||||
test("fails when typography prompt references are missing", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.rmSync(path.join(refs, "resolve-design-brief", "style-director-prompt.md"));
|
||||
const result = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /missing required prompt reads/);
|
||||
assert.match(result.stderr, /resolve-design-brief\/style-director-prompt\.md/);
|
||||
});
|
||||
|
||||
test("selects matching chart snippets by chart_type", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeChartReferenceSet(refs);
|
||||
const result = run([deck, "--task", "generate_chart", "--chart-type", "waterfall", "--references", refs]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(fs.readFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), "utf8"));
|
||||
const paths = receipt.read_order.map((entry) => entry.path);
|
||||
assert.ok(paths.includes("generate-svg-chart/snippets/waterfall-connector.md"));
|
||||
assert.ok(paths.includes("generate-svg-chart/snippets/value-line.md"));
|
||||
assert.deepEqual(receipt.fallbacks, []);
|
||||
});
|
||||
|
||||
test("uses full fallback when snippet routing is uncertain", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeChartReferenceSet(refs);
|
||||
const result = run([deck, "--task", "generate_chart", "--references", refs, "--use-full-fallback"]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
assert.ok(receipt.read_order.some((entry) => entry.path === "generate-svg-chart/full.md"));
|
||||
assert.deepEqual(receipt.fallbacks, ["generate-svg-chart/full.md"]);
|
||||
});
|
||||
|
||||
test("generate_deck can include chart fallback reads when deck uses charts", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
writeChartReferenceSet(refs);
|
||||
const result = run([deck, "--task", "generate_deck", "--references", refs, "--use-full-fallback"]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
const paths = receipt.read_order.map((entry) => entry.path);
|
||||
assert.equal(receipt.task, "generate_deck");
|
||||
assert.ok(paths.includes("system-prompt.md"));
|
||||
assert.ok(paths.includes("generate-svg-chart/input-schema.md"));
|
||||
assert.ok(paths.includes("generate-svg-chart/full.md"));
|
||||
assert.deepEqual(receipt.fallbacks, ["generate-svg-chart/full.md"]);
|
||||
const validate = run([deck, "--validate", "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(validate.status, 0, validate.stderr || validate.stdout);
|
||||
});
|
||||
|
||||
test("fails when a required prompt file is missing", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
fs.rmSync(path.join(refs, "tools", "slides-edit.md"));
|
||||
const result = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /missing required prompt reads/);
|
||||
});
|
||||
|
||||
test("validate fails after referenced markdown sha drifts", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
fs.appendFileSync(path.join(refs, "system-prompt.md"), "\nchanged\n");
|
||||
const validate = run([deck, "--validate", "--references", refs]);
|
||||
assert.equal(validate.status, 1);
|
||||
assert.match(validate.stderr, /sha mismatch/);
|
||||
});
|
||||
|
||||
test("validate fails when read_order omits required prompt files", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
receipt.read_order = receipt.read_order.slice(0, 1);
|
||||
receipt.missing_required_reads = [];
|
||||
fs.writeFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const validate = run([deck, "--validate", "--references", refs]);
|
||||
assert.equal(validate.status, 1);
|
||||
assert.match(validate.stderr, /read_order mismatch/);
|
||||
});
|
||||
|
||||
test("validate fails when read_order order is reversed", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
receipt.read_order.reverse();
|
||||
fs.writeFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const validate = run([deck, "--validate", "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(validate.status, 1);
|
||||
assert.match(validate.stderr, /read_order mismatch/);
|
||||
});
|
||||
|
||||
test("validate fails when read_order contains duplicate paths", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
receipt.read_order.push({ ...receipt.read_order[0] });
|
||||
fs.writeFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const validate = run([deck, "--validate", "--references", refs]);
|
||||
assert.equal(validate.status, 1);
|
||||
assert.match(validate.stderr, /duplicate read path/);
|
||||
});
|
||||
|
||||
test("validate fails when read_order entries are missing required metadata", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeDeckReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
const cases = [
|
||||
{
|
||||
label: "role",
|
||||
mutate(entry) {
|
||||
delete entry.role;
|
||||
},
|
||||
pattern: /invalid read_order entry role/
|
||||
},
|
||||
{
|
||||
label: "reason",
|
||||
mutate(entry) {
|
||||
entry.reason = "";
|
||||
},
|
||||
pattern: /invalid read_order entry reason/
|
||||
},
|
||||
{
|
||||
label: "sha256",
|
||||
mutate(entry) {
|
||||
entry.sha256 = "F".repeat(64);
|
||||
},
|
||||
pattern: /invalid read_order entry sha256/
|
||||
}
|
||||
];
|
||||
|
||||
for (const { label, mutate, pattern } of cases) {
|
||||
const mutated = JSON.parse(JSON.stringify(receipt));
|
||||
mutate(mutated.read_order[0]);
|
||||
fs.writeFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), `${JSON.stringify(mutated, null, 2)}\n`);
|
||||
const validate = run([deck, "--validate", "--references", refs]);
|
||||
assert.equal(validate.status, 1, label);
|
||||
assert.match(validate.stderr, pattern, label);
|
||||
}
|
||||
});
|
||||
|
||||
test("validate fails when fallback metadata does not match required fallback reads", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeChartReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_chart", "--references", refs, "--use-full-fallback"]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
const receipt = readReceipt(deck);
|
||||
receipt.fallbacks = [];
|
||||
fs.writeFileSync(path.join(deck, "receipts", "prompt_read_trace.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const validate = run([deck, "--validate", "--references", refs]);
|
||||
assert.equal(validate.status, 1);
|
||||
assert.match(validate.stderr, /fallback mismatch/);
|
||||
});
|
||||
|
||||
test("validate fails when an expected task does not match receipt task", () => {
|
||||
const refs = tempRoot();
|
||||
const deck = tempRoot();
|
||||
writeChartReferenceSet(refs);
|
||||
const create = run([deck, "--task", "generate_chart", "--references", refs, "--use-full-fallback"]);
|
||||
assert.equal(create.status, 0, create.stderr || create.stdout);
|
||||
const validate = run([deck, "--validate", "--task", "generate_deck", "--references", refs]);
|
||||
assert.equal(validate.status, 1);
|
||||
assert.match(validate.stderr, /task mismatch/);
|
||||
});
|
||||
146
skills/lark-slides/scripts/svg_slides_readability_safety.mjs
Normal file
146
skills/lark-slides/scripts/svg_slides_readability_safety.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function attrValue(tag, name) {
|
||||
return tag.match(new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}="([^"]*)"`))?.[1] || "";
|
||||
}
|
||||
|
||||
function numberAttr(tag, name, fallback = 0) {
|
||||
const value = Number(attrValue(tag, name));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function area(box) {
|
||||
return Math.max(0, box.width) * Math.max(0, box.height);
|
||||
}
|
||||
|
||||
function intersection(a, b) {
|
||||
const x1 = Math.max(a.x, b.x);
|
||||
const y1 = Math.max(a.y, b.y);
|
||||
const x2 = Math.min(a.x + a.width, b.x + b.width);
|
||||
const y2 = Math.min(a.y + a.height, b.y + b.height);
|
||||
return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
|
||||
}
|
||||
|
||||
function isForeground(object) {
|
||||
return object.layer === "content" || object.layer === "overlay" || object.tag === "foreignObject";
|
||||
}
|
||||
|
||||
function isMask(object) {
|
||||
return object.role === "shape" && object.layer === "mask";
|
||||
}
|
||||
|
||||
function isDecoration(object) {
|
||||
return object.layer === "decoration" || (object.opacity <= 0.18 && object.tag !== "foreignObject");
|
||||
}
|
||||
|
||||
function isContainer(object) {
|
||||
return object.container || ["card", "callout", "timeline_node", "chart_container", "table_container", "worksheet_area"].includes(object.frameRole);
|
||||
}
|
||||
|
||||
function containedByContainer(foreground, container) {
|
||||
const center = { x: foreground.box.x + foreground.box.width / 2, y: foreground.box.y + foreground.box.height / 2 };
|
||||
const box = container.box;
|
||||
const overlap = intersection(foreground.box, box);
|
||||
return center.x >= box.x && center.x <= box.x + box.width && center.y >= box.y && center.y <= box.y + box.height && area(overlap) / Math.max(1, area(foreground.box)) >= 0.8;
|
||||
}
|
||||
|
||||
function isTextImagePair(a, b) {
|
||||
return (a.tag === "foreignObject" && b.tag === "image" && b.role === "image")
|
||||
|| (b.tag === "foreignObject" && a.tag === "image" && a.role === "image");
|
||||
}
|
||||
|
||||
function parseObjects(svg) {
|
||||
const objects = [];
|
||||
for (const match of svg.matchAll(/<(rect|image|foreignObject|circle|ellipse|line|polyline|polygon)\b([^>]*)>/g)) {
|
||||
const tag = match[1];
|
||||
const attrs = match[2];
|
||||
const role = attrValue(attrs, "slide:role");
|
||||
const layer = attrValue(attrs, "data-svglide-layer");
|
||||
const opacity = Number(attrValue(attrs, "opacity") || "1");
|
||||
const common = {
|
||||
tag,
|
||||
role,
|
||||
layer,
|
||||
frameRole: attrValue(attrs, "data-frame-role"),
|
||||
container: attrValue(attrs, "data-svglide-container") === "true",
|
||||
allowOverlap: attrValue(attrs, "data-svglide-allow-overlap") === "true",
|
||||
overlapReason: attrValue(attrs, "data-svglide-overlap-reason"),
|
||||
opacity: Number.isFinite(opacity) ? opacity : 1,
|
||||
raw: match[0],
|
||||
};
|
||||
if (tag === "circle") {
|
||||
const cx = numberAttr(attrs, "cx");
|
||||
const cy = numberAttr(attrs, "cy");
|
||||
const r = numberAttr(attrs, "r");
|
||||
objects.push({ ...common, box: { x: cx - r, y: cy - r, width: r * 2, height: r * 2 } });
|
||||
} else {
|
||||
objects.push({ ...common, box: {
|
||||
x: numberAttr(attrs, "x", numberAttr(attrs, "x1")),
|
||||
y: numberAttr(attrs, "y", numberAttr(attrs, "y1")),
|
||||
width: numberAttr(attrs, "width", Math.abs(numberAttr(attrs, "x2") - numberAttr(attrs, "x1"))),
|
||||
height: numberAttr(attrs, "height", Math.abs(numberAttr(attrs, "y2") - numberAttr(attrs, "y1"))),
|
||||
} });
|
||||
}
|
||||
}
|
||||
return objects.filter((object) => object.role !== "background");
|
||||
}
|
||||
|
||||
function checkSlide(file) {
|
||||
const svg = fs.readFileSync(file, "utf8");
|
||||
const objects = parseObjects(svg);
|
||||
const errors = [];
|
||||
const masks = objects.filter(isMask);
|
||||
for (let i = 0; i < objects.length; i += 1) {
|
||||
for (let j = i + 1; j < objects.length; j += 1) {
|
||||
const a = objects[i];
|
||||
const b = objects[j];
|
||||
if (isDecoration(a) || isDecoration(b) || isMask(a) || isMask(b)) continue;
|
||||
if (isTextImagePair(a, b)) continue;
|
||||
if (isContainer(a) && containedByContainer(b, a)) continue;
|
||||
if (isContainer(b) && containedByContainer(a, b)) continue;
|
||||
const overlap = intersection(a.box, b.box);
|
||||
const overlapArea = area(overlap);
|
||||
if (overlapArea <= 48 || overlapArea / Math.max(1, Math.min(area(a.box), area(b.box))) <= 0.03) continue;
|
||||
if ((a.allowOverlap && a.overlapReason) || (b.allowOverlap && b.overlapReason)) continue;
|
||||
if (isForeground(a) || isForeground(b)) {
|
||||
errors.push({ rule: "readability.overlap", severity: "error", message: "foreground objects overlap without containment or reason", a: a.box, b: b.box });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const text of objects.filter((object) => object.tag === "foreignObject")) {
|
||||
for (const image of objects.filter((object) => object.tag === "image" && object.role === "image")) {
|
||||
const overlap = intersection(text.box, image.box);
|
||||
if (area(overlap) <= 48) continue;
|
||||
const coveredByMask = masks.some((mask) => area(intersection(text.box, mask.box)) / Math.max(1, area(text.box)) >= 0.8);
|
||||
if (!coveredByMask) {
|
||||
errors.push({ rule: "readability.text-image-without-mask", severity: "error", message: "text overlaps image without scrim or mask", text: text.box, image: image.box });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const object of objects.filter(isForeground)) {
|
||||
if (object.box.x < 0 || object.box.y < 0 || object.box.x + object.box.width > 960 || object.box.y + object.box.height > 540) {
|
||||
errors.push({ rule: "readability.out-of-bounds", severity: "error", message: "foreground object is outside 960x540 canvas", box: object.box });
|
||||
}
|
||||
if (object.allowOverlap && !object.overlapReason) {
|
||||
errors.push({ rule: "readability.missing-overlap-reason", severity: "error", message: "allowed overlap requires data-svglide-overlap-reason", box: object.box });
|
||||
}
|
||||
}
|
||||
return { file: path.relative(process.cwd(), file), errors, warnings: [] };
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const targetArg = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!targetArg) {
|
||||
console.error("Usage: node skills/lark-slides/scripts/svg_slides_readability_safety.mjs <deck-dir-or-slides-dir> [--json]");
|
||||
process.exit(2);
|
||||
}
|
||||
const target = path.resolve(targetArg);
|
||||
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
|
||||
const files = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort().map((file) => path.join(slidesDir, file));
|
||||
const results = files.map(checkSlide);
|
||||
const totalErrors = results.reduce((sum, result) => sum + result.errors.length, 0);
|
||||
const report = { version: "svg-slides.readability-safety.v1", ok: totalErrors === 0, totalErrors, totalWarnings: 0, results };
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_readability_safety.mjs");
|
||||
const fixtureRoot = path.resolve("skills/lark-slides/scripts/testdata/readability");
|
||||
|
||||
function runFixture(name) {
|
||||
return spawnSync("node", [script, path.join(fixtureRoot, name), "--json"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("foreground overlap fails readability safety", () => {
|
||||
const result = runFixture("overlap-callout");
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, false);
|
||||
assert.ok(report.results[0].errors.some((error) => error.rule === "readability.overlap"));
|
||||
});
|
||||
|
||||
test("chart value covered by explanation block fails readability safety", () => {
|
||||
const result = runFixture("chart-value-covered");
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.ok(report.results[0].errors.some((error) => error.rule === "readability.overlap"));
|
||||
});
|
||||
|
||||
test("collage text without mask fails and collage with mask passes", () => {
|
||||
const bad = runFixture("collage-without-mask");
|
||||
assert.equal(bad.status, 1);
|
||||
assert.ok(JSON.parse(bad.stdout).results[0].errors.some((error) => error.rule === "readability.text-image-without-mask"));
|
||||
|
||||
const good = runFixture("collage-with-mask");
|
||||
assert.equal(good.status, 0, good.stderr || good.stdout);
|
||||
assert.equal(JSON.parse(good.stdout).ok, true);
|
||||
});
|
||||
|
||||
test("container card and decorative overlap pass", () => {
|
||||
for (const name of ["container-card", "decorative-overlap"]) {
|
||||
const result = runFixture(name);
|
||||
assert.equal(result.status, 0, `${name}\n${result.stderr || result.stdout}`);
|
||||
assert.equal(JSON.parse(result.stdout).ok, true);
|
||||
}
|
||||
});
|
||||
212
skills/lark-slides/scripts/svg_slides_readback_gate.mjs
Normal file
212
skills/lark-slides/scripts/svg_slides_readback_gate.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const valueFlags = new Set(["--readback-file", "--presentation-id"]);
|
||||
|
||||
function argValue(argv, name) {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : "";
|
||||
}
|
||||
|
||||
function positionalArgs(argv) {
|
||||
const values = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (valueFlags.has(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("--")) values.push(arg);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeXml(value) {
|
||||
return String(value || "")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return decodeXml(value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
||||
}
|
||||
|
||||
function collectTextBlocks(inner) {
|
||||
const textWithBoundaries = inner
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(?:p|h1|h2|h3|small|li|div|span|strong|em|b|i)>/gi, "\n")
|
||||
.replace(/<(?:p|h1|h2|h3|small|li|div|span|strong|em|b|i)\b[^>]*>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, " ");
|
||||
return unique(
|
||||
decodeXml(textWithBoundaries)
|
||||
.split(/\n+/)
|
||||
.map((text) => text.replace(/\s+/g, " ").trim())
|
||||
.filter((text) => text.length >= 3)
|
||||
);
|
||||
}
|
||||
|
||||
function attrValue(tag, name) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return decodeXml(tag.match(new RegExp(`${escaped}\\s*=\\s*(["'])(.*?)\\1`, "i"))?.[2] || "");
|
||||
}
|
||||
|
||||
function styleDeclarations(styleText) {
|
||||
const declarations = {};
|
||||
for (const declaration of decodeXml(styleText).split(";")) {
|
||||
const [rawName, ...rawValue] = declaration.split(":");
|
||||
if (!rawName || rawValue.length === 0) continue;
|
||||
declarations[rawName.trim().toLowerCase()] = rawValue.join(":").trim();
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function normalizeFontFamily(value) {
|
||||
return decodeXml(value)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function isConcreteFontFamily(value) {
|
||||
const normalized = normalizeFontFamily(value);
|
||||
return normalized
|
||||
&& !normalized.includes(",")
|
||||
&& !/^var\s*\(/i.test(normalized)
|
||||
&& !/^(serif|sans-serif|monospace|cursive|system-ui)$/i.test(normalized);
|
||||
}
|
||||
|
||||
function collectFontFamilies(source) {
|
||||
const families = [];
|
||||
for (const match of source.matchAll(/\bstyle\s*=\s*(["'])(.*?)\1/gi)) {
|
||||
const family = styleDeclarations(match[2])["font-family"];
|
||||
if (family) families.push(normalizeFontFamily(family));
|
||||
}
|
||||
for (const match of source.matchAll(/\bfont-family\s*=\s*(["'])(.*?)\1/gi)) {
|
||||
families.push(normalizeFontFamily(match[2]));
|
||||
}
|
||||
return unique(families.filter(isConcreteFontFamily));
|
||||
}
|
||||
|
||||
function collectLocalText(svg) {
|
||||
return [...svg.matchAll(/<foreignObject\b[^>]*\bslide:shape-type\s*=\s*["']text["'][^>]*>([\s\S]*?)<\/foreignObject>/gi)]
|
||||
.flatMap((match) => collectTextBlocks(match[1]));
|
||||
}
|
||||
|
||||
function collectLocalImageHrefs(svg) {
|
||||
return [...svg.matchAll(/<image\b[^>]*\bslide:role\s*=\s*["']image["'][^>]*>/gi)]
|
||||
.map((match) => attrValue(match[0], "href") || attrValue(match[0], "xlink:href"))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectLocalSlides(root) {
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
return fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort()
|
||||
.map((file) => ({
|
||||
file: path.join(slidesDir, file),
|
||||
svg: fs.readFileSync(path.join(slidesDir, file), "utf8")
|
||||
}));
|
||||
}
|
||||
|
||||
function relativeOutputPath(filePath) {
|
||||
const relative = path.relative(process.cwd(), filePath).split(path.sep).join("/");
|
||||
if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
|
||||
throw new Error(`readback output must be under the current directory: ${filePath}`);
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
function readOnlineContent(argv, root) {
|
||||
const readbackFile = argValue(argv, "--readback-file");
|
||||
if (readbackFile) return fs.readFileSync(path.resolve(readbackFile), "utf8");
|
||||
|
||||
const presentationId = argValue(argv, "--presentation-id");
|
||||
if (!presentationId) {
|
||||
throw new Error("Usage: node skills/lark-slides/scripts/svg_slides_readback_gate.mjs <deck-dir> --readback-file <xml> [--json]");
|
||||
}
|
||||
|
||||
const outputPath = path.join(root, "receipts", "online_readback.xml");
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
const result = spawnSync("lark-cli", [
|
||||
"slides",
|
||||
"+xml-get",
|
||||
"--as",
|
||||
"user",
|
||||
"--presentation",
|
||||
presentationId,
|
||||
"--output",
|
||||
relativeOutputPath(outputPath)
|
||||
], { encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
|
||||
return fs.readFileSync(outputPath, "utf8");
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const [rootArg] = positionalArgs(argv);
|
||||
if (!rootArg) {
|
||||
console.error("Usage: node skills/lark-slides/scripts/svg_slides_readback_gate.mjs <deck-dir> --readback-file <xml> [--json]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const root = path.resolve(rootArg);
|
||||
const localSlides = collectLocalSlides(root);
|
||||
const localText = unique(localSlides.flatMap((slide) => collectLocalText(slide.svg)));
|
||||
const localImageHrefs = unique(localSlides.flatMap((slide) => collectLocalImageHrefs(slide.svg)));
|
||||
const expectedFontFamilies = unique(localSlides.flatMap((slide) => collectFontFamilies(slide.svg)));
|
||||
const content = readOnlineContent(argv, root);
|
||||
const readbackText = stripTags(content);
|
||||
const missingText = localText.filter((text) => !readbackText.includes(text));
|
||||
const readbackImages = (content.match(/<img\b|<image\b/gi) || []).length;
|
||||
const leakedHrefs = localImageHrefs.filter((href) => content.includes(href));
|
||||
const readbackFontFamilies = collectFontFamilies(content);
|
||||
const missingFontFamilies = expectedFontFamilies.filter((family) => !readbackFontFamilies.includes(family));
|
||||
const errors = [];
|
||||
const readbackSlideCount = (content.match(/<svg\b/gi) || []).length;
|
||||
|
||||
if (readbackSlideCount !== localSlides.length) errors.push("readback slide svg count mismatch");
|
||||
if (missingText.length > 0) errors.push("readback missing expected text");
|
||||
if (readbackImages < localImageHrefs.length) errors.push("readback image count below expected");
|
||||
if (leakedHrefs.length > 0) errors.push("readback still contains local asset hrefs");
|
||||
if (expectedFontFamilies.length > 0 && readbackFontFamilies.length > 0 && missingFontFamilies.length > 0) {
|
||||
errors.push("readback missing expected font family");
|
||||
}
|
||||
|
||||
const report = {
|
||||
version: "svg-slides.readback-gate.v1",
|
||||
ok: errors.length === 0,
|
||||
totalErrors: errors.length,
|
||||
errors,
|
||||
expected_slide_count: localSlides.length,
|
||||
readback_slide_count: readbackSlideCount,
|
||||
expected_text_count: localText.length,
|
||||
missing_text: missingText,
|
||||
expected_images: localImageHrefs.length,
|
||||
readback_images: readbackImages,
|
||||
local_href_leaks: leakedHrefs.length,
|
||||
leaked_hrefs: leakedHrefs,
|
||||
expected_font_families: expectedFontFamilies,
|
||||
readback_font_families: readbackFontFamilies,
|
||||
missing_font_families: missingFontFamilies
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
144
skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs
Normal file
144
skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_readback_gate.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "readback-gate-"));
|
||||
fs.mkdirSync(path.join(root, "slides"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeLocalSlide(root) {
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="s1" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="500" height="80" style="font-size:34px;font-family:Noto Sans SC;color:rgba(15,23,42,1)"><p xmlns="http://www.w3.org/1999/xhtml">DeepSeek V4</p></foreignObject>
|
||||
<image slide:role="image" slide:shape-type="image" href="assets/fig.png" x="80" y="180" width="300" height="160"/>
|
||||
</svg>`);
|
||||
}
|
||||
|
||||
function writeLocalSlideWithMultiParagraphText(root) {
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="s1" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="500" height="120" style="font-size:34px;color:rgba(15,23,42,1)">
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml">The Pursuit of Happyness</h1>
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml">当幸福来敲门</h1>
|
||||
</foreignObject>
|
||||
<image slide:role="image" slide:shape-type="image" href="assets/fig.png" x="80" y="220" width="300" height="160"/>
|
||||
</svg>`);
|
||||
}
|
||||
|
||||
function writeLocalSlideWithInlineBreaks(root) {
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="s1" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="500" height="120" style="font-size:34px;color:rgba(15,23,42,1)">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml"><strong>Essential Question</strong><br/>How can conjunctions make our ideas clearer?</p>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="220" width="500" height="120" style="font-size:22px;color:rgba(15,23,42,1)">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">and / but / or / so<br/>连接同等层级:词、短语、句子。<br/><span>I tried hard, but I failed.</span></p>
|
||||
</foreignObject>
|
||||
<image slide:role="image" slide:shape-type="image" href="assets/fig.png" x="80" y="360" width="300" height="120"/>
|
||||
</svg>`);
|
||||
}
|
||||
|
||||
test("readback gate fails when online content lost text and images", () => {
|
||||
const root = tempDeck();
|
||||
writeLocalSlide(root);
|
||||
const readback = path.join(root, "readback.xml");
|
||||
fs.writeFileSync(readback, `<presentation><svg><rect slide:role="background"/></svg></presentation>`);
|
||||
|
||||
const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, false);
|
||||
assert.deepEqual(report.missing_text, ["DeepSeek V4"]);
|
||||
assert.equal(report.expected_images, 1);
|
||||
assert.equal(report.readback_images, 0);
|
||||
});
|
||||
|
||||
test("readback gate passes when text and image are preserved", () => {
|
||||
const root = tempDeck();
|
||||
writeLocalSlide(root);
|
||||
const readback = path.join(root, "readback.xml");
|
||||
fs.writeFileSync(readback, `<presentation><svg><shape type="text">DeepSeek V4</shape><img src="boxcn-token"/></svg></presentation>`);
|
||||
|
||||
const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
});
|
||||
|
||||
test("readback gate matches local multi-paragraph text after online XML rewrites tags", () => {
|
||||
const root = tempDeck();
|
||||
writeLocalSlideWithMultiParagraphText(root);
|
||||
const readback = path.join(root, "readback.xml");
|
||||
fs.writeFileSync(readback, `<presentation><svg>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text">
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml"><strong><span>The Pursuit of Happyness</span></strong></h1>
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml"><strong><span>当幸福来敲门</span></strong></h1>
|
||||
</foreignObject>
|
||||
<img src="boxcn-token"/>
|
||||
</svg></presentation>`);
|
||||
|
||||
const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
});
|
||||
|
||||
test("readback gate matches inline text split by br and inline formatting", () => {
|
||||
const root = tempDeck();
|
||||
writeLocalSlideWithInlineBreaks(root);
|
||||
const readback = path.join(root, "readback.xml");
|
||||
fs.writeFileSync(readback, `<presentation><svg>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text">
|
||||
<p><strong><span>Essential Question</span></strong></p>
|
||||
<p><span>How can conjunctions make our ideas clearer?</span></p>
|
||||
<p><span>and / but / or / so</span></p>
|
||||
<p><span>连接同等层级:词、短语、句子。</span></p>
|
||||
<p><span>I tried hard, but I failed.</span></p>
|
||||
</foreignObject>
|
||||
<img src="boxcn-token"/>
|
||||
</svg></presentation>`);
|
||||
|
||||
const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
});
|
||||
|
||||
test("readback gate fails when local asset href leaks online", () => {
|
||||
const root = tempDeck();
|
||||
writeLocalSlide(root);
|
||||
const readback = path.join(root, "readback.xml");
|
||||
fs.writeFileSync(readback, `<presentation><svg><shape type="text">DeepSeek V4</shape><image href="assets/fig.png"/></svg></presentation>`);
|
||||
|
||||
const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.local_href_leaks, 1);
|
||||
assert.deepEqual(report.leaked_hrefs, ["assets/fig.png"]);
|
||||
});
|
||||
|
||||
test("readback gate fails when exposed online font family is remapped", () => {
|
||||
const root = tempDeck();
|
||||
writeLocalSlide(root);
|
||||
const readback = path.join(root, "readback.xml");
|
||||
fs.writeFileSync(readback, `<presentation><svg>
|
||||
<foreignObject style="font-family:Songti SC"><p>DeepSeek V4</p></foreignObject>
|
||||
<img src="boxcn-token"/>
|
||||
</svg></presentation>`);
|
||||
|
||||
const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, false);
|
||||
assert.match(report.errors.join("\n"), /readback missing expected font family/);
|
||||
assert.deepEqual(report.expected_font_families, ["Noto Sans SC"]);
|
||||
assert.deepEqual(report.readback_font_families, ["Songti SC"]);
|
||||
assert.deepEqual(report.missing_font_families, ["Noto Sans SC"]);
|
||||
});
|
||||
161
skills/lark-slides/scripts/svg_slides_repair_context.mjs
Normal file
161
skills/lark-slides/scripts/svg_slides_repair_context.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const valueFlags = new Set(["--out"]);
|
||||
|
||||
function argValue(argv, name, fallback = "") {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function positionalArgs(argv) {
|
||||
const values = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (valueFlags.has(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("--")) values.push(arg);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function rel(root, file) {
|
||||
return path.relative(root, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function sha256(raw) {
|
||||
return crypto.createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
function readJsonIfExists(file) {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function normalizeSlideFile(root, file) {
|
||||
const raw = String(file || "").split(path.sep).join("/");
|
||||
if (!raw) return "";
|
||||
const rootResolved = path.resolve(root);
|
||||
if (path.isAbsolute(raw)) return rel(rootResolved, raw);
|
||||
|
||||
const absFromCwd = path.resolve(process.cwd(), raw);
|
||||
const relativeFromRoot = rel(rootResolved, absFromCwd);
|
||||
if (relativeFromRoot && !relativeFromRoot.startsWith("../") && relativeFromRoot !== "..") {
|
||||
return relativeFromRoot;
|
||||
}
|
||||
|
||||
const slidesIndex = raw.lastIndexOf("slides/");
|
||||
if (slidesIndex >= 0) return raw.slice(slidesIndex);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeReceiptErrors(receiptPath, result) {
|
||||
const receipt = path.basename(receiptPath);
|
||||
const errors = Array.isArray(result.errors)
|
||||
? result.errors.filter((error) => (
|
||||
error?.severity !== "warn"
|
||||
&& error?.severity !== "warning"
|
||||
&& error?.repairable !== false
|
||||
))
|
||||
: [];
|
||||
return errors.map((error) => ({ ...error, receipt }));
|
||||
}
|
||||
|
||||
function collectFailures(root) {
|
||||
const receiptFiles = [
|
||||
"receipts/validate_svg_deck.json",
|
||||
"receipts/frame_discipline.json",
|
||||
"receipts/readability_safety.json",
|
||||
"receipts/asset_readiness_report.json",
|
||||
"receipts/typography_plan_report.json"
|
||||
];
|
||||
const byFile = new Map();
|
||||
for (const receiptRel of receiptFiles) {
|
||||
const receiptPath = path.join(root, receiptRel);
|
||||
const report = readJsonIfExists(receiptPath);
|
||||
if (!report) continue;
|
||||
for (const result of Array.isArray(report.results) ? report.results : []) {
|
||||
const file = normalizeSlideFile(root, result.file);
|
||||
if (!file) continue;
|
||||
const errors = normalizeReceiptErrors(receiptPath, result);
|
||||
if (errors.length === 0) continue;
|
||||
byFile.set(file, [...(byFile.get(file) || []), ...errors]);
|
||||
}
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
|
||||
function collectAssets(root, slideFile) {
|
||||
const receipt = readJsonIfExists(path.join(root, "receipts", "asset_readiness.json")) || {};
|
||||
const assets = Array.isArray(receipt.assets) ? receipt.assets : [];
|
||||
return assets.filter((asset) => normalizeSlideFile(root, asset.slide) === slideFile);
|
||||
}
|
||||
|
||||
function collectSlideMetadata(root, slideFile) {
|
||||
const receipt = readJsonIfExists(path.join(root, "receipts", "asset_readiness.json")) || {};
|
||||
const slides = Array.isArray(receipt.slides) ? receipt.slides : [];
|
||||
return slides.find((slide) => normalizeSlideFile(root, slide.file) === slideFile) || {};
|
||||
}
|
||||
|
||||
function writeContext(root, outDir, slideFile, errors) {
|
||||
const abs = path.join(root, slideFile);
|
||||
const original = fs.readFileSync(abs, "utf8");
|
||||
const base = path.basename(slideFile, ".svg");
|
||||
const context = {
|
||||
version: "svg-slides.repair-context.v1",
|
||||
slide_file: slideFile,
|
||||
allowed_output_file: slideFile,
|
||||
slide_sha256: sha256(original),
|
||||
original_svg: original,
|
||||
errors,
|
||||
slide_metadata: collectSlideMetadata(root, slideFile),
|
||||
available_assets: collectAssets(root, slideFile),
|
||||
repair_instructions: [
|
||||
"Return one complete replacement SVG for allowed_output_file only.",
|
||||
"Do not edit, reorder, add, or delete any other slide file.",
|
||||
"Keep the root SVG at viewBox 0 0 960 540 with slide:role=\"slide\".",
|
||||
"Use foreignObject slide:role=\"shape\" slide:shape-type=\"text\" for all visible text.",
|
||||
"Use slide:role=\"image\" slide:shape-type=\"image\" for content images.",
|
||||
"Fix the listed validation errors without hiding the problem behind data-svglide-allow-overlap."
|
||||
]
|
||||
};
|
||||
const contextPath = path.join(outDir, `${base}.context.json`);
|
||||
fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`);
|
||||
return { slide_file: slideFile, context_file: rel(root, contextPath), error_count: errors.length };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const [rootArg] = positionalArgs(argv);
|
||||
if (!rootArg) {
|
||||
console.error("Usage: node skills/lark-slides/scripts/svg_slides_repair_context.mjs <deck-dir> --out <repair-dir> [--json]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const root = path.resolve(rootArg);
|
||||
const outDir = path.resolve(argValue(argv, "--out", path.join(root, "repairs")));
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const failures = collectFailures(root);
|
||||
const contexts = [...failures.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([slideFile, errors]) => writeContext(root, outDir, slideFile, errors));
|
||||
const report = {
|
||||
version: "svg-slides.repair-context-index.v1",
|
||||
ok: true,
|
||||
failed_pages: contexts.map((item) => item.slide_file),
|
||||
contexts
|
||||
};
|
||||
fs.writeFileSync(path.join(outDir, "repair_context.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
120
skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs
Normal file
120
skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const valueFlags = new Set(["--write", "--baseline", "--allowed"]);
|
||||
|
||||
function argValue(argv, name, fallback = "") {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function argValues(argv, name) {
|
||||
const values = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === name && argv[index + 1]) {
|
||||
values.push(...argv[index + 1].split(",").map((item) => item.trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function positionalArgs(argv) {
|
||||
const values = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (valueFlags.has(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("--")) values.push(arg);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function rel(root, file) {
|
||||
return path.relative(root, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function normalizeDeckRel(root, value) {
|
||||
const raw = String(value || "").split(path.sep).join("/");
|
||||
if (!raw) return "";
|
||||
if (path.isAbsolute(raw)) return rel(root, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function sha256(raw) {
|
||||
return crypto.createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
function collectHashes(root) {
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
const hashes = {};
|
||||
for (const file of fs.readdirSync(slidesDir).filter((item) => item.endsWith(".svg")).sort()) {
|
||||
const abs = path.join(slidesDir, file);
|
||||
hashes[rel(root, abs)] = sha256(fs.readFileSync(abs));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
function compareHashes(before, after, allowed) {
|
||||
const beforeFiles = Object.keys(before).sort();
|
||||
const afterFiles = Object.keys(after).sort();
|
||||
const changed = beforeFiles.filter((file) => after[file] && before[file] !== after[file]);
|
||||
const missing = beforeFiles.filter((file) => !after[file]);
|
||||
const added = afterFiles.filter((file) => !before[file]);
|
||||
const allowedSet = new Set(allowed);
|
||||
const unexpectedChanged = changed.filter((file) => !allowedSet.has(file));
|
||||
return {
|
||||
changed_files: changed,
|
||||
unexpected_changed_files: unexpectedChanged,
|
||||
missing_files: missing,
|
||||
added_files: added,
|
||||
ok: unexpectedChanged.length === 0 && missing.length === 0 && added.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const [rootArg] = positionalArgs(argv);
|
||||
if (!rootArg) {
|
||||
console.error("Usage: node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs <deck-dir> --write <file> | --baseline <file> --allowed <slide.svg>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const root = path.resolve(rootArg);
|
||||
const hashes = collectHashes(root);
|
||||
const writePath = argValue(argv, "--write");
|
||||
if (writePath) {
|
||||
const report = { version: "svg-slides.repair-hashes.v1", root, hashes };
|
||||
fs.mkdirSync(path.dirname(path.resolve(writePath)), { recursive: true });
|
||||
fs.writeFileSync(path.resolve(writePath), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ ok: true, hash_count: Object.keys(hashes).length, file: path.resolve(writePath) }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const baselinePath = argValue(argv, "--baseline");
|
||||
if (!baselinePath) {
|
||||
console.error("Missing --write or --baseline");
|
||||
process.exit(2);
|
||||
}
|
||||
const baseline = JSON.parse(fs.readFileSync(path.resolve(baselinePath), "utf8"));
|
||||
const allowed = argValues(argv, "--allowed").map((file) => normalizeDeckRel(root, file));
|
||||
const report = {
|
||||
version: "svg-slides.repair-hash-guard.v1",
|
||||
...compareHashes(baseline.hashes || {}, hashes, allowed)
|
||||
};
|
||||
report.totalErrors = report.ok
|
||||
? 0
|
||||
: report.unexpected_changed_files.length + report.missing_files.length + report.added_files.length;
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
256
skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs
Normal file
256
skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs
Normal file
@@ -0,0 +1,256 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const contextScript = path.resolve("skills/lark-slides/scripts/svg_slides_repair_context.mjs");
|
||||
const hashGuardScript = path.resolve("skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-repair-loop-"));
|
||||
fs.mkdirSync(path.join(root, "slides"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function slide(title, extra = "") {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="${title.toLowerCase().replace(/[^a-z0-9]+/g, "_")}" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" data-svglide-layer="content" x="80" y="80" width="620" height="80" style="font-size:32px;color:rgba(15,23,42,1)">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">${title}</p>
|
||||
</foreignObject>
|
||||
${extra}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function writeReceipts(root, fileForSlide2 = "slides/slide_02.svg") {
|
||||
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify({
|
||||
target: root,
|
||||
slideCount: 2,
|
||||
totalErrors: 0,
|
||||
results: [
|
||||
{ file: "slides/slide_01.svg", errorCount: 0, errors: [] },
|
||||
{ file: fileForSlide2, errorCount: 0, errors: [] }
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "frame_discipline.json"), `${JSON.stringify({
|
||||
ok: true,
|
||||
totalErrors: 0,
|
||||
results: [
|
||||
{ file: "slides/slide_01.svg", errorCount: 0, errors: [] },
|
||||
{ file: fileForSlide2, errorCount: 0, errors: [] }
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "readability_safety.json"), `${JSON.stringify({
|
||||
version: "svg-slides.readability-safety.v1",
|
||||
ok: false,
|
||||
totalErrors: 1,
|
||||
results: [
|
||||
{ file: "slides/slide_01.svg", errors: [], warnings: [] },
|
||||
{
|
||||
file: fileForSlide2,
|
||||
errors: [
|
||||
{
|
||||
rule: "readability.overlap",
|
||||
severity: "error",
|
||||
message: "foreground objects overlap without containment or reason",
|
||||
a: { x: 80, y: 80, width: 620, height: 80 },
|
||||
b: { x: 96, y: 92, width: 580, height: 70 }
|
||||
}
|
||||
],
|
||||
warnings: []
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness.json"), `${JSON.stringify({
|
||||
version: "svg-slides.asset-readiness.v1",
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_02.svg",
|
||||
purpose: "repair visual",
|
||||
status: "ready",
|
||||
local_path: "resources/images/repair.png"
|
||||
}
|
||||
],
|
||||
slides: [
|
||||
{ file: "slides/slide_01.svg", slide_role: "section" },
|
||||
{ file: "slides/slide_02.svg", slide_role: "analysis", layout_family: "text-with-callout" }
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeAssetAndTypographyPageReceipts(root) {
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness.json"), `${JSON.stringify({
|
||||
version: "svg-slides.asset-readiness.v1",
|
||||
assets: [
|
||||
{
|
||||
slide: "slides/slide_02.svg",
|
||||
purpose: "repair visual",
|
||||
status: "ready",
|
||||
local_path: "resources/images/repair.png"
|
||||
}
|
||||
],
|
||||
slides: [
|
||||
{ file: "slides/slide_01.svg", slide_role: "section" },
|
||||
{ file: "slides/slide_02.svg", slide_role: "analysis", layout_family: "text-with-callout" }
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness_report.json"), `${JSON.stringify({
|
||||
version: "svg-slides.asset-readiness-report.v1",
|
||||
ok: false,
|
||||
deck_errors: [],
|
||||
results: [
|
||||
{
|
||||
file: "slides/slide_02.svg",
|
||||
errors: [
|
||||
{
|
||||
rule: "asset.primary_visual_embedded",
|
||||
severity: "error",
|
||||
repairable: true,
|
||||
message: "slide #2 primary_visual is not embedded in slide SVG: resources/images/hero.png"
|
||||
}
|
||||
],
|
||||
warnings: []
|
||||
}
|
||||
],
|
||||
errors: ["slide #2 primary_visual is not embedded in slide SVG: resources/images/hero.png"]
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "typography_plan.json"), `${JSON.stringify({
|
||||
version: "svg-slides.typography-plan.v1",
|
||||
font_category: "clean sans display and body",
|
||||
display_font: "DM Sans, 黑体, sans-serif",
|
||||
body_font: "DM Sans, 黑体, sans-serif",
|
||||
label_font: "DM Sans, 黑体, sans-serif",
|
||||
source: "visual_system.typography",
|
||||
selection_reason: "The clean sans pairing matches a technical deck and keeps body text readable.",
|
||||
forbidden_substitutions: ["calligraphy body"],
|
||||
runtime_candidates_used: false,
|
||||
requested_fonts: [],
|
||||
resolved_fonts: ["DM Sans, 黑体, sans-serif"],
|
||||
fallbacks: [],
|
||||
fallback_reason: "",
|
||||
unavailable_fonts: [],
|
||||
user_requested_font_preserved: true
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "typography_plan_report.json"), `${JSON.stringify({
|
||||
version: "svg-slides.typography-plan-report.v1",
|
||||
ok: false,
|
||||
deck_errors: [],
|
||||
results: [
|
||||
{
|
||||
file: "slides/slide_02.svg",
|
||||
errors: [
|
||||
{
|
||||
rule: "typography.font_family_not_allowed",
|
||||
severity: "error",
|
||||
repairable: true,
|
||||
message: "font-family not allowed by typography_plan: Playfair Display, serif in slides/slide_02.svg"
|
||||
}
|
||||
],
|
||||
warnings: []
|
||||
}
|
||||
],
|
||||
errors: ["font-family not allowed by typography_plan: Playfair Display, serif in slides/slide_02.svg"]
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("repair context isolates the failed slide and keeps original SVG", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), slide("Stable Page"));
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_02.svg"), slide("Broken Page", `<rect slide:role="shape" slide:shape-type="rect" data-svglide-layer="overlay" x="96" y="92" width="580" height="70" fill="rgba(255,127,0,0.9)"/>`));
|
||||
writeReceipts(root);
|
||||
|
||||
const result = spawnSync("node", [contextScript, root, "--out", path.join(root, "repairs"), "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.ok, true);
|
||||
assert.deepEqual(report.failed_pages, ["slides/slide_02.svg"]);
|
||||
|
||||
const contextPath = path.join(root, "repairs", "slide_02.context.json");
|
||||
assert.equal(fs.existsSync(contextPath), true);
|
||||
const context = JSON.parse(fs.readFileSync(contextPath, "utf8"));
|
||||
assert.equal(context.slide_file, "slides/slide_02.svg");
|
||||
assert.equal(context.allowed_output_file, "slides/slide_02.svg");
|
||||
assert.equal(context.errors[0].rule, "readability.overlap");
|
||||
assert.match(context.original_svg, /Broken Page/);
|
||||
assert.equal(context.available_assets.length, 1);
|
||||
});
|
||||
|
||||
test("repair context normalizes validator paths back to deck-relative slides", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), slide("Stable Page"));
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_02.svg"), slide("Broken Page"));
|
||||
writeReceipts(root, path.relative(process.cwd(), path.join(root, "slides", "slide_02.svg")));
|
||||
|
||||
const result = spawnSync("node", [contextScript, root, "--out", path.join(root, "repairs"), "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.deepEqual(report.failed_pages, ["slides/slide_02.svg"]);
|
||||
});
|
||||
|
||||
test("hash guard allows only the failed page to change during repair", () => {
|
||||
const root = tempDeck();
|
||||
const slide1 = path.join(root, "slides", "slide_01.svg");
|
||||
const slide2 = path.join(root, "slides", "slide_02.svg");
|
||||
fs.writeFileSync(slide1, slide("Stable Page"));
|
||||
fs.writeFileSync(slide2, slide("Broken Page"));
|
||||
|
||||
const baseline = path.join(root, "receipts", "repair_hashes.before.json");
|
||||
const write = spawnSync("node", [hashGuardScript, root, "--write", baseline, "--json"], { encoding: "utf8" });
|
||||
assert.equal(write.status, 0, write.stderr || write.stdout);
|
||||
|
||||
fs.writeFileSync(slide2, slide("Repaired Page"));
|
||||
const allowed = spawnSync("node", [hashGuardScript, root, "--baseline", baseline, "--allowed", "slides/slide_02.svg", "--json"], { encoding: "utf8" });
|
||||
assert.equal(allowed.status, 0, allowed.stderr || allowed.stdout);
|
||||
assert.deepEqual(JSON.parse(allowed.stdout).changed_files, ["slides/slide_02.svg"]);
|
||||
|
||||
fs.writeFileSync(slide1, slide("Unexpected Change"));
|
||||
const rejected = spawnSync("node", [hashGuardScript, root, "--baseline", baseline, "--allowed", "slides/slide_02.svg", "--json"], { encoding: "utf8" });
|
||||
assert.equal(rejected.status, 1);
|
||||
const report = JSON.parse(rejected.stdout);
|
||||
assert.equal(report.ok, false);
|
||||
assert.deepEqual(report.unexpected_changed_files, ["slides/slide_01.svg"]);
|
||||
});
|
||||
|
||||
test("repair context includes asset and typography page-level generation failures", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), slide("Stable Page"));
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_02.svg"), slide("Broken Generation Page"));
|
||||
writeAssetAndTypographyPageReceipts(root);
|
||||
|
||||
const result = spawnSync("node", [contextScript, root, "--out", path.join(root, "repairs"), "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.deepEqual(report.failed_pages, ["slides/slide_02.svg"]);
|
||||
|
||||
const context = JSON.parse(fs.readFileSync(path.join(root, "repairs", "slide_02.context.json"), "utf8"));
|
||||
const rules = context.errors.map((error) => error.rule).sort();
|
||||
assert.deepEqual(rules, ["asset.primary_visual_embedded", "typography.font_family_not_allowed"]);
|
||||
});
|
||||
|
||||
test("repair context ignores deck-level asset and typography failures", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), slide("Stable Page"));
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_02.svg"), slide("Stable Page 2"));
|
||||
fs.writeFileSync(path.join(root, "receipts", "asset_readiness_report.json"), `${JSON.stringify({
|
||||
version: "svg-slides.asset-readiness-report.v1",
|
||||
ok: false,
|
||||
deck_errors: [{ rule: "asset.missing_web_search_evidence", severity: "error", repairable: false, message: "missing web search evidence for required entities: CHAGEE" }],
|
||||
results: [],
|
||||
errors: ["missing web search evidence for required entities: CHAGEE"]
|
||||
}, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "receipts", "typography_plan_report.json"), `${JSON.stringify({
|
||||
version: "svg-slides.typography-plan-report.v1",
|
||||
ok: false,
|
||||
deck_errors: [{ rule: "typography.display_font_latin_first", severity: "error", repairable: false, message: "display_font must start with a Latin font" }],
|
||||
results: [],
|
||||
errors: ["display_font must start with a Latin font"]
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const result = spawnSync("node", [contextScript, root, "--out", path.join(root, "repairs"), "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.deepEqual(report.failed_pages, []);
|
||||
});
|
||||
93
skills/lark-slides/scripts/svg_slides_semantic_renderer.mjs
Normal file
93
skills/lark-slides/scripts/svg_slides_semantic_renderer.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
function escapeText(value) {
|
||||
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeText(value).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function attrs(input) {
|
||||
return Object.entries(input)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
||||
.map(([key, value]) => ` ${key}="${escapeAttr(value)}"`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function background(fill = "rgba(255,255,255,1)") {
|
||||
return `<rect slide:role="background" x="0" y="0" width="960" height="540" fill="${escapeAttr(fill)}"/>`;
|
||||
}
|
||||
|
||||
export function rect({ id, x, y, width, height, fill, stroke, strokeWidth, rx = 0, ry = rx, opacity }) {
|
||||
return `<rect${attrs({
|
||||
"slide:role": "shape",
|
||||
"slide:shape-type": rx || ry ? "round-rect" : "rect",
|
||||
id, x, y, width, height, rx, ry,
|
||||
fill,
|
||||
stroke,
|
||||
"stroke-width": strokeWidth,
|
||||
opacity,
|
||||
})}/>`;
|
||||
}
|
||||
|
||||
export function circle({ id, cx, cy, r, fill, stroke, strokeWidth, opacity }) {
|
||||
return `<circle${attrs({
|
||||
"slide:role": "shape",
|
||||
"slide:shape-type": "circle",
|
||||
id, cx, cy, r, fill, stroke, "stroke-width": strokeWidth, opacity,
|
||||
})}/>`;
|
||||
}
|
||||
|
||||
export function line({ id, x1, y1, x2, y2, stroke, strokeWidth = 1, opacity }) {
|
||||
return `<line${attrs({
|
||||
"slide:role": "shape",
|
||||
"slide:shape-type": "line",
|
||||
id, x1, y1, x2, y2, stroke, "stroke-width": strokeWidth, opacity,
|
||||
})}/>`;
|
||||
}
|
||||
|
||||
export function image({ id, href, x, y, width, height, opacity }) {
|
||||
return `<image${attrs({
|
||||
"slide:role": "image",
|
||||
"slide:shape-type": "image",
|
||||
id, href, x, y, width, height, opacity,
|
||||
})}/>`;
|
||||
}
|
||||
|
||||
export function textBox({
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
text,
|
||||
fontSize = 18,
|
||||
fontFamily = "DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif",
|
||||
fontWeight = 500,
|
||||
color = "rgba(15,23,42,1)",
|
||||
lineHeight = 1.25,
|
||||
textAlign = "left",
|
||||
verticalAlign = "top",
|
||||
}) {
|
||||
const style = [
|
||||
`font-size:${fontSize}px`,
|
||||
`font-family:${fontFamily}`,
|
||||
`color:${color}`,
|
||||
`font-weight:${fontWeight}`,
|
||||
`line-height:${lineHeight}`,
|
||||
`text-align:${textAlign}`,
|
||||
`vertical-align:${verticalAlign}`,
|
||||
"letter-spacing:0px",
|
||||
"padding:0px",
|
||||
].join(";");
|
||||
return `<foreignObject${attrs({
|
||||
"slide:role": "shape",
|
||||
"slide:shape-type": "text",
|
||||
id, x, y, width, height, style,
|
||||
})}><p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:${fontSize}px;line-height:${lineHeight};color:${color};font-weight:${fontWeight};letter-spacing:0px">${escapeText(text)}</p></foreignObject>`;
|
||||
}
|
||||
|
||||
export function renderSlide({ id, children }) {
|
||||
if (!id) throw new Error("renderSlide requires id");
|
||||
if (!Array.isArray(children) || children.length === 0) throw new Error("renderSlide requires children");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="${escapeAttr(id)}" viewBox="0 0 960 540">\n ${children.join("\n ")}\n</svg>\n`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { background, image, rect, renderSlide, textBox } from "./svg_slides_semantic_renderer.mjs";
|
||||
|
||||
const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
|
||||
|
||||
test("semantic renderer emits parser-legible SVG", () => {
|
||||
const deck = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-renderer-"));
|
||||
fs.mkdirSync(path.join(deck, "slides"));
|
||||
const svg = renderSlide({
|
||||
id: "semantic-001",
|
||||
children: [
|
||||
background("rgba(255,255,255,1)"),
|
||||
rect({ id: "card", x: 64, y: 80, width: 420, height: 160, fill: "rgba(248,250,252,1)", rx: 16 }),
|
||||
textBox({ id: "title", x: 88, y: 104, width: 360, height: 56, text: "DeepSeek V4", fontSize: 34, fontWeight: 800, color: "rgba(15,23,42,1)" }),
|
||||
image({ id: "fig", href: "assets/fig.png", x: 560, y: 110, width: 280, height: 180 }),
|
||||
],
|
||||
});
|
||||
fs.writeFileSync(path.join(deck, "slides", "slide_01.svg"), svg);
|
||||
|
||||
assert.equal(svg.includes("<text"), false);
|
||||
assert.equal(svg.includes("<tspan"), false);
|
||||
assert.match(svg, /<foreignObject[^>]*slide:role="shape"[^>]*slide:shape-type="text"/);
|
||||
assert.match(svg, /<image[^>]*slide:role="image"[^>]*slide:shape-type="image"/);
|
||||
|
||||
const result = spawnSync("node", [validator, deck, "--json"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user