Files
tyapi-server/internal/shared/pdf/page_builder.go

1668 lines
56 KiB
Go
Raw Normal View History

2025-12-03 12:03:42 +08:00
package pdf
import (
"context"
"fmt"
"math"
"os"
2025-12-05 14:59:23 +08:00
"path/filepath"
2025-12-03 12:03:42 +08:00
"strings"
"tyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
2025-12-05 14:59:23 +08:00
qrcode "github.com/skip2/go-qrcode"
2025-12-03 12:03:42 +08:00
"go.uber.org/zap"
)
// PageBuilder 页面构建器
type PageBuilder struct {
logger *zap.Logger
fontManager *FontManager
textProcessor *TextProcessor
markdownProc *MarkdownProcessor
markdownConverter *MarkdownConverter
tableParser *TableParser
tableRenderer *TableRenderer
jsonProcessor *JSONProcessor
logoPath string
watermarkText string
}
// NewPageBuilder 创建页面构建器
func NewPageBuilder(
logger *zap.Logger,
fontManager *FontManager,
textProcessor *TextProcessor,
markdownProc *MarkdownProcessor,
tableParser *TableParser,
tableRenderer *TableRenderer,
jsonProcessor *JSONProcessor,
logoPath string,
watermarkText string,
) *PageBuilder {
markdownConverter := NewMarkdownConverter(textProcessor)
return &PageBuilder{
logger: logger,
fontManager: fontManager,
textProcessor: textProcessor,
markdownProc: markdownProc,
markdownConverter: markdownConverter,
tableParser: tableParser,
tableRenderer: tableRenderer,
jsonProcessor: jsonProcessor,
logoPath: logoPath,
watermarkText: watermarkText,
}
}
2026-03-13 18:07:24 +08:00
// 封面页底部为价格预留的高度mm避免价格被挤到单独一页
const firstPagePriceReservedHeight = 18.0
// ContentStartYBelowHeader 页眉logo+横线)下方的正文起始 Ymm表格等 AddPage 后须设为此值,避免与 logo 重叠(留足顶间距)
const ContentStartYBelowHeader = 50.0
2025-12-03 12:03:42 +08:00
// AddFirstPage 添加第一页(封面页 - 产品功能简述)
2026-03-13 18:07:24 +08:00
// 页眉与水印由 SetHeaderFunc 在每页 AddPage 时自动绘制,此处不再重复调用
// 自动限制描述/详情高度,保证价格与封面同页,不单独成页
2025-12-03 12:03:42 +08:00
func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
2026-03-13 18:07:24 +08:00
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
// 内容区最大 Y超出则不再绘制留出底部给价格避免价格单独一页
maxContentY := pageHeight - bottomMargin - firstPagePriceReservedHeight
2025-12-03 12:03:42 +08:00
2026-03-13 18:07:24 +08:00
// 标题区域(在页眉下方留足间距,避免与 logo 重叠)
pdf.SetY(ContentStartYBelowHeader + 6)
2025-12-03 12:03:42 +08:00
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 32)
_, lineHt := pdf.GetFontSize()
cleanName := pb.textProcessor.CleanText(product.Name)
pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "")
2026-03-13 18:07:24 +08:00
pdf.Ln(6)
2025-12-03 12:03:42 +08:00
pb.fontManager.SetFont(pdf, "", 18)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "")
2026-03-13 18:07:24 +08:00
// 产品编码
pdf.Ln(16)
2025-12-03 12:03:42 +08:00
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "")
2026-03-13 18:07:24 +08:00
pdf.Ln(12)
pdf.SetLineWidth(0.5)
pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
2026-03-16 12:32:41 +08:00
// 产品描述(居中,宋体小四)
2025-12-03 12:03:42 +08:00
if product.Description != "" {
2026-03-13 18:07:24 +08:00
pdf.Ln(10)
desc := pb.textProcessor.HTMLToPlainWithBreaks(product.Description)
2025-12-03 12:03:42 +08:00
desc = pb.textProcessor.CleanText(desc)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
_, lineHt = pdf.GetFontSize()
2026-03-13 18:07:24 +08:00
pb.drawRichTextBlock(pdf, desc, pageWidth*0.7, lineHt*1.5, maxContentY, "C", true, chineseFontAvailable)
2025-12-03 12:03:42 +08:00
}
2026-03-13 18:07:24 +08:00
// 产品详情已移至单独一页,见 AddProductContentPage
2025-12-03 12:03:42 +08:00
if !product.Price.IsZero() {
2025-12-04 12:30:33 +08:00
pb.fontManager.SetFont(pdf, "", 14)
_, priceLineHt := pdf.GetFontSize()
2026-03-13 18:07:24 +08:00
reservedZoneY := pageHeight - bottomMargin - firstPagePriceReservedHeight + 6
priceY := reservedZoneY
if pdf.GetY()+5 > reservedZoneY {
priceY = pdf.GetY() + 5
}
pdf.SetY(priceY)
pdf.SetTextColor(0, 0, 0)
2025-12-04 12:30:33 +08:00
priceText := fmt.Sprintf("价格:%s 元", product.Price.String())
textWidth := pdf.GetStringWidth(priceText)
pdf.SetX(pageWidth - textWidth - 15)
pdf.CellFormat(textWidth, priceLineHt, priceText, "", 0, "R", false, 0, "")
2025-12-03 12:03:42 +08:00
}
2026-03-13 18:07:24 +08:00
}
2026-03-16 12:32:41 +08:00
// AddProductContentPage 添加产品详情页(另起一页,左对齐,符合 HTML 富文本:段落、加粗、标题)
2026-03-13 18:07:24 +08:00
func (pb *PageBuilder) AddProductContentPage(pdf *gofpdf.Fpdf, product *entities.Product, chineseFontAvailable bool) {
if product.Content == "" {
return
}
pdf.AddPage()
pageWidth, _ := pdf.GetPageSize()
2025-12-04 12:30:33 +08:00
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, titleHt := pdf.GetFontSize()
pdf.CellFormat(0, titleHt, "产品详情", "", 1, "L", false, 0, "")
pdf.Ln(6)
2026-03-16 12:32:41 +08:00
// 按 HTML 富文本解析并绘制(宋体小四):段落、换行、加粗、标题,自动分页且不遮挡 logo
pb.drawHTMLContent(pdf, product.Content, pageWidth*0.9, chineseFontAvailable)
}
// drawHTMLContent 按 HTML 富文本绘制产品详情:段落、换行、加粗、标题;每行前确保在页眉下,避免分页后遮挡 logo
func (pb *PageBuilder) drawHTMLContent(pdf *gofpdf.Fpdf, htmlContent string, contentWidth float64, chineseFontAvailable bool) {
segments := pb.textProcessor.ParseHTMLToSegments(htmlContent)
cleanSegments := make([]HTMLSegment, 0, len(segments))
for _, s := range segments {
t := pb.textProcessor.CleanText(s.Text)
if s.Text != "" {
cleanSegments = append(cleanSegments, HTMLSegment{Text: t, Bold: s.Bold, NewLine: s.NewLine, NewParagraph: s.NewParagraph, HeadingLevel: s.HeadingLevel})
} else {
cleanSegments = append(cleanSegments, s)
}
}
segments = cleanSegments
leftMargin, _, _, _ := pdf.GetMargins()
currentX := leftMargin
firstLineOfBlock := true
for _, seg := range segments {
if seg.NewParagraph {
pdf.Ln(4)
firstLineOfBlock = true
continue
}
if seg.NewLine {
pdf.Ln(1)
continue
}
if seg.Text == "" {
continue
}
// 字体与行高
fontSize := 12.0
style := ""
if seg.Bold {
style = "B"
}
if seg.HeadingLevel == 1 {
fontSize = 18
style = "B"
} else if seg.HeadingLevel == 2 {
fontSize = 16
style = "B"
} else if seg.HeadingLevel == 3 {
fontSize = 14
style = "B"
}
pb.fontManager.SetBodyFont(pdf, style, fontSize)
_, lineHt := pdf.GetFontSize()
lineHeight := lineHt * 1.4
wrapped := pb.safeSplitText(pdf, seg.Text, contentWidth, chineseFontAvailable)
for _, w := range wrapped {
pb.ensureContentBelowHeader(pdf)
x := currentX
if firstLineOfBlock {
x = leftMargin + paragraphIndentMM
}
pdf.SetX(x)
pdf.SetTextColor(0, 0, 0)
pdf.CellFormat(contentWidth, lineHeight, w, "", 1, "L", false, 0, "")
firstLineOfBlock = false
}
}
2025-12-03 12:03:42 +08:00
}
2026-03-13 18:07:24 +08:00
// drawRichTextBlockNoLimit 渲染富文本块,不根据 maxContentY 截断,允许自动分页,适合“产品详情”等必须全部展示的内容
func (pb *PageBuilder) drawRichTextBlockNoLimit(pdf *gofpdf.Fpdf, text string, contentWidth, lineHeight float64, align string, firstLineIndent bool, chineseFontAvailable bool) {
pageWidth, _ := pdf.GetPageSize()
leftMargin, _, _, _ := pdf.GetMargins()
currentX := (pageWidth - contentWidth) / 2
if align == "L" {
currentX = leftMargin
}
paragraphs := strings.Split(text, "\n\n")
for pIdx, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
if pIdx > 0 {
pdf.Ln(4)
}
firstLineOfPara := true
lines := strings.Split(para, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable)
for _, w := range wrapped {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2026-03-13 18:07:24 +08:00
x := currentX
if align == "L" && firstLineIndent && firstLineOfPara {
x = leftMargin + paragraphIndentMM
}
pdf.SetX(x)
pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "")
firstLineOfPara = false
}
}
2025-12-03 12:03:42 +08:00
}
2026-03-13 18:07:24 +08:00
}
2025-12-03 12:03:42 +08:00
2026-03-13 18:07:24 +08:00
// AddDocumentationPages 添加接口文档页面
2026-03-16 12:32:41 +08:00
// 每页的页眉与水印由 SetHeaderFunc / SetFooterFunc 在 AddPage 时自动绘制
2026-03-13 18:07:24 +08:00
func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
2025-12-03 12:03:42 +08:00
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
2025-12-03 12:03:42 +08:00
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
// 请求URL
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, "请求URL", "", 1, "L", false, 0, "")
// URL使用黑体字体可能包含中文字符
// 先清理URL中的乱码
cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
// 请求方法
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
// 基本信息
if doc.BasicInfo != "" {
pdf.Ln(8)
pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
}
// 请求参数
if doc.RequestParams != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理请求参数
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.RequestParams)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
// 生成JSON示例
if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
pdf.Ln(5)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
2026-03-13 18:07:24 +08:00
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
2025-12-03 12:03:42 +08:00
}
}
// 响应示例
if doc.ResponseExample != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
2026-03-13 18:07:24 +08:00
// 优先尝试提取和格式化JSON表格包裹居中内容左对齐
2025-12-03 12:03:42 +08:00
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
2026-03-13 18:07:24 +08:00
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
2025-12-03 12:03:42 +08:00
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseExample)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
2026-03-13 18:07:24 +08:00
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
2025-12-03 12:03:42 +08:00
if doc.ResponseFields != "" {
2026-03-13 18:07:24 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
zap.Error(err),
zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseFields)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理错误代码
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ErrorCodes)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
2025-12-05 14:59:23 +08:00
// 添加说明文字和二维码
pb.addAdditionalInfo(pdf, doc, chineseFontAvailable)
2025-12-03 12:03:42 +08:00
}
2025-12-11 11:14:31 +08:00
// AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明)
2026-03-16 12:32:41 +08:00
// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc/SetFooterFunc 自动绘制。
2025-12-11 11:14:31 +08:00
func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
2026-03-13 18:07:24 +08:00
pdf.AddPage()
2025-12-11 11:14:31 +08:00
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
2025-12-11 11:14:31 +08:00
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
// 请求URL
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, "请求URL", "", 1, "L", false, 0, "")
// URL使用黑体字体可能包含中文字符
// 先清理URL中的乱码
cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
// 请求方法
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
// 基本信息
if doc.BasicInfo != "" {
pdf.Ln(8)
pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
}
// 请求参数
if doc.RequestParams != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理请求参数
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.RequestParams)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
// 生成JSON示例
if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
pdf.Ln(5)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
2026-03-13 18:07:24 +08:00
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
2025-12-11 11:14:31 +08:00
}
}
// 响应示例
if doc.ResponseExample != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
2026-03-13 18:07:24 +08:00
// 优先尝试提取和格式化JSON表格包裹居中内容左对齐
2025-12-11 11:14:31 +08:00
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
2026-03-13 18:07:24 +08:00
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
2025-12-11 11:14:31 +08:00
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseExample)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
2026-03-13 18:07:24 +08:00
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
2025-12-11 11:14:31 +08:00
if doc.ResponseFields != "" {
2026-03-13 18:07:24 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
zap.Error(err),
zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseFields)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理错误代码
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ErrorCodes)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 注意:这里不添加二维码和说明,由调用方统一添加
}
// AddSubProductDocumentationPages 添加子产品的接口文档页面(用于组合包)
2026-03-13 18:07:24 +08:00
// 每页页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制
2025-12-11 11:14:31 +08:00
func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProduct *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool, isLastSubProduct bool) {
2026-03-13 18:07:24 +08:00
pdf.AddPage()
2025-12-11 11:14:31 +08:00
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
2025-12-11 11:14:31 +08:00
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
// 显示子产品标题
subProductTitle := fmt.Sprintf("子产品接口文档:%s", subProduct.Name)
if subProduct.Code != "" {
subProductTitle = fmt.Sprintf("子产品接口文档:%s (%s)", subProduct.Name, subProduct.Code)
}
pdf.CellFormat(0, lineHt, subProductTitle, "", 1, "L", false, 0, "")
// 请求URL
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, "请求URL", "", 1, "L", false, 0, "")
// URL使用黑体字体可能包含中文字符
// 先清理URL中的乱码
cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
// 请求方法
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
// 基本信息
if doc.BasicInfo != "" {
pdf.Ln(8)
pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
}
// 请求参数
if doc.RequestParams != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理请求参数
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.RequestParams)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
// 生成JSON示例
if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
pdf.Ln(5)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
2026-03-13 18:07:24 +08:00
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
2025-12-11 11:14:31 +08:00
}
}
// 响应示例
if doc.ResponseExample != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
2026-03-13 18:07:24 +08:00
// 优先尝试提取和格式化JSON表格包裹居中内容左对齐
2025-12-11 11:14:31 +08:00
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
2026-03-13 18:07:24 +08:00
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
2025-12-11 11:14:31 +08:00
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseExample)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
2026-03-13 18:07:24 +08:00
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
2025-12-11 11:14:31 +08:00
if doc.ResponseFields != "" {
2026-03-13 18:07:24 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
zap.Error(err),
zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseFields)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-11 11:14:31 +08:00
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理错误代码
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ErrorCodes)
if strings.TrimSpace(text) != "" {
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-11 11:14:31 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 注意:这里不添加二维码和说明,由调用方统一添加
}
2025-12-03 12:03:42 +08:00
// addSection 添加章节
func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
_, lineHt := pdf.GetFontSize()
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, title+"", "", 1, "L", false, 0, "")
// 第一步预处理和转换标准化markdown格式
content = pb.markdownConverter.PreprocessContent(content)
// 第二步将内容格式化为标准的markdown表格格式如果还不是
content = pb.markdownProc.FormatContentAsMarkdownTable(content)
// 先尝试提取JSON如果是代码块格式
if jsonContent := pb.jsonProcessor.ExtractJSON(content); jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false)
} else {
// 按#号标题分割内容,每个标题下的内容单独处理
sections := pb.markdownProc.SplitByMarkdownHeaders(content)
if len(sections) > 0 {
// 如果有多个章节,逐个处理
for i, section := range sections {
if i > 0 {
pdf.Ln(5) // 章节之间的间距
}
// 如果有标题,先显示标题
if section.Title != "" {
titleLevel := section.Level
fontSize := 14.0 - float64(titleLevel-2)*2 // ## 是14, ### 是12, #### 是10
if fontSize < 10 {
fontSize = 10
}
pb.fontManager.SetFont(pdf, "B", fontSize)
pdf.SetTextColor(0, 0, 0)
// 清理标题中的#号
cleanTitle := strings.TrimSpace(strings.TrimLeft(section.Title, "#"))
pdf.CellFormat(0, lineHt*1.2, cleanTitle, "", 1, "L", false, 0, "")
pdf.Ln(3)
}
// 处理该章节的内容(可能是表格或文本)
pb.processSectionContent(pdf, section.Content, chineseFontAvailable, lineHt)
}
} else {
// 如果没有标题分割,直接处理整个内容
pb.processSectionContent(pdf, content, chineseFontAvailable, lineHt)
}
}
}
// processRequestParams 处理请求参数:直接解析所有表格,确保表格能够正确渲染
func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
// 第一步预处理和转换标准化markdown格式
content = pb.markdownConverter.PreprocessContent(content)
// 第二步将数据格式化为标准的markdown表格格式
processedContent := pb.markdownProc.FormatContentAsMarkdownTable(content)
// 解析并显示所有表格(不按标题分组)
// 将内容按表格分割,找到所有表格块
allTables := pb.tableParser.ExtractAllTables(processedContent)
if len(allTables) > 0 {
// 有表格,逐个渲染
for i, tableBlock := range allTables {
if i > 0 {
pdf.Ln(5) // 表格之间的间距
}
// 渲染表格前的说明文字(包括标题)
if tableBlock.BeforeText != "" {
beforeText := tableBlock.BeforeText
// 处理标题和文本
pb.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
pdf.Ln(3)
}
// 渲染表格
if len(tableBlock.TableData) > 0 && pb.tableParser.IsValidTable(tableBlock.TableData) {
pb.tableRenderer.RenderTable(pdf, tableBlock.TableData)
}
// 渲染表格后的说明文字
if tableBlock.AfterText != "" {
afterText := pb.textProcessor.StripHTML(tableBlock.AfterText)
afterText = pb.textProcessor.CleanText(afterText)
if strings.TrimSpace(afterText) != "" {
pdf.Ln(3)
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 没有表格,显示为文本
text := pb.textProcessor.StripHTML(processedContent)
text = pb.textProcessor.CleanText(text)
if strings.TrimSpace(text) != "" {
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// processResponseExample 处理响应示例不按markdown标题分级直接解析所有表格但保留标题显示
func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
// 第一步预处理和转换标准化markdown格式
content = pb.markdownConverter.PreprocessContent(content)
// 第二步将数据格式化为标准的markdown表格格式
processedContent := pb.markdownProc.FormatContentAsMarkdownTable(content)
// 尝试提取JSON内容如果存在代码块
jsonContent := pb.jsonProcessor.ExtractJSON(processedContent)
if jsonContent != "" {
pdf.SetTextColor(0, 0, 0)
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
pdf.Ln(5)
}
// 解析并显示所有表格(不按标题分组)
// 将内容按表格分割,找到所有表格块
allTables := pb.tableParser.ExtractAllTables(processedContent)
if len(allTables) > 0 {
// 有表格,逐个渲染
for i, tableBlock := range allTables {
if i > 0 {
pdf.Ln(5) // 表格之间的间距
}
// 渲染表格前的说明文字(包括标题)
if tableBlock.BeforeText != "" {
beforeText := tableBlock.BeforeText
// 处理标题和文本
pb.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
pdf.Ln(3)
}
// 渲染表格
if len(tableBlock.TableData) > 0 && pb.tableParser.IsValidTable(tableBlock.TableData) {
pb.tableRenderer.RenderTable(pdf, tableBlock.TableData)
}
// 渲染表格后的说明文字
if tableBlock.AfterText != "" {
afterText := pb.textProcessor.StripHTML(tableBlock.AfterText)
afterText = pb.textProcessor.CleanText(afterText)
if strings.TrimSpace(afterText) != "" {
pdf.Ln(3)
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 没有表格,显示为文本
text := pb.textProcessor.StripHTML(processedContent)
text = pb.textProcessor.CleanText(text)
if strings.TrimSpace(text) != "" {
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// processSectionContent 处理单个章节的内容(解析表格或显示文本)
func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
// 尝试解析markdown表格
tableData := pb.tableParser.ParseMarkdownTable(content)
// 检查内容是否包含表格标记(|符号)
hasTableMarkers := strings.Contains(content, "|")
// 如果解析出了表格数据(即使验证失败),或者内容包含表格标记,都尝试渲染表格
// 放宽条件:支持只有表头的表格(单行表格)
if len(tableData) >= 1 && hasTableMarkers {
// 如果表格有效,或者至少有表头,都尝试渲染
if pb.tableParser.IsValidTable(tableData) {
// 如果是有效的表格,先检查表格前后是否有说明文字
// 提取表格前后的文本(用于显示说明)
lines := strings.Split(content, "\n")
var beforeTable []string
var afterTable []string
inTable := false
tableStartLine := -1
tableEndLine := -1
// 找到表格的起始和结束行
usePipeDelimiter := false
for _, line := range lines {
if strings.Contains(strings.TrimSpace(line), "|") {
usePipeDelimiter = true
break
}
}
for i, line := range lines {
trimmedLine := strings.TrimSpace(line)
if usePipeDelimiter && strings.Contains(trimmedLine, "|") {
if !inTable {
tableStartLine = i
inTable = true
}
tableEndLine = i
} else if inTable && usePipeDelimiter && !strings.Contains(trimmedLine, "|") {
// 表格可能结束了
if strings.HasPrefix(trimmedLine, "```") {
tableEndLine = i - 1
break
}
}
}
// 提取表格前的文本
if tableStartLine > 0 {
beforeTable = lines[0:tableStartLine]
}
// 提取表格后的文本
if tableEndLine >= 0 && tableEndLine < len(lines)-1 {
afterTable = lines[tableEndLine+1:]
}
// 显示表格前的说明文字
if len(beforeTable) > 0 {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
beforeText := strings.Join(beforeTable, "\n")
beforeText = pb.textProcessor.StripHTML(beforeText)
beforeText = pb.textProcessor.CleanText(beforeText)
if strings.TrimSpace(beforeText) != "" {
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false)
pdf.Ln(3)
}
}
// 渲染表格
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
pb.tableRenderer.RenderTable(pdf, tableData)
// 显示表格后的说明文字
if len(afterTable) > 0 {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
afterText := strings.Join(afterTable, "\n")
afterText = pb.textProcessor.StripHTML(afterText)
afterText = pb.textProcessor.CleanText(afterText)
if strings.TrimSpace(afterText) != "" {
pdf.Ln(3)
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 如果不是有效表格显示为文本完整显示markdown内容
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
text := pb.textProcessor.StripHTML(content)
text = pb.textProcessor.CleanText(text) // 清理无效字符,保留中文
// 如果文本不为空,显示它
if strings.TrimSpace(text) != "" {
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// renderTextWithTitles 渲染包含markdown标题的文本
func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chineseFontAvailable bool, lineHt float64) {
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
lines := strings.Split(text, "\n")
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
// 检查是否是标题行
if strings.HasPrefix(trimmedLine, "#") {
// 计算标题级别
level := 0
for _, r := range trimmedLine {
if r == '#' {
level++
} else {
break
}
}
// 提取标题文本(移除#号)
titleText := strings.TrimSpace(trimmedLine[level:])
if titleText == "" {
continue
}
// 根据级别设置字体大小
fontSize := 14.0 - float64(level-2)*2
if fontSize < 10 {
fontSize = 10
}
if fontSize > 16 {
fontSize = 16
}
// 渲染标题
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", fontSize)
_, titleLineHt := pdf.GetFontSize()
pdf.CellFormat(0, titleLineHt*1.2, titleText, "", 1, "L", false, 0, "")
pdf.Ln(2)
} else if strings.TrimSpace(line) != "" {
// 普通文本行只去除HTML标签保留markdown格式
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2025-12-03 12:03:42 +08:00
cleanText := pb.textProcessor.StripHTML(line)
cleanText = pb.textProcessor.CleanTextPreservingMarkdown(cleanText)
if strings.TrimSpace(cleanText) != "" {
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-03 12:03:42 +08:00
pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false)
}
} else {
// 空行,添加间距
pdf.Ln(2)
}
}
}
// addHeader 添加页眉logo和文字
func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
pdf.SetY(5)
// 绘制logo如果存在
if pb.logoPath != "" {
if _, err := os.Stat(pb.logoPath); err == nil {
pdf.ImageOptions(pb.logoPath, 15, 5, 15, 15, false, gofpdf.ImageOptions{}, 0, "")
} else {
2025-12-04 10:35:11 +08:00
pb.logger.Warn("logo文件不存在", zap.String("path", pb.logoPath))
2025-12-03 12:03:42 +08:00
}
}
// 绘制"天远数据"文字(使用中文字体如果可用)
pdf.SetXY(33, 8)
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, 10, "天远数据", "", 0, "L", false, 0, "")
// 绘制下横线优化位置左边距是15mm
pdf.Line(15, 22, 75, 22)
2026-03-13 18:07:24 +08:00
// 所有自动分页后的正文统一从页眉下方固定位置开始,避免内容顶到 logo 或水印
pdf.SetY(ContentStartYBelowHeader)
}
// ensureContentBelowHeader 若当前 Y 在页眉区内则下移到正文区,避免与 logo 重叠
func (pb *PageBuilder) ensureContentBelowHeader(pdf *gofpdf.Fpdf) {
if pdf.GetY() < ContentStartYBelowHeader {
pdf.SetY(ContentStartYBelowHeader)
}
2025-12-03 12:03:42 +08:00
}
2026-03-13 18:07:24 +08:00
// addWatermark 添加水印:自左下角往右上角倾斜 45°单条水印居中于页面样式柔和
2025-12-03 12:03:42 +08:00
func (pb *PageBuilder) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
if !chineseFontAvailable {
return
}
pdf.TransformBegin()
defer pdf.TransformEnd()
2026-03-13 18:07:24 +08:00
pageWidth, pageHeight := pdf.GetPageSize()
2025-12-03 12:03:42 +08:00
leftMargin, topMargin, _, bottomMargin := pdf.GetMargins()
usableHeight := pageHeight - topMargin - bottomMargin
2026-03-13 18:07:24 +08:00
usableWidth := pageWidth - leftMargin*2
2025-12-03 12:03:42 +08:00
2026-03-13 18:07:24 +08:00
fontSize := 42.0
2025-12-03 12:03:42 +08:00
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
2026-03-13 18:07:24 +08:00
// 加深水印:更深的灰与更高不透明度,保证可见
pdf.SetTextColor(150, 150, 150)
pdf.SetAlpha(0.32, "Normal")
2025-12-03 12:03:42 +08:00
textWidth := pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
2026-03-13 18:07:24 +08:00
// 旋转后对角线长度,用于缩放与定位
2025-12-03 12:03:42 +08:00
rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize)
2026-03-13 18:07:24 +08:00
if rotatedDiagonal > usableHeight*0.75 {
fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal
2025-12-03 12:03:42 +08:00
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
textWidth = pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
2026-03-13 18:07:24 +08:00
rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize)
2025-12-03 12:03:42 +08:00
}
2026-03-13 18:07:24 +08:00
// 自左下角往右上角:起点在可用区域左下角,逆时针旋转 +45°
startX := leftMargin
startY := pageHeight - bottomMargin
// 沿 +45° 方向居中:对角线在可用区域内居中
diagW := rotatedDiagonal * math.Cos(45*math.Pi/180)
offsetX := (usableWidth - diagW) * 0.5
startX += offsetX
startY -= rotatedDiagonal * 0.5
pdf.TransformTranslate(startX, startY)
pdf.TransformRotate(45, 0, 0)
2025-12-03 12:03:42 +08:00
pdf.SetXY(0, 0)
pdf.CellFormat(textWidth, fontSize, pb.watermarkText, "", 0, "L", false, 0, "")
pdf.SetAlpha(1.0, "Normal")
2026-03-13 18:07:24 +08:00
pdf.SetTextColor(0, 0, 0)
}
// 段前缩进宽度约两字符mm
const paragraphIndentMM = 7.0
2026-03-17 17:18:54 +08:00
// drawRichTextBlock 按段落与换行绘制文本块(还原 HTML 换行)
2026-03-13 18:07:24 +08:00
// align: "C" 居中;"L" 左对齐。firstLineIndent 为 true 时每段首行缩进(段前两空格效果)。
func (pb *PageBuilder) drawRichTextBlock(pdf *gofpdf.Fpdf, text string, contentWidth, lineHeight float64, maxContentY float64, align string, firstLineIndent bool, chineseFontAvailable bool) {
pageWidth, _ := pdf.GetPageSize()
leftMargin, _, _, _ := pdf.GetMargins()
currentX := (pageWidth - contentWidth) / 2
if align == "L" {
currentX = leftMargin
}
paragraphs := strings.Split(text, "\n\n")
for pIdx, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
if pIdx > 0 && pdf.GetY()+lineHeight <= maxContentY {
pdf.Ln(4)
}
firstLineOfPara := true
lines := strings.Split(para, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable)
for _, w := range wrapped {
if pdf.GetY()+lineHeight > maxContentY {
pdf.SetX(currentX)
pdf.CellFormat(contentWidth, lineHeight, "…", "", 1, align, false, 0, "")
return
}
x := currentX
if align == "L" && firstLineIndent && firstLineOfPara {
x = leftMargin + paragraphIndentMM
}
pdf.SetX(x)
pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "")
firstLineOfPara = false
}
}
}
2025-12-03 12:03:42 +08:00
}
// getContentPreview 获取内容预览(用于日志记录)
func (pb *PageBuilder) getContentPreview(content string, maxLen int) string {
content = strings.TrimSpace(content)
2026-03-16 13:10:42 +08:00
if maxLen <= 0 || len(content) <= maxLen {
2025-12-03 12:03:42 +08:00
return content
}
2026-03-16 13:10:42 +08:00
n := maxLen
if n > len(content) {
n = len(content)
}
return content[:n] + "..."
2025-12-03 12:03:42 +08:00
}
2025-12-03 16:53:31 +08:00
2026-03-16 12:32:41 +08:00
// wrapJSONLinesToWidth 将 JSON 文本按宽度换行,返回用于绘制的行列表(兼容中文等)
func (pb *PageBuilder) wrapJSONLinesToWidth(pdf *gofpdf.Fpdf, jsonContent string, width float64) []string {
chineseFontAvailable := pb.fontManager != nil && pb.fontManager.IsChineseFontAvailable()
var out []string
for _, line := range strings.Split(jsonContent, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
out = append(out, "")
continue
}
wrapped := pb.safeSplitText(pdf, line, width, chineseFontAvailable)
out = append(out, wrapped...)
}
return out
}
// drawJSONInCenteredTable 在居中表格中绘制 JSON 文本(表格居中,内容左对齐);多页时每页独立边框完整包裹当页内容,且不遮挡 logo
2026-03-13 18:07:24 +08:00
func (pb *PageBuilder) drawJSONInCenteredTable(pdf *gofpdf.Fpdf, jsonContent string, lineHt float64) {
jsonContent = strings.TrimSpace(jsonContent)
if jsonContent == "" {
return
}
2026-03-16 12:32:41 +08:00
pb.ensureContentBelowHeader(pdf)
2026-03-13 18:07:24 +08:00
pageWidth, pageHeight := pdf.GetPageSize()
leftMargin, _, rightMargin, bottomMargin := pdf.GetMargins()
usableWidth := pageWidth - leftMargin - rightMargin
2026-03-16 12:32:41 +08:00
tableWidth := usableWidth * 0.92
2026-03-13 18:07:24 +08:00
startX := (pageWidth - tableWidth) / 2
padding := 4.0
innerWidth := tableWidth - 2*padding
lineHeight := lineHt * 1.3
pdf.SetTextColor(0, 0, 0)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
// 使用 safeSplitText 兼容中文等字符,避免 SplitText panic按行先拆再对每行按宽度换行
allLines := pb.wrapJSONLinesToWidth(pdf, jsonContent, innerWidth)
// 每页可用高度(从当前 Y 到页底),用于分块
maxH := pageHeight - bottomMargin - pdf.GetY()
linesPerPage := int((maxH - 2*padding) / lineHeight)
if linesPerPage < 1 {
linesPerPage = 1
2026-03-13 18:07:24 +08:00
}
2026-03-16 12:32:41 +08:00
chunkStart := 0
for chunkStart < len(allLines) {
pb.ensureContentBelowHeader(pdf)
currentY := pdf.GetY()
// 本页剩余高度不足则换页再从页眉下开始
if currentY < ContentStartYBelowHeader {
currentY = ContentStartYBelowHeader
pdf.SetY(currentY)
}
maxH = pageHeight - bottomMargin - currentY
linesPerPage = int((maxH - 2*padding) / lineHeight)
if linesPerPage < 1 {
pdf.AddPage()
currentY = ContentStartYBelowHeader
pdf.SetY(currentY)
linesPerPage = int((pageHeight - bottomMargin - currentY - 2*padding) / lineHeight)
if linesPerPage < 1 {
linesPerPage = 1
}
}
chunkEnd := chunkStart + linesPerPage
if chunkEnd > len(allLines) {
chunkEnd = len(allLines)
}
chunk := allLines[chunkStart:chunkEnd]
chunkStart = chunkEnd
chunkHeight := float64(len(chunk))*lineHeight + 2*padding
// 若本页放不下整块,先换页
if currentY+chunkHeight > pageHeight-bottomMargin {
pdf.AddPage()
currentY = ContentStartYBelowHeader
pdf.SetY(currentY)
}
startY := currentY
pdf.SetDrawColor(180, 180, 180)
pdf.Rect(startX, startY, tableWidth, chunkHeight, "D")
pdf.SetDrawColor(0, 0, 0)
pdf.SetY(startY + padding)
for _, line := range chunk {
pdf.SetX(startX + padding)
pdf.CellFormat(innerWidth, lineHeight, line, "", 1, "L", false, 0, "")
}
pdf.SetY(startY + chunkHeight)
2026-03-13 18:07:24 +08:00
}
}
2025-12-03 16:53:31 +08:00
// safeSplitText 安全地分割文本避免在没有中文字体时调用SplitText导致panic
func (pb *PageBuilder) safeSplitText(pdf *gofpdf.Fpdf, text string, width float64, chineseFontAvailable bool) []string {
// 检查文本是否包含中文字符
hasChinese := false
for _, r := range text {
if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
(r >= 0x3400 && r <= 0x4DBF) || // 扩展A
(r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
(r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
(r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
hasChinese = true
break
}
}
// 如果文本包含中文但中文字体不可用,直接使用手动分割方法
// 如果中文字体可用且文本不包含中文可以尝试使用SplitText
// 即使中文字体可用,如果文本包含中文,也要小心处理(字体可能未正确加载)
if chineseFontAvailable && !hasChinese {
// 对于纯英文/ASCII文本可以安全使用SplitText
var lines []string
func() {
defer func() {
if r := recover(); r != nil {
pb.logger.Warn("SplitText发生panic使用备用方法", zap.Any("error", r))
}
}()
lines = pdf.SplitText(text, width)
}()
if len(lines) > 0 {
return lines
}
}
// 使用手动分割方法适用于中文文本或SplitText失败的情况
// 估算字符宽度中文字符约6mm英文字符约3mm基于14号字体
fontSize, _ := pdf.GetFontSize()
chineseCharWidth := fontSize * 0.43 // 中文字符宽度mm
englishCharWidth := fontSize * 0.22 // 英文字符宽度mm
var lines []string
var currentLine strings.Builder
currentWidth := 0.0
runes := []rune(text)
for _, r := range runes {
// 计算字符宽度
var charWidth float64
if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
(r >= 0x3400 && r <= 0x4DBF) || // 扩展A
(r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
(r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
(r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
charWidth = chineseCharWidth
} else {
charWidth = englishCharWidth
}
// 检查是否需要换行
if currentWidth+charWidth > width && currentLine.Len() > 0 {
// 保存当前行
lines = append(lines, currentLine.String())
currentLine.Reset()
currentWidth = 0.0
}
// 处理换行符
if r == '\n' {
if currentLine.Len() > 0 {
lines = append(lines, currentLine.String())
currentLine.Reset()
currentWidth = 0.0
} else {
// 空行
lines = append(lines, "")
}
continue
}
// 添加字符到当前行
currentLine.WriteRune(r)
currentWidth += charWidth
}
// 添加最后一行
if currentLine.Len() > 0 {
lines = append(lines, currentLine.String())
}
// 如果没有分割出任何行,至少返回一行
if len(lines) == 0 {
lines = []string{text}
}
return lines
}
2025-12-05 14:59:23 +08:00
2025-12-11 11:14:31 +08:00
// AddAdditionalInfo 添加说明文字和二维码(公开方法)
func (pb *PageBuilder) AddAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pb.addAdditionalInfo(pdf, doc, chineseFontAvailable)
}
// addAdditionalInfo 添加说明文字和二维码(私有方法)
2025-12-05 14:59:23 +08:00
func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
// 检查是否需要换页
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
remainingHeight := pageHeight - currentY - bottomMargin
2026-03-13 18:07:24 +08:00
// 如果剩余空间不足,添加新页(页眉与水印由 SetHeaderFunc 自动绘制)
2025-12-05 14:59:23 +08:00
if remainingHeight < 100 {
pdf.AddPage()
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
2025-12-05 14:59:23 +08:00
}
// 添加分隔线
pdf.Ln(10)
pdf.SetLineWidth(0.5)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(15, pdf.GetY(), pageWidth-15, pdf.GetY())
pdf.SetDrawColor(0, 0, 0)
// 添加说明文字标题
pdf.Ln(15)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 16)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接入流程说明", "", 1, "L", false, 0, "")
// 读取说明文本文件
explanationText := pb.readExplanationText()
if explanationText != "" {
pb.logger.Debug("开始渲染说明文本",
zap.Int("text_length", len(explanationText)),
zap.Int("line_count", len(strings.Split(explanationText, "\n"))),
)
pdf.Ln(5)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-05 14:59:23 +08:00
_, lineHt = pdf.GetFontSize()
// 处理说明文本,按行分割并显示
lines := strings.Split(explanationText, "\n")
renderedLines := 0
for i, line := range lines {
// 保留原始行用于日志
originalLine := line
line = strings.TrimSpace(line)
// 处理空行
if line == "" {
pdf.Ln(3)
continue
}
// 清理文本(保留中文字符和标点)
cleanLine := pb.textProcessor.CleanText(line)
// 检查清理后的文本是否为空
if strings.TrimSpace(cleanLine) == "" {
pb.logger.Warn("文本行清理后为空,跳过渲染",
zap.Int("line_number", i+1),
zap.String("original_line", originalLine),
)
continue
}
// 渲染文本行
// 使用MultiCell自动换行支持长文本
pdf.MultiCell(0, lineHt*1.4, cleanLine, "", "L", false)
renderedLines++
}
pb.logger.Info("说明文本渲染完成",
zap.Int("total_lines", len(lines)),
zap.Int("rendered_lines", renderedLines),
)
} else {
pb.logger.Warn("说明文本为空,跳过渲染")
}
// 添加二维码生成方法和使用方法说明
pb.addQRCodeSection(pdf, doc, chineseFontAvailable)
}
// readExplanationText 读取说明文本文件
func (pb *PageBuilder) readExplanationText() string {
resourcesPDFDir := GetResourcesPDFDir()
2025-12-11 11:14:31 +08:00
if resourcesPDFDir == "" {
pb.logger.Error("无法获取resources/pdf目录路径")
return ""
}
2025-12-05 14:59:23 +08:00
textFilePath := filepath.Join(resourcesPDFDir, "后勤服务.txt")
2025-12-11 11:14:31 +08:00
// 记录尝试读取的文件路径
pb.logger.Debug("尝试读取说明文本文件", zap.String("path", textFilePath))
2025-12-05 14:59:23 +08:00
// 检查文件是否存在
2025-12-11 11:14:31 +08:00
fileInfo, err := os.Stat(textFilePath)
if err != nil {
if os.IsNotExist(err) {
pb.logger.Warn("说明文本文件不存在",
zap.String("path", textFilePath),
zap.String("resources_dir", resourcesPDFDir),
)
} else {
pb.logger.Error("检查说明文本文件时出错",
zap.String("path", textFilePath),
zap.Error(err),
)
}
return ""
}
// 检查文件大小
if fileInfo.Size() == 0 {
pb.logger.Warn("说明文本文件为空", zap.String("path", textFilePath))
2025-12-05 14:59:23 +08:00
return ""
}
// 尝试读取文件使用os.ReadFile替代已废弃的ioutil.ReadFile
content, err := os.ReadFile(textFilePath)
if err != nil {
pb.logger.Error("读取说明文本文件失败",
zap.String("path", textFilePath),
zap.Error(err),
)
return ""
}
// 转换为字符串
text := string(content)
2025-12-11 11:14:31 +08:00
// 检查内容是否为空(去除空白字符后)
trimmedText := strings.TrimSpace(text)
if trimmedText == "" {
pb.logger.Warn("说明文本文件内容为空(只有空白字符)",
zap.String("path", textFilePath),
zap.Int("file_size", len(content)),
)
return ""
}
2025-12-05 14:59:23 +08:00
// 记录读取成功的信息
pb.logger.Info("成功读取说明文本文件",
zap.String("path", textFilePath),
2025-12-11 11:14:31 +08:00
zap.Int64("file_size", fileInfo.Size()),
zap.Int("content_length", len(content)),
2025-12-05 14:59:23 +08:00
zap.Int("text_length", len(text)),
zap.Int("line_count", len(strings.Split(text, "\n"))),
)
// 返回文本内容
return text
}
// addQRCodeSection 添加二维码生成方法和使用方法说明
func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
_, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
// 检查是否需要换页(为二维码预留空间)
if pageHeight-currentY-bottomMargin < 120 {
pdf.AddPage()
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
2025-12-05 14:59:23 +08:00
}
// 添加二维码标题
pdf.Ln(15)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 16)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "天远api官网二维码", "", 1, "L", false, 0, "")
// 先生成并添加二维码图片(确保二维码能够正常显示)
pb.addQRCodeImage(pdf, "https://tianyuanapi.com/", chineseFontAvailable)
// 二维码说明文字(简化版,放在二维码之后)
pdf.Ln(10)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-05 14:59:23 +08:00
_, lineHt = pdf.GetFontSize()
qrCodeExplanation := "使用手机扫描上方二维码可直接跳转到天远API官网https://tianyuanapi.com/),获取更多接口文档和资源。\n\n" +
"二维码使用方法:\n" +
"1. 使用手机相机或二维码扫描应用扫描二维码\n" +
"2. 扫描后会自动跳转到天远API官网首页\n" +
"3. 在官网可以查看完整的产品列表、接口文档和使用说明"
// 处理说明文本,按行分割并显示
lines := strings.Split(qrCodeExplanation, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
pdf.Ln(2)
continue
}
// 普通文本行
cleanLine := pb.textProcessor.CleanText(line)
if strings.TrimSpace(cleanLine) != "" {
pdf.MultiCell(0, lineHt*1.3, cleanLine, "", "L", false)
}
}
}
// addQRCodeImage 生成并添加二维码图片到PDF
func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool) {
// 检查是否需要换页
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
// 二维码大小40mm
qrSize := 40.0
if pageHeight-currentY-bottomMargin < qrSize+20 {
pdf.AddPage()
2026-03-13 18:07:24 +08:00
pdf.SetY(ContentStartYBelowHeader)
2025-12-05 14:59:23 +08:00
}
// 生成二维码
qr, err := qrcode.New(content, qrcode.Medium)
if err != nil {
pb.logger.Warn("生成二维码失败", zap.Error(err))
return
}
// 将二维码转换为PNG字节
qrBytes, err := qr.PNG(256)
if err != nil {
pb.logger.Warn("转换二维码为PNG失败", zap.Error(err))
return
}
// 创建临时文件保存二维码使用os.CreateTemp替代已废弃的ioutil.TempFile
tmpFile, err := os.CreateTemp("", "qrcode_*.png")
if err != nil {
pb.logger.Warn("创建临时文件失败", zap.Error(err))
return
}
defer os.Remove(tmpFile.Name()) // 清理临时文件
// 写入二维码数据
if _, err := tmpFile.Write(qrBytes); err != nil {
pb.logger.Warn("写入二维码数据失败", zap.Error(err))
tmpFile.Close()
return
}
tmpFile.Close()
// 添加二维码说明
pdf.Ln(10)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-05 14:59:23 +08:00
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "官网二维码:", "", 1, "L", false, 0, "")
// 计算二维码位置(居中)
qrX := (pageWidth - qrSize) / 2
// 添加二维码图片
pdf.Ln(5)
pdf.ImageOptions(tmpFile.Name(), qrX, pdf.GetY(), qrSize, qrSize, false, gofpdf.ImageOptions{}, 0, "")
// 添加二维码下方的说明文字
pdf.SetY(pdf.GetY() + qrSize + 5)
2026-03-16 12:32:41 +08:00
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
2025-12-05 14:59:23 +08:00
_, lineHt = pdf.GetFontSize()
qrNote := "使用手机扫描上方二维码可访问官网获取更多详情"
noteWidth := pdf.GetStringWidth(qrNote)
noteX := (pageWidth - noteWidth) / 2
pdf.SetX(noteX)
pdf.CellFormat(noteWidth, lineHt, qrNote, "", 1, "C", false, 0, "")
}