fonchain-fiee/pkg/utils/pdf.go

574 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package utils
import (
"errors"
"fmt"
"image"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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"`
ImageURL string `json:"image_url"` // 图片URL如果有图片则生成单独一页PDF
}
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})
// 如果有模板路径,则导入模板
if templatePath != "" {
err := pdf.ImportPagesFromSource(templatePath, "/MediaBox")
if err != nil {
return fmt.Errorf("无法导入页面: %v", err)
}
}
// 获取模板文件的总页数(如果有模板)
totalPages := pdf.GetNumberOfPages()
fmt.Printf("模板文件的总页数: %d\n", totalPages)
// 确定字体路径
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)
// 加载中文字体
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
// 如果有内容要写入,确保在第一页
if totalPages > 0 || (data.HighlightAnalysis.Summary != "" || data.OverallSummary != "") {
pdf.SetPage(1)
// 概述 - 使用逐行写入一行最多35个字
pdf.SetXY(200, 104)
summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35.0)
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.0)
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.0)
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.0)
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.0)
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.0)
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.0)
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.0)
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.0)
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.0)
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.0)
for i, line := range overallSummaryLines {
pdf.SetXY(200, 676+float64(i)*lineHeight)
pdf.Cell(nil, line)
}
}
// 如果有图片URL添加新页面并居中显示图片
if data.ImageURL != "" {
// 添加新页面
pdf.AddPage()
// 下载图片
resp, err := http.Get(data.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)
}
// 解析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页面宽度595pt210mm高度842pt297mm
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 {
return fmt.Errorf("error writing final PDF: %v", err)
}
return nil
}
// getCharWidth 获取字符的宽度权重
// 英文字母、数字、英文符号返回 0.5,其他字符返回 1.0
func getCharWidth(r rune) float64 {
// 英文字母 (A-Z, a-z)
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
return 0.5
}
// 数字 (0-9)
if r >= '0' && r <= '9' {
return 0.5
}
// 英文符号
if (r >= 0x21 && r <= 0x2F) || // ! " # $ % & ' ( ) * + , - . /
(r >= 0x3A && r <= 0x40) || // : ; < = > ? @
(r >= 0x5B && r <= 0x60) || // [ \ ] ^ _ `
(r >= 0x7B && r <= 0x7E) { // { | } ~
return 0.5
}
return 1.0
}
// splitTextByRune 将文本按指定字符宽度拆分成多行
// 按每行最大宽度拆分,英文字母/数字/英文符号按0.5计算其他按1计算
func splitTextByRune(text string, maxWidth float64) []string {
if text == "" {
return []string{}
}
runes := []rune(text)
var lines []string
currentLine := ""
currentWidth := 0.0
for _, r := range runes {
charWidth := getCharWidth(r)
// 检查加上这个字符是否会超过最大宽度
if currentWidth+charWidth > maxWidth {
// 超过最大宽度,保存当前行并开始新行
if currentLine != "" {
lines = append(lines, currentLine)
}
currentLine = string(r)
currentWidth = charWidth
} else {
currentLine += string(r)
currentWidth += charWidth
}
}
// 添加最后一行
if currentLine != "" {
lines = append(lines, currentLine)
}
return lines
}
// ConvertCompetitorReportToText 将竞品报告数据转换为文本格式
// 参数:
// - data: 竞品报告数据
// - isVideo: 是否为视频(视频需要包含完播率和配乐亮点)
//
// 返回: 转换后的文本内容
func ConvertCompetitorReportToText(data CompetitorReportData, isVideo bool) string {
var sb strings.Builder
// 一、亮点表现分析
sb.WriteString("一、亮点表现分析\n")
sb.WriteString(data.HighlightAnalysis.Summary)
sb.WriteString("\n\n")
sb.WriteString("1. 标题亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Theme)
sb.WriteString("\n")
sb.WriteString("2. 题材亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
sb.WriteString("\n")
sb.WriteString("3. 内容亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Content)
sb.WriteString("\n")
sb.WriteString("4. 文案亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
sb.WriteString("\n")
sb.WriteString("5. 数据亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Data)
sb.WriteString("\n")
if isVideo && data.HighlightAnalysis.Points.Music != "" {
sb.WriteString("6. 配乐亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Music)
sb.WriteString("\n")
}
// 二、数据表现分析
sb.WriteString("\n二、数据表现分析\n")
sb.WriteString("1. 浏览量表现:")
sb.WriteString(data.DataPerformance.Views)
sb.WriteString("\n")
if isVideo && data.DataPerformance.Completion != "" {
sb.WriteString("2. 完播率表现:")
sb.WriteString(data.DataPerformance.Completion)
sb.WriteString("\n")
sb.WriteString("3. 点赞/分享/评论表现:")
} else {
sb.WriteString("2. 点赞/分享/评论表现:")
}
sb.WriteString(data.DataPerformance.Engagement)
sb.WriteString("\n")
// 三、整体总结及可优化建议
sb.WriteString("\n三、整体总结及可优化建议\n")
sb.WriteString(data.OverallSummary)
return sb.String()
}