diff --git a/internal/shared/pdf/database_table_renderer.go b/internal/shared/pdf/database_table_renderer.go index 1394d86..7fdb5da 100644 --- a/internal/shared/pdf/database_table_renderer.go +++ b/internal/shared/pdf/database_table_renderer.go @@ -30,6 +30,10 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa r.logger.Warn("表格数据为空,跳过渲染") return nil } + // 避免表格绘制在页眉区,防止遮挡 logo + if pdf.GetY() < ContentStartYBelowHeader { + pdf.SetY(ContentStartYBelowHeader) + } // 检查表头是否有有效内容 hasValidHeader := false @@ -67,8 +71,13 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa // 即使没有数据行,也渲染表头(单行表格) // 但如果没有表头也没有数据,则不渲染 - // 设置字体 - r.fontManager.SetFont(pdf, "", 9) + // 表格线细线(返回字段说明等表格线不要太粗) + savedLineWidth := pdf.GetLineWidth() + pdf.SetLineWidth(0.2) + defer pdf.SetLineWidth(savedLineWidth) + + // 正文字体:宋体小四 12pt + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt := pdf.GetFontSize() // 计算页面可用宽度 @@ -112,7 +121,7 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa // 策略:先确保每列最短内容能完整显示(不换行),然后根据内容长度分配剩余空间 func (r *DatabaseTableRenderer) calculateColumnWidths(pdf *gofpdf.Fpdf, tableData *TableData, availableWidth float64) []float64 { numCols := len(tableData.Headers) - r.fontManager.SetFont(pdf, "", 9) + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) // 第一步:找到每列中最短的内容(包括表头),计算其完整显示所需的最小宽度 colMinWidths := make([]float64, numCols) @@ -362,8 +371,8 @@ func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, // 绘制表头背景和文本 pdf.SetFillColor(74, 144, 226) // 蓝色背景 - pdf.SetTextColor(0, 0, 0) // 黑色文字 - r.fontManager.SetFont(pdf, "B", 9) + pdf.SetTextColor(0, 0, 0) // 黑色文字 + r.fontManager.SetBodyFont(pdf, "B", BodyFontSizeXiaosi) currentX := 15.0 for i, header := range headers { @@ -396,8 +405,7 @@ func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, pdf.SetXY(currentX+2, textStartY) // 确保颜色为深黑色(在渲染前再次设置,防止被覆盖) pdf.SetTextColor(0, 0, 0) // 表头是黑色文字 - // 设置字体,确保颜色不会变淡 - r.fontManager.SetFont(pdf, "B", 9) + r.fontManager.SetBodyFont(pdf, "B", BodyFontSizeXiaosi) // 再次确保颜色为深黑色(在渲染前最后一次设置) pdf.SetTextColor(0, 0, 0) // 使用正常的行高,文本已经垂直居中(减少内边距,给文本更多空间) @@ -417,7 +425,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co numCols := len(colWidths) pdf.SetFillColor(245, 245, 220) // 米色背景 pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰 - r.fontManager.SetFont(pdf, "", 9) + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) // 获取页面尺寸和边距 _, pageHeight := pdf.GetPageSize() @@ -575,8 +583,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co pdf.SetXY(currentX+2, textStartY) // 再次确保颜色为深黑色(防止被其他设置覆盖) pdf.SetTextColor(0, 0, 0) - // 设置字体,确保颜色不会变淡 - r.fontManager.SetFont(pdf, "", 9) + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) // 再次确保颜色为深黑色(在渲染前最后一次设置) pdf.SetTextColor(0, 0, 0) // 安全地渲染文本,使用正常的行高 diff --git a/internal/shared/pdf/font_manager.go b/internal/shared/pdf/font_manager.go index 88aca87..f3e810a 100644 --- a/internal/shared/pdf/font_manager.go +++ b/internal/shared/pdf/font_manager.go @@ -16,6 +16,8 @@ type FontManager struct { chineseFontLoaded bool watermarkFontName string watermarkFontLoaded bool + bodyFontName string + bodyFontLoaded bool } // NewFontManager 创建字体管理器 @@ -24,6 +26,7 @@ func NewFontManager(logger *zap.Logger) *FontManager { logger: logger, chineseFontName: "ChineseFont", watermarkFontName: "WatermarkFont", + bodyFontName: "BodyFont", } } @@ -73,6 +76,21 @@ func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool { return false } +// LoadBodyFont 加载正文用宋体(用于描述、详情、说明、表格文字等) +func (fm *FontManager) LoadBodyFont(pdf *gofpdf.Fpdf) bool { + if fm.bodyFontLoaded { + return true + } + fontPaths := fm.getBodyFontPaths() + for _, fontPath := range fontPaths { + if fm.tryAddFont(pdf, fontPath, fm.bodyFontName) { + fm.bodyFontLoaded = true + return true + } + } + return false +} + // tryAddFont 尝试添加字体(统一处理中文字体和水印字体) func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath, fontName string) bool { defer func() { @@ -214,6 +232,18 @@ func (fm *FontManager) getWatermarkFontPaths() []string { return fm.buildFontPaths(fontNames) } +// getBodyFontPaths 获取正文宋体路径列表(小四对应 12pt) +// 优先使用 resources/pdf/fonts/simsun.ttc(宋体) +func (fm *FontManager) getBodyFontPaths() []string { + fontNames := []string{ + // "simsun.ttc", // 宋体(项目内 resources/pdf/fonts) + "simsun.ttf", + "SimSun.ttf", + "WenYuanSerifSC-Bold.ttf", // 文渊宋体风格,备选 + } + return fm.buildFontPaths(fontNames) +} + // buildFontPaths 构建字体文件路径列表(仅从resources/pdf/fonts加载) // 返回所有存在的字体文件的绝对路径 func (fm *FontManager) buildFontPaths(fontNames []string) []string { @@ -297,6 +327,28 @@ func (fm *FontManager) SetWatermarkFont(pdf *gofpdf.Fpdf, style string, size flo } } +// BodyFontSizeXiaosi 正文小四字号(约 12pt) +const BodyFontSizeXiaosi = 12.0 + +// SetBodyFont 设置正文字体(宋体小四:描述、详情、说明、表格文字等) +func (fm *FontManager) SetBodyFont(pdf *gofpdf.Fpdf, style string, size float64) { + if size <= 0 { + size = BodyFontSizeXiaosi + } + if fm.bodyFontLoaded { + pdf.SetFont(fm.bodyFontName, style, size) + } else if fm.watermarkFontLoaded { + pdf.SetFont(fm.watermarkFontName, style, size) + } else { + fm.SetFont(pdf, style, size) + } +} + +// IsBodyFontAvailable 正文字体(宋体)是否已加载 +func (fm *FontManager) IsBodyFontAvailable() bool { + return fm.bodyFontLoaded || fm.watermarkFontLoaded +} + // IsChineseFontAvailable 检查中文字体是否可用 func (fm *FontManager) IsChineseFontAvailable() bool { return fm.chineseFontLoaded diff --git a/internal/shared/pdf/page_builder.go b/internal/shared/pdf/page_builder.go index f1eb17f..11db7db 100644 --- a/internal/shared/pdf/page_builder.go +++ b/internal/shared/pdf/page_builder.go @@ -98,12 +98,12 @@ func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, 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.SetFont(pdf, "", 14) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() pb.drawRichTextBlock(pdf, desc, pageWidth*0.7, lineHt*1.5, maxContentY, "C", true, chineseFontAvailable) } @@ -126,7 +126,7 @@ func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, } } -// AddProductContentPage 添加产品详情页(另起一页,左对齐,段前两空格) +// AddProductContentPage 添加产品详情页(另起一页,左对齐,符合 HTML 富文本:段落、加粗、标题) func (pb *PageBuilder) AddProductContentPage(pdf *gofpdf.Fpdf, product *entities.Product, chineseFontAvailable bool) { if product.Content == "" { return @@ -140,12 +140,74 @@ func (pb *PageBuilder) AddProductContentPage(pdf *gofpdf.Fpdf, product *entities _, titleHt := pdf.GetFontSize() pdf.CellFormat(0, titleHt, "产品详情", "", 1, "L", false, 0, "") pdf.Ln(6) - content := pb.textProcessor.HTMLToPlainWithBreaks(product.Content) - content = pb.textProcessor.CleanText(content) - pb.fontManager.SetFont(pdf, "", 12) - _, lineHt := pdf.GetFontSize() - // 产品详情页不做“略写”截断,全部内容都渲染出来,允许 gofpdf 自动分页 - pb.drawRichTextBlockNoLimit(pdf, content, pageWidth*0.9, lineHt*1.4, "L", true, chineseFontAvailable) + // 按 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 截断,允许自动分页,适合“产品详情”等必须全部展示的内容 @@ -174,6 +236,7 @@ func (pb *PageBuilder) drawRichTextBlockNoLimit(pdf *gofpdf.Fpdf, text string, c } wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable) for _, w := range wrapped { + pb.ensureContentBelowHeader(pdf) x := currentX if align == "L" && firstLineIndent && firstLineOfPara { x = leftMargin + paragraphIndentMM @@ -187,7 +250,7 @@ func (pb *PageBuilder) drawRichTextBlockNoLimit(pdf *gofpdf.Fpdf, text string, c } // AddDocumentationPages 添加接口文档页面 -// 每页的页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制 +// 每页的页眉与水印由 SetHeaderFunc / SetFooterFunc 在 AddPage 时自动绘制 func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { pdf.AddPage() @@ -204,7 +267,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // URL使用黑体字体(可能包含中文字符) // 先清理URL中的乱码 cleanURL := pb.textProcessor.CleanText(doc.RequestURL) - pb.fontManager.SetFont(pdf, "", 10) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) @@ -221,6 +284,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 请求参数 if doc.RequestParams != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -234,7 +298,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.RequestParams) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -251,6 +315,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 响应示例 if doc.ResponseExample != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) @@ -273,7 +338,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseExample) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } @@ -299,7 +364,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseFields) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } else { pb.logger.Warn("返回字段内容为空或只有空白字符") @@ -311,6 +376,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 错误代码 if doc.ErrorCodes != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -324,7 +390,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ErrorCodes) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -335,7 +401,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro } // AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明) -// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc 自动绘制。 +// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc/SetFooterFunc 自动绘制。 func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { pdf.AddPage() @@ -352,7 +418,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // URL使用黑体字体(可能包含中文字符) // 先清理URL中的乱码 cleanURL := pb.textProcessor.CleanText(doc.RequestURL) - pb.fontManager.SetFont(pdf, "", 10) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) @@ -369,6 +435,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 请求参数 if doc.RequestParams != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -382,7 +449,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.RequestParams) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -399,6 +466,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 响应示例 if doc.ResponseExample != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) @@ -421,7 +489,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseExample) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } @@ -447,7 +515,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseFields) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } else { pb.logger.Warn("返回字段内容为空或只有空白字符") @@ -459,6 +527,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 错误代码 if doc.ErrorCodes != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -472,7 +541,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ErrorCodes) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -504,7 +573,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // URL使用黑体字体(可能包含中文字符) // 先清理URL中的乱码 cleanURL := pb.textProcessor.CleanText(doc.RequestURL) - pb.fontManager.SetFont(pdf, "", 10) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) @@ -521,6 +590,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 请求参数 if doc.RequestParams != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -534,7 +604,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.RequestParams) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -551,6 +621,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 响应示例 if doc.ResponseExample != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) @@ -573,7 +644,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseExample) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } @@ -599,7 +670,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseFields) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } else { pb.logger.Warn("返回字段内容为空或只有空白字符") @@ -611,6 +682,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 错误代码 if doc.ErrorCodes != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -624,7 +696,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ErrorCodes) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -634,6 +706,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 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, "") @@ -652,7 +725,7 @@ func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chine jsonContent = formattedJSON } pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false) } else { // 按#号标题分割内容,每个标题下的内容单独处理 @@ -726,7 +799,7 @@ func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, ch if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } @@ -737,7 +810,7 @@ func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, ch text = pb.textProcessor.CleanText(text) if strings.TrimSpace(text) != "" { pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -759,7 +832,7 @@ func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, if err == nil { jsonContent = formattedJSON } - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) pdf.Ln(5) } @@ -795,7 +868,7 @@ func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } @@ -806,7 +879,7 @@ func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, text = pb.textProcessor.CleanText(text) if strings.TrimSpace(text) != "" { pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -871,29 +944,32 @@ func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, c // 显示表格前的说明文字 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.SetFont(pdf, "", 10) + 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.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } @@ -901,7 +977,7 @@ func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, c } else { // 如果不是有效表格,显示为文本(完整显示markdown内容) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) text := pb.textProcessor.StripHTML(content) text = pb.textProcessor.CleanText(text) // 清理无效字符,保留中文 // 如果文本不为空,显示它 @@ -913,6 +989,7 @@ func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, c // 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 { @@ -946,6 +1023,7 @@ func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chine } // 渲染标题 + pb.ensureContentBelowHeader(pdf) pdf.SetTextColor(0, 0, 0) pb.fontManager.SetFont(pdf, "B", fontSize) _, titleLineHt := pdf.GetFontSize() @@ -953,11 +1031,12 @@ func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chine 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.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false) } } else { @@ -1113,47 +1192,97 @@ func (pb *PageBuilder) getContentPreview(content string, maxLen int) string { return content[:maxLen] + "..." } -// drawJSONInCenteredTable 在居中表格中绘制 JSON 文本(表格居中,内容左对齐);空间不足时自动换页避免压住 logo +// 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 - // 表格宽度取可用宽度的 85%,居中 - tableWidth := usableWidth * 0.85 + 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.SetFont(pdf, "", 9) - lines := pdf.SplitText(jsonContent, innerWidth) - totalHeight := float64(len(lines))*lineHeight + 2*padding + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) + // 使用 safeSplitText 兼容中文等字符,避免 SplitText panic;按行先拆再对每行按宽度换行 + allLines := pb.wrapJSONLinesToWidth(pdf, jsonContent, innerWidth) - // 当前页剩余高度不足时先换页,避免表格被 logo 或底部裁掉 - currentY := pdf.GetY() - if currentY+totalHeight > pageHeight-bottomMargin { - pdf.AddPage() - currentY = ContentStartYBelowHeader - pdf.SetY(currentY) + // 每页可用高度(从当前 Y 到页底),用于分块 + maxH := pageHeight - bottomMargin - pdf.GetY() + linesPerPage := int((maxH - 2*padding) / lineHeight) + if linesPerPage < 1 { + linesPerPage = 1 } - startY := currentY - // 绘制表格边框 - pdf.SetDrawColor(180, 180, 180) - pdf.Rect(startX, startY, tableWidth, totalHeight, "D") - pdf.SetDrawColor(0, 0, 0) - // 表格内内容左对齐 - pdf.SetY(startY + padding) - for _, line := range lines { - pdf.SetX(startX + padding) - pdf.CellFormat(innerWidth, lineHeight, line, "", 1, "L", false, 0, "") + 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) } - pdf.SetY(startY + totalHeight) } // safeSplitText 安全地分割文本,避免在没有中文字体时调用SplitText导致panic @@ -1295,7 +1424,7 @@ func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.Product ) pdf.Ln(5) - pb.fontManager.SetFont(pdf, "", 11) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() // 处理说明文本,按行分割并显示 @@ -1439,7 +1568,7 @@ func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductD // 二维码说明文字(简化版,放在二维码之后) pdf.Ln(10) - pb.fontManager.SetFont(pdf, "", 11) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() qrCodeExplanation := "使用手机扫描上方二维码可直接跳转到天远API官网(https://tianyuanapi.com/),获取更多接口文档和资源。\n\n" + @@ -1511,7 +1640,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF // 添加二维码说明 pdf.Ln(10) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "官网二维码:", "", 1, "L", false, 0, "") @@ -1524,7 +1653,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF // 添加二维码下方的说明文字 pdf.SetY(pdf.GetY() + qrSize + 5) - pb.fontManager.SetFont(pdf, "", 9) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() qrNote := "使用手机扫描上方二维码可访问官网获取更多详情" noteWidth := pdf.GetStringWidth(qrNote) diff --git a/internal/shared/pdf/pdf_generator_refactored.go b/internal/shared/pdf/pdf_generator_refactored.go index ae23eab..555e658 100644 --- a/internal/shared/pdf/pdf_generator_refactored.go +++ b/internal/shared/pdf/pdf_generator_refactored.go @@ -153,18 +153,23 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent pdf := gofpdf.New("P", "mm", "A4", "") // 上边距与 ContentStartYBelowHeader 一致,这样自动分页后新页内容从 logo 下方开始,不被遮挡 pdf.SetMargins(15, ContentStartYBelowHeader, 15) + // 开启自动分页并预留底边距,避免内容贴底;分页后由 SetHeaderFunc 绘制页眉,正文从 ContentStartYBelowHeader 起排 + pdf.SetAutoPageBreak(true, 18) // 加载黑体字体(用于所有内容,除了水印) // 注意:此时工作目录应该是根目录(/),这样gofpdf处理路径时就能正确解析 chineseFontAvailable := g.fontManager.LoadChineseFont(pdf) - // 加载水印字体(使用宋体或其他非黑体字体) + // 加载水印字体 watermarkFontAvailable := g.fontManager.LoadWatermarkFont(pdf) + // 加载正文宋体(描述、详情、说明、表格文字等使用小四 12pt) + bodyFontAvailable := g.fontManager.LoadBodyFont(pdf) // 记录字体加载状态,便于诊断问题 g.logger.Info("PDF字体加载状态", zap.Bool("chinese_font_loaded", chineseFontAvailable), zap.Bool("watermark_font_loaded", watermarkFontAvailable), + zap.Bool("body_font_loaded", bodyFontAvailable), zap.String("watermark_text", g.watermarkText), ) @@ -176,9 +181,11 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent // 创建页面构建器 pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText) - // 使用 SetHeaderFunc 确保每页(包括表格等内部调用 AddPage 的页)都会先绘制页眉+水印 + // 页眉只绘制 logo 和横线;水印改到页脚绘制,确保水印在最上层不被表格等内容遮挡 pdf.SetHeaderFunc(func() { pageBuilder.addHeader(pdf, chineseFontAvailable) + }) + pdf.SetFooterFunc(func() { pageBuilder.addWatermark(pdf, chineseFontAvailable) }) diff --git a/internal/shared/pdf/text_processor.go b/internal/shared/pdf/text_processor.go index b32c8ad..5dfdb32 100644 --- a/internal/shared/pdf/text_processor.go +++ b/internal/shared/pdf/text_processor.go @@ -106,6 +106,130 @@ func (tp *TextProcessor) HTMLToPlainWithBreaks(text string) string { return strings.TrimSpace(text) } +// HTMLSegment 用于 PDF 绘制的 HTML 片段:支持段落、换行、加粗、标题 +type HTMLSegment struct { + Text string // 纯文本(已去标签、已解码实体) + Bold bool // 是否加粗 + NewLine bool // 是否换行(如
) + NewParagraph bool // 是否新段落(如

