Compare commits

...

2 Commits

Author SHA1 Message Date
songtianyi.theo
5c6459966c fix(slides): align SVGlide native role classifier 2026-06-09 18:26:52 +08:00
songtianyi.theo
37986331f4 feat(slides): add SVGlide SVG fallback classifier 2026-06-09 17:03:20 +08:00
4 changed files with 1252 additions and 24 deletions

View File

@@ -44,7 +44,8 @@ var SlidesCreateSVG = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
filePaths := runtime.StrArray("file")
svgs, err := readSVGFiles(runtime, filePaths)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
@@ -52,7 +53,15 @@ var SlidesCreateSVG = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
pages, uploadPaths := dryRunRewriteSVGImagePlaceholders(svgs, assets)
classified, err := classifySVGlideSVGPages(filePaths, svgs)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
rewriteResult, uploadPaths, err := dryRunRewriteClassifiedSVGPages(classified, assets)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
pages := rewriteResult.Pages
dry := common.NewDryRunAPI()
total := 1 + len(uploadPaths) + len(pages)
@@ -90,7 +99,8 @@ var SlidesCreateSVG = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
filePaths := runtime.StrArray("file")
svgs, err := readSVGFiles(runtime, filePaths)
if err != nil {
return err
}
@@ -98,6 +108,20 @@ var SlidesCreateSVG = common.Shortcut{
if err != nil {
return err
}
classified, err := classifySVGlideSVGPages(filePaths, svgs)
if err != nil {
return err
}
if hasFallbackPages(classified) {
if err := svgFallbackRasterizer.CheckAvailable(ctx); err != nil {
return err
}
}
renderedFallbacks, err := renderSVGFallbackPages(ctx, classified, svgFallbackRasterizer)
if err != nil {
return err
}
defer cleanupRenderedSVGFallbacks(renderedFallbacks)
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
@@ -111,15 +135,19 @@ var SlidesCreateSVG = common.Shortcut{
result["revision_id"] = revisionID
}
pages, uploaded, err := rewriteSVGImagePlaceholders(runtime, presentationID, svgs, assets)
rewriteResult, err := rewriteClassifiedSVGPages(runtime, presentationID, classified, assets, renderedFallbacks)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
err, presentationID, rewriteResult.ImagesUploaded)
}
if uploaded > 0 {
result["images_uploaded"] = uploaded
if rewriteResult.ImagesUploaded > 0 {
result["images_uploaded"] = rewriteResult.ImagesUploaded
}
if rewriteResult.FallbackPages > 0 {
result["fallback_pages"] = rewriteResult.FallbackPages
}
pages := rewriteResult.Pages
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",

View File

@@ -5,6 +5,7 @@ package slides
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
@@ -139,8 +140,10 @@ func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
}
assertSlideCreateBodyContains(t, slideStub1, testSVGlidePage1)
assertSlideCreateBodyContains(t, slideStub2, testSVGlidePage2)
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
}
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
@@ -407,6 +410,218 @@ func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
}
}
func TestSlidesCreateSVGFallbackRendersUploadsAndAddsImageOnlySVG(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"><text x="80" y="120">render me</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{pngPath: "fallback.png", pngBytes: []byte("png-bytes")}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
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_fallback", "revision_id": 1}},
})
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": "boxcn_fallback"}},
}
reg.Register(uploadStub)
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_fallback/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_fallback", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_fallback", "https://x.feishu.cn/slides/pres_fallback")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "fallback",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fake.calls) != 1 || fake.calls[0] != "fallback.svg" {
t.Fatalf("rasterizer calls = %v, want [fallback.svg]", fake.calls)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["fallback_pages"] != float64(1) {
t.Fatalf("fallback_pages = %v, want 1", data["fallback_pages"])
}
if data["images_uploaded"] != float64(1) {
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{
`slide:contract-version="svglide-authoring-contract/v1"`,
`<image slide:role="image" href="boxcn_fallback" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
`<metadata data-svglide-assets="true"><img src="boxcn_fallback" /></metadata>`,
} {
if !strings.Contains(content, want) {
t.Fatalf("fallback slide content missing %s: %s", want, content)
}
}
if strings.Contains(content, "<text") {
t.Fatalf("fallback slide content should not contain original text node: %s", content)
}
}
func TestSlidesCreateSVGRejectsUnsafeBeforePresentationCreate(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"><script>alert(1)</script></svg>`
if err := os.WriteFile("unsafe.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write unsafe.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "unsafe.svg",
"--title", "unsafe",
"--as", "user",
})
if err == nil {
t.Fatal("expected preflight reject")
}
for _, want := range []string{"disallowed_script", "unsafe.svg"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err = %v, want %q", err, want)
}
}
}
func TestSlidesCreateSVGRendererUnavailableBeforePresentationCreate(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"><text x="80" y="120">needs fallback</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{
availableErr: newSVGlideDiagnosticsError("renderer unavailable", []SVGlideDiagnostic{{
Code: svgDiagRendererUnavailable,
Severity: svgDiagSeverityError,
Path: "fallback.svg",
Message: "renderer missing",
}}),
}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "renderer unavailable",
"--as", "user",
})
if err == nil {
t.Fatal("expected renderer unavailable error")
}
if !strings.Contains(err.Error(), svgDiagRendererUnavailable) {
t.Fatalf("err = %v, want renderer_unavailable", err)
}
if len(fake.calls) != 0 {
t.Fatalf("rasterizer should not render when availability check fails, calls=%v", fake.calls)
}
if fake.checkCalls != 1 {
t.Fatalf("renderer availability checks = %d, want 1", fake.checkCalls)
}
}
func TestSlidesCreateSVGRasterFailureBeforePresentationCreate(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"><text x="80" y="120">needs fallback</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{
renderErr: newSVGlideDiagnosticsError("render failed", []SVGlideDiagnostic{{
Code: svgDiagRendererFailed,
Severity: svgDiagSeverityError,
Path: "fallback.svg",
Message: "render failed",
}}),
}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "render failure",
"--as", "user",
})
if err == nil {
t.Fatal("expected raster failure error")
}
if !strings.Contains(err.Error(), svgDiagRendererFailed) {
t.Fatalf("err = %v, want renderer_failed", err)
}
if len(fake.calls) != 1 {
t.Fatalf("rasterizer calls = %v, want one render attempt", fake.calls)
}
}
type fakeSVGFallbackRasterizer struct {
availableErr error
renderErr error
pngPath string
pngBytes []byte
checkCalls int
calls []string
}
func (f *fakeSVGFallbackRasterizer) CheckAvailable(context.Context) error {
f.checkCalls++
return f.availableErr
}
func (f *fakeSVGFallbackRasterizer) Rasterize(_ context.Context, svgPath string) (string, int64, error) {
f.calls = append(f.calls, svgPath)
if f.renderErr != nil {
return "", 0, f.renderErr
}
if f.pngPath == "" {
f.pngPath = "fallback.png"
}
if len(f.pngBytes) == 0 {
f.pngBytes = []byte("png")
}
if err := os.WriteFile(f.pngPath, f.pngBytes, 0o644); err != nil {
return "", 0, err
}
return f.pngPath, int64(len(f.pngBytes)), nil
}
func setTestSVGFallbackRasterizer(r svgRasterizer) func() {
old := svgFallbackRasterizer
svgFallbackRasterizer = r
return func() {
svgFallbackRasterizer = old
}
}
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}

