支持 SVGlide 图表 spec 标记

This commit is contained in:
songtianyi.theo
2026-06-11 19:51:06 +08:00
parent 566f6cfd47
commit 07e57d7857
8 changed files with 270 additions and 99 deletions

View File

@@ -148,7 +148,7 @@ func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
func TestSlidesCreateSVGChartMarkerPassesThroughSlideContent(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart><chartData /></chart>`)) + `</svg>`
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`
if err := os.WriteFile("chart.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write chart.svg: %v", err)
}
@@ -196,8 +196,8 @@ func TestSlidesCreateSVGChartMarkerPassesThroughSlideContent(t *testing.T) {
`slide:contract-version="svglide-authoring-contract/v1"`,
`<g slide:role="chart" slide:chart-ref="chart-1" x="80" y="96" width="420" height="260">`,
`data-svglide-chart="svglide-chart-inline/v1"`,
`data-format="sxsd-chart-v1"`,
`data-encoding="base64url"`,
`data-format="svglide-chart-spec-v1"`,
`data-encoding="base64url-json"`,
`data-payload-hash="sha256:`,
} {
if !strings.Contains(content, want) {

View File

@@ -9,9 +9,8 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"math"
"path/filepath"
"regexp"
"strings"
@@ -26,10 +25,27 @@ const (
svglideSlideNS = "https://slides.bytedance.com/ns"
svglideContractVersion = "svglide-authoring-contract/v1"
svglideChartMarkerVersion = "svglide-chart-inline/v1"
svglideChartFormat = "sxsd-chart-v1"
svglideChartEncoding = "base64url"
svglideChartFormat = "svglide-chart-spec-v1"
svglideChartSpecVersion = "svglide-chart-spec/v1"
svglideChartEncoding = "base64url-json"
)
type svglideChartSpecPayload struct {
Version *string `json:"version"`
ChartType *string `json:"chartType"`
Data *svglideChartSpecData `json:"data"`
}
type svglideChartSpecData struct {
Categories []string `json:"categories"`
Series []svglideChartSpecSeries `json:"series"`
}
type svglideChartSpecSeries struct {
Name *string `json:"name"`
Values []json.RawMessage `json:"values"`
}
type RewrittenSVGPage struct {
Content string
Tokens []string
@@ -532,8 +548,8 @@ func validateSVGlideChartMarker(path, attrs, inner string) (string, error) {
if !strings.EqualFold(hash, "sha256:"+hex.EncodeToString(sum[:])) {
return "", output.ErrValidation("--file %s: chart marker metadata data-payload-hash does not match decoded payload", path)
}
if err := validateSVGlideChartPayloadXML(decoded); err != nil {
return "", output.ErrValidation("--file %s: chart marker metadata decoded payload must be a single <chart> root: %v", path, err)
if err := validateSVGlideChartSpecPayload(decoded); err != nil {
return "", output.ErrValidation("--file %s: chart marker metadata decoded payload must be valid %s JSON: %v", path, svglideChartFormat, err)
}
return chartRef, nil
}
@@ -610,44 +626,76 @@ func decodeSVGlideChartPayload(payload string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(payload)
}
func validateSVGlideChartPayloadXML(payload []byte) error {
decoder := xml.NewDecoder(bytes.NewReader(payload))
rootSeen := false
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
func validateSVGlideChartSpecPayload(payload []byte) error {
var spec svglideChartSpecPayload
if err := json.Unmarshal(payload, &spec); err != nil {
return err
}
if spec.Version == nil || strings.TrimSpace(*spec.Version) == "" {
return fmt.Errorf("missing version")
}
if strings.TrimSpace(*spec.Version) != svglideChartSpecVersion {
return fmt.Errorf("version must be %q", svglideChartSpecVersion)
}
if spec.ChartType == nil || strings.TrimSpace(*spec.ChartType) == "" {
return fmt.Errorf("missing chartType")
}
switch strings.TrimSpace(*spec.ChartType) {
case "bar", "line":
default:
return fmt.Errorf("chartType must be one of bar,line")
}
if spec.Data == nil {
return fmt.Errorf("missing data")
}
if len(spec.Data.Categories) == 0 {
return fmt.Errorf("data.categories must be a non-empty array")
}
for i, category := range spec.Data.Categories {
if strings.TrimSpace(category) == "" {
return fmt.Errorf("data.categories[%d] must be a non-empty string", i)
}
if err != nil {
return err
}
if len(spec.Data.Series) == 0 {
return fmt.Errorf("data.series must be a non-empty array")
}
for i, series := range spec.Data.Series {
if series.Name == nil || strings.TrimSpace(*series.Name) == "" {
return fmt.Errorf("data.series[%d].name must be a non-empty string", i)
}
switch t := token.(type) {
case xml.StartElement:
if !rootSeen {
if t.Name.Local != "chart" {
return fmt.Errorf("got <%s>", t.Name.Local)
}
rootSeen = true
} else if depth == 0 {
return fmt.Errorf("multiple root elements")
}
depth++
case xml.EndElement:
if depth > 0 {
depth--
}
case xml.CharData:
if depth == 0 && strings.TrimSpace(string(t)) != "" {
return fmt.Errorf("non-whitespace text outside root")
if len(series.Values) != len(spec.Data.Categories) {
return fmt.Errorf("data.series[%d].values length must match data.categories length", i)
}
for j, value := range series.Values {
if err := validateSVGlideChartNumber(value); err != nil {
return fmt.Errorf("data.series[%d].values[%d] must be a finite number: %v", i, j, err)
}
}
}
if !rootSeen {
return fmt.Errorf("missing <chart> root")
return nil
}
func validateSVGlideChartNumber(raw json.RawMessage) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return err
}
if depth != 0 {
return fmt.Errorf("unclosed <chart> root")
var extra any
if err := decoder.Decode(&extra); err == nil {
return fmt.Errorf("multiple JSON values")
}
number, ok := value.(json.Number)
if !ok {
return fmt.Errorf("got %T", value)
}
n, err := number.Float64()
if err != nil {
return err
}
if math.IsNaN(n) || math.IsInf(n, 0) {
return fmt.Errorf("non-finite number")
}
return nil
}

View File

@@ -177,7 +177,7 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
},
{
name: "root chart marker with inline payload",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart><chartData /></chart>`)) + `</svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`,
},
{
name: "style and nested defs are ignored",
@@ -297,43 +297,63 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
},
{
name: "nested chart marker is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g>` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart />`)) + `</g></svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g>` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</g></svg>`,
wantErr: `<g slide:role="chart"> must be a direct child of root <svg>`,
},
{
name: "chart marker requires ref",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" x="0" y="0" width="100" height="60">` + testSVGlideChartMetadata(`<chart />`) + `</g></svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" x="0" y="0" width="100" height="60">` + testSVGlideChartMetadata(testSVGlideChartSpecJSON()) + `</g></svg>`,
wantErr: `missing required attribute "slide:chart-ref"`,
},
{
name: "chart marker rejects bad bbox",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" slide:chart-ref="chart-1" x="10%" y="0" width="100" height="60">` + testSVGlideChartMetadata(`<chart />`) + `</g></svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" slide:chart-ref="chart-1" x="10%" y="0" width="100" height="60">` + testSVGlideChartMetadata(testSVGlideChartSpecJSON()) + `</g></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "chart marker requires single metadata",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart />`)+testSVGlideChartMetadata(`<chart />`)) + `</svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())+testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`,
wantErr: `must contain exactly one metadata child`,
},
{
name: "chart marker rejects duplicate chart refs",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart />`)) + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart><chartData /></chart>`)) + `</svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideLineChartSpecJSON())) + `</svg>`,
wantErr: `duplicate slide:chart-ref "chart-1"`,
},
{
name: "chart marker rejects bad base64url",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="sxsd-chart-v1" data-encoding="base64url" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">bad+payload</metadata>`) + `</svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="svglide-chart-spec-v1" data-encoding="base64url-json" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">bad+payload</metadata>`) + `</svg>`,
wantErr: `payload must be base64url`,
},
{
name: "chart marker rejects old sxsd chart format",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="sxsd-chart-v1" data-encoding="base64url" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">`+base64.RawURLEncoding.EncodeToString([]byte(`<chart />`))+`</metadata>`) + `</svg>`,
wantErr: `data-format="svglide-chart-spec-v1"`,
},
{
name: "chart marker rejects hash mismatch",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadataWithHash(`<chart />`, "sha256:"+strings.Repeat("0", 64))) + `</svg>`,
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadataWithHash(testSVGlideChartSpecJSON(), "sha256:"+strings.Repeat("0", 64))) + `</svg>`,
wantErr: `data-payload-hash does not match`,
},
{
name: "chart marker decoded payload must be chart root",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<table />`)) + `</svg>`,
wantErr: `decoded payload must be a single <chart> root`,
name: "chart marker decoded payload must be json",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart />`)) + `</svg>`,
wantErr: `decoded payload must be valid svglide-chart-spec-v1 JSON`,
},
{
name: "chart marker rejects unsupported chart type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"pie","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":[12]}]}}`)) + `</svg>`,
wantErr: `chartType must be one of bar,line`,
},
{
name: "chart marker rejects values length mismatch",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12]}]}}`)) + `</svg>`,
wantErr: `values length must match data.categories length`,
},
{
name: "chart marker rejects nonnumeric values",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"line","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":["12"]}]}}`)) + `</svg>`,
wantErr: `must be a finite number`,
},
}
@@ -361,15 +381,23 @@ func testSVGlideChartMarker(metadata string) string {
return `<g slide:role="chart" slide:chart-ref="chart-1" x="80" y="96" width="420" height="260">` + metadata + `</g>`
}
func testSVGlideChartMetadata(chartXML string) string {
sum := sha256.Sum256([]byte(chartXML))
return testSVGlideChartMetadataWithHash(chartXML, fmt.Sprintf("sha256:%x", sum))
func testSVGlideChartSpecJSON() string {
return `{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}`
}
func testSVGlideChartMetadataWithHash(chartXML, hash string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(chartXML))
func testSVGlideLineChartSpecJSON() string {
return `{"version":"svglide-chart-spec/v1","chartType":"line","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}`
}
func testSVGlideChartMetadata(chartJSON string) string {
sum := sha256.Sum256([]byte(chartJSON))
return testSVGlideChartMetadataWithHash(chartJSON, fmt.Sprintf("sha256:%x", sum))
}
func testSVGlideChartMetadataWithHash(chartJSON, hash string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(chartJSON))
return fmt.Sprintf(
`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="sxsd-chart-v1" data-encoding="base64url" data-payload-hash="%s">%s</metadata>`,
`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="svglide-chart-spec-v1" data-encoding="base64url-json" data-payload-hash="%s">%s</metadata>`,
hash,
payload,
)

View File

@@ -20,7 +20,7 @@ metadata:
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `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` |
| 上传或使用图片 | Preview 阶段优先多用真实图片增强视觉冲击;可先用公开可访问 http(s)/data 图片或本地 `@./path`,来源/授权只 warning 不阻断;正式交付再替换为授权清晰的 file token / 本地资产 | `slides +media-upload`,或 `+create --slides` / `+create-svg``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | XML 路径使用原生 `<chart>`SVG 路径如需原生 chart snapshot,使用 `slide:role="chart"` marker | `xml-schema-quick-ref.md``svg-protocol.md` |
| 在 slide 中绘制柱/条/折线等 MVP 支持的数据图表 | XML 路径使用原生 `<chart>`SVG 路径如需原生 chart使用 `slide:role="chart"` spec marker | `xml-schema-quick-ref.md``svg-protocol.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | XML 路径必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素;`slides +create-svg` 明确不支持 whiteboard marker | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)、`svg-protocol.md` |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
@@ -32,7 +32,7 @@ metadata:
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVGroot `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。**
**CRITICAL — `slides +create-svg` 只允许 chart direct snapshot marker不允许 whiteboard marker。chart marker 必须是 root `<svg>` 的直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">`,且只包含一个 `data-svglide-chart="svglide-chart-inline/v1"` metadataCLI 校验 marker 外壳、base64url/hash 和 decoded 单根 `<chart>`,不解析 chart 业务合法性,不会为 chart 调任何额外 API。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 必须改走 XML/whiteboard 路径。**
**CRITICAL — `slides +create-svg` 只允许 chart spec marker不允许 whiteboard marker。chart marker 必须是 root `<svg>` 的直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">`,且只包含一个 `data-svglide-chart="svglide-chart-inline/v1"` metadatametadata 必须使用 `data-format="svglide-chart-spec-v1"`、`data-encoding="base64url-json"`payload 是 canonical JSON chart spec不是 SXSD `<chart>` XML也不是 chart snapshot/staticData。CLI 校验 marker 外壳、base64url/hash、JSON 基础结构、bar/line chartType、categories/series/values 长度和数值合法性,不会为 chart 调任何额外 API。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 必须改走 XML/whiteboard 路径。**
**CRITICAL — SVGlide deck 页数默认值:当用户要求生成 SVG/SVGlide 幻灯片但未说明页数,或使用“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时默认生成 `10` 页,不要仅因页数缺失而停下来追问。只有用户明确说“一页 / 单页 / onepage / one slide / 只要封面”等单页意图时,才生成 `1` 页;用户给出明确页数时始终服从用户要求。默认 10 页时必须在 `slide_plan.json` 写入 `page_count` 或 `target_slide_count=10`,并包含明确 closing slide。**

