package utils import ( "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "unicode" "github.com/phpdave11/gofpdf" "github.com/signintech/gopdf" "go.uber.org/zap" ) // cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji) // gofpdf库不支持某些特殊字符 func cleanTextForPDF(text string) string { var result []rune for _, r := range text { // 保留基本多文种平面(BMP)内的字符(码点 <= 0xFFFF) // 这样可以保留中文、英文、数字等常用字符,但过滤掉emoji等特殊字符 if r <= 0xFFFF && (unicode.IsPrint(r) || unicode.IsSpace(r)) { result = append(result, r) } } return string(result) } // loadChineseFont 加载中文字体 func loadChineseFont(pdf *gofpdf.Fpdf, fontPath string) error { var fontData []byte var err error // 如果提供了本地字体路径,优先使用本地字体 if fontPath == "" { return errors.New("字体文件路径不能为空") } fontData, err = os.ReadFile(fontPath) if err != nil { return fmt.Errorf("读取字体文件失败: %v", err) } // 使用本地字体文件 pdf.AddUTF8FontFromBytes("Chinese", "", fontData) return nil } // GeneratePDF 生成PDF文件 func GeneratePDF(text, imageURL, outputPath, fontPath string) error { if text == "" { return errors.New("文本不能为空") } // 创建PDF实例,P=纵向,mm=毫米单位,A4=页面大小 pdf := gofpdf.New("P", "mm", "A4", "") // 加载中文字体 err := loadChineseFont(pdf, fontPath) if err != nil { return fmt.Errorf("加载中文字体失败: %v", err) } // 添加新页面 pdf.AddPage() // 设置字体,使用中文字体,12号字体 pdf.SetFont("Chinese", "", 12) // 设置页面边距(左、上、右) pdf.SetMargins(20, 10, 20) // 设置当前位置(x, y),从左上角开始 pdf.SetXY(20, 10) // 清理文本,移除PDF不支持的字符(如emoji) cleanedText := cleanTextForPDF(text) // 添加文本内容 // 使用MultiCell方法处理多行文本,支持自动换行 // 参数:宽度、行高、文本内容、边框、对齐方式、是否填充 // A4页面宽度210mm,减去左右边距40mm,可用宽度170mm textWidth := 170.0 lineHeight := 7.0 pdf.MultiCell(textWidth, lineHeight, cleanedText, "", "L", false) // 如果提供了图片URL,则添加图片 if imageURL != "" { // 添加一些间距 pdf.Ln(5) // 解析URL获取文件扩展名 u, err := url.Parse(imageURL) if err != nil { return fmt.Errorf("图片链接解析错误: %v", err) } fileExt := filepath.Ext(u.Path) // 如果没有扩展名,默认使用.jpg if fileExt == "" { fileExt = ".jpg" } // 下载图片 resp, err := http.Get(imageURL) if err != nil { return fmt.Errorf("下载图片失败: %v", err) } defer resp.Body.Close() // 读取图片数据 imageData, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("读取图片数据失败: %v", err) } // 将图片数据保存到临时文件(gofpdf需要文件路径) 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() // A4纵向页面宽度210mm,减去左右边距40mm,可用宽度170mm // 图片宽度设为可用宽度的70% imageWidth := textWidth * 0.7 // 计算居中位置:页面宽度210mm,图片居中 imageX := (210.0 - imageWidth) / 2 currentY := pdf.GetY() // 注册图片并获取原始尺寸,用于计算缩放后的高度 imgInfo := pdf.RegisterImageOptions(tmpFile.Name(), gofpdf.ImageOptions{}) if imgInfo == nil { return fmt.Errorf("注册图片失败") } // 计算缩放后的图片高度(按比例缩放) // 原始宽度:原始高度 = 缩放后宽度:缩放后高度 originalWidth, originalHeight := imgInfo.Extent() imageHeight := (imageWidth / originalWidth) * originalHeight // A4页面高度297mm,底部边距10mm,计算可用的最大Y坐标 pageHeight := 297.0 bottomMargin := 10.0 maxY := pageHeight - bottomMargin // 检查当前页面剩余空间是否足够放下图片 // 如果图片底部会超出页面可用区域,则添加新页面 if currentY+imageHeight > maxY { pdf.AddPage() // 新页面从顶部边距开始 currentY = 10.0 } // 添加图片 // ImageOptions参数:图片路径、x坐标、y坐标、宽度、高度、是否流式布局、选项、链接 // 高度设为0表示按比例自动计算 pdf.ImageOptions(tmpFile.Name(), imageX, currentY, imageWidth, 0, false, gofpdf.ImageOptions{}, 0, "") } // 生成并保存PDF文件 err = pdf.OutputFileAndClose(outputPath) if err != nil { return fmt.Errorf("生成PDF失败: %v", err) } return nil } // CompetitorReportData 竞品报告数据 type CompetitorReportData struct { HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"` DataPerformance DataPerformanceData `json:"data_performance_analysis"` OverallSummary string `json:"overall_summary_and_optimization"` } type HighlightAnalysisData struct { Summary string `json:"summary"` Points PointsData `json:"points"` } type PointsData struct { Theme string `json:"theme"` Narrative string `json:"narrative"` Content string `json:"content"` Copywriting string `json:"copywriting"` Data string `json:"data"` Music string `json:"music,omitempty"` } type DataPerformanceData struct { Views string `json:"views"` Completion string `json:"completion_rate,omitempty"` Engagement string `json:"engagement"` } // GenerateCompetitorReportPDF 生成竞品报告PDF // 参数: // - templatePath: 模板文件路径 // - outputPath: 输出PDF路径 // - data: 竞品报告数据 // // 返回: 错误信息 func GenerateCompetitorReportPDF(templatePath, outputPath string, data CompetitorReportData) error { fmt.Println("================================templatePath:", templatePath) fmt.Println("================================outputPath:", outputPath) pdf := gopdf.GoPdf{} pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) // 导入模板文件中的页面 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目录 fontPath = filepath.Join("data", "simfang.ttf") } fmt.Printf("字体文件路径: %s\n", fontPath) // 加载中文字体 ttfErr := pdf.AddTTFFont("simfang", fontPath) if ttfErr != nil { zap.L().Error("加载字体失败", zap.String("fontPath", fontPath), zap.Error(ttfErr)) return fmt.Errorf("加载中文字体失败: %v", ttfErr) } // 设置字体和字号 err = pdf.SetFont("simfang", "", 10) if err != nil { return fmt.Errorf("设置字体失败: %v", err) } // 行高15pt lineHeight := 15.0 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) } // 标题亮点 - 一行最多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(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(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) 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) } // 生成新的 PDF if err = pdf.WritePdf(outputPath); err != nil { return fmt.Errorf("error writing final PDF: %v", err) } 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 == "" { return []string{} } var lines []string runes := []rune(text) currentLine := "" for _, r := range runes { // 如果当前行字符数达到最大限度,换行 if len(currentLine) >= maxLen { lines = append(lines, currentLine) currentLine = string(r) } else { currentLine += string(r) } } // 添加最后一行 if len(currentLine) > 0 { lines = append(lines, currentLine) } return lines }