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 := time.Parse("2006-01-02", data.BundleStartDate) if err != nil { return fmt.Errorf("BundleStartDate格式错误: %w", err) } endTime, err := time.Parse("2006-01-02", data.BundleEndDate) if err != nil { return fmt.Errorf("BundleEndDate格式错误: %w", err) } submissionDate, err := time.Parse("2006-01-02", 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 "★★★★★" } }