1668 lines
56 KiB
Go
1668 lines
56 KiB
Go
package pdf
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"tyapi-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, "天远api官网二维码", "", 1, "L", false, 0, "")
|
||
|
||
// 先生成并添加二维码图片(确保二维码能够正常显示)
|
||
pb.addQRCodeImage(pdf, "https://tianyuanapi.com/", chineseFontAvailable)
|
||
|
||
// 二维码说明文字(简化版,放在二维码之后)
|
||
pdf.Ln(10)
|
||
pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
|
||
_, 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()
|
||
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, "")
|
||
}
|