、) + HeadingLevel int // 1-3 表示 h1-h3,0 表示正文 +} + +// ParseHTMLToSegments 将 HTML 解析为用于 PDF 绘制的片段序列,保留段落、换行、加粗与标题 +func (tp *TextProcessor) ParseHTMLToSegments(htmlStr string) []HTMLSegment { + htmlStr = html.UnescapeString(htmlStr) + var out []HTMLSegment + blockSplit := regexp.MustCompile(`(?i)(

|||)\s*`) + parts := blockSplit.Split(htmlStr, -1) + tags := blockSplit.FindAllString(htmlStr, -1) + for i, block := range parts { + block = strings.TrimSpace(block) + var prevTag string + if i > 0 && i-1 < len(tags) { + prevTag = strings.ToLower(strings.TrimSpace(tags[i-1])) + } + isNewParagraph := strings.Contains(prevTag, "

") || strings.Contains(prevTag, "") || + strings.HasPrefix(prevTag, " 0 { + if isNewParagraph || headingLevel > 0 { + out = append(out, HTMLSegment{NewParagraph: true, HeadingLevel: headingLevel}) + } else if isNewLine { + out = append(out, HTMLSegment{NewLine: true}) + } + } + for _, seg := range segments { + if seg.Text != "" { + out = append(out, HTMLSegment{Text: seg.Text, Bold: seg.Bold, HeadingLevel: headingLevel}) + } + } + } + return out +} + +// inlineSeg 内联片段(文本 + 是否加粗) +type inlineSeg struct { + Text string + Bold bool +} + +// parseInlineSegments 解析块内文本,按 / 拆成片段 +func (tp *TextProcessor) parseInlineSegments(block string) []inlineSeg { + var segs []inlineSeg + // 移除所有标签并收集加粗区间(按字符偏移) + reBoldOpen := regexp.MustCompile(`(?i)<(strong|b)>`) + reBoldClose := regexp.MustCompile(`(?i)`) + plain := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(block, "") + plain = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plain, " ") + plain = strings.TrimSpace(plain) + if plain == "" { + return segs + } + // 在原始 block 上找加粗区间,再映射到 plain(去掉标签后的位置) + work := block + var boldRanges [][2]int + offset := 0 + for { + idxOpen := reBoldOpen.FindStringIndex(work) + if idxOpen == nil { + break + } + afterOpen := work[idxOpen[1]:] + idxClose := reBoldClose.FindStringIndex(afterOpen) + if idxClose == nil { + break + } + startInWork := offset + idxOpen[1] + endInWork := offset + idxOpen[1] + idxClose[0] + // 将 work 坐标映射到 plain:需要数 plain 中对应字符 + workBefore := work[:startInWork] + plainBefore := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(workBefore, "") + plainBefore = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plainBefore, " ") + startPlain := len([]rune(plainBefore)) + workUntil := work[:endInWork] + plainUntil := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(workUntil, "") + plainUntil = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plainUntil, " ") + endPlain := len([]rune(plainUntil)) + boldRanges = append(boldRanges, [2]int{startPlain, endPlain}) + work = work[endInWork+len(reBoldClose.FindString(afterOpen)):] + offset = endInWork + len(reBoldClose.FindString(afterOpen)) + } + // 按 boldRanges 切分 plain + runes := []rune(plain) + inBold := false + var start int + for i := 0; i <= len(runes); i++ { + nowBold := false + for _, r := range boldRanges { + if i >= r[0] && i < r[1] { + nowBold = true + break + } + } + if nowBold != inBold || i == len(runes) { + if i > start { + segs = append(segs, inlineSeg{Text: string(runes[start:i]), Bold: inBold}) + } + start = i + inBold = nowBold + } + } + if len(segs) == 0 && plain != "" { + segs = append(segs, inlineSeg{Text: plain, Bold: false}) + } + return segs +} + // RemoveMarkdownSyntax 移除markdown语法,保留纯文本 func (tp *TextProcessor) RemoveMarkdownSyntax(text string) string { // 移除粗体标记 **text** 或 __text__