diff --git a/cmd/mark2note/main.go b/cmd/mark2note/main.go index eef0217..9721a97 100644 --- a/cmd/mark2note/main.go +++ b/cmd/mark2note/main.go @@ -82,6 +82,7 @@ Flags: --author one-off cover author input (blank falls back to deck.author) (default from deck.author) --prompt-extra extra natural-language guidance for deck generation --asset-manifest poster asset manifest for list/article cover cards + --card-manifest card article manifest json input path; skips AI deck generation --auto-posters automatically search poster candidates and hydrate cover cards --poster-sources poster providers for --auto-posters (default: bilibili,bangumi,anilist,mydramalist) --publish-xhs publish generated PNG files to Xiaohongshu after render @@ -262,6 +263,7 @@ func parseOptions(args []string) (Options, error) { fs.StringVar(&opts.Author, "author", opts.Author, "one-off cover author input (blank falls back to deck.author)") fs.StringVar(&opts.PromptExtra, "prompt-extra", opts.PromptExtra, "extra natural-language guidance for deck generation") fs.StringVar(&opts.AssetManifestPath, "asset-manifest", opts.AssetManifestPath, "poster asset manifest for list/article cover cards") + fs.StringVar(&opts.CardManifestPath, "card-manifest", opts.CardManifestPath, "card article manifest json input path") fs.BoolVar(&opts.AutoPosters, "auto-posters", opts.AutoPosters, "automatically search poster candidates and hydrate cover cards") fs.StringVar(&posterSources, "poster-sources", posterSources, "comma-separated poster providers for --auto-posters") fs.BoolVar(&opts.PublishXHS, "publish-xhs", opts.PublishXHS, "publish generated PNG files to Xiaohongshu after render") @@ -308,6 +310,9 @@ func parseOptions(args []string) (Options, error) { if hasFromDeck && strings.TrimSpace(opts.PromptExtra) != "" { return Options{}, fmt.Errorf("--prompt-extra can only be used with --input\n\n%s", usageText()) } + if hasFromDeck && strings.TrimSpace(opts.CardManifestPath) != "" { + return Options{}, fmt.Errorf("--card-manifest can only be used with --input\n\n%s", usageText()) + } if hasFromDeck && opts.PublishXHS { return Options{}, fmt.Errorf("--publish-xhs can only be used with --input\n\n%s", usageText()) } diff --git a/cmd/mark2note/main_test.go b/cmd/mark2note/main_test.go index e13b046..cd486f6 100644 --- a/cmd/mark2note/main_test.go +++ b/cmd/mark2note/main_test.go @@ -225,6 +225,16 @@ func TestParseOptionsParsesAssetManifest(t *testing.T) { } } +func TestParseOptionsParsesCardManifest(t *testing.T) { + opts, err := parseOptions([]string{"--input", "article.md", "--card-manifest", "manifest.json"}) + if err != nil { + t.Fatalf("parseOptions() error = %v", err) + } + if opts.CardManifestPath != "manifest.json" { + t.Fatalf("CardManifestPath = %q, want manifest.json", opts.CardManifestPath) + } +} + func TestParseOptionsParsesAutoPostersAndSources(t *testing.T) { opts, err := parseOptions([]string{"--input", "article.md", "--auto-posters", "--poster-sources", "anilist, mydramalist"}) if err != nil { diff --git a/internal/app/card_manifest_deck.go b/internal/app/card_manifest_deck.go new file mode 100644 index 0000000..101d190 --- /dev/null +++ b/internal/app/card_manifest_deck.go @@ -0,0 +1,357 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/walker1211/mark2note/internal/deck" +) + +const cardArticleManifestSchemaV1 = "card-article-manifest/v1" + +const ( + cardManifestCoverSummaryMaxItems = 6 + cardManifestCoverSummaryItemMaxRunes = 46 + cardManifestImageCaptionBodyMaxRunes = 360 + cardManifestTextCaptionBodyMaxRunes = 460 +) + +type cardArticleManifest struct { + SchemaVersion string `json:"schema_version"` + SourceApp string `json:"source_app"` + Document cardManifestDocument `json:"document"` + Items []cardManifestItem `json:"items"` +} + +type cardManifestDocument struct { + Title string `json:"title"` + Date string `json:"date"` + Period string `json:"period"` + Summary []string `json:"summary"` + Subtitle string `json:"subtitle"` +} + +type cardManifestItem struct { + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Summary string `json:"summary"` + Impact string `json:"impact"` + Source string `json:"source"` + PublishedAt string `json:"published_at"` + URL string `json:"url"` + Image cardManifestImage `json:"image"` +} + +type cardManifestImage struct { + Src string `json:"src"` + Alt string `json:"alt"` +} + +func (s Service) buildCardManifestDeckJSON(path string) (string, error) { + data, err := s.effectiveReadFile()(path) + if err != nil { + return "", fmt.Errorf("read card manifest: %w", err) + } + return buildCardManifestDeckJSON(data) +} + +func buildCardManifestDeckJSON(data []byte) (string, error) { + var manifest cardArticleManifest + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return "", fmt.Errorf("parse card manifest json: %w", err) + } + if strings.TrimSpace(manifest.SchemaVersion) != cardArticleManifestSchemaV1 { + return "", fmt.Errorf("unsupported card manifest schema_version %q", manifest.SchemaVersion) + } + if strings.TrimSpace(manifest.Document.Title) == "" { + return "", fmt.Errorf("card manifest document.title is required") + } + if len(manifest.Items) == 0 { + return "", fmt.Errorf("card manifest items is required") + } + + pageCount := len(manifest.Items) + 1 + pages := make([]deck.Page, 0, pageCount) + pages = append(pages, deck.Page{ + Name: "p01-cover", + Variant: "cover", + Meta: deck.PageMeta{ + Badge: cardManifestCoverBadge(manifest.Document), + Counter: fmt.Sprintf("1/%d", pageCount), + Theme: deck.ThemeDefault, + CTA: cardManifestCoverCTA(manifest.SourceApp), + }, + Content: deck.PageContent{ + Title: strings.TrimSpace(manifest.Document.Title), + Subtitle: cardManifestCoverSubtitle(manifest.Document), + }, + }) + + for i, item := range manifest.Items { + page, err := cardManifestItemPage(item, i+2, pageCount) + if err != nil { + return "", err + } + pages = append(pages, page) + } + + rawDeck, err := json.Marshal(deck.Deck{Pages: pages}) + if err != nil { + return "", err + } + return string(rawDeck), nil +} + +func cardManifestItemPage(item cardManifestItem, pageNumber int, pageCount int) (deck.Page, error) { + title := strings.TrimSpace(item.Title) + if title == "" { + return deck.Page{}, fmt.Errorf("card manifest items[%d].title is required", pageNumber-2) + } + variant := "text-caption" + imageSrc := strings.TrimSpace(item.Image.Src) + if imageSrc != "" { + variant = "image-caption" + } + body := cardManifestItemBody(item, cardManifestBodyMaxRunes(variant)) + if body == "" { + body = title + } + content := deck.PageContent{Title: title, Body: body} + if imageSrc != "" { + imageAlt := strings.TrimSpace(item.Image.Alt) + if imageAlt == "" { + imageAlt = title + } + content.Images = []deck.ImageBlock{{Src: imageSrc, Alt: imageAlt}} + } + return deck.Page{ + Name: fmt.Sprintf("p%02d-%s", pageNumber, variant), + Variant: variant, + Meta: deck.PageMeta{ + Badge: cardManifestItemBadge(item, pageNumber), + Counter: fmt.Sprintf("%d/%d", pageNumber, pageCount), + Theme: deck.ThemeDefault, + CTA: cardManifestSourceMarker(item), + }, + Content: content, + }, nil +} + +func cardManifestCoverSubtitle(document cardManifestDocument) string { + bullets := make([]string, 0, minInt(len(document.Summary), cardManifestCoverSummaryMaxItems)) + for _, item := range document.Summary { + item = cardManifestFitText(item, cardManifestCoverSummaryItemMaxRunes) + if item == "" { + continue + } + bullets = append(bullets, "• "+item) + if len(bullets) == cardManifestCoverSummaryMaxItems { + break + } + } + if len(bullets) > 0 { + return strings.Join(bullets, "\n") + } + return cardManifestFitText(document.Subtitle, cardManifestCoverSummaryItemMaxRunes*cardManifestCoverSummaryMaxItems) +} + +func cardManifestBodyMaxRunes(variant string) int { + if variant == "image-caption" { + return cardManifestImageCaptionBodyMaxRunes + } + return cardManifestTextCaptionBodyMaxRunes +} + +func cardManifestItemBody(item cardManifestItem, maxRunes int) string { + summary, impact := cardManifestFitSummaryImpact(item.Summary, item.Impact, maxRunes) + parts := make([]string, 0, 2) + if summary != "" { + parts = append(parts, "摘要:"+summary) + } + if impact != "" { + parts = append(parts, "影响:"+impact) + } + return strings.Join(parts, "\n\n") +} + +func cardManifestFitSummaryImpact(summary string, impact string, maxRunes int) (string, string) { + summary = cardManifestNormalizeText(summary) + impact = cardManifestNormalizeText(impact) + if cardManifestBodyRuneCount(summary, impact) <= maxRunes { + return summary, impact + } + if maxRunes <= 0 { + return "", "" + } + if summary == "" { + impactBudget := maxRunes - runeCount("影响:") + return "", cardManifestFitText(impact, impactBudget) + } + if impact == "" { + summaryBudget := maxRunes - runeCount("摘要:") + return cardManifestFitText(summary, summaryBudget), "" + } + + contentBudget := maxRunes - runeCount("摘要:\n\n影响:") + if contentBudget < 2 { + return "", "" + } + summaryBudget := contentBudget * 2 / 3 + impactBudget := contentBudget - summaryBudget + summaryRunes := runeCount(summary) + impactRunes := runeCount(impact) + if summaryRunes < summaryBudget { + impactBudget += summaryBudget - summaryRunes + summaryBudget = summaryRunes + } + if impactRunes < impactBudget { + summaryBudget += impactBudget - impactRunes + impactBudget = impactRunes + } + summary = cardManifestFitText(summary, summaryBudget) + impact = cardManifestFitText(impact, impactBudget) + for cardManifestBodyRuneCount(summary, impact) > maxRunes { + if runeCount(impact) >= runeCount(summary) { + impact = cardManifestFitText(impact, runeCount(impact)-1) + } else { + summary = cardManifestFitText(summary, runeCount(summary)-1) + } + } + return summary, impact +} + +func cardManifestBodyRuneCount(summary string, impact string) int { + return runeCount(cardManifestBodyText(summary, impact)) +} + +func cardManifestBodyText(summary string, impact string) string { + parts := make([]string, 0, 2) + if summary != "" { + parts = append(parts, "摘要:"+summary) + } + if impact != "" { + parts = append(parts, "影响:"+impact) + } + return strings.Join(parts, "\n\n") +} + +func cardManifestFitText(value string, maxRunes int) string { + value = cardManifestNormalizeText(value) + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + if maxRunes <= 0 { + return "" + } + if maxRunes == 1 { + return "…" + } + limit := maxRunes - 1 + boundary := cardManifestSentenceBoundary(runes, limit) + if boundary == 0 { + boundary = limit + } + return strings.TrimSpace(string(runes[:boundary])) + "…" +} + +func cardManifestSentenceBoundary(runes []rune, limit int) int { + minBoundary := limit * 2 / 3 + if minBoundary < 12 { + minBoundary = minInt(limit, 12) + } + boundary := 0 + for i, r := range runes { + position := i + 1 + if position > limit { + break + } + if position >= minBoundary && isCardManifestSentenceBoundary(r) { + boundary = position + } + } + return boundary +} + +func isCardManifestSentenceBoundary(r rune) bool { + switch r { + case '。', '!', '?', ';', '.', '!', '?', ';': + return true + default: + return false + } +} + +func cardManifestNormalizeText(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func runeCount(value string) int { + return len([]rune(value)) +} + +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + +func cardManifestSourceMarker(item cardManifestItem) string { + parts := make([]string, 0, 2) + if source := strings.TrimSpace(item.Source); source != "" { + parts = append(parts, source) + } + if published := formatCardManifestPublishedAt(item.PublishedAt); published != "" { + parts = append(parts, published) + } + if len(parts) == 0 { + return "来源:未标注" + } + return "来源:" + strings.Join(parts, " / ") +} + +func formatCardManifestPublishedAt(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t.Format("2006-01-02 15:04") + } + return value +} + +func cardManifestItemBadge(item cardManifestItem, pageNumber int) string { + if category := strings.TrimSpace(item.Category); category != "" { + return fmt.Sprintf("%02d|%s", pageNumber-1, category) + } + return fmt.Sprintf("第 %d 页", pageNumber) +} + +func cardManifestCoverBadge(document cardManifestDocument) string { + date := strings.TrimSpace(document.Date) + period := strings.TrimSpace(document.Period) + switch { + case date != "" && period != "": + return date + " " + period + case date != "": + return date + case period != "": + return period + default: + return "今日速览" + } +} + +func cardManifestCoverCTA(sourceApp string) string { + if sourceApp = strings.TrimSpace(sourceApp); sourceApp != "" { + return "来源:" + sourceApp + } + return "来源:manifest" +} diff --git a/internal/app/service.go b/internal/app/service.go index 70cb7ba..6b0cc2c 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -52,6 +52,7 @@ type Options struct { Author string PromptExtra string AssetManifestPath string + CardManifestPath string AutoPosters bool PosterSources []string PublishXHS bool @@ -160,17 +161,28 @@ func (s Service) GeneratePreview(opts Options) (result Result, err error) { } markdown := string(markdownBytes) - rawJSON, matched, err := buildElectronicPicklesDeckJSON(markdown) - if err != nil { - return Result{}, fmt.Errorf("%w: %v", ErrBuildDeckJSON, err) - } - if !matched { - s.PromptExtra = opts.PromptExtra - buildDeckDone := timing.Stage("app.GeneratePreview.ai_build_deck_json") - rawJSON, err = s.effectiveBuildDeckJSON()(cfg, markdown) - buildDeckDone(err) + var rawJSON string + if strings.TrimSpace(opts.CardManifestPath) != "" { + buildManifestDone := timing.Stage("app.GeneratePreview.build_card_manifest_deck") + rawJSON, err = s.buildCardManifestDeckJSON(opts.CardManifestPath) + buildManifestDone(err) + if err != nil { + return Result{}, fmt.Errorf("%w: %v", ErrBuildDeckJSON, err) + } + } else { + var matched bool + rawJSON, matched, err = buildElectronicPicklesDeckJSON(markdown) if err != nil { - return Result{}, fmt.Errorf("%w: %w", ErrBuildDeckJSON, err) + return Result{}, fmt.Errorf("%w: %v", ErrBuildDeckJSON, err) + } + if !matched { + s.PromptExtra = opts.PromptExtra + buildDeckDone := timing.Stage("app.GeneratePreview.ai_build_deck_json") + rawJSON, err = s.effectiveBuildDeckJSON()(cfg, markdown) + buildDeckDone(err) + if err != nil { + return Result{}, fmt.Errorf("%w: %w", ErrBuildDeckJSON, err) + } } } @@ -188,7 +200,11 @@ func (s Service) GeneratePreview(opts Options) (result Result, err error) { if err != nil { return Result{}, err } - d = hydrateLocalImageAssets(d, filepath.Dir(opts.InputPath)) + imageBaseDir := filepath.Dir(opts.InputPath) + if strings.TrimSpace(opts.CardManifestPath) != "" { + imageBaseDir = filepath.Dir(opts.CardManifestPath) + } + d = hydrateLocalImageAssets(d, imageBaseDir) renderDeckDone := timing.Stage("app.GeneratePreview.render_deck") result, err = s.renderDeck(opts, cfg, d, sourceRenderMeta{}) diff --git a/internal/app/service_test.go b/internal/app/service_test.go index d42b884..b95d897 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -11,12 +11,14 @@ import ( "strings" "testing" "time" + "unicode/utf8" "github.com/walker1211/mark2note/internal/ai" "github.com/walker1211/mark2note/internal/config" "github.com/walker1211/mark2note/internal/deck" "github.com/walker1211/mark2note/internal/poster" "github.com/walker1211/mark2note/internal/render" + "github.com/walker1211/mark2note/internal/timing" ) type fakeAICommandRunner struct { @@ -213,6 +215,282 @@ func TestServiceGeneratePreviewSuccess(t *testing.T) { } } +func TestServiceGeneratePreviewBuildsCardManifestDeckWithoutAI(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + manifest := `{ + "schema_version":"card-article-manifest/v1", + "source_app":"news-briefing", + "document":{"title":"今日 AI 晚报","date":"2026-06-16","period":"1800","summary":["模型发布提速","终端侧竞争升温"]}, + "items":[ + {"id":"a1","category":"AI/科技","title":"OpenAI 发布新模型","summary":"模型能力提升。","impact":"应用开发门槛下降。","source":"The Verge","published_at":"2026-06-16T11:00:00+08:00","url":"https://example.com/a1","image":{"src":"assets/openai.jpg","alt":"模型发布现场"}}, + {"id":"a2","category":"硬件","title":"AI 眼镜更新","summary":"新品强调续航。","impact":"穿戴设备竞争加剧。","source":"Bloomberg","published_at":"2026-06-16T12:30:00+08:00","url":"https://example.com/a2"} + ] + }` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + cfg := &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}} + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { return cfg, nil }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored by card manifest"), nil + } + return os.ReadFile(path) + }, + BuildDeckJSON: func(*config.Config, string) (string, error) { + t.Fatal("BuildDeckJSON called for card manifest") + return "", nil + }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + result, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + if result.PageCount != 3 || len(r.rendered.Pages) != 3 { + t.Fatalf("page count = result:%d rendered:%d, want 3", result.PageCount, len(r.rendered.Pages)) + } + cover := r.rendered.Pages[0] + if cover.Variant != "cover" || cover.Content.Title != "今日 AI 晚报" { + t.Fatalf("cover = %#v", cover) + } + wantSubtitle := "• 模型发布提速\n• 终端侧竞争升温" + if cover.Content.Subtitle != wantSubtitle { + t.Fatalf("cover subtitle = %q, want %q", cover.Content.Subtitle, wantSubtitle) + } + imagePage := r.rendered.Pages[1] + if imagePage.Variant != "image-caption" || imagePage.Content.Title != "OpenAI 发布新模型" { + t.Fatalf("image page = %#v", imagePage) + } + if imagePage.Content.Body != "摘要:模型能力提升。\n\n影响:应用开发门槛下降。" { + t.Fatalf("image page body = %q", imagePage.Content.Body) + } + if imagePage.Meta.CTA != "来源:The Verge / 2026-06-16 11:00" { + t.Fatalf("image page cta = %q", imagePage.Meta.CTA) + } + if !reflect.DeepEqual(imagePage.Content.Images, []deck.ImageBlock{{Src: "assets/openai.jpg", Alt: "模型发布现场"}}) { + t.Fatalf("image page images = %#v", imagePage.Content.Images) + } + textPage := r.rendered.Pages[2] + if textPage.Variant != "text-caption" || textPage.Content.Title != "AI 眼镜更新" { + t.Fatalf("text page = %#v", textPage) + } + if textPage.Meta.CTA != "来源:Bloomberg / 2026-06-16 12:30" { + t.Fatalf("text page cta = %q", textPage.Meta.CTA) + } +} + +func TestServiceGeneratePreviewBuildsNoImageCardManifestItemsAsTextCaption(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + manifest := `{"schema_version":"card-article-manifest/v1","source_app":"news-briefing","document":{"title":"今日速览","subtitle":"无图新闻"},"items":[{"id":"a1","category":"AI","title":"第一条","summary":"摘要一","source":"Source A"},{"id":"a2","category":"科技","title":"第二条","impact":"影响二","source":"Source B"}]}` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + BuildDeckJSON: func(*config.Config, string) (string, error) { + t.Fatal("BuildDeckJSON called for card manifest") + return "", nil + }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + for _, page := range r.rendered.Pages[1:] { + if page.Variant != "text-caption" { + t.Fatalf("page %s variant = %q, want text-caption", page.Name, page.Variant) + } + if len(page.Content.Images) != 0 { + t.Fatalf("page %s images = %#v, want none", page.Name, page.Content.Images) + } + } +} + +func TestServiceGeneratePreviewFitsCardManifestTextForNewsCards(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + longSummary := strings.Repeat("这是一段很长的摘要,用来覆盖主要事实、背景、争议和当前公开信息。", 10) + longImpact := strings.Repeat("这是一段很长的影响分析,用来说明企业客户、开发者生态和后续监管变化。", 8) + manifest := fmt.Sprintf(`{ + "schema_version":"card-article-manifest/v1", + "source_app":"news-briefing", + "document":{"title":"今日 AI 晚报","summary":["第一条速览内容会保留","第二条速览内容会保留","第三条速览内容会保留","第四条速览内容会保留","第五条速览内容会保留","第六条速览内容会保留","第七条速览内容不应进入封面"]}, + "items":[ + {"id":"a1","category":"AI/科技","title":"配图新闻","summary":%q,"impact":%q,"source":"The Verge","image":{"src":"assets/news.jpg","alt":"新闻图"}}, + {"id":"a2","category":"AI/科技","title":"无图新闻","summary":%q,"impact":%q,"source":"Hacker News"} + ] + }`, longSummary, longImpact, longSummary, longImpact) + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + BuildDeckJSON: func(*config.Config, string) (string, error) { + t.Fatal("BuildDeckJSON called for card manifest") + return "", nil + }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + coverLines := strings.Split(r.rendered.Pages[0].Content.Subtitle, "\n") + if len(coverLines) != 6 { + t.Fatalf("cover subtitle lines = %d, want 6: %q", len(coverLines), r.rendered.Pages[0].Content.Subtitle) + } + if strings.Contains(r.rendered.Pages[0].Content.Subtitle, "第七条") { + t.Fatalf("cover subtitle should omit seventh item: %q", r.rendered.Pages[0].Content.Subtitle) + } + imageBody := r.rendered.Pages[1].Content.Body + if utf8.RuneCountInString(imageBody) > cardManifestImageCaptionBodyMaxRunes { + t.Fatalf("image-caption body runes = %d, want <= %d", utf8.RuneCountInString(imageBody), cardManifestImageCaptionBodyMaxRunes) + } + if !strings.Contains(imageBody, "摘要:") || !strings.Contains(imageBody, "影响:") || !strings.Contains(imageBody, "…") { + t.Fatalf("image-caption body should preserve labels and ellipsis: %q", imageBody) + } + textBody := r.rendered.Pages[2].Content.Body + if utf8.RuneCountInString(textBody) > cardManifestTextCaptionBodyMaxRunes { + t.Fatalf("text-caption body runes = %d, want <= %d", utf8.RuneCountInString(textBody), cardManifestTextCaptionBodyMaxRunes) + } + if !strings.Contains(textBody, "摘要:") || !strings.Contains(textBody, "影响:") || !strings.Contains(textBody, "…") { + t.Fatalf("text-caption body should preserve labels and ellipsis: %q", textBody) + } +} + +func TestServiceGeneratePreviewInlinesCardManifestImagesRelativeToManifest(t *testing.T) { + root := t.TempDir() + manifestDir := filepath.Join(root, "manifest") + if err := os.MkdirAll(filepath.Join(manifestDir, "assets"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(manifestDir, "assets", "news.png"), []byte("\x89PNG\r\n\x1a\nimage"), 0o644); err != nil { + t.Fatalf("WriteFile(image) error = %v", err) + } + manifestPath := filepath.Join(manifestDir, "manifest.json") + manifest := `{"schema_version":"card-article-manifest/v1","document":{"title":"今日速览"},"items":[{"id":"a1","title":"配图新闻","summary":"摘要一","source":"Source A","image":{"src":"assets/news.png","alt":"新闻图"}},{"id":"a2","title":"第二条","summary":"摘要二","source":"Source B"}]}` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == filepath.Join(root, "article.md") { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: filepath.Join(root, "article.md"), ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + got := r.rendered.Pages[1].Content.Images[0].Src + if !strings.HasPrefix(got, "data:image/png;base64,") { + t.Fatalf("card manifest image src = %q, want data URI from manifest-relative path", got) + } +} + +func TestServiceGeneratePreviewReturnsErrorForInvalidCardManifest(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + if err := os.WriteFile(manifestPath, []byte(`{"schema_version":"unsupported","document":{"title":"标题"},"items":[]}`), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + BuildDeckJSON: func(*config.Config, string) (string, error) { + t.Fatal("BuildDeckJSON called for invalid card manifest") + return "", nil + }, + NewRenderer: func(Options) DeckRenderer { return &fakeRenderer{} }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err == nil { + t.Fatalf("GeneratePreview() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "build deck json failed") || !strings.Contains(err.Error(), "unsupported card manifest schema_version") { + t.Fatalf("GeneratePreview() error = %v", err) + } +} + +func TestServiceGeneratePreviewEmitsCardManifestTimingStage(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + manifest := `{"schema_version":"card-article-manifest/v1","document":{"title":"今日速览"},"items":[{"id":"a1","title":"第一条","summary":"摘要一","source":"Source A"},{"id":"a2","title":"第二条","summary":"摘要二","source":"Source B"}]}` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + var timingOutput strings.Builder + oldTimingOutput := timing.SetOutput(&timingOutput) + defer timing.SetOutput(oldTimingOutput) + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + NewRenderer: func(Options) DeckRenderer { return &fakeRenderer{} }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + output := timingOutput.String() + if !strings.Contains(output, "stage=app.GeneratePreview.build_card_manifest_deck") { + t.Fatalf("timing output = %q, want build_card_manifest_deck stage", output) + } + if strings.Contains(output, "stage=app.GeneratePreview.ai_build_deck_json") { + t.Fatalf("timing output = %q, want no ai_build_deck_json stage", output) + } +} + func TestServiceGeneratePreviewBuildsElectronicPicklesDeckWithoutAI(t *testing.T) { cfg := &config.Config{Output: config.OutputCfg{Dir: t.TempDir()}, Deck: config.DeckCfg{MaxPages: 12}} titles := []string{