mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 10:29:48 +08:00
Compare commits
2 Commits
feat/svgli
...
feat/svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6459966c | ||
|
|
37986331f4 |
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
Reference in New Issue
Block a user