View File

@@ -15,7 +15,7 @@ lark-cli slides +create-svg \
- AI 已经能生成符合 [svg-protocol.md](svg-protocol.md) 的 SVGlide SVG。
- 希望按文件逐页创建,避免把大段 XML/SVG 塞进 shell 参数。
- 需要 SVG 内本地图片占位符自动上传并替换为 file token。
- 需要把已经生成好的原生 `<chart>` snapshot 作为 root direct chart marker 透传给服务端。
- 需要把原生 chart 的 canonical JSON spec 作为 root chart spec marker 透传给服务端。
不适用:
@@ -59,7 +59,7 @@ body
不会新增 `/svg_slide` 路由,也不会把 `file_meta_map` 当成 CLI 到服务端的契约。
chart direct snapshot 也不新增 API。CLI 不会解析 chart 业务合法性,不会上传 chart 资源,也不会调用任何 chart 创建接口;它只把通过校验的 marker 留在同一个 `slide.content` SVG 中。
chart spec marker 也不新增 API。CLI 不会上传 chart 资源,也不会调用任何 chart 创建接口;它只把通过 marker 外壳、hash 和 JSON spec 基础校验的 marker 留在同一个 `slide.content` SVG 中。
## 图片处理
@@ -85,9 +85,9 @@ CLI 会:
}
```
## Chart Direct Snapshot
## Chart Spec Marker
`slides +create-svg` 支持一种最小 chart marker用于透传已经生成好的 SXSD chart XML snapshot
`slides +create-svg` 支持一种最小 chart marker用于透传 canonical JSON chart spec。payload 不是 SXSD `<chart>` XML,也不是 chart snapshot/staticData服务端会在 SVGlide parser 内部把 spec 转成 chart 创建所需数据
```xml
<svg xmlns="http://www.w3.org/2000/svg"
@@ -100,24 +100,31 @@ CLI 会:
x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="sxsd-chart-v1"
data-encoding="base64url"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
</svg>
```
Decoded canonical JSON shape:
```json
{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}
```
CLI 校验范围只包括:
- marker 必须是 root `<svg>` 直系 `<g slide:role="chart">`
- `slide:chart-ref``x/y/width/height` bbox 必填bbox 只接受数字或 `px`
- marker 内必须且只能有一个 `<metadata>`
- metadata 必须使用 `data-svglide-chart="svglide-chart-inline/v1"``data-format="sxsd-chart-v1"``data-encoding="base64url"`
- payload 必须是无 padding base64url`data-payload-hash` 必须匹配 decoded bytes 的 sha256
- decoded payload 必须是单根 `<chart>` XML
- metadata 必须使用 `data-svglide-chart="svglide-chart-inline/v1"``data-format="svglide-chart-spec-v1"``data-encoding="base64url-json"`
- payload 必须是无 padding base64url`data-payload-hash` 必须匹配 decoded canonical JSON bytes 的 sha256不要对 base64 文本计算 hash
- decoded payload 必须是 JSON object且包含 `version="svglide-chart-spec/v1"``chartType``data.categories``data.series[].name``data.series[].values`
- MVP 只支持 `chartType="bar"` / `"line"``categories` 和每个 `values` 数组长度必须一致;`values` 只能是有限 JSON number。
CLI 不检查 `<chart>` 内部的系列、样式、数据范围等业务合法性;这些仍由服务端 chart/snapshot 消费方负责`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 明确不属于 `+create-svg` 协议面。
`sxsd-chart-v1` / `base64url` 的 SXSD `<chart>` XML payload 不属于 SVGlide chart marker 协议面,会被 CLI 拒绝`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 明确不属于 `+create-svg` 协议面。
## 生成质量规则
@@ -617,7 +624,7 @@ Preview 阶段优先使用这些来源来快速获得丰富视觉;正式交付
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;如果必须保留原生 chart snapshot使用 root direct chart marker不要把外部图表截图当成唯一方案。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;如果需要原生 bar/line chart使用 root chart spec marker不要把外部图表截图当成唯一方案。
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
视觉区要补足可读细节,避免只有装饰符号:

View File

@@ -31,7 +31,7 @@
- `slide:role="shape"` 目前只支持 `rect``ellipse``circle``line``path``foreignObject`
- 文本使用文本型 shape`<foreignObject slide:role="shape" slide:shape-type="text">...</foreignObject>`
- 图片使用 `<image slide:role="image" href="file_token">`;本地占位符写成 `href="@./image.png"`
- 原生 chart snapshot 可使用 root 直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">` markermarker 内只能有一个 chart metadatametadata payload 是 base64url 编码后的单根 `<chart>` XML
- 原生 chart 可使用 root 直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">` spec markermarker 内只能有一个 chart metadatametadata payload 是 base64url 编码后的 canonical JSON chart spec不是 SXSD `<chart>` XML也不是 chart snapshot/staticData
- `<defs>``<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
@@ -69,7 +69,7 @@ CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px
- Shape: `rect``ellipse``circle``line``path``foreignObject`
- Container: `g`、嵌套 `svg`
- Chart marker: root 直系 `g slide:role="chart"`,用于透传已经生成好的 SXSD chart snapshot
- Chart marker: root 直系 `g slide:role="chart"`,用于透传 canonical JSON chart spec
- Definitions: `defs` 内的 `linearGradient``radialGradient``filter/feDropShadow`;支持嵌套 `defs` 和 gradient `href` / `xlink:href` 继承。
- CSS: tag、`.class``#id``.a.b``tag.class` 等简单 selector支持 specificity 和 source order复杂 selector、media query、伪类会被忽略。
- Paint: `fill``stroke``stroke-width``opacity``fill-opacity``stroke-opacity``stroke-dasharray``stroke-linecap``stroke-linejoin`
@@ -109,9 +109,9 @@ CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px
虚线描边也不是稳定协议面:`stroke-dasharray`,尤其是自定义 path 上的虚线闭环,可能在 readback 中降级。关键流程线、路线图和闭环图用短 line segment 或 filled dot markers 显式绘制;带 `route``path``flow``loop``timeline``rail` 等语义的 dashed path 会被 `svg_preflight.py` 作为 error 拦截。普通装饰虚线也需要 live readback 复核。
## Chart Direct Snapshot Marker
## Chart Spec Marker
当 SVG 页面需要携带原生 `<chart>` snapshot 时,使用 root 直系 chart marker
当 SVG 页面需要创建原生 chart 时,使用 root 直系 chart marker。payload 是 canonical JSON bytes先对这段 decoded JSON bytes 计算 `sha256`,再用无 padding base64url 写入 metadata 文本
```xml
<g slide:role="chart"
@@ -119,21 +119,28 @@ CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px
x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="sxsd-chart-v1"
data-encoding="base64url"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
```
Decoded canonical JSON shape:
```json
{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}
```
约束:
- `g slide:role="chart"` 必须是 root `<svg>` 的直系子节点,不能嵌套在普通 `g` / 嵌套 `svg` 中。
- `slide:chart-ref``x``y``width``height` 必填bbox 数值只能是数字或 `px` 长度。
- marker 内必须且只能有一个 `<metadata>` 子节点。
- metadata 必须声明 `data-svglide-chart="svglide-chart-inline/v1"``data-format="sxsd-chart-v1"``data-encoding="base64url"``data-payload-hash="sha256:<64 hex>"`
- metadata 文本是无 padding 的 base64url payload解码后必须是单根 `<chart>` XML
- CLI 只做上述轻校验和透传,不解析 chart 数据、样式、系列等业务合法性,也不会为 chart 调用任何额外 API。请求体仍是 `{ "slide": { "content": "<svg ...>" } }`
- metadata 必须声明 `data-svglide-chart="svglide-chart-inline/v1"``data-format="svglide-chart-spec-v1"``data-encoding="base64url-json"``data-payload-hash="sha256:<64 hex>"`
- metadata 文本是无 padding 的 base64url payload解码后必须是 canonical JSON chart spec。不要把 SXSD `<chart>` XML 放入 SVGlide chart marker
- JSON spec 必须包含 `version="svglide-chart-spec/v1"``chartType``data.categories``data.series[].name``data.series[].values`。MVP 只支持 `chartType="bar"` / `"line"``categories` 和每个 `values` 数组长度必须一致;`values` 只能是有限 JSON number
- CLI 校验上述结构、hash 和基础数据合法性,不会为 chart 调用任何额外 API。请求体仍是 `{ "slide": { "content": "<svg ...>" } }`
- 不要把 chart marker 当成 SVG 手绘微图表的替代品;如果只是视觉小图、指标条或仪表盘装饰,仍优先用 SVGlide-safe shape/path 直接绘制。
## 不支持

