894 lines
24 KiB
Go
894 lines
24 KiB
Go
package utils
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"fonchain-fiee/pkg/service/bundle/model"
|
||
"image"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"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页面宽度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 {
|
||
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
|
||
}
|
||
|
||
// TruncateTextByRune 按字符权重截断文本
|
||
// 中文字符算1.0,英文字母/数字/英文符号算0.5
|
||
// 返回截断后的文本,确保总权重不超过maxWidth
|
||
func TruncateTextByRune(text string, maxWidth float64) string {
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
|
||
runes := []rune(text)
|
||
var result []rune
|
||
currentWidth := 0.0
|
||
|
||
for _, r := range runes {
|
||
charWidth := getCharWidth(r)
|
||
if currentWidth+charWidth > maxWidth {
|
||
break
|
||
}
|
||
result = append(result, r)
|
||
currentWidth += charWidth
|
||
}
|
||
|
||
return string(result)
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
//生成问卷调查pdf
|
||
|
||
func QuestionnaireSurveyPDF(templatePath, outputPath string, data *model.QuestionnairePDFData) error {
|
||
pdf := gopdf.GoPdf{}
|
||
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
|
||
|
||
if err := pdf.ImportPagesFromSource(templatePath, "/MediaBox"); err != nil {
|
||
return fmt.Errorf("导入模板失败: %w", err)
|
||
}
|
||
|
||
if err := pdf.AddTTFFont("simfang", "./data/simfang.ttf"); err != nil {
|
||
return fmt.Errorf("加载字体失败: %w", err)
|
||
}
|
||
|
||
if err := pdf.SetFont("simfang", "", 12); err != nil {
|
||
return fmt.Errorf("设置字体失败: %w", err)
|
||
}
|
||
|
||
startTime, err := parseFlexibleDate(data.BundleStartDate)
|
||
if err != nil {
|
||
return fmt.Errorf("BundleStartDate格式错误: %w", err)
|
||
}
|
||
endTime, err := parseFlexibleDate(data.BundleEndDate)
|
||
if err != nil {
|
||
return fmt.Errorf("BundleEndDate格式错误: %w", err)
|
||
}
|
||
submissionDate, err := parseFlexibleDate(data.SubmissionDate)
|
||
if err != nil {
|
||
return fmt.Errorf("SubmissionDate格式错误: %w", err)
|
||
}
|
||
nowTime := time.Now().Format(time.DateTime)
|
||
onePage := 1
|
||
twoPage := 2
|
||
threePage := 3
|
||
fourPage := 4
|
||
|
||
// 第1页:客户基本信息
|
||
pdf.SetPage(onePage)
|
||
//姓名
|
||
pdf.SetX(165)
|
||
pdf.SetY(420)
|
||
pdf.Cell(nil, data.CustomerName)
|
||
//套餐名称
|
||
pdf.SetX(205)
|
||
pdf.SetY(443)
|
||
pdf.Cell(nil, data.BundleName)
|
||
//开始日期
|
||
pdf.SetX(205)
|
||
pdf.SetY(467)
|
||
pdf.Cell(nil, startTime.Format("2006"))
|
||
pdf.SetX(260)
|
||
pdf.SetY(467)
|
||
pdf.Cell(nil, startTime.Format("01"))
|
||
pdf.SetX(300)
|
||
pdf.SetY(467)
|
||
pdf.Cell(nil, startTime.Format("02"))
|
||
|
||
//结束日期
|
||
pdf.SetX(350)
|
||
pdf.SetY(467)
|
||
pdf.Cell(nil, endTime.Format("2006"))
|
||
pdf.SetX(398)
|
||
pdf.SetY(467)
|
||
pdf.Cell(nil, endTime.Format("01"))
|
||
pdf.SetX(437)
|
||
pdf.SetY(467)
|
||
pdf.Cell(nil, endTime.Format("02"))
|
||
//视频数
|
||
pdf.SetX(220)
|
||
pdf.SetY(583)
|
||
pdf.Cell(nil, data.VideoNum)
|
||
//"账号数: "+
|
||
pdf.SetX(230)
|
||
pdf.SetY(625)
|
||
pdf.Cell(nil, data.AccountNum)
|
||
// "图文数: "+
|
||
pdf.SetX(253)
|
||
pdf.SetY(667)
|
||
pdf.Cell(nil, data.ImagesNum)
|
||
//"数据分析数: "+
|
||
pdf.SetX(280)
|
||
pdf.SetY(727)
|
||
pdf.Cell(nil, data.DataAnalysisNum)
|
||
// 第1页内容写完后
|
||
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间:"+nowTime); err != nil {
|
||
return err
|
||
}
|
||
// 第2页:服务数量
|
||
pdf.SetPage(twoPage)
|
||
|
||
//"竞品分析数: "+
|
||
pdf.SetX(205)
|
||
pdf.SetY(72)
|
||
pdf.Cell(nil, data.CompetitiveNum)
|
||
//"增值视频数: "+
|
||
pdf.SetX(270)
|
||
pdf.SetY(156)
|
||
pdf.Cell(nil, data.ValueAddVideoNum)
|
||
//"评分1: "+
|
||
pdf.SetX(123)
|
||
pdf.SetY(485)
|
||
pdf.Cell(nil, scoreStars(data.Score1))
|
||
//"评分2: "+
|
||
pdf.SetX(343)
|
||
pdf.SetY(526)
|
||
pdf.Cell(nil, scoreStars(data.Score2))
|
||
//"评分3: "+
|
||
pdf.SetX(230)
|
||
pdf.SetY(568)
|
||
pdf.Cell(nil, scoreStars(data.Score3))
|
||
//"评分4: "+
|
||
pdf.SetX(362)
|
||
pdf.SetY(610)
|
||
pdf.Cell(nil, scoreStars(data.Score4))
|
||
//"评分5: "+
|
||
pdf.SetX(220)
|
||
pdf.SetY(652)
|
||
pdf.Cell(nil, scoreStars(data.Score5))
|
||
//"评分6: "+
|
||
pdf.SetX(164)
|
||
pdf.SetY(694)
|
||
pdf.Cell(nil, scoreStars(data.Score6))
|
||
//"评分7: "+
|
||
pdf.SetX(197)
|
||
pdf.SetY(735)
|
||
pdf.Cell(nil, scoreStars(data.Score7))
|
||
//水印
|
||
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间:"+nowTime); err != nil {
|
||
return err
|
||
}
|
||
// 第3页:评分与意见
|
||
pdf.SetPage(threePage)
|
||
// Opinion 超过100字符时自动换行,每行约20个中文字符,行高18pt
|
||
drawWrappedText(&pdf, data.Opinion1, 90, 145, 24, 34)
|
||
drawWrappedText(&pdf, data.Opinion2, 90, 377, 24, 34)
|
||
drawWrappedText(&pdf, data.Opinion3, 90, 574, 24, 34)
|
||
//水印
|
||
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间:"+nowTime); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 第4页:提交信息
|
||
pdf.SetPage(fourPage)
|
||
//"提交人: "+
|
||
pdf.SetX(135)
|
||
pdf.SetY(103)
|
||
pdf.Cell(nil, data.Submitter)
|
||
//提交时间: "
|
||
pdf.SetX(148)
|
||
pdf.SetY(128)
|
||
pdf.Cell(nil, submissionDate.Format("2006"))
|
||
pdf.SetX(207)
|
||
pdf.SetY(128)
|
||
pdf.Cell(nil, submissionDate.Format("01"))
|
||
pdf.SetX(260)
|
||
pdf.SetY(128)
|
||
pdf.Cell(nil, submissionDate.Format("02"))
|
||
//水印
|
||
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间:"+nowTime); err != nil {
|
||
return err
|
||
}
|
||
if err := pdf.WritePdf(outputPath); err != nil {
|
||
return fmt.Errorf("写入PDF失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// addWatermark 在当前页叠加浅色斜向双行水印,并在结束后恢复样式
|
||
func addWatermark(pdf *gopdf.GoPdf, text string) error {
|
||
const normalFontSize = 12
|
||
const watermarkFontSize = 22
|
||
const lineHeight = 32.0 // 水印行间距
|
||
|
||
// 设置水印样式
|
||
pdf.SetGrayFill(0.85)
|
||
if err := pdf.SetFont("simfang", "", watermarkFontSize); err != nil {
|
||
return fmt.Errorf("设置水印字体失败: %w", err)
|
||
}
|
||
|
||
// 按换行拆分,逐行绘制(Cell 不负责自动换行)
|
||
lines := strings.Split(text, "\n")
|
||
if len(lines) == 0 {
|
||
lines = []string{text}
|
||
}
|
||
|
||
drawBlock := func(x, y, cx, cy float64) {
|
||
pdf.Rotate(35, cx, cy)
|
||
for i, line := range lines {
|
||
pdf.SetX(x)
|
||
pdf.SetY(y + float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
pdf.RotateReset()
|
||
}
|
||
|
||
// 两处重复水印
|
||
drawBlock(90, 420, 300, 420)
|
||
drawBlock(130, 620, 300, 420)
|
||
|
||
// 恢复样式,避免影响后续正文
|
||
pdf.SetGrayFill(0)
|
||
if err := pdf.SetFont("simfang", "", normalFontSize); err != nil {
|
||
return fmt.Errorf("恢复字体失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// drawWrappedText 在 PDF 上绘制自动换行的文字
|
||
// pdf: GoPdf 实例, text: 文字内容, startX/startY: 起始坐标
|
||
// maxWidth: 最大宽度(pt), lineHeight: 行高(pt), charsPerLine: 每行最多字符数(按中文字符计)
|
||
func drawWrappedText(pdf *gopdf.GoPdf, text string, startX, startY, lineHeight float64, charsPerLine int) {
|
||
runes := []rune(text)
|
||
total := len(runes)
|
||
if total == 0 {
|
||
return
|
||
}
|
||
|
||
lineStart := 0
|
||
currentLine := 0
|
||
|
||
for lineStart < total {
|
||
end := lineStart + charsPerLine
|
||
if end > total {
|
||
end = total
|
||
}
|
||
// 计算实际宽度以决定换行点(按字节估算:ASCII=0.5中文字符宽)
|
||
charCount := 0
|
||
splitAt := lineStart
|
||
for i := lineStart; i < total; i++ {
|
||
r := runes[i]
|
||
runeBytes := utf8.RuneLen(r)
|
||
if runeBytes > 1 {
|
||
charCount += 2 // 中文等宽字符算2个单位
|
||
} else {
|
||
charCount += 1 // ASCII 算1个单位
|
||
}
|
||
if charCount > charsPerLine*2 {
|
||
break
|
||
}
|
||
splitAt = i + 1
|
||
}
|
||
if splitAt == lineStart {
|
||
splitAt = lineStart + 1
|
||
}
|
||
|
||
line := string(runes[lineStart:splitAt])
|
||
pdf.SetX(startX)
|
||
pdf.SetY(startY + float64(currentLine)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
|
||
lineStart = splitAt
|
||
currentLine++
|
||
}
|
||
}
|
||
|
||
func scoreStars(score int) string {
|
||
switch {
|
||
case score <= 1:
|
||
return "★☆☆☆☆"
|
||
case score == 2:
|
||
return "★★☆☆☆"
|
||
case score == 3:
|
||
return "★★★☆☆"
|
||
case score == 4:
|
||
return "★★★★☆"
|
||
default:
|
||
return "★★★★★"
|
||
}
|
||
}
|
||
func parseFlexibleDate(value string) (time.Time, error) {
|
||
v := strings.TrimSpace(value)
|
||
if v == "" {
|
||
return time.Time{}, fmt.Errorf("日期为空")
|
||
}
|
||
|
||
layouts := []string{
|
||
"2006-01-02",
|
||
time.RFC3339,
|
||
"2006-01-02 15:04:05",
|
||
"2006-01-02 15:04",
|
||
}
|
||
|
||
var lastErr error
|
||
for _, layout := range layouts {
|
||
t, err := time.Parse(layout, v)
|
||
if err == nil {
|
||
return t, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
return time.Time{}, fmt.Errorf("不支持的日期格式: %s, %w", v, lastErr)
|
||
}
|