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+横线)下方的正文起始 Y(mm),表格等 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, "") }