Files
hyapi-server/internal/shared/pdf/page_builder.go
2026-04-21 22:36:48 +08:00

1668 lines
56 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package pdf
import (
"context"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"hyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
qrcode "github.com/skip2/go-qrcode"
"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,
}
}
// 封面页底部为价格预留的高度mm避免价格被挤到单独一页
const firstPagePriceReservedHeight = 18.0
// ContentStartYBelowHeader 页眉logo+横线)下方的正文起始 Ymm表格等 AddPage 后须设为此值,避免与 logo 重叠(留足顶间距)
const ContentStartYBelowHeader = 50.0
// AddFirstPage 添加第一页(封面页 - 产品功能简述)
// 页眉与水印由 SetHeaderFunc 在每页 AddPage 时自动绘制,此处不再重复调用
// 自动限制描述/详情高度,保证价格与封面同页,不单独成页
func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
// 内容区最大 Y超出则不再绘制留出底部给价格避免价格单独一页
maxContentY := pageHeight - bottomMargin - firstPagePriceReservedHeight
// 标题区域(在页眉下方留足间距,避免与 logo 重叠)
pdf.SetY(ContentStartYBelowHeader + 6)
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, "")
pdf.Ln(6)
pb.fontManager.SetFont(pdf, "", 18)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "")
// 产品编码
pdf.Ln(16)
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, "")
pdf.Ln(12)
pdf.SetLineWidth(0.5)
pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
// 产品描述(居中,宋体小四)
if product.Description != "" {
pdf.Ln(10)
desc := pb.textProcessor.HTMLToPlainWithBreaks(product.Description)
desc = pb.textProcessor.CleanText(desc)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
_, lineHt = pdf.GetFontSize()
pb.drawRichTextBlock(pdf, desc, pageWidth*0.7, lineHt*1.5, maxContentY, "C", true, chineseFontAvailable)
}
// 产品详情已移至单独一页,见 AddProductContentPage
if !product.Price.IsZero() {
pb.fontManager.SetFont(pdf, "", 14)
_, priceLineHt := pdf.GetFontSize()
reservedZoneY := pageHeight - bottomMargin - firstPagePriceReservedHeight + 6
priceY := reservedZoneY
if pdf.GetY()+5 > reservedZoneY {
priceY = pdf.GetY() + 5
}
pdf.SetY(priceY)
pdf.SetTextColor(0, 0, 0)
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, "")
}
}
// AddProductContentPage 添加产品详情页(另起一页,左对齐,符合 HTML 富文本:段落、加粗、标题)
func (pb *PageBuilder) AddProductContentPage(pdf *gofpdf.Fpdf, product *entities.Product, chineseFontAvailable bool) {
if product.Content == "" {
return
}
pdf.AddPage()
pageWidth, _ := pdf.GetPageSize()
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)
// 按 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
}
}
}
// 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 {
pb.ensureContentBelowHeader(pdf)
x := currentX
if align == "L" && firstLineIndent && firstLineOfPara {
x = leftMargin + paragraphIndentMM
}
pdf.SetX(x)
pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "")
firstLineOfPara = false
}
}
}
}
// AddDocumentationPages 添加接口文档页面
// 每页的页眉与水印由 SetHeaderFunc / SetFooterFunc 在 AddPage 时自动绘制
func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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 != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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, "")
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
}
}
// 响应示例
if doc.ResponseExample != "" {
pb.ensureContentBelowHeader(pdf)
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, "")
// 优先尝试提取和格式化JSON表格包裹居中内容左对齐
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
} 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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
if doc.ResponseFields != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 添加说明文字和二维码
pb.addAdditionalInfo(pdf, doc, chineseFontAvailable)
}
// AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明)
// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc/SetFooterFunc 自动绘制。
func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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 != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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, "")
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
}
}
// 响应示例
if doc.ResponseExample != "" {
pb.ensureContentBelowHeader(pdf)
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, "")
// 优先尝试提取和格式化JSON表格包裹居中内容左对齐
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
} 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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
if doc.ResponseFields != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 注意:这里不添加二维码和说明,由调用方统一添加
}
// AddSubProductDocumentationPages 添加子产品的接口文档页面(用于组合包)
// 每页页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制
func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProduct *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool, isLastSubProduct bool) {
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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 != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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, "")
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
}
}
// 响应示例
if doc.ResponseExample != "" {
pb.ensureContentBelowHeader(pdf)
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, "")
// 优先尝试提取和格式化JSON表格包裹居中内容左对齐
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
} 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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
if doc.ResponseFields != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
pb.ensureContentBelowHeader(pdf)
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) != "" {
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 注意:这里不添加二维码和说明,由调用方统一添加
}
// addSection 添加章节
func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) {
pb.ensureContentBelowHeader(pdf)
_, 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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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
}
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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 {
pb.ensureContentBelowHeader(pdf)
beforeText := strings.Join(beforeTable, "\n")
beforeText = pb.textProcessor.StripHTML(beforeText)
beforeText = pb.textProcessor.CleanText(beforeText)
if strings.TrimSpace(beforeText) != "" {
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false)
pdf.Ln(3)
}
}
// 渲染表格
pb.ensureContentBelowHeader(pdf)
pb.tableRenderer.RenderTable(pdf, tableData)
// 显示表格后的说明文字
if len(afterTable) > 0 {
pb.ensureContentBelowHeader(pdf)
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 如果不是有效表格显示为文本完整显示markdown内容
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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) {
pb.ensureContentBelowHeader(pdf)
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
}
// 渲染标题
pb.ensureContentBelowHeader(pdf)
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格式
pb.ensureContentBelowHeader(pdf)
cleanText := pb.textProcessor.StripHTML(line)
cleanText = pb.textProcessor.CleanTextPreservingMarkdown(cleanText)
if strings.TrimSpace(cleanText) != "" {
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
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 {
pb.logger.Warn("logo文件不存在", zap.String("path", pb.logoPath))
}
}
// 绘制"海宇数据"文字(使用中文字体如果可用)
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)
// 所有自动分页后的正文统一从页眉下方固定位置开始,避免内容顶到 logo 或水印
pdf.SetY(ContentStartYBelowHeader)
}
// ensureContentBelowHeader 若当前 Y 在页眉区内则下移到正文区,避免与 logo 重叠
func (pb *PageBuilder) ensureContentBelowHeader(pdf *gofpdf.Fpdf) {
if pdf.GetY() < ContentStartYBelowHeader {
pdf.SetY(ContentStartYBelowHeader)
}
}
// addWatermark 添加水印:自左下角往右上角倾斜 45°单条水印居中于页面样式柔和
func (pb *PageBuilder) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
if !chineseFontAvailable {
return
}
pdf.TransformBegin()
defer pdf.TransformEnd()
pageWidth, pageHeight := pdf.GetPageSize()
leftMargin, topMargin, _, bottomMargin := pdf.GetMargins()
usableHeight := pageHeight - topMargin - bottomMargin
usableWidth := pageWidth - leftMargin*2
fontSize := 42.0
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
// 加深水印:更深的灰与更高不透明度,保证可见
pdf.SetTextColor(150, 150, 150)
pdf.SetAlpha(0.32, "Normal")
textWidth := pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
// 旋转后对角线长度,用于缩放与定位
rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize)
if rotatedDiagonal > usableHeight*0.75 {
fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
textWidth = pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize)
}
// 自左下角往右上角:起点在可用区域左下角,逆时针旋转 +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)
pdf.SetXY(0, 0)
pdf.CellFormat(textWidth, fontSize, pb.watermarkText, "", 0, "L", false, 0, "")
pdf.SetAlpha(1.0, "Normal")
pdf.SetTextColor(0, 0, 0)
}
// 段前缩进宽度约两字符mm
const paragraphIndentMM = 7.0
// drawRichTextBlock 按段落与换行绘制文本块(还原 HTML 换行)
// 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
}
}
}
}
// getContentPreview 获取内容预览(用于日志记录)
func (pb *PageBuilder) getContentPreview(content string, maxLen int) string {
content = strings.TrimSpace(content)
if maxLen <= 0 || len(content) <= maxLen {
return content
}
n := maxLen
if n > len(content) {
n = len(content)
}
return content[:n] + "..."
}
// 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
func (pb *PageBuilder) drawJSONInCenteredTable(pdf *gofpdf.Fpdf, jsonContent string, lineHt float64) {
jsonContent = strings.TrimSpace(jsonContent)
if jsonContent == "" {
return
}
pb.ensureContentBelowHeader(pdf)
pageWidth, pageHeight := pdf.GetPageSize()
leftMargin, _, rightMargin, bottomMargin := pdf.GetMargins()
usableWidth := pageWidth - leftMargin - rightMargin
tableWidth := usableWidth * 0.92
startX := (pageWidth - tableWidth) / 2
padding := 4.0
innerWidth := tableWidth - 2*padding
lineHeight := lineHt * 1.3
pdf.SetTextColor(0, 0, 0)
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
}
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)
}
}
// 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
}
// AddAdditionalInfo 添加说明文字和二维码(公开方法)
func (pb *PageBuilder) AddAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pb.addAdditionalInfo(pdf, doc, chineseFontAvailable)
}
// addAdditionalInfo 添加说明文字和二维码(私有方法)
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
// 如果剩余空间不足,添加新页(页眉与水印由 SetHeaderFunc 自动绘制)
if remainingHeight < 100 {
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
}
// 添加分隔线
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
_, 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()
if resourcesPDFDir == "" {
pb.logger.Error("无法获取resources/pdf目录路径")
return ""
}
textFilePath := filepath.Join(resourcesPDFDir, "后勤服务.txt")
// 记录尝试读取的文件路径
pb.logger.Debug("尝试读取说明文本文件", zap.String("path", textFilePath))
// 检查文件是否存在
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))
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)
// 检查内容是否为空(去除空白字符后)
trimmedText := strings.TrimSpace(text)
if trimmedText == "" {
pb.logger.Warn("说明文本文件内容为空(只有空白字符)",
zap.String("path", textFilePath),
zap.Int("file_size", len(content)),
)
return ""
}
// 记录读取成功的信息
pb.logger.Info("成功读取说明文本文件",
zap.String("path", textFilePath),
zap.Int64("file_size", fileInfo.Size()),
zap.Int("content_length", len(content)),
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()
pdf.SetY(ContentStartYBelowHeader)
}
// 添加二维码标题
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, "")
// 先生成并添加二维码图片(确保二维码能够正常显示)
pb.addQRCodeImage(pdf, "https://haiyudata.com/", chineseFontAvailable)
// 二维码说明文字(简化版,放在二维码之后)
pdf.Ln(10)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
_, lineHt = pdf.GetFontSize()
qrCodeExplanation := "使用手机扫描上方二维码可直接跳转到海宇数据官网https://haiyudata.com/),获取更多接口文档和资源。\n\n" +
"二维码使用方法:\n" +
"1. 使用手机相机或二维码扫描应用扫描二维码\n" +
"2. 扫描后会自动跳转到海宇数据官网首页\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()
pdf.SetY(ContentStartYBelowHeader)
}
// 生成二维码
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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
_, 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)
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
_, lineHt = pdf.GetFontSize()
qrNote := "使用手机扫描上方二维码可访问官网获取更多详情"
noteWidth := pdf.GetStringWidth(qrNote)
noteX := (pageWidth - noteWidth) / 2
pdf.SetX(noteX)
pdf.CellFormat(noteWidth, lineHt, qrNote, "", 1, "C", false, 0, "")
}