From f751b37e66f53c305a784a2ed53cacd6eb4c9113 Mon Sep 17 00:00:00 2001 From: cjy Date: Mon, 2 Mar 2026 13:32:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AB=9E=E5=93=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=9F=E6=88=90=E5=B8=A6=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=9A=84pdf=EF=BC=8C=E5=B9=B6=E6=B7=BB=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/utils/pdf.go | 278 +++++++++++++++++++++---------- pkg/utils/pdf_competitor_test.go | 52 ++++++ 2 files changed, 238 insertions(+), 92 deletions(-) diff --git a/pkg/utils/pdf.go b/pkg/utils/pdf.go index 8d6bce1d..47618d12 100644 --- a/pkg/utils/pdf.go +++ b/pkg/utils/pdf.go @@ -3,6 +3,7 @@ package utils import ( "errors" "fmt" + "image" "io" "net/http" "net/url" @@ -180,6 +181,7 @@ type CompetitorReportData struct { HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"` DataPerformance DataPerformanceData `json:"data_performance_analysis"` OverallSummary string `json:"overall_summary_and_optimization"` + ImageURL string `json:"image_url"` // 图片URL,如果有图片则生成单独一页PDF } type HighlightAnalysisData struct { @@ -204,7 +206,7 @@ type DataPerformanceData struct { // GenerateCompetitorReportPDF 生成竞品报告PDF // 参数: -// - templatePath: 模板文件路径 +// - templatePath: 模板文件路径(保留参数以兼容现有调用,传空则不使用模板) // - outputPath: 输出PDF路径 // - data: 竞品报告数据 // @@ -216,21 +218,27 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito pdf := gopdf.GoPdf{} pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) - // 导入模板文件中的页面 - err := pdf.ImportPagesFromSource(templatePath, "/MediaBox") - if err != nil { - return fmt.Errorf("无法导入页面: %v", err) + // 如果有模板路径,则导入模板 + if templatePath != "" { + err := pdf.ImportPagesFromSource(templatePath, "/MediaBox") + if err != nil { + return fmt.Errorf("无法导入页面: %v", err) + } } - // 获取模板文件的总页数 + // 获取模板文件的总页数(如果有模板) totalPages := pdf.GetNumberOfPages() fmt.Printf("模板文件的总页数: %d\n", totalPages) - // 根据模板路径推断字体路径(假设字体文件和模板在同一目录或data目录下) - dir := filepath.Dir(templatePath) - fontPath := filepath.Join(dir, "simfang.ttf") - if _, err := os.Stat(fontPath); err != nil { - // 尝试使用项目根目录下的data目录 + // 确定字体路径 + var fontPath string + if templatePath != "" { + dir := filepath.Dir(templatePath) + fontPath = filepath.Join(dir, "simfang.ttf") + if _, err := os.Stat(fontPath); err != nil { + fontPath = filepath.Join("data", "simfang.ttf") + } + } else { fontPath = filepath.Join("data", "simfang.ttf") } fmt.Printf("字体文件路径: %s\n", fontPath) @@ -243,7 +251,7 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito } // 设置字体和字号 - err = pdf.SetFont("simfang", "", 10) + err := pdf.SetFont("simfang", "", 10) if err != nil { return fmt.Errorf("设置字体失败: %v", err) } @@ -251,104 +259,190 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito // 行高15pt lineHeight := 15.0 - pdf.SetPage(1) + // 如果有内容要写入,确保在第一页 + if totalPages > 0 || (data.HighlightAnalysis.Summary != "" || data.OverallSummary != "") { + pdf.SetPage(1) - // 概述 - 使用逐行写入,一行最多35个字 - pdf.SetXY(200, 104) - summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35) - for i, line := range summaryLines { - pdf.SetXY(200, 104+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 概述 - 使用逐行写入,一行最多35个字 + pdf.SetXY(200, 104) + 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) - 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(200, 184) + 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) - 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(330, 184) + 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) - 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(460, 184) + 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) - 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(200, 323) + 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) - 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个字 + pdf.SetXY(330, 323) + 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) - musicLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Music), 9) - for i, line := range musicLines { - pdf.SetXY(460, 323+float64(i)*lineHeight) + // 配乐亮点(仅视频) - 一行最多9个字 + if data.HighlightAnalysis.Points.Music != "" { + pdf.SetXY(460, 323) + 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) + 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 != "" { + 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) + 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) + overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35) + for i, line := range overallSummaryLines { + pdf.SetXY(200, 676+float64(i)*lineHeight) pdf.Cell(nil, line) } } - // 浏览量 - 一行最多35个字 - pdf.SetXY(200, 474) - viewsLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Views), 35) - for i, line := range viewsLines { - pdf.SetXY(200, 474+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 如果有图片URL,添加新页面并居中显示图片 + if data.ImageURL != "" { + // 添加新页面 + pdf.AddPage() - // 完播率 - 一行最多35个字 - // 始终显示在固定位置,有内容时填充内容 - pdf.SetXY(200, 539) - if 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) + // 下载图片 + resp, err := http.Get(data.ImageURL) + if err != nil { + return fmt.Errorf("下载图片失败: %v", err) } - } + defer resp.Body.Close() - // 点赞/分享/评论 - 一行最多35个字 - // 始终固定在完播率下面的位置 - pdf.SetXY(200, 600) - engagementLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Engagement), 35) - for i, line := range engagementLines { - pdf.SetXY(200, 600+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + imageData, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取图片数据失败: %v", err) + } - // 整体总结及可优化建议 - 一行最多35个字 - pdf.SetXY(200, 676) - overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35) - for i, line := range overallSummaryLines { - pdf.SetXY(200, 676+float64(i)*lineHeight) - pdf.Cell(nil, line) + // 解析URL获取文件扩展名 + u, err := url.Parse(data.ImageURL) + if err != nil { + return fmt.Errorf("图片链接解析错误: %v", err) + } + fileExt := filepath.Ext(u.Path) + if fileExt == "" { + fileExt = ".jpg" + } + + // 保存到临时文件 + tmpFile, err := os.CreateTemp("", "pdf_image_*"+fileExt) + if err != nil { + return fmt.Errorf("创建临时文件失败: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + _, err = tmpFile.Write(imageData) + if err != nil { + return fmt.Errorf("写入临时文件失败: %v", err) + } + tmpFile.Close() + + // 使用 image 包获取图片原始尺寸 + imgFile, err := os.Open(tmpFile.Name()) + if err != nil { + return fmt.Errorf("打开图片文件失败: %v", err) + } + defer imgFile.Close() + + config, format, err := image.DecodeConfig(imgFile) + if err != nil { + return fmt.Errorf("获取图片尺寸失败: %v", err) + } + _ = format // 忽略格式 + + // A4页面宽度595pt(210mm),高度842pt(297mm) + pageWidth := 595.0 + pageHeight := 842.0 + margin := 20.0 + + // 计算可用宽度 + availableWidth := pageWidth - 2*margin + + // 计算缩放后的图片尺寸(保持宽高比,宽度为可用宽度的80%) + imageWidth := availableWidth * 0.8 + originalWidth := float64(config.Width) + originalHeight := float64(config.Height) + imageHeight := (imageWidth / originalWidth) * originalHeight + + // 计算居中位置 + imageX := (pageWidth - imageWidth) / 2 + imageY := (pageHeight - imageHeight) / 2 + + // 使用 ImageHolderByBytes 添加图片 + imgH1, err := gopdf.ImageHolderByBytes(imageData) + if err != nil { + return fmt.Errorf("创建图片Holder失败: %v", err) + } + + // 绘制图片 + err = pdf.ImageByHolder(imgH1, imageX, imageY, &gopdf.Rect{W: imageWidth, H: imageHeight}) + if err != nil { + return fmt.Errorf("绘制图片失败: %v", err) + } } // 生成新的 PDF - if err = pdf.WritePdf(outputPath); err != nil { + if err := pdf.WritePdf(outputPath); err != nil { return fmt.Errorf("error writing final PDF: %v", err) } diff --git a/pkg/utils/pdf_competitor_test.go b/pkg/utils/pdf_competitor_test.go index e507dfb5..71521c44 100644 --- a/pkg/utils/pdf_competitor_test.go +++ b/pkg/utils/pdf_competitor_test.go @@ -121,3 +121,55 @@ func TestGenerateCompetitorReportPDFImageOnly(t *testing.T) { fmt.Printf("PDF生成成功: %s\n", outputPath) } + +// TestGenerateCompetitorReportPDFWithImage 测试带图片的竞品报告PDF +// 注意:此测试需要网络连接来下载图片,如果网络不可用会被跳过 +func TestGenerateCompetitorReportPDFWithImage(t *testing.T) { + // 获取项目根目录 + root := getProjectRoot() + + // 准备测试数据(带图片) + // 使用一个已知可用的测试图片URL + 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: "整体来看,该竞品视频在内容策划、表现形式和互动数据方面都表现优秀。", + ImageURL: "https://cdn-test.szjixun.cn/fonchain-main/test/image/12345/artwork/0.png", // 测试用图片URL + } + + // 模板路径 + templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf") + // 输出路径 + outputPath := filepath.Join(root, "data", "output", "竞品报告测试_带图片.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.Logf("图片下载测试跳过(网络问题): %v", err) + t.Skip("网络不可用,跳过图片测试") + return + } + + fmt.Printf("PDF生成成功: %s\n", outputPath) +}