mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
支持 SVGlide 图表 spec 标记
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 SVG:root `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。**
|
||||
|
||||
**CRITICAL — `slides +create-svg` 只允许 chart 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"` metadata;CLI 只校验 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"` metadata;metadata 必须使用 `data-format="svglide-chart-spec-v1"`、`data-encoding="base64url-json"`,payload 是 canonical JSON chart spec,不是 SXSD `<chart>` XML,也不是 chart snapshot/staticData。CLI 校验 marker 外壳、base64url/hash、JSON 基础结构、bar/line chartType、categories/series/values 长度和数值合法性,不会为 chart 调任何额外 API。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 必须改走 XML/whiteboard 路径。**
|
||||
|
||||
**CRITICAL — SVGlide deck 页数默认值:当用户要求生成 SVG/SVGlide 幻灯片但未说明页数,或使用“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时,默认生成 `10` 页,不要仅因页数缺失而停下来追问。只有用户明确说“一页 / 单页 / onepage / one slide / 只要封面”等单页意图时,才生成 `1` 页;用户给出明确页数时始终服从用户要求。默认 10 页时必须在 `slide_plan.json` 写入 `page_count` 或 `target_slide_count=10`,并包含明确 closing slide。**
|
||||
|
||||
|
||||
@@ -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、局部标签、对比线和图例。
|
||||
|
||||
视觉区要补足可读细节,避免只有装饰符号:
|
||||
|
||||
@@ -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="...">` marker;marker 内只能有一个 chart metadata,metadata payload 是 base64url 编码后的单根 `<chart>` XML。
|
||||
- 原生 chart 可使用 root 直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">` spec marker;marker 内只能有一个 chart metadata,metadata payload 是 base64url 编码后的 canonical JSON chart spec,不是 SXSD `<chart>` XML,也不是 chart snapshot/staticData。
|
||||
- `<defs>` 和 `<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
|
||||
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
|
||||
|
||||
@@ -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 直接绘制。
|
||||
|
||||
## 不支持
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user