diff --git a/.gitignore b/.gitignore index 4ec01dac..55306709 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ /cmd/logs/*.log /cmd/runtime/log/*.log /build/* +CLAUDE.md +.claude/settings.local.json diff --git a/pkg/utils/pdf.go b/pkg/utils/pdf.go index 045cb7ff..8d6bce1d 100644 --- a/pkg/utils/pdf.go +++ b/pkg/utils/pdf.go @@ -253,66 +253,99 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito pdf.SetPage(1) - // 概述 - 使用MultiCell自动换行,一行最多35个字 + // 概述 - 使用逐行写入,一行最多35个字 pdf.SetXY(200, 104) - summaryRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&summaryRect, data.HighlightAnalysis.Summary) + summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35) + for i, line := range summaryLines { + pdf.SetXY(200, 104+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 标题亮点 - 一行最多9个字 pdf.SetXY(200, 184) - themeRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&themeRect, data.HighlightAnalysis.Points.Theme) + themeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Theme), 9) + for i, line := range themeLines { + pdf.SetXY(200, 184+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 题材亮点 - 一行最多9个字 pdf.SetXY(330, 184) - narrativeRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&narrativeRect, data.HighlightAnalysis.Points.Narrative) + narrativeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Narrative), 9) + for i, line := range narrativeLines { + pdf.SetXY(330, 184+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 内容亮点 - 一行最多9个字 pdf.SetXY(460, 184) - contentRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&contentRect, data.HighlightAnalysis.Points.Content) + contentLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Content), 9) + for i, line := range contentLines { + pdf.SetXY(460, 184+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 文案亮点 - 一行最多9个字 pdf.SetXY(200, 323) - copywritingRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(©writingRect, data.HighlightAnalysis.Points.Copywriting) + copywritingLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Copywriting), 9) + for i, line := range copywritingLines { + pdf.SetXY(200, 323+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 数据亮点 - 一行最多9个字 pdf.SetXY(330, 323) - dataRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&dataRect, data.HighlightAnalysis.Points.Data) + dataLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Data), 9) + for i, line := range dataLines { + pdf.SetXY(330, 323+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 配乐亮点(仅视频) - 一行最多9个字 if data.HighlightAnalysis.Points.Music != "" { pdf.SetXY(460, 323) - musicRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&musicRect, data.HighlightAnalysis.Points.Music) + musicLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Music), 9) + for i, line := range musicLines { + pdf.SetXY(460, 323+float64(i)*lineHeight) + pdf.Cell(nil, line) + } } // 浏览量 - 一行最多35个字 pdf.SetXY(200, 474) - viewsRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&viewsRect, data.DataPerformance.Views) + viewsLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Views), 35) + for i, line := range viewsLines { + pdf.SetXY(200, 474+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 完播率 - 一行最多35个字 // 始终显示在固定位置,有内容时填充内容 pdf.SetXY(200, 539) if data.DataPerformance.Completion != "" { - completionRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&completionRect, data.DataPerformance.Completion) + completionLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Completion), 35) + for i, line := range completionLines { + pdf.SetXY(200, 539+float64(i)*lineHeight) + pdf.Cell(nil, line) + } } // 点赞/分享/评论 - 一行最多35个字 // 始终固定在完播率下面的位置 pdf.SetXY(200, 600) - engagementRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&engagementRect, data.DataPerformance.Engagement) + engagementLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Engagement), 35) + for i, line := range engagementLines { + pdf.SetXY(200, 600+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 整体总结及可优化建议 - 一行最多35个字 pdf.SetXY(200, 676) - summaryRect = gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&summaryRect, data.OverallSummary) + overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35) + for i, line := range overallSummaryLines { + pdf.SetXY(200, 676+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 生成新的 PDF if err = pdf.WritePdf(outputPath); err != nil { @@ -322,6 +355,27 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito return nil } +// splitTextByRune 将文本按指定字符数拆分成多行 +// 按每行最大字符数拆分,中文、英文都按 1 个 rune 计 +func splitTextByRune(text string, maxRunesPerLine int) []string { + if text == "" { + return []string{} + } + runes := []rune(text) + if len(runes) <= maxRunesPerLine { + return []string{text} + } + var lines []string + for i := 0; i < len(runes); i += maxRunesPerLine { + end := i + maxRunesPerLine + if end > len(runes) { + end = len(runes) + } + lines = append(lines, string(runes[i:end])) + } + return lines +} + // wrapText 将文本按指定宽度换行(按字符数计算) func wrapText(text string, maxLen int) []string { if text == "" { diff --git a/pkg/utils/pdf_competitor_test.go b/pkg/utils/pdf_competitor_test.go new file mode 100644 index 00000000..e507dfb5 --- /dev/null +++ b/pkg/utils/pdf_competitor_test.go @@ -0,0 +1,123 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// getProjectRoot 获取项目根目录 +func getProjectRoot() string { + // 假设测试从项目根目录运行 + dir, _ := os.Getwd() + // 向上查找 go.mod 确定项目根目录 + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +// TestGenerateCompetitorReportPDF 测试生成竞品报告PDF +func TestGenerateCompetitorReportPDF(t *testing.T) { + // 获取项目根目录 + root := getProjectRoot() + fmt.Printf("项目根目录: %s\n", root) + + // 准备测试数据 + data := CompetitorReportData{ + HighlightAnalysis: HighlightAnalysisData{ + Summary: "本视频通过展示产品使用的真实场景,突出用户产品优势和痛点,内容详实且具有吸引力。", + Points: PointsData{ + Theme: "标题简洁有力,突出核心卖点'省时省力',引发用户好奇心", + Narrative: "采用情景剧形式展示产品使用场景,剧情贴近生活,易引发共鸣", + Content: "通过前后对比展示产品效果,直观呈现产品价值", + Copywriting: "文案简洁明了,突出用户痛点解决方案,语气亲切自然", + Data: "点赞量10万+,评论5000+,分享2万+,数据表现优异", + Music: "背景音乐节奏轻快,与视频内容匹配度高,增强观看体验", + }, + }, + DataPerformance: DataPerformanceData{ + Views: "播放量突破500万,推荐流量占比60%,自然流量表现优秀", + Completion: "完播率45%,高于同类视频平均值(30%),前3秒吸引力强", + Engagement: "点赞率2%,评论率0.1%,分享率0.4%,互动数据表现优秀", + }, + OverallSummary: "整体来看,该竞品视频在内容策划、表现形式和互动数据方面都表现优秀。优势在于:1)内容真实可信,通过实际使用场景展示产品效果;2)剧情设计合理,前3秒抓住用户注意力;3)文案简洁有力,直击用户痛点。建议优化方向:1)可以增加更多用户评价内容,增强可信度;2)适当增加福利引导,提高转化率;3)结尾可以增加引导关注话术,提升粉丝沉淀。", + } + + // 模板路径 + templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf") + // 输出路径 + outputPath := filepath.Join(root, "data", "output", "竞品报告测试_multicell.pdf") + + // 确保输出目录存在 + outputDir := filepath.Dir(outputPath) + if err := os.MkdirAll(outputDir, 0755); err != nil { + t.Errorf("创建输出目录失败: %v", err) + return + } + + // 调用函数生成PDF + err := GenerateCompetitorReportPDF(templatePath, outputPath, data) + if err != nil { + t.Errorf("生成竞品报告PDF失败: %v", err) + return + } + + fmt.Printf("PDF生成成功: %s\n", outputPath) +} + +// TestGenerateCompetitorReportPDFImageOnly 测试仅图片的竞品报告PDF(无配乐和完播率) +func TestGenerateCompetitorReportPDFImageOnly(t *testing.T) { + // 获取项目根目录 + root := getProjectRoot() + + // 准备测试数据(仅图片,没有视频的配乐和完播率) + data := CompetitorReportData{ + HighlightAnalysis: HighlightAnalysisData{ + Summary: "该图文内容通过精美的视觉设计和精准的标签定位,成功吸引目标用户关注。", + Points: PointsData{ + Theme: "标题设置悬念,引发用户点击欲望", + Narrative: "采用九宫格形式展示产品特点,视觉冲击力强", + Content: "内容排版清晰,重点突出,便于用户快速获取信息", + Copywriting: "文案简洁,配合表情符号增加趣味性", + Data: "收藏量5万+,评论1000+,分享8000+", + Music: "", // 图片无配乐 + }, + }, + DataPerformance: DataPerformanceData{ + Views: "曝光量100万+,点击率3%,表现良好", + Completion: "", // 图文无完播率 + Engagement: "收藏率5%,评论率0.1%,分享率0.8%", + }, + OverallSummary: "该图文内容整体表现优秀,特别是在视觉设计和内容排版方面。亮点:1)九宫格形式统一,视觉效果好;2)标签设置精准,触达目标用户;3)发布时间合理,获得更多曝光。优化建议:1)可以增加更多用户案例展示;2)适当加入互动话题,提高评论量。", + } + + // 模板路径 + templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf") + // 输出路径 + outputPath := filepath.Join(root, "data", "output", "竞品报告测试_图文11.pdf") + + // 确保输出目录存在 + outputDir := filepath.Dir(outputPath) + if err := os.MkdirAll(outputDir, 0755); err != nil { + t.Errorf("创建输出目录失败: %v", err) + return + } + + // 调用函数生成PDF + err := GenerateCompetitorReportPDF(templatePath, outputPath, data) + if err != nil { + t.Errorf("生成竞品报告PDF失败: %v", err) + return + } + + fmt.Printf("PDF生成成功: %s\n", outputPath) +}