From 99cf19c374253b28c26f1ea5cc1407cac951448f Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Wed, 10 Jun 2026 11:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(render):=20=E6=94=AF=E6=8C=81=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E5=9B=BE=E4=B8=8E=20AI=20=E4=B8=B4=E6=97=B6=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Markdown 封面图迁移到 cover 页面,避免后续图文页重复渲染,并为 AI 命令的临时锁/网络失败增加重试。 --- internal/ai/deck_builder.go | 53 +++++++++- internal/ai/deck_builder_test.go | 97 ++++++++++++++++++- internal/ai/title_builder.go | 2 +- internal/ai/topic_builder.go | 2 +- internal/app/service.go | 69 +++++++++++++ internal/app/service_test.go | 64 +++++++++++- internal/deck/deck.go | 13 ++- internal/deck/deck_test.go | 35 ++++++- internal/render/assets/base.css | 56 +++++++++-- internal/render/assets/page.tmpl | 18 +++- internal/render/template.go | 7 +- internal/render/template_test.go | 79 +++++++++++++-- .../golden-html/default-all-variants.sha256 | 2 +- .../golden-html/default-author-global.sha256 | 2 +- .../golden-html/default-author-long.sha256 | 2 +- .../golden-html/default-author-none.sha256 | 2 +- .../default-author-override.sha256 | 2 +- .../default-semantic-highlight.sha256 | 2 +- .../editorial-cool-all-variants.sha256 | 2 +- .../editorial-cool-semantic-highlight.sha256 | 2 +- .../fresh-green-all-variants.sha256 | 2 +- .../golden-html/plum-ink-all-variants.sha256 | 2 +- .../golden-html/sage-mist-all-variants.sha256 | 2 +- .../golden-html/tech-noir-all-variants.sha256 | 2 +- .../tech-noir-semantic-highlight.sha256 | 2 +- .../warm-paper-all-variants.sha256 | 2 +- .../warm-paper-semantic-highlight.sha256 | 2 +- 27 files changed, 471 insertions(+), 54 deletions(-) diff --git a/internal/ai/deck_builder.go b/internal/ai/deck_builder.go index a22a500..6729e39 100644 --- a/internal/ai/deck_builder.go +++ b/internal/ai/deck_builder.go @@ -7,6 +7,7 @@ import ( "os/exec" "slices" "strings" + "time" ) const defaultMaxPages = 12 @@ -24,9 +25,9 @@ const deckPromptConstraintsTemplate = `你是一个严格的 JSON 生成器。 8. meta.theme 保留为兼容字段,可使用 default;实际整套卡片颜色由顶层 theme 决定 9. variant 只能使用:cover、quote、image-caption、text-caption、bullets、compare、gallery-steps、ending 10. content 字段按 variant 严格约束: - - cover 只能使用 title/subtitle,且 cover 的 title 必填 + - cover 只能使用 title/subtitle/images,且 cover 的 title 必填;如果提供 images,最多 1 项,每个 image 都必须包含 src 和 alt - quote 只能使用 title/quote/note/tip,且 quote 的 title 和 quote 必填 - - image-caption 只能使用 title/body/images,且 image-caption 的 title 必填、image-caption 的 images 必须正好 1 项,每个 image 都必须包含 src 和 alt + - image-caption 只能使用 title/body/images,且 image-caption 的 title 必填、image-caption 的 images 最多 1 项、image-caption 必须提供 body 或 images,每个 image 都必须包含 src 和 alt - text-caption 只能使用 title/body/tip,且 text-caption 的 title 和 body 必填 - bullets 只能使用 title/items,且 bullets 的 title 必填、items 至少 1 项 - compare 只能使用 title/compare,且 compare 的 title 必填、compare 必须使用 compare{leftLabel,rightLabel,rows}、compare.leftLabel/rightLabel 必填、rows 至少 1 项,且每个 rows 项都必须包含 left 和 right @@ -42,7 +43,8 @@ const deckPromptConstraintsTemplate = `你是一个严格的 JSON 生成器。 18. 每页可见内容必须完整放进 1242x1656 竖版卡片,不能依赖浏览器裁切 19. 长正文和长代码块必须保留原文完整内容,不要用省略号、省略说明或伪代码替代 fenced code block 20. 需要容纳长内容时优先选择 text-caption、image-caption 或 ending,由渲染层缩小字号和间距;不要为了排版删减原文代码 -21. fenced code block 过长时不要强行塞进单页;拆成连续的 text-caption 或 image-caption 页面,每页保留连续、完整、可执行的原始代码片段,不得用省略号替代被拆分的代码` +21. fenced code block 过长时不要强行塞进单页;拆成连续的 text-caption 或 image-caption 页面,每页保留连续、完整、可执行的原始代码片段,不得用省略号替代被拆分的代码 +22. Markdown 开头第一张 alt 包含“封面”或 cover 的图应放入第一页 cover.images,作为视觉封面;不要再把同一张图重复放入后续 image-caption 页面` const deckPromptMarkdownFooter = "\n\nMarkdown 如下:\n" const promptExtraIntro = "以下是本次生成的额外约束,只能用于控制风格、安全边界和取舍;不得原文复制、不得改写、不得概括到 JSON 的任何可见字段里,包括 title、subtitle、body、quote、note、tip、cta、items、steps、compare、images.alt。可见文案只能来自 Markdown 原文:" @@ -101,6 +103,49 @@ func (execRunner) Run(name string, args ...string) (string, string, error) { return "", "", err } +var aiCommandRetryDelays = []time.Duration{time.Second, 3 * time.Second} + +func runAICommand(runner CommandRunner, name string, args ...string) (string, string, error) { + var stdout string + var stderr string + var err error + for attempt := 0; attempt <= len(aiCommandRetryDelays); attempt++ { + stdout, stderr, err = runner.Run(name, args...) + if err == nil { + return stdout, stderr, nil + } + if attempt == len(aiCommandRetryDelays) || !isTransientAICommandError(err, stdout, stderr) { + return stdout, stderr, err + } + time.Sleep(aiCommandRetryDelays[attempt]) + } + return stdout, stderr, err +} + +func isTransientAICommandError(err error, stdout, stderr string) bool { + if err == nil { + return false + } + combined := strings.ToLower(strings.Join([]string{err.Error(), stdout, stderr}, "\n")) + for _, marker := range []string{ + "lock file is already being held", + "failed to acquire startup lock", + "another ccs process may be starting cliproxy", + "elocked", + "enotacquired", + "temporarily unavailable", + "timeout", + "timed out", + "connection reset", + "connection refused", + } { + if strings.Contains(combined, marker) { + return true + } + } + return false +} + func (b *Builder) SetCommand(command string, args []string) { b.Command = command b.Args = append([]string(nil), args...) @@ -119,7 +164,7 @@ func (b Builder) BuildDeckJSON(markdown string) (string, error) { args = append(args, "--bare") } args = append(args, "-p", buildDeckPromptWithMaxPages(markdown, b.PromptExtra, b.MaxPages)) - stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...) + stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...) if err != nil { return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr) } diff --git a/internal/ai/deck_builder_test.go b/internal/ai/deck_builder_test.go index 35baa11..93219b3 100644 --- a/internal/ai/deck_builder_test.go +++ b/internal/ai/deck_builder_test.go @@ -21,6 +21,26 @@ func (r *fakeRunner) Run(name string, args ...string) (string, string, error) { return r.stdout, r.stderr, r.err } +type runnerCall struct { + stdout string + stderr string + err error +} + +type sequenceRunner struct { + calls []runnerCall + count int +} + +func (r *sequenceRunner) Run(_ string, _ ...string) (string, string, error) { + if r.count >= len(r.calls) { + return "", "", errors.New("unexpected runner call") + } + call := r.calls[r.count] + r.count++ + return call.stdout, call.stderr, call.err +} + func TestBuildDeckPromptKeepsLegacyLayoutWhenPromptExtraEmpty(t *testing.T) { got := buildDeckPrompt("# 标题", "") if !strings.Contains(got, "Markdown 如下:\n# 标题") { @@ -128,12 +148,15 @@ func TestBuildDeckJSONUsesConfiguredCommand(t *testing.T) { if strings.Contains(prompt, "meta.theme 只能使用 orange 或 green") { t.Fatalf("prompt still contains old visual meta.theme constraint: %q", prompt) } - if !strings.Contains(prompt, "cover 只能使用 title/subtitle") { + if !strings.Contains(prompt, "cover 只能使用 title/subtitle/images") { t.Fatalf("prompt missing cover content whitelist: %q", prompt) } if !strings.Contains(prompt, "cover 的 title 必填") { t.Fatalf("prompt missing cover required title rule: %q", prompt) } + if !strings.Contains(prompt, "Markdown 开头第一张 alt 包含“封面”或 cover 的图应放入第一页 cover.images") { + t.Fatalf("prompt missing first Markdown cover image guidance: %q", prompt) + } if !strings.Contains(prompt, "quote 只能使用 title/quote/note/tip") { t.Fatalf("prompt missing quote content whitelist: %q", prompt) } @@ -149,11 +172,14 @@ func TestBuildDeckJSONUsesConfiguredCommand(t *testing.T) { if !strings.Contains(prompt, "image-caption 的 title 必填") { t.Fatalf("prompt missing image-caption required title rule: %q", prompt) } - if !strings.Contains(prompt, "image-caption 的 images 必须正好 1 项") { - t.Fatalf("prompt missing image-caption exact image count: %q", prompt) + if !strings.Contains(prompt, "image-caption 的 images 最多 1 项") { + t.Fatalf("prompt missing image-caption max image count: %q", prompt) + } + if !strings.Contains(prompt, "image-caption 必须提供 body 或 images") { + t.Fatalf("prompt missing image-caption body-or-image rule: %q", prompt) } - if strings.Contains(prompt, "image-caption 最多 1 张图") { - t.Fatalf("prompt should not allow image-caption without an image: %q", prompt) + if strings.Contains(prompt, "image-caption 的 images 必须正好 1 项") { + t.Fatalf("prompt should allow image-caption without an image when body exists: %q", prompt) } if !strings.Contains(prompt, "每个 image 都必须包含 src 和 alt") { t.Fatalf("prompt missing image src/alt rule: %q", prompt) @@ -338,6 +364,27 @@ func TestBuildPublishTopicsRejectsInvalidJSON(t *testing.T) { } } +func TestBuildPublishTopicsRetriesTransientAILockError(t *testing.T) { + runner := &sequenceRunner{calls: []runnerCall{ + {stderr: "[X] Lock file is already being held", err: errors.New("exit status 1")}, + {stdout: `{"topics":["AI编程"]}`}, + }} + b := TopicBuilder{Runner: runner} + b.SetCommand("ccs", []string{"codex"}) + + got, err := b.BuildPublishTopics("# 标题", "标题") + if err != nil { + t.Fatalf("BuildPublishTopics() error = %v", err) + } + want := []string{"AI编程"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildPublishTopics() = %#v, want %#v", got, want) + } + if runner.count != 2 { + t.Fatalf("runner calls = %d, want 2", runner.count) + } +} + func TestBuildPublishTitleUsesConfiguredCommand(t *testing.T) { runner := &fakeRunner{stdout: `{"title":"把代码合进真实开源"}`} b := TitleBuilder{Runner: runner} @@ -375,6 +422,26 @@ func TestBuildPublishTitleRejectsInvalidJSON(t *testing.T) { } } +func TestBuildPublishTitleRetriesTransientAILockError(t *testing.T) { + runner := &sequenceRunner{calls: []runnerCall{ + {stderr: "[X] Lock file is already being held", err: errors.New("exit status 1")}, + {stdout: `{"title":"短标题"}`}, + }} + b := TitleBuilder{Runner: runner} + b.SetCommand("ccs", []string{"codex"}) + + got, err := b.BuildPublishTitle("# 标题", "标题", 20) + if err != nil { + t.Fatalf("BuildPublishTitle() error = %v", err) + } + if got != "短标题" { + t.Fatalf("BuildPublishTitle() = %q, want retry title", got) + } + if runner.count != 2 { + t.Fatalf("runner calls = %d, want 2", runner.count) + } +} + func TestBuildPublishTitleReturnsStderrOnRunnerError(t *testing.T) { runner := &fakeRunner{stderr: "boom", err: errors.New("exit status 1")} b := TitleBuilder{Runner: runner} @@ -429,6 +496,26 @@ func TestBuildDeckJSONReturnsStderrOnRunnerError(t *testing.T) { } } +func TestBuildDeckJSONRetriesTransientAILockError(t *testing.T) { + runner := &sequenceRunner{calls: []runnerCall{ + {stderr: "[X] Lock file is already being held", err: errors.New("exit status 1")}, + {stdout: `{"pages":[]}`}, + }} + b := Builder{Runner: runner} + b.SetCommand("ccs", []string{"codex"}) + + got, err := b.BuildDeckJSON("# title") + if err != nil { + t.Fatalf("BuildDeckJSON() error = %v", err) + } + if got != `{"pages":[]}` { + t.Fatalf("BuildDeckJSON() = %q, want JSON from retry", got) + } + if runner.count != 2 { + t.Fatalf("runner calls = %d, want 2", runner.count) + } +} + func TestBuildDeckJSONAppendsBareForCCSCodex(t *testing.T) { runner := &fakeRunner{stdout: `{"pages":[]}`} b := Builder{Runner: runner} diff --git a/internal/ai/title_builder.go b/internal/ai/title_builder.go index 8f94687..7e4f4dc 100644 --- a/internal/ai/title_builder.go +++ b/internal/ai/title_builder.go @@ -55,7 +55,7 @@ func (b TitleBuilder) BuildPublishTitle(markdown, title string, maxRunes int) (s args = append(args, "--bare") } args = append(args, "-p", buildPublishTitlePrompt(markdown, title, maxRunes)) - stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...) + stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...) if err != nil { return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr) } diff --git a/internal/ai/topic_builder.go b/internal/ai/topic_builder.go index a52f904..7934244 100644 --- a/internal/ai/topic_builder.go +++ b/internal/ai/topic_builder.go @@ -55,7 +55,7 @@ func (b TopicBuilder) BuildPublishTopics(markdown, title string) ([]string, erro args = append(args, "--bare") } args = append(args, "-p", buildTopicPrompt(markdown, title)) - stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...) + stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...) if err != nil { return nil, fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr) } diff --git a/internal/app/service.go b/internal/app/service.go index 3f545de..1e94075 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "time" @@ -156,6 +157,7 @@ func (s Service) GeneratePreview(opts Options) (Result, error) { if err != nil { return Result{}, fmt.Errorf("%w: %v", ErrParseDeck, err) } + d = moveLeadingMarkdownImageToCover(d, string(markdownBytes)) var posterWarnings []string if d, posterWarnings, err = s.hydratePosters(opts, d, string(markdownBytes)); err != nil { return Result{}, err @@ -284,6 +286,73 @@ func appendMissingPosterWarning(warnings []string, title string) []string { return append(warnings, warning) } +var markdownImagePattern = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)(?:\s+[^)]*)?\)`) + +func moveLeadingMarkdownImageToCover(d deck.Deck, markdown string) deck.Deck { + if len(d.Pages) == 0 || d.Pages[0].Variant != "cover" || len(d.Pages[0].Content.Images) > 0 { + return d + } + image, ok := leadingMarkdownImage(markdown) + if !ok { + return d + } + coverImage := image + for pageIndex := 1; pageIndex < len(d.Pages); pageIndex++ { + images := d.Pages[pageIndex].Content.Images + kept := images[:0] + for _, existing := range images { + if strings.TrimSpace(existing.Src) == image.Src { + if strings.TrimSpace(existing.Alt) != "" { + coverImage.Alt = strings.TrimSpace(existing.Alt) + } + continue + } + kept = append(kept, existing) + } + if len(kept) != len(images) { + d.Pages[pageIndex].Content.Images = kept + if d.Pages[pageIndex].Variant == "image-caption" && strings.TrimSpace(d.Pages[pageIndex].Content.Body) == "" { + d.Pages[pageIndex].Content.Body = coverImage.Alt + } + } + } + d.Pages[0].Content.Images = []deck.ImageBlock{coverImage} + return d +} + +func leadingMarkdownImage(markdown string) (deck.ImageBlock, bool) { + match := markdownImagePattern.FindStringSubmatchIndex(markdown) + if match == nil { + return deck.ImageBlock{}, false + } + prefix := markdown[:match[0]] + for _, line := range strings.Split(prefix, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "# ") { + continue + } + return deck.ImageBlock{}, false + } + parts := markdownImagePattern.FindStringSubmatch(markdown[match[0]:match[1]]) + if len(parts) < 3 { + return deck.ImageBlock{}, false + } + alt := strings.TrimSpace(parts[1]) + if !isCoverImageAlt(alt) { + return deck.ImageBlock{}, false + } + src := strings.TrimSpace(parts[2]) + if src == "" { + return deck.ImageBlock{}, false + } + return deck.ImageBlock{Src: src, Alt: alt}, true +} + +func isCoverImageAlt(alt string) bool { + alt = strings.ToLower(strings.TrimSpace(alt)) + return strings.Contains(alt, "封面") || strings.Contains(alt, "cover") +} + func hydrateLocalImageAssets(d deck.Deck, baseDir string) deck.Deck { baseDir = strings.TrimSpace(baseDir) if baseDir == "" { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 5e408f6..8acf879 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -213,6 +213,59 @@ func TestServiceGeneratePreviewSuccess(t *testing.T) { } } +func TestServiceGeneratePreviewMovesLeadingMarkdownImageToCover(t *testing.T) { + cfg := &config.Config{Output: config.OutputCfg{Dir: t.TempDir()}} + coverImage := "https://example.com/cover.png" + markdown := "# 标题\n\n\n\n## 背景\n\n正文" + deckJSON := `{"pages":[{"name":"p1-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"default","cta":"cta1"},"content":{"title":"封面"}},{"name":"p2-image-caption","variant":"image-caption","meta":{"badge":"第 2 页","counter":"2/3","theme":"default","cta":"cta2"},"content":{"title":"背景","body":"正文","images":[{"src":"https://example.com/cover.png","alt":"封面"}]}},{"name":"p3-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"default","cta":"cta3"},"content":{"title":"结尾","body":"正文"}}]}` + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { return cfg, nil }, + ReadFile: func(string) ([]byte, error) { return []byte(markdown), nil }, + BuildDeckJSON: func(*config.Config, string) (string, error) { return deckJSON, nil }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + if got := r.rendered.Pages[0].Content.Images; !reflect.DeepEqual(got, []deck.ImageBlock{{Src: coverImage, Alt: "封面"}}) { + t.Fatalf("cover images = %#v", got) + } + if got := r.rendered.Pages[1].Content.Images; len(got) != 0 { + t.Fatalf("second page images = %#v, want image moved out", got) + } + if r.rendered.Pages[1].Content.Body != "正文" { + t.Fatalf("second page body = %q", r.rendered.Pages[1].Content.Body) + } +} + +func TestServiceGeneratePreviewKeepsLeadingMarkdownImageWhenAltIsNotCover(t *testing.T) { + cfg := &config.Config{Output: config.OutputCfg{Dir: t.TempDir()}} + image := "https://example.com/news.png" + markdown := "# 标题\n\n\n\n## 背景\n\n正文" + deckJSON := `{"pages":[{"name":"p1-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"default","cta":"cta1"},"content":{"title":"封面"}},{"name":"p2-image-caption","variant":"image-caption","meta":{"badge":"第 2 页","counter":"2/3","theme":"default","cta":"cta2"},"content":{"title":"背景","body":"正文","images":[{"src":"https://example.com/news.png","alt":"新闻图"}]}},{"name":"p3-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"default","cta":"cta3"},"content":{"title":"结尾","body":"正文"}}]}` + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { return cfg, nil }, + ReadFile: func(string) ([]byte, error) { return []byte(markdown), nil }, + BuildDeckJSON: func(*config.Config, string) (string, error) { return deckJSON, nil }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + if got := r.rendered.Pages[0].Content.Images; len(got) != 0 { + t.Fatalf("cover images = %#v, want ordinary leading image kept out of cover", got) + } + if got := r.rendered.Pages[1].Content.Images; !reflect.DeepEqual(got, []deck.ImageBlock{{Src: image, Alt: "新闻图"}}) { + t.Fatalf("second page images = %#v, want ordinary leading image kept", got) + } +} + func TestServiceGeneratePreviewInlinesLocalImageAssetsBeforeRendering(t *testing.T) { root := t.TempDir() articleDir := filepath.Join(root, "article") @@ -224,11 +277,11 @@ func TestServiceGeneratePreviewInlinesLocalImageAssetsBeforeRendering(t *testing t.Fatalf("WriteFile(image) error = %v", err) } articlePath := filepath.Join(articleDir, "article.md") - if err := os.WriteFile(articlePath, []byte("# 标题\n\n\n"), 0o644); err != nil { + if err := os.WriteFile(articlePath, []byte("# 标题\n\n\n"), 0o644); err != nil { t.Fatalf("WriteFile(article) error = %v", err) } cfg := &config.Config{Output: config.OutputCfg{Dir: root}} - deckJSON := `{"pages":[{"name":"p1-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"default","cta":"cta1"},"content":{"title":"封面"}},{"name":"p2-image-caption","variant":"image-caption","meta":{"badge":"第 2 页","counter":"2/3","theme":"default","cta":"cta2"},"content":{"title":"图文","images":[{"src":"media/inline-local-001.png","alt":"图"}] }},{"name":"p3-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"default","cta":"cta3"},"content":{"title":"结尾","body":"正文"}}]}` + deckJSON := `{"pages":[{"name":"p1-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"default","cta":"cta1"},"content":{"title":"封面"}},{"name":"p2-image-caption","variant":"image-caption","meta":{"badge":"第 2 页","counter":"2/3","theme":"default","cta":"cta2"},"content":{"title":"图文","images":[{"src":"media/inline-local-001.png","alt":"封面"}] }},{"name":"p3-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"default","cta":"cta3"},"content":{"title":"结尾","body":"正文"}}]}` r := &fakeRenderer{} svc := Service{ LoadConfig: func(string) (*config.Config, error) { return cfg, nil }, @@ -241,9 +294,12 @@ func TestServiceGeneratePreviewInlinesLocalImageAssetsBeforeRendering(t *testing if err != nil { t.Fatalf("GeneratePreview() error = %v", err) } - got := r.rendered.Pages[1].Content.Images[0].Src + got := r.rendered.Pages[0].Content.Images[0].Src if !strings.HasPrefix(got, "data:image/png;base64,") { - t.Fatalf("rendered image src = %q, want local image data URI", got) + t.Fatalf("rendered cover image src = %q, want local image data URI", got) + } + if len(r.rendered.Pages[1].Content.Images) != 0 { + t.Fatalf("second page images = %#v, want leading image moved to cover", r.rendered.Pages[1].Content.Images) } } diff --git a/internal/deck/deck.go b/internal/deck/deck.go index 8d88eed..6d6f432 100644 --- a/internal/deck/deck.go +++ b/internal/deck/deck.go @@ -165,6 +165,7 @@ var allowedContentFieldsByVariant = map[string]map[string]struct{}{ "cover": { "title": {}, "subtitle": {}, + "images": {}, }, "quote": { "title": {}, @@ -510,6 +511,14 @@ func validateContent(page Page) error { if content.Title == "" { return pageErr(page.Name, page.Variant, "title is required") } + if len(content.Images) > 1 { + return pageErr(page.Name, page.Variant, "images accepts at most 1 item") + } + for i, image := range content.Images { + if image.Src == "" || image.Alt == "" { + return pageErr(page.Name, page.Variant, "images[%d].src and images[%d].alt are required", i, i) + } + } case "quote": if content.Title == "" { return pageErr(page.Name, page.Variant, "title is required") @@ -521,8 +530,8 @@ func validateContent(page Page) error { if content.Title == "" { return pageErr(page.Name, page.Variant, "title is required") } - if len(content.Images) == 0 { - return pageErr(page.Name, page.Variant, "images requires exactly 1 item") + if len(content.Images) == 0 && content.Body == "" { + return pageErr(page.Name, page.Variant, "images or body is required") } if len(content.Images) > 1 { return pageErr(page.Name, page.Variant, "images accepts at most 1 item") diff --git a/internal/deck/deck_test.go b/internal/deck/deck_test.go index 14961c6..53c3cdb 100644 --- a/internal/deck/deck_test.go +++ b/internal/deck/deck_test.go @@ -138,6 +138,23 @@ func TestDeckFromJSONAcceptsThreePagesWithAnchors(t *testing.T) { } } +func TestDeckFromJSONAcceptsCoverImage(t *testing.T) { + raw := `{ + "pages": [ + {"name":"p01-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"orange","cta":"cta1"},"content":{"title":"封面","subtitle":"副标题","images":[{"src":"https://example.com/cover.png","alt":"封面"}]}}, + {"name":"p02-bullets","variant":"bullets","meta":{"badge":"第 2 页","counter":"2/3","theme":"orange","cta":"cta2"},"content":{"title":"中间页","items":["要点"]}}, + {"name":"p03-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"green","cta":"cta3"},"content":{"title":"结尾","body":"正文3"}} + ] +}` + d, err := FromJSON(raw, "/tmp/out") + if err != nil { + t.Fatalf("FromJSON() error = %v", err) + } + if got := d.Pages[0].Content.Images; !reflect.DeepEqual(got, []ImageBlock{{Src: "https://example.com/cover.png", Alt: "封面"}}) { + t.Fatalf("cover images = %#v", got) + } +} + func TestDeckFromJSONAcceptsTwelvePagesWithContentSchema(t *testing.T) { raw := `{ "pages": [ @@ -353,7 +370,21 @@ func TestDeckFromJSONRejectsUnsupportedVariant(t *testing.T) { } } -func TestDeckFromJSONRejectsImageCaptionWithoutImages(t *testing.T) { +func TestDeckFromJSONAcceptsImageCaptionWithoutImagesWhenBodyExists(t *testing.T) { + raw := `{ + "pages": [ + {"name":"p01-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"orange","cta":"cta"},"content":{"title":"封面"}}, + {"name":"p02-image","variant":"image-caption","meta":{"badge":"第 2 页","counter":"2/3","theme":"orange","cta":"cta"},"content":{"title":"图文","body":"图片移到封面,这里保留正文"}}, + {"name":"p03-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"green","cta":"cta"},"content":{"title":"结尾","body":"总结"}} + ] +}` + + if _, err := FromJSON(raw, "/tmp/out"); err != nil { + t.Fatalf("FromJSON() error = %v", err) + } +} + +func TestDeckFromJSONRejectsImageCaptionWithoutImagesOrBody(t *testing.T) { raw := `{ "pages": [ {"name":"p01-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"orange","cta":"cta"},"content":{"title":"封面"}}, @@ -366,7 +397,7 @@ func TestDeckFromJSONRejectsImageCaptionWithoutImages(t *testing.T) { if err == nil { t.Fatal("FromJSON() error = nil, want non-nil") } - if !strings.Contains(err.Error(), `page "p02-image" (image-caption): images requires exactly 1 item`) { + if !strings.Contains(err.Error(), `page "p02-image" (image-caption): images or body is required`) { t.Fatalf("error = %v", err) } } diff --git a/internal/render/assets/base.css b/internal/render/assets/base.css index 3758136..e35f4c0 100644 --- a/internal/render/assets/base.css +++ b/internal/render/assets/base.css @@ -223,6 +223,44 @@ html, body { margin: 0 auto; } +.cover-main-with-image { + top: 150px; + bottom: 330px; + align-items: center; +} + +.cover-copy-with-image { + max-width: 1000px; + display: flex; + flex-direction: column; + gap: 46px; +} + +.cover-copy-with-image .cover-title { + font-size: 72px; + line-height: 1.14; +} + +.cover-image-frame { + width: 100%; + border: 2px solid var(--line); + border-radius: 42px; + overflow: hidden; + background: var(--card); +} + +.cover-image-frame img { + width: 100%; + height: auto; + display: block; + border-radius: 40px; +} + +.cover-copy-with-image .cover-subtitle { + margin-top: 0; + font-size: 32px; +} + .cover-title { margin: 0; } @@ -341,9 +379,11 @@ html, body { } .image-frame img { - width: 100%; - height: 100%; - object-fit: cover; + width: auto; + max-width: 100%; + height: auto; + max-height: 100%; + object-fit: contain; border-radius: 40px; display: block; background: transparent; @@ -407,9 +447,12 @@ html, body { } .image-caption-image { - height: var(--image-caption-image-height); + max-height: var(--image-caption-image-height); flex: 0 1 auto; - min-height: var(--image-caption-image-min-height); + min-height: 0; + display: flex; + align-items: center; + justify-content: center; } .image-caption-cta { @@ -582,6 +625,7 @@ html, body { --code-font-size: 24px; --code-line-height: 1.45; margin-top: 42px; + min-height: 1100px; display: flex; flex-direction: column; gap: 28px; @@ -622,7 +666,7 @@ html, body { .text-caption-cta { position: static; - margin-top: 0; + margin-top: auto; left: auto; right: auto; bottom: auto; diff --git a/internal/render/assets/page.tmpl b/internal/render/assets/page.tmpl index 4435cc3..ef6289d 100644 --- a/internal/render/assets/page.tmpl +++ b/internal/render/assets/page.tmpl @@ -18,9 +18,12 @@ {{if eq .Page.Variant "cover"}}
-