406 lines
12 KiB
Go
406 lines
12 KiB
Go
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
|
||
}
|