diff --git a/internal/svglide/author.go b/internal/svglide/author.go index 12411cbdf..8559ba9c8 100644 --- a/internal/svglide/author.go +++ b/internal/svglide/author.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "html" + "os" "strings" ) @@ -41,9 +42,30 @@ type authorSlideContentFile struct { } type authorSlideContent struct { + ID string `json:"id"` + Content string `json:"content"` + Notes string `json:"notes"` + SourceRefs []string `json:"source_refs"` + Visuals []authorSlideVisual `json:"visuals"` +} + +type authorSlideVisual struct { + ID string `json:"id"` + Type string `json:"type"` + Instruction string `json:"instruction"` +} + +type authorAssetsFile struct { + Assets []authorAsset `json:"assets"` +} + +type authorAsset struct { ID string `json:"id"` - Content string `json:"content"` - Notes string `json:"notes"` + SlideID string `json:"slide_id"` + Type string `json:"type"` + Path string `json:"path"` + Usage string `json:"usage"` + Status string `json:"status"` } type authorVisualSystem struct { @@ -72,6 +94,7 @@ type authorTheme struct { type authorSlideTarget struct { Slide authorDeckSlide Content authorSlideContent + Assets []authorAsset Path string Target string Page int @@ -99,7 +122,8 @@ func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, err if err != nil { return AuthorReport{}, err } - if err := readAuthorJSONContract(safeRoot, "assets/assets_plan.json"); err != nil { + assetsBySlideID, err := readAuthorAssets(safeRoot, "assets/assets_plan.json") + if err != nil { return AuthorReport{}, err } if err := validateAuthorDeckContent(deck, contentByID); err != nil { @@ -126,6 +150,7 @@ func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, err targets = append(targets, authorSlideTarget{ Slide: slide, Content: contentByID[strings.TrimSpace(slide.ID)], + Assets: selectAuthorRenderableImageAssets(safeRoot, contentByID[strings.TrimSpace(slide.ID)], assetsBySlideID[strings.TrimSpace(slide.ID)]), Path: slidePath, Target: target, Page: i + 1, @@ -138,7 +163,7 @@ func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, err } for _, target := range targets { - svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, theme, target.Page, len(deck.Slides)) + svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, target.Assets, theme, target.Page, len(deck.Slides)) if err := writeText(target.Target, svg); err != nil { return AuthorReport{}, err } @@ -194,6 +219,86 @@ func readAuthorContent(safeRoot string, path string) (map[string]authorSlideCont return byID, nil } +func readAuthorAssets(safeRoot string, path string) (map[string][]authorAsset, error) { + raw, err := readRunRegularArtifact(safeRoot, path) + if err != nil { + return nil, fmt.Errorf("read assets plan %q: %w", path, err) + } + var file authorAssetsFile + if err := json.Unmarshal(raw, &file); err != nil { + return nil, fmt.Errorf("read assets plan %q: %w", path, err) + } + bySlideID := make(map[string][]authorAsset, len(file.Assets)) + for _, asset := range file.Assets { + if strings.TrimSpace(asset.Status) != "ready" { + continue + } + slideID := strings.TrimSpace(asset.SlideID) + bySlideID[slideID] = append(bySlideID[slideID], asset) + } + return bySlideID, nil +} + +func selectAuthorRenderableImageAssets(safeRoot string, content authorSlideContent, assets []authorAsset) []authorAsset { + if len(content.Visuals) == 0 || len(assets) == 0 { + return nil + } + assetByID := make(map[string]authorAsset, len(assets)) + for _, asset := range assets { + if strings.TrimSpace(asset.Type) != "image" { + continue + } + id := strings.TrimSpace(asset.ID) + if id == "" { + continue + } + assetByID[id] = asset + } + for _, visual := range content.Visuals { + if strings.TrimSpace(visual.Type) != "image" { + continue + } + id := strings.TrimSpace(visual.ID) + if id == "" { + continue + } + asset, ok := assetByID[id] + if !ok { + continue + } + if !authorImageAssetUsable(safeRoot, asset) { + continue + } + return []authorAsset{asset} + } + return nil +} + +func authorImageAssetUsable(safeRoot string, asset authorAsset) bool { + if strings.TrimSpace(asset.Type) != "image" { + return false + } + path := strings.TrimSpace(asset.Path) + if path == "" { + return false + } + lowerPath := strings.ToLower(path) + if strings.HasPrefix(lowerPath, "http://") || strings.HasPrefix(lowerPath, "https://") { + return false + } + if isAbsoluteRunPath(path) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + return true + } + if _, err := readRunRegularArtifact(safeRoot, path); err != nil { + return false + } + return true +} + func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error { deckIDs := make(map[string]bool, len(deck.Slides)) for _, slide := range deck.Slides { @@ -238,18 +343,6 @@ func readAuthorTheme(safeRoot string, path string) (authorTheme, error) { return theme, nil } -func readAuthorJSONContract(safeRoot string, path string) error { - raw, err := readRunRegularArtifact(safeRoot, path) - if err != nil { - return fmt.Errorf("read assets plan %q: %w", path, err) - } - var contract any - if err := json.Unmarshal(raw, &contract); err != nil { - return fmt.Errorf("read assets plan %q: %w", path, err) - } - return nil -} - func normalizeAuthorColor(value string, fallback string) string { value = strings.TrimSpace(value) if isAllowedAuthorHexColor(value) { @@ -274,7 +367,7 @@ func isAllowedAuthorHexColor(value string) bool { return true } -func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, theme authorTheme, page int, total int) string { +func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, assets []authorAsset, theme authorTheme, page int, total int) string { title := firstNonEmpty(slide.Title, "Untitled slide") keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary) bodyLines := authorBodyLines(content.Content) @@ -282,12 +375,19 @@ func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlid if footer == "" { footer = "SVGlide" } + footnote := authorSourceFootnote(content.SourceRefs) + heroAsset := firstReadyAuthorImageAsset(assets) + contentWidth := 848 + contentHeight := 404 + if heroAsset != nil { + contentWidth = 500 + } var b strings.Builder fmt.Fprintf(&b, ``+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight) fmt.Fprintf(&b, ` `+"\n", escapeAttr(theme.Background)) fmt.Fprintf(&b, ` `+"\n", escapeAttr(theme.Accent)) - fmt.Fprintf(&b, ` `+"\n") + fmt.Fprintf(&b, ` `+"\n", contentWidth, contentHeight) fmt.Fprintf(&b, `
`+"\n", escapeAttr(theme.Ink)) fmt.Fprintf(&b, `
%s
`+"\n", theme.TitleSize, escapeText(title)) if keyMessage != "" { @@ -300,7 +400,15 @@ func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlid fmt.Fprintf(&b, "
\n") fmt.Fprintf(&b, " \n") fmt.Fprintf(&b, "
\n") - fmt.Fprintf(&b, ` `+"\n") + if footnote != "" { + fmt.Fprintf(&b, ` `+"\n") + fmt.Fprintf(&b, `
%s
`+"\n", escapeAttr(theme.Muted), escapeText(footnote)) + fmt.Fprintf(&b, "
\n") + } + if heroAsset != nil { + fmt.Fprintf(&b, ` `+"\n", escapeAttr(heroAsset.Path)) + } + fmt.Fprintf(&b, ` `+"\n") fmt.Fprintf(&b, `
`+"\n", escapeAttr(theme.Muted)) fmt.Fprintf(&b, " %s%d / %d\n", escapeText(footer), page, total) fmt.Fprintf(&b, "
\n") @@ -323,6 +431,36 @@ func authorBodyLines(content string) []string { return lines } +func authorSourceFootnote(sourceRefs []string) string { + if len(sourceRefs) == 0 { + return "" + } + refs := make([]string, 0, len(sourceRefs)) + for _, ref := range sourceRefs { + if trimmed := strings.TrimSpace(ref); trimmed != "" { + refs = append(refs, trimmed) + } + } + if len(refs) == 0 { + return "" + } + return "来源:" + strings.Join(refs, " / ") +} + +func firstReadyAuthorImageAsset(assets []authorAsset) *authorAsset { + for i := range assets { + asset := &assets[i] + if strings.TrimSpace(asset.Type) != "image" { + continue + } + if strings.TrimSpace(asset.Path) == "" { + continue + } + return asset + } + return nil +} + func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { diff --git a/internal/svglide/author_test.go b/internal/svglide/author_test.go index ee6fdd64f..f8f7f5811 100644 --- a/internal/svglide/author_test.go +++ b/internal/svglide/author_test.go @@ -15,8 +15,7 @@ func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) { mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`) mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`) mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`) - mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note"},{"id":"s2","content":"Point A\nPoint B\nPoint C"}]}`) - mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`) + writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`) run := readStatusTestRunFile(t) run.CurrentStage = StageSVGAuthor @@ -56,6 +55,7 @@ func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) { `slide:role="slide"`, `viewBox="0 0 960 540"`, `foreignObject`, + `slide:role="shape"`, `slide:shape-type="text"`, } { if !strings.Contains(svg, want) { @@ -136,7 +136,7 @@ func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) { `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`, `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`, ) - mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"},{"id":"s1","content":"Duplicate body line"}]}`) + mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s1","content":"Duplicate body line","source_refs":[],"visuals":[{"id":"none-s1b","type":"none","instruction":"Text-only"}]}]}`) if _, err := AuthorSlides("demo"); err == nil { t.Fatal("expected duplicate slide content id to fail") @@ -146,20 +146,247 @@ func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) { } } +func TestAuthorSlidesDoesNotRenderImageForNoneVisualDespiteReadyAsset(t *testing.T) { + initAuthorDemoRun(t, + `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`, + `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`, + ) + mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`) + mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`) + if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := AuthorSlides("demo"); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), `