View File

@@ -4,26 +4,277 @@
package slides
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const maxSVGFileSizeBytes int64 = 2 * 1024 * 1024
const (
maxSVGFileSizeBytes int64 = 2 * 1024 * 1024
svgContractNamespace = "https://slides.bytedance.com/ns"
svgContractVersion = "svglide-authoring-contract/v1"
svgContractVersionAttr = "slide:contract-version"
svgRasterizerEnv = "SVGLIDE_RASTERIZER"
defaultSVGRenderer = "resvg"
defaultSVGRenderWait = 15 * time.Second
svgDiagSeverityError = "error"
svgDiagSeverityWarning = "warning"
svgDiagRootMissing = "root_missing"
svgDiagRootUnsupported = "root_unsupported"
svgDiagMissingNamespace = "missing_slide_namespace"
svgDiagMissingSlideRole = "missing_slide_role"
svgDiagContractVersion = "contract_version_mismatch"
svgDiagMalformedElement = "malformed_element"
svgDiagDisallowedScript = "disallowed_script"
svgDiagDisallowedEventAttr = "disallowed_event_attribute"
svgDiagExternalReference = "external_reference"
svgDiagNativeUnsupported = "native_unsupported"
svgDiagMissingPageSize = "missing_page_size"
svgDiagRendererUnavailable = "renderer_unavailable"
svgDiagRendererFailed = "renderer_failed"
svgDiagRendererTimeout = "renderer_timeout"
svgDiagRasterOutputMissing = "raster_output_missing"
svgDiagRasterOutputEmpty = "raster_output_empty"
svgDiagRasterOutputTooLarge = "raster_output_too_large"
)
type svgClassifyMode string
const (
svgClassifyNative svgClassifyMode = "native"
svgClassifyFallback svgClassifyMode = "fallback"
svgClassifyReject svgClassifyMode = "reject"
)
type RewrittenSVGPage struct {
Content string
Tokens []string
}
type SVGlideDiagnostic struct {
Code string `json:"code"`
Severity string `json:"severity"`
Path string `json:"path,omitempty"`
PageIndex int `json:"page_index"`
TagName string `json:"tag_name,omitempty"`
AttrName string `json:"attr_name,omitempty"`
Message string `json:"message"`
}
type svgClassifiedPage struct {
Path string
Content string
Mode svgClassifyMode
Diagnostics []SVGlideDiagnostic
}
type svglideDiagnosticsError struct {
message string
diagnostics []SVGlideDiagnostic
}
func (e *svglideDiagnosticsError) Error() string {
if len(e.diagnostics) == 0 {
return e.message
}
return e.message + ": " + formatSVGlideDiagnostics(e.diagnostics)
}
func newSVGlideDiagnosticsError(message string, diagnostics []SVGlideDiagnostic) error {
return &svglideDiagnosticsError{message: message, diagnostics: diagnostics}
}
func svglideDiagnosticsFromError(err error) []SVGlideDiagnostic {
var diagErr *svglideDiagnosticsError
if errors.As(err, &diagErr) {
return diagErr.diagnostics
}
return nil
}
func formatSVGlideDiagnostics(diagnostics []SVGlideDiagnostic) string {
data, err := json.Marshal(diagnostics)
if err != nil {
return fmt.Sprintf("%v", diagnostics)
}
return string(data)
}
func newSVGlideDiagnostic(code, severity, path string, pageIndex int, message string) SVGlideDiagnostic {
return SVGlideDiagnostic{
Code: code,
Severity: severity,
Path: path,
PageIndex: pageIndex,
Message: message,
}
}
type svgRasterizer interface {
CheckAvailable(ctx context.Context) error
Rasterize(ctx context.Context, svgPath string) (string, int64, error)
}
var svgFallbackRasterizer svgRasterizer = newCommandSVGRasterizer()
type commandSVGRasterizer struct {
command string
timeout time.Duration
maxOutputSize int64
env []string
lookPath func(string) (string, error)
}
func newCommandSVGRasterizer() commandSVGRasterizer {
command := strings.TrimSpace(os.Getenv(svgRasterizerEnv))
if command == "" {
command = defaultSVGRenderer
}
return commandSVGRasterizer{
command: command,
timeout: defaultSVGRenderWait,
maxOutputSize: common.MaxDriveMediaUploadSinglePartSize,
}
}
func (r commandSVGRasterizer) commandName() string {
if strings.TrimSpace(r.command) != "" {
return strings.TrimSpace(r.command)
}
return defaultSVGRenderer
}
func (r commandSVGRasterizer) renderTimeout() time.Duration {
if r.timeout > 0 {
return r.timeout
}
return defaultSVGRenderWait
}
func (r commandSVGRasterizer) outputLimit() int64 {
if r.maxOutputSize > 0 {
return r.maxOutputSize
}
return common.MaxDriveMediaUploadSinglePartSize
}
func (r commandSVGRasterizer) resolveCommand() (string, error) {
lookPath := r.lookPath
if lookPath == nil {
lookPath = exec.LookPath
}
command := r.commandName()
resolved, err := lookPath(command)
if err != nil {
return "", newSVGlideDiagnosticsError("SVGlide fallback renderer unavailable", []SVGlideDiagnostic{{
Code: svgDiagRendererUnavailable,
Severity: svgDiagSeverityError,
Message: fmt.Sprintf("renderer %q was not found; install resvg or set %s", command, svgRasterizerEnv),
}})
}
return resolved, nil
}
func (r commandSVGRasterizer) CheckAvailable(context.Context) error {
_, err := r.resolveCommand()
return err
}
func (r commandSVGRasterizer) Rasterize(ctx context.Context, svgPath string) (string, int64, error) {
command, err := r.resolveCommand()
if err != nil {
return "", 0, err
}
out, err := os.CreateTemp("", "lark-cli-svglide-*.png")
if err != nil {
return "", 0, newSVGlideDiagnosticsError("SVGlide fallback raster output unavailable", []SVGlideDiagnostic{{
Code: svgDiagRasterOutputMissing,
Severity: svgDiagSeverityError,
Path: svgPath,
Message: err.Error(),
}})
}
outPath := out.Name()
if closeErr := out.Close(); closeErr != nil {
_ = os.Remove(outPath)
return "", 0, closeErr
}
renderCtx, cancel := context.WithTimeout(ctx, r.renderTimeout())
defer cancel()
cmd := exec.CommandContext(renderCtx, command, svgPath, outPath)
if len(r.env) > 0 {
cmd.Env = append(os.Environ(), r.env...)
}
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(outPath)
code := svgDiagRendererFailed
if renderCtx.Err() == context.DeadlineExceeded {
code = svgDiagRendererTimeout
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", 0, newSVGlideDiagnosticsError("SVGlide fallback rasterization failed", []SVGlideDiagnostic{{
Code: code,
Severity: svgDiagSeverityError,
Path: svgPath,
Message: message,
}})
}
stat, err := os.Stat(outPath)
if err != nil {
_ = os.Remove(outPath)
return "", 0, newSVGlideDiagnosticsError("SVGlide fallback raster output missing", []SVGlideDiagnostic{{
Code: svgDiagRasterOutputMissing,
Severity: svgDiagSeverityError,
Path: svgPath,
Message: err.Error(),
}})
}
if stat.Size() == 0 {
_ = os.Remove(outPath)
return "", 0, newSVGlideDiagnosticsError("SVGlide fallback raster output is empty", []SVGlideDiagnostic{{
Code: svgDiagRasterOutputEmpty,
Severity: svgDiagSeverityError,
Path: svgPath,
Message: "renderer produced an empty PNG",
}})
}
if limit := r.outputLimit(); stat.Size() > limit {
_ = os.Remove(outPath)
return "", 0, newSVGlideDiagnosticsError("SVGlide fallback raster output is too large", []SVGlideDiagnostic{{
Code: svgDiagRasterOutputTooLarge,
Severity: svgDiagSeverityError,
Path: svgPath,
Message: fmt.Sprintf("renderer output %s exceeds %s limit", common.FormatSize(stat.Size()), common.FormatSize(limit)),
}})
}
return outPath, stat.Size(), nil
}
var (
svgRootOpenTagRegex = regexp.MustCompile(`(?s)\A(\s*(?:<\?[^?]*(?:\?[^>][^?]*)*\?>\s*)?(?:<!DOCTYPE[^>]*>\s*)?(?:<!--.*?-->\s*)*)<([A-Za-z_][\w.:-]*)((?:\s[^>]*?)?)(/?>)`)
svgAttrRegex = regexp.MustCompile(`(?is)(?:^|\s)([A-Za-z_:][\w.:-]*)\s*=\s*(["'])([^"']*)(["'])`)
svgImageTagRegex = regexp.MustCompile(`(?is)<image\b[^>]*>`)
svgImageHrefRegex = regexp.MustCompile(`(?is)(^|\s)(xlink:href|href)\s*=\s*(["'])([^"']*)(["'])`)
svgMetadataRegex = regexp.MustCompile(`(?is)<metadata\b[^>]*\bdata-svglide-assets\s*=\s*(["'])true(["'])[^>]*>.*?</metadata>`)
@@ -32,6 +283,7 @@ var (
svgNumberRegex = regexp.MustCompile(`^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$`)
svgPathNumberRegex = regexp.MustCompile(`[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?`)
svgTransformRegex = regexp.MustCompile(`(?is)([a-zA-Z]+)\(([^)]*)\)`)
svgExternalURLRegex = regexp.MustCompile(`(?is)url\(\s*['"]?https?://`)
svgShapeTags = map[string]bool{
"circle": true,
"ellipse": true,
@@ -111,15 +363,270 @@ func readSVGFiles(runtime *common.RuntimeContext, paths []string) ([]string, err
if strings.TrimSpace(string(data)) == "" {
return nil, output.ErrValidation("--file %s: SVG file is empty", path)
}
svg := string(data)
if err := validateSVGlideSVG(svg, path); err != nil {
return nil, err
}
svgs = append(svgs, svg)
svgs = append(svgs, string(data))
}
return svgs, nil
}
func classifySVGlideSVGPages(paths, svgs []string) ([]svgClassifiedPage, error) {
pages := make([]svgClassifiedPage, 0, len(svgs))
var rejected []SVGlideDiagnostic
for i, svg := range svgs {
path := ""
if i < len(paths) {
path = paths[i]
}
page := classifySVGlideSVGPage(svg, path, i)
pages = append(pages, page)
if page.Mode == svgClassifyReject {
rejected = append(rejected, page.Diagnostics...)
}
}
if len(rejected) > 0 {
return nil, newSVGlideDiagnosticsError("SVGlide preflight rejected SVG input", rejected)
}
return pages, nil
}
func classifySVGlideSVGPage(svg, path string, pageIndex int) svgClassifiedPage {
page := svgClassifiedPage{
Path: path,
Content: svg,
Mode: svgClassifyNative,
}
if diagnostics := preflightSVGlideStaticDiagnostics(svg, path, pageIndex); len(diagnostics) > 0 {
page.Diagnostics = diagnostics
page.Mode = svgClassifyReject
return page
}
if version := rootSVGlideContractVersion(svg); version != "" && version != svgContractVersion && declaresNativeSVGlideContent(svg) {
diag := newSVGlideDiagnostic(
svgDiagContractVersion,
svgDiagSeverityError,
path,
pageIndex,
fmt.Sprintf("root <svg> has unsupported %s=%q; expected %q", svgContractVersionAttr, version, svgContractVersion),
)
diag.AttrName = svgContractVersionAttr
page.Diagnostics = []SVGlideDiagnostic{diag}
page.Mode = svgClassifyReject
return page
}
if err := validateSVGlideSVG(svg, path); err == nil {
return page
} else {
page.Diagnostics = []SVGlideDiagnostic{{
Code: svgDiagNativeUnsupported,
Severity: svgDiagSeverityWarning,
Path: path,
PageIndex: pageIndex,
Message: err.Error(),
}}
}
if _, err := svgFallbackPageBox(svg, path, pageIndex); err != nil {
page.Mode = svgClassifyReject
page.Diagnostics = svglideDiagnosticsFromError(err)
if len(page.Diagnostics) == 0 {
page.Diagnostics = []SVGlideDiagnostic{
newSVGlideDiagnostic(svgDiagMissingPageSize, svgDiagSeverityError, path, pageIndex, err.Error()),
}
}
return page
}
page.Mode = svgClassifyFallback
return page
}
func rootSVGlideContractVersion(svg string) string {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return ""
}
return xmlAttrValue(svg[m[6]:m[7]], svgContractVersionAttr)
}
func declaresNativeSVGlideContent(svg string) bool {
for _, m := range regexp.MustCompile(`(?is)<([A-Za-z_][\w.:-]*)((?:\s[^>]*?)?)>`).FindAllStringSubmatch(svg, -1) {
if len(m) < 3 {
continue
}
tagName := m[1]
attrs := m[2]
switch xmlAttrValue(attrs, "slide:role") {
case "shape":
if svgShapeTags[tagName] {
return true
}
case "image":
if tagName == "image" {
return true
}
case "line":
if tagName == "line" {
return true
}
case "text":
if tagName == "foreignObject" {
return true
}
}
}
return false
}
func preflightSVGlideStaticDiagnostics(svg, path string, pageIndex int) []SVGlideDiagnostic {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return []SVGlideDiagnostic{
newSVGlideDiagnostic(svgDiagRootMissing, svgDiagSeverityError, path, pageIndex, "SVG root element not found"),
}
}
tagName := svg[m[4]:m[5]]
if tagName != "svg" {
diag := newSVGlideDiagnostic(svgDiagRootUnsupported, svgDiagSeverityError, path, pageIndex, "root element must be non-namespaced <svg>")
diag.TagName = tagName
return []SVGlideDiagnostic{diag}
}
attrs := svg[m[6]:m[7]]
var diagnostics []SVGlideDiagnostic
if !hasXMLAttr(attrs, "xmlns:slide", svgContractNamespace) {
diagnostics = append(diagnostics, newSVGlideDiagnostic(
svgDiagMissingNamespace,
svgDiagSeverityError,
path,
pageIndex,
fmt.Sprintf("root <svg> must declare xmlns:slide=%q", svgContractNamespace),
))
}
if !hasXMLAttr(attrs, "slide:role", "slide") {
diagnostics = append(diagnostics, newSVGlideDiagnostic(
svgDiagMissingSlideRole,
svgDiagSeverityError,
path,
pageIndex,
`root <svg> must include slide:role="slide"`,
))
}
diagnostics = append(diagnostics, inspectSVGStaticRejectRules(svg[m[9]:], path, pageIndex)...)
return diagnostics
}
func inspectSVGStaticRejectRules(svgAfterRootOpen, path string, pageIndex int) []SVGlideDiagnostic {
var diagnostics []SVGlideDiagnostic
for i := 0; i < len(svgAfterRootOpen); {
rel := strings.IndexByte(svgAfterRootOpen[i:], '<')
if rel < 0 {
return diagnostics
}
i += rel
switch {
case strings.HasPrefix(svgAfterRootOpen[i:], "<!--"):
end := strings.Index(svgAfterRootOpen[i+4:], "-->")
if end < 0 {
return append(diagnostics, newSVGlideDiagnostic(svgDiagMalformedElement, svgDiagSeverityError, path, pageIndex, "malformed SVG comment"))
}
i += 4 + end + 3
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "<![CDATA["):
end := strings.Index(svgAfterRootOpen[i+9:], "]]>")
if end < 0 {
return append(diagnostics, newSVGlideDiagnostic(svgDiagMalformedElement, svgDiagSeverityError, path, pageIndex, "malformed SVG CDATA"))
}
i += 9 + end + 3
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "<?"):
end := strings.Index(svgAfterRootOpen[i+2:], "?>")
if end < 0 {
return append(diagnostics, newSVGlideDiagnostic(svgDiagMalformedElement, svgDiagSeverityError, path, pageIndex, "malformed SVG processing instruction"))
}
i += 2 + end + 2
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "</"):
end := findSVGTagEnd(svgAfterRootOpen, i)
if end < 0 {
return append(diagnostics, newSVGlideDiagnostic(svgDiagMalformedElement, svgDiagSeverityError, path, pageIndex, "malformed SVG closing tag"))
}
i = end + 1
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "<!"):
end := findSVGTagEnd(svgAfterRootOpen, i)
if end < 0 {
return append(diagnostics, newSVGlideDiagnostic(svgDiagMalformedElement, svgDiagSeverityError, path, pageIndex, "malformed SVG declaration"))
}
i = end + 1
continue
}
end := findSVGTagEnd(svgAfterRootOpen, i)
if end < 0 {
return append(diagnostics, newSVGlideDiagnostic(svgDiagMalformedElement, svgDiagSeverityError, path, pageIndex, "malformed SVG element"))
}
name, attrs, _ := parseSVGStartTag(svgAfterRootOpen[i+1 : end])
if name == "" {
i = end + 1
continue
}
diagnostics = append(diagnostics, inspectSVGElementStaticRejectRules(path, pageIndex, name, attrs)...)
i = end + 1
}
return diagnostics
}
func inspectSVGElementStaticRejectRules(path string, pageIndex int, tagName, attrs string) []SVGlideDiagnostic {
var diagnostics []SVGlideDiagnostic
lowerTag := strings.ToLower(tagName)
if lowerTag == "script" {
diag := newSVGlideDiagnostic(svgDiagDisallowedScript, svgDiagSeverityError, path, pageIndex, "<script> is not allowed in SVGlide SVG")
diag.TagName = tagName
diagnostics = append(diagnostics, diag)
}
for _, attr := range svgAttrRegex.FindAllStringSubmatch(attrs, -1) {
if len(attr) < 5 || attr[2] != attr[4] {
continue
}
name := attr[1]
value := strings.TrimSpace(attr[3])
lowerName := strings.ToLower(name)
switch {
case strings.HasPrefix(lowerName, "on"):
diag := newSVGlideDiagnostic(svgDiagDisallowedEventAttr, svgDiagSeverityError, path, pageIndex, "event handler attributes are not allowed in SVGlide SVG")
diag.TagName = tagName
diag.AttrName = name
diagnostics = append(diagnostics, diag)
case lowerName == "href" || lowerName == "xlink:href" || lowerName == "src":
if isHTTPURL(value) {
diag := newSVGlideDiagnostic(svgDiagExternalReference, svgDiagSeverityError, path, pageIndex, "external http(s) references are not allowed in SVGlide SVG")
diag.TagName = tagName
diag.AttrName = name
diagnostics = append(diagnostics, diag)
}
case lowerName == "style":
if svgExternalURLRegex.MatchString(value) {
diag := newSVGlideDiagnostic(svgDiagExternalReference, svgDiagSeverityError, path, pageIndex, "external http(s) style references are not allowed in SVGlide SVG")
diag.TagName = tagName
diag.AttrName = name
diagnostics = append(diagnostics, diag)
}
}
}
return diagnostics
}
func hasFallbackPages(pages []svgClassifiedPage) bool {
for _, page := range pages {
if page.Mode == svgClassifyFallback {
return true
}
}
return false
}
func validateSVGlideSVG(svg, path string) error {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
@@ -130,12 +637,15 @@ func validateSVGlideSVG(svg, path string) error {
return output.ErrValidation("--file %s: root element must be non-namespaced <svg>", path)
}
attrs := svg[m[6]:m[7]]
if !hasXMLAttr(attrs, "xmlns:slide", "https://slides.bytedance.com/ns") {
return output.ErrValidation("--file %s: root <svg> must declare xmlns:slide=\"https://slides.bytedance.com/ns\"", path)
if !hasXMLAttr(attrs, "xmlns:slide", svgContractNamespace) {
return output.ErrValidation("--file %s: root <svg> must declare xmlns:slide=\"%s\"", path, svgContractNamespace)
}
if !hasXMLAttr(attrs, "slide:role", "slide") {
return output.ErrValidation("--file %s: root <svg> must include slide:role=\"slide\"", path)
}
if version := xmlAttrValue(attrs, svgContractVersionAttr); version != "" && version != svgContractVersion {
return output.ErrValidation("--file %s: root <svg> has unsupported %s=%q; expected %q", path, svgContractVersionAttr, version, svgContractVersion)
}
if svg[m[8]:m[9]] == "/>" {
return nil
}
@@ -156,6 +666,23 @@ func xmlAttrValue(attrs, name string) string {
return ""
}
func ensureSVGlideContractRootAttrs(svg string) (string, error) {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return "", output.ErrValidation("SVG root element not found")
}
attrs := svg[m[6]:m[7]]
version := xmlAttrValue(attrs, svgContractVersionAttr)
if version != "" && version != svgContractVersion {
return "", output.ErrValidation("root <svg> has unsupported %s=%q; expected %q", svgContractVersionAttr, version, svgContractVersion)
}
if version == svgContractVersion {
return svg, nil
}
insertAt := m[8]
return svg[:insertAt] + fmt.Sprintf(` %s="%s"`, svgContractVersionAttr, svgContractVersion) + svg[insertAt:], nil
}
func validateSVGlideChildren(svgAfterRootOpen, path string) error {
depth := 0
skipDepth := -1
@@ -312,7 +839,7 @@ func validateSVGlideElement(path, tagName, attrs string) (svgValidationMode, err
role := xmlAttrValue(attrs, "slide:role")
if role == "" {
return svgValidationStop, output.ErrValidation("--file %s: <%s> must include slide:role=\"shape\" or slide:role=\"image\" for SVGlide", path, tagName)
return svgValidationStop, output.ErrValidation("--file %s: <%s> must include slide:role=\"shape\", \"image\", \"line\", or \"text\" for SVGlide", path, tagName)
}
switch role {
@@ -345,8 +872,24 @@ func validateSVGlideElement(path, tagName, attrs string) (svgValidationMode, err
return svgValidationStop, err
}
return svgValidationSkipSubtree, nil
case "line":
if tagName != "line" {
return svgValidationStop, output.ErrValidation("--file %s: <%s slide:role=\"line\"> is not supported by SVGlide; use <line>", path, tagName)
}
if err := validateSVGlideRequiredAttrs(path, tagName, role, attrs); err != nil {
return svgValidationStop, err
}
return svgValidationSkipSubtree, nil
case "text":
if tagName != "foreignObject" {
return svgValidationStop, output.ErrValidation("--file %s: <%s slide:role=\"text\"> is not supported by SVGlide; use <foreignObject>", path, tagName)
}
if err := validateSVGlideRequiredAttrs(path, tagName, role, attrs); err != nil {
return svgValidationStop, err
}
return svgValidationSkipSubtree, nil
default:
return svgValidationStop, output.ErrValidation("--file %s: <%s> has unsupported slide:role=%q; use \"shape\" or \"image\"", path, tagName, role)
return svgValidationStop, output.ErrValidation("--file %s: <%s> has unsupported slide:role=%q; use \"shape\", \"image\", \"line\", or \"text\"", path, tagName, role)
}
}
@@ -422,11 +965,228 @@ func validateSVGlidePathData(path, attrs string) error {
func isExternalSVGHref(value string) bool {
lower := strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(lower, "http://") ||
strings.HasPrefix(lower, "https://") ||
return isHTTPURL(lower) ||
strings.HasPrefix(lower, "data:")
}
func isHTTPURL(value string) bool {
lower := strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")
}
type svgFallbackBox struct {
ViewBox string
Width string
Height string
}
func svgFallbackPageBox(svg, path string, pageIndex int) (svgFallbackBox, error) {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return svgFallbackBox{}, newSVGlideDiagnosticsError("SVGlide fallback page size unavailable", []SVGlideDiagnostic{
newSVGlideDiagnostic(svgDiagRootMissing, svgDiagSeverityError, path, pageIndex, "SVG root element not found"),
})
}
attrs := svg[m[6]:m[7]]
viewBox := strings.TrimSpace(xmlAttrValue(attrs, "viewBox"))
if viewBox != "" {
parts := splitSVGNumberList(viewBox)
if len(parts) == 4 && isSVGlideNumber(parts[2]) && isSVGlideNumber(parts[3]) {
return svgFallbackBox{ViewBox: strings.Join(parts, " "), Width: stripSVGNumberUnit(parts[2]), Height: stripSVGNumberUnit(parts[3])}, nil
}
}
width := strings.TrimSpace(xmlAttrValue(attrs, "width"))
height := strings.TrimSpace(xmlAttrValue(attrs, "height"))
if isSVGlideNumber(width) && isSVGlideNumber(height) {
width = stripSVGNumberUnit(width)
height = stripSVGNumberUnit(height)
return svgFallbackBox{ViewBox: fmt.Sprintf("0 0 %s %s", width, height), Width: width, Height: height}, nil
}
return svgFallbackBox{}, newSVGlideDiagnosticsError("SVGlide fallback page size unavailable", []SVGlideDiagnostic{
newSVGlideDiagnostic(svgDiagMissingPageSize, svgDiagSeverityError, path, pageIndex, "fallback SVG must include a numeric viewBox or width/height"),
})
}
func splitSVGNumberList(value string) []string {
raw := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || isXMLSpace(r)
})
var out []string
for _, part := range raw {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func stripSVGNumberUnit(value string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(strings.ToLower(value), "px") {
return strings.TrimSpace(value[:len(value)-2])
}
return value
}
func buildSVGFallbackImageOnlyPage(svg, fileToken string) (string, error) {
box, err := svgFallbackPageBox(svg, "", 0)
if err != nil {
return "", err
}
token := xmlEscape(fileToken)
viewBox := xmlEscape(box.ViewBox)
width := xmlEscape(box.Width)
height := xmlEscape(box.Height)
return fmt.Sprintf(
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="%s" slide:role="slide" %s="%s" viewBox="%s"><image slide:role="image" href="%s" x="0" y="0" width="%s" height="%s" preserveAspectRatio="none"/></svg>`,
svgContractNamespace,
svgContractVersionAttr,
svgContractVersion,
viewBox,
token,
width,
height,
), nil
}
type classifiedSVGRewriteResult struct {
Pages []RewrittenSVGPage
ImagesUploaded int
FallbackPages int
}
type renderedSVGFallback struct {
PNGPath string
PNGSize int64
}
func renderSVGFallbackPages(ctx context.Context, pages []svgClassifiedPage, rasterizer svgRasterizer) (map[int]renderedSVGFallback, error) {
rendered := map[int]renderedSVGFallback{}
for i, page := range pages {
if page.Mode != svgClassifyFallback {
continue
}
pngPath, pngSize, err := rasterizer.Rasterize(ctx, page.Path)
if err != nil {
cleanupRenderedSVGFallbacks(rendered)
return nil, err
}
rendered[i] = renderedSVGFallback{PNGPath: pngPath, PNGSize: pngSize}
}
return rendered, nil
}
func cleanupRenderedSVGFallbacks(rendered map[int]renderedSVGFallback) {
for _, item := range rendered {
if item.PNGPath != "" {
_ = os.Remove(item.PNGPath)
}
}
}
func dryRunRewriteClassifiedSVGPages(pages []svgClassifiedPage, assets map[string]string) (classifiedSVGRewriteResult, []string, error) {
nativeSVGs := make([]string, 0, len(pages))
for _, page := range pages {
if page.Mode == svgClassifyNative {
nativeSVGs = append(nativeSVGs, page.Content)
}
}
paths := extractSVGImagePlaceholderPaths(nativeSVGs, assets)
localTokens := make(map[string]string, len(paths))
for _, path := range paths {
localTokens[path] = "<uploaded_file_token:" + filepath.Base(path) + ">"
}
tokens := mergedSVGAssetTokens(assets, localTokens)
out := classifiedSVGRewriteResult{Pages: make([]RewrittenSVGPage, 0, len(pages))}
uploadPaths := append([]string(nil), paths...)
for i, page := range pages {
switch page.Mode {
case svgClassifyNative:
content, usedTokens := rewriteSVGImagePlaceholdersWithTokens(page.Content, tokens)
content, err := ensureSVGlideContractRootAttrs(content)
if err != nil {
return classifiedSVGRewriteResult{}, nil, err
}
out.Pages = append(out.Pages, RewrittenSVGPage{Content: content, Tokens: usedTokens})
case svgClassifyFallback:
out.FallbackPages++
placeholder := fmt.Sprintf("<uploaded_fallback_png:page_%d>", i+1)
content, err := buildSVGFallbackImageOnlyPage(page.Content, placeholder)
if err != nil {
return classifiedSVGRewriteResult{}, nil, err
}
out.Pages = append(out.Pages, RewrittenSVGPage{Content: content, Tokens: []string{placeholder}})
uploadPaths = append(uploadPaths, fmt.Sprintf("<rendered_png:page_%d>", i+1))
default:
return classifiedSVGRewriteResult{}, nil, newSVGlideDiagnosticsError("SVGlide rejected page cannot be rewritten", page.Diagnostics)
}
}
out.ImagesUploaded = len(uploadPaths)
return out, uploadPaths, nil
}
func rewriteClassifiedSVGPages(runtime *common.RuntimeContext, presentationID string, pages []svgClassifiedPage, assets map[string]string, renderedFallbacks map[int]renderedSVGFallback) (classifiedSVGRewriteResult, error) {
nativeSVGs := make([]string, 0, len(pages))
for _, page := range pages {
if page.Mode == svgClassifyNative {
nativeSVGs = append(nativeSVGs, page.Content)
}
}
paths := extractSVGImagePlaceholderPaths(nativeSVGs, assets)
localTokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, paths)
if err != nil {
return classifiedSVGRewriteResult{ImagesUploaded: uploaded}, err
}
tokens := mergedSVGAssetTokens(assets, localTokens)
out := classifiedSVGRewriteResult{
Pages: make([]RewrittenSVGPage, 0, len(pages)),
ImagesUploaded: uploaded,
}
for i, page := range pages {
switch page.Mode {
case svgClassifyNative:
content, usedTokens := rewriteSVGImagePlaceholdersWithTokens(page.Content, tokens)
content, err := ensureSVGlideContractRootAttrs(content)
if err != nil {
return out, err
}
out.Pages = append(out.Pages, RewrittenSVGPage{Content: content, Tokens: usedTokens})
case svgClassifyFallback:
out.FallbackPages++
rendered, ok := renderedFallbacks[i]
if !ok {
return out, newSVGlideDiagnosticsError("SVGlide fallback raster output missing", []SVGlideDiagnostic{{
Code: svgDiagRasterOutputMissing,
Severity: svgDiagSeverityError,
Path: page.Path,
PageIndex: i,
Message: "fallback page was not pre-rendered",
}})
}
fileName := filepath.Base(rendered.PNGPath)
token, err := uploadSlidesMedia(runtime, rendered.PNGPath, fileName, rendered.PNGSize, presentationID)
if err != nil {
return out, err
}
out.ImagesUploaded++
content, err := buildSVGFallbackImageOnlyPage(page.Content, token)
if err != nil {
return out, err
}
out.Pages = append(out.Pages, RewrittenSVGPage{Content: content, Tokens: []string{token}})
default:
return out, newSVGlideDiagnosticsError("SVGlide rejected page cannot be rewritten", page.Diagnostics)
}
}
return out, nil
}
func parseSVGAssets(runtime *common.RuntimeContext, path string) (map[string]string, error) {
if strings.TrimSpace(path) == "" {
return nil, nil

View File

@@ -4,10 +4,14 @@
package slides
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
@@ -92,6 +96,204 @@ func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
}
}
func TestClassifySVGlideSVGPageRoutes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
svg string
wantMode svgClassifyMode
wantCode string
}{
{
name: "native supported shape",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
wantMode: svgClassifyNative,
},
{
name: "native supported server line role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><line slide:role="line" x1="0" y1="0" x2="100" y2="60" stroke="#112233"/></svg>`,
wantMode: svgClassifyNative,
},
{
name: "native supported server text role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
wantMode: svgClassifyNative,
},
{
name: "marked svg text still falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text slide:role="text" x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "wrong contract native rejects",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagContractVersion,
},
{
name: "wrong contract server text role rejects",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagContractVersion,
},
{
name: "unsupported but renderable text falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "wrong contract fallback-only svg still falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "table defaults to fallback",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject x="20" y="40" width="400" height="240"><table xmlns="http://www.w3.org/1999/xhtml"><tr><td>a</td></tr></table></foreignObject></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "script rejects before create",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagDisallowedScript,
},
{
name: "external href rejects before create",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image href="https://example.com/a.png" x="0" y="0" width="10" height="10"/></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagExternalReference,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := classifySVGlideSVGPage(tt.svg, "page.svg", 0)
if got.Mode != tt.wantMode {
t.Fatalf("mode = %s, want %s; diagnostics=%v", got.Mode, tt.wantMode, got.Diagnostics)
}
if tt.wantCode == "" {
return
}
if len(got.Diagnostics) == 0 || got.Diagnostics[0].Code != tt.wantCode {
t.Fatalf("diagnostics = %v, want first code %s", got.Diagnostics, tt.wantCode)
}
})
}
}
func TestBuildSVGFallbackImageOnlyPage(t *testing.T) {
t.Parallel()
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">fallback</text></svg>`
got, err := buildSVGFallbackImageOnlyPage(source, "boxcn_full_page")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
`xmlns:slide="https://slides.bytedance.com/ns"`,
`slide:role="slide"`,
`slide:contract-version="svglide-authoring-contract/v1"`,
`viewBox="0 0 1280 720"`,
`<image slide:role="image" href="boxcn_full_page" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
} {
if !strings.Contains(got, want) {
t.Fatalf("image-only SVG missing %s: %s", want, got)
}
}
if err := validateSVGlideSVG(got, "fallback.svg"); err != nil {
t.Fatalf("image-only SVG should be native-valid: %v", err)
}
}
func TestEnsureSVGlideContractRootAttrsInjectsMissingVersion(t *testing.T) {
t.Parallel()
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
got, err := ensureSVGlideContractRootAttrs(source)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("contract version missing: %s", got)
}
if strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1" slide:contract-version`) {
t.Fatalf("contract version duplicated: %s", got)
}
}
func TestCommandSVGRasterizerUnavailableDiagnostic(t *testing.T) {
t.Parallel()
r := commandSVGRasterizer{
command: "missing-svglide-renderer",
lookPath: func(string) (string, error) {
return "", os.ErrNotExist
},
}
err := r.CheckAvailable(context.Background())
if err == nil {
t.Fatal("expected renderer unavailable error")
}
diags := svglideDiagnosticsFromError(err)
if len(diags) != 1 || diags[0].Code != svgDiagRendererUnavailable {
t.Fatalf("diagnostics = %v, want renderer_unavailable", diags)
}
}
func TestCommandSVGRasterizerArgvAndOutputSize(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "fake-resvg")
argvFile := filepath.Join(dir, "argv.txt")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGV_FILE\"\nprintf png > \"$2\"\n"), 0o755); err != nil {
t.Fatalf("write fake renderer: %v", err)
}
in := filepath.Join(dir, "page.svg")
if err := os.WriteFile(in, []byte(`<svg/>`), 0o644); err != nil {
t.Fatalf("write svg: %v", err)
}
r := commandSVGRasterizer{
command: script,
timeout: time.Second,
maxOutputSize: 20,
env: []string{"ARGV_FILE=" + argvFile},
}
out, size, err := r.Rasterize(context.Background(), in)
if err != nil {
t.Fatalf("unexpected rasterize error: %v", err)
}
if size != int64(len("png")) {
t.Fatalf("size = %d, want %d", size, len("png"))
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("output file missing: %v", err)
}
argv, err := os.ReadFile(argvFile)
if err != nil {
t.Fatalf("read argv: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
if len(lines) != 2 || lines[0] != in || lines[1] != out {
t.Fatalf("argv = %q, want input and output path", string(argv))
}
r.maxOutputSize = 2
_, _, err = r.Rasterize(context.Background(), in)
if err == nil {
t.Fatal("expected output-size validation error")
}
diags := svglideDiagnosticsFromError(err)
if len(diags) == 0 || diags[0].Code != svgDiagRasterOutputTooLarge {
t.Fatalf("diagnostics = %v, want raster_output_too_large", diags)
}
}
func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
t.Parallel()
@@ -108,6 +310,14 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
name: "supported text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported server text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported server line role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><line slide:role="line" x1="0" y1="0" x2="100" y2="60"/></svg>`,
},
{
name: "supported image href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
@@ -164,12 +374,12 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
{
name: "root child missing role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
},
{
name: "group child missing role is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
},
{
name: "unsupported text element remains rejected",
@@ -214,13 +424,28 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
{
name: "plain metadata remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
wantErr: `<metadata> must include slide:role="shape" or slide:role="image"`,
wantErr: `<metadata> must include slide:role="shape", "image", "line", or "text"`,
},
{
name: "foreignObject shape requires text type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
},
{
name: "line role must be line tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="line" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect slide:role="line"> is not supported`,
},
{
name: "text role must be foreignObject tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="text" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect slide:role="text"> is not supported`,
},
{
name: "svg text role is not native yet",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="text" x="0" y="20">later</text></svg>`,
wantErr: `<text slide:role="text"> is not supported`,
},
{
name: "image role must be image tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,