View File

@@ -21,8 +21,9 @@ XLINK_NS = "http://www.w3.org/1999/xlink"
SVG_NS = "http://www.w3.org/2000/svg"
SVG_CONTRACT_VERSION = "svglide-authoring-contract/v1"
SVG_CHART_MARKER_VERSION = "svglide-chart-inline/v1"
SVG_CHART_FORMAT = "sxsd-chart-v1"
SVG_CHART_ENCODING = "base64url"
SVG_CHART_FORMAT = "svglide-chart-spec-v1"
SVG_CHART_SPEC_VERSION = "svglide-chart-spec/v1"
SVG_CHART_ENCODING = "base64url-json"
CANVAS_WIDTH = 960.0
CANVAS_HEIGHT = 540.0
SAFE_AREA = {"x": 48.0, "y": 40.0, "width": 864.0, "height": 460.0}
@@ -520,6 +521,53 @@ def decode_base64url_payload(payload: str) -> bytes:
raise ValueError(str(error)) from error
def reject_json_constant(value: str) -> None:
raise ValueError(f"invalid JSON number {value}")
def validate_chart_spec_payload(decoded: bytes) -> None:
try:
spec = json.loads(decoded.decode("utf-8"), parse_constant=reject_json_constant)
except UnicodeDecodeError as error:
raise ValueError(f"payload must be UTF-8 JSON: {error}") from error
except json.JSONDecodeError as error:
raise ValueError(f"payload must be JSON: {error}") from error
if not isinstance(spec, dict):
raise ValueError("payload root must be a JSON object")
if spec.get("version") != SVG_CHART_SPEC_VERSION:
raise ValueError(f'version must be "{SVG_CHART_SPEC_VERSION}"')
chart_type = spec.get("chartType")
if chart_type not in {"bar", "line"}:
raise ValueError('chartType must be one of "bar","line"')
data = spec.get("data")
if not isinstance(data, dict):
raise ValueError("data must be an object")
categories = data.get("categories")
if not isinstance(categories, list) or not categories:
raise ValueError("data.categories must be a non-empty array")
for index, category in enumerate(categories):
if not isinstance(category, str) or not category.strip():
raise ValueError(f"data.categories[{index}] must be a non-empty string")
series = data.get("series")
if not isinstance(series, list) or not series:
raise ValueError("data.series must be a non-empty array")
for series_index, item in enumerate(series):
if not isinstance(item, dict):
raise ValueError(f"data.series[{series_index}] must be an object")
name = item.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError(f"data.series[{series_index}].name must be a non-empty string")
values = item.get("values")
if not isinstance(values, list):
raise ValueError(f"data.series[{series_index}].values must be an array")
if len(values) != len(categories):
raise ValueError(f"data.series[{series_index}].values length must match data.categories length")
for value_index, value in enumerate(values):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError(f"data.series[{series_index}].values[{value_index}] must be a finite number")
def validate_chart_markers(root: ET.Element) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
direct_chart_ids = {id(element) for element in chart_marker_elements(root)}
@@ -594,12 +642,10 @@ def validate_chart_markers(root: ET.Element) -> list[dict[str, Any]]:
issues.append(issue("error", "chart_marker_payload_hash_mismatch", "chart marker metadata data-payload-hash does not match decoded payload", metadata))
continue
try:
chart_root = ET.fromstring(decoded)
except ET.ParseError as error:
issues.append(issue("error", "chart_marker_payload_xml", f"chart marker decoded payload is not well-formed XML: {error}", metadata))
validate_chart_spec_payload(decoded)
except ValueError as error:
issues.append(issue("error", "chart_marker_payload_spec", f"chart marker decoded payload must be valid {SVG_CHART_FORMAT} JSON: {error}", metadata))
continue
if local_name(chart_root.tag) != "chart":
issues.append(issue("error", "chart_marker_payload_root", "chart marker decoded payload must be a single <chart> root", metadata))
return issues

View File

@@ -95,13 +95,21 @@ def recipe_fields(recipe: str, primitives: list[str]) -> dict[str, object]:
}
def chart_metadata(chart_xml: str, payload_hash: str | None = None) -> str:
payload = base64.urlsafe_b64encode(chart_xml.encode("utf-8")).decode("ascii").rstrip("=")
def chart_spec(chart_type: str = "bar") -> str:
return (
'{"version":"svglide-chart-spec/v1",'
f'"chartType":"{chart_type}",'
'"data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}'
)
def chart_metadata(chart_json: str, payload_hash: str | None = None, data_format: str = "svglide-chart-spec-v1", data_encoding: str = "base64url-json") -> str:
payload = base64.urlsafe_b64encode(chart_json.encode("utf-8")).decode("ascii").rstrip("=")
if payload_hash is None:
payload_hash = "sha256:" + hashlib.sha256(chart_xml.encode("utf-8")).hexdigest()
payload_hash = "sha256:" + hashlib.sha256(chart_json.encode("utf-8")).hexdigest()
return (
'<metadata data-svglide-chart="svglide-chart-inline/v1" '
'data-format="sxsd-chart-v1" data-encoding="base64url" '
f'data-format="{data_format}" data-encoding="{data_encoding}" '
f'data-payload-hash="{payload_hash}">{payload}</metadata>'
)
@@ -182,7 +190,7 @@ class SvgPreflightTest(unittest.TestCase):
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
{chart_marker(chart_metadata("<chart><chartData /></chart>"))}
{chart_marker(chart_metadata(chart_spec()))}
</svg>
"""
result = svg_preflight.lint_svg(with_contract(svg))
@@ -196,7 +204,7 @@ class SvgPreflightTest(unittest.TestCase):
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<g>{chart_marker(chart_metadata("<table />", "sha256:" + "0" * 64))}</g>
<g>{chart_marker(chart_metadata(chart_spec(), "sha256:" + "0" * 64))}</g>
<g slide:role="whiteboard" x="0" y="0" width="100" height="60" />
<metadata data-svglide-whiteboard="svglide-whiteboard-inline/v1">abc</metadata>
</svg>
@@ -207,20 +215,47 @@ class SvgPreflightTest(unittest.TestCase):
self.assertIn("unsupported_whiteboard_role", codes)
self.assertIn("legacy_whiteboard_marker", codes)
def test_lint_svg_rejects_chart_marker_payload_hash_and_root(self) -> None:
def test_lint_svg_rejects_chart_marker_payload_hash_and_spec(self) -> None:
svg = f"""
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
{chart_marker(chart_metadata("<chart />", "sha256:" + "0" * 64))}
{chart_marker(chart_metadata("<table />"))}
{chart_marker(chart_metadata(chart_spec(), "sha256:" + "0" * 64))}
{chart_marker(chart_metadata('{"version":"svglide-chart-spec/v1","chartType":"pie","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":[12]}]}}'))}
</svg>
"""
result = svg_preflight.lint_svg(with_contract(svg))
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("chart_marker_payload_hash_mismatch", codes)
self.assertIn("chart_marker_payload_root", codes)
self.assertIn("chart_marker_payload_spec", codes)
def test_lint_svg_rejects_old_sxsd_chart_marker_format(self) -> None:
svg = f"""
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
{chart_marker(chart_metadata("<chart />", data_format="sxsd-chart-v1", data_encoding="base64url"))}
</svg>
"""
result = svg_preflight.lint_svg(with_contract(svg))
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("chart_marker_metadata_format", codes)
self.assertIn("chart_marker_metadata_encoding", codes)
def test_lint_svg_rejects_chart_marker_values_mismatch(self) -> None:
svg = f"""
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
{chart_marker(chart_metadata('{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12]}]}}'))}
</svg>
"""
result = svg_preflight.lint_svg(with_contract(svg))
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("chart_marker_payload_spec", codes)
def test_lint_svg_rejects_duplicate_chart_refs(self) -> None:
svg = f"""
@@ -228,8 +263,8 @@ class SvgPreflightTest(unittest.TestCase):
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
{chart_marker(chart_metadata("<chart />"))}
{chart_marker(chart_metadata("<chart><chartData /></chart>"))}
{chart_marker(chart_metadata(chart_spec()))}
{chart_marker(chart_metadata(chart_spec("line")))}
</svg>
"""
result = svg_preflight.lint_svg(with_contract(svg))