diff --git a/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go b/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go index b75ad87..4e0ea3c 100644 --- a/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go @@ -14,7 +14,7 @@ import ( "github.com/tidwall/gjson" ) -// ProcessFLXG0V4BRequest FLXG0V4B API处理方法 +// ProcessFLXG0V4BRequest FLXG0V4B API处理方法(身份证排空入口,身份证身份证身份证身份证身份证) func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { var paramsDto dto.FLXG0V4BReq if err := json.Unmarshal(params, ¶msDto); err != nil { @@ -24,7 +24,7 @@ func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998"|| paramsDto.IDCard == "640102198708020925"{ + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name) diff --git a/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go b/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go index 3786220..4e53724 100644 --- a/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go @@ -20,7 +20,7 @@ func ProcessFLXG5A3BRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"{ + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name) diff --git a/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go b/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go index bc4ecfd..c9caf10 100644 --- a/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go @@ -20,7 +20,7 @@ func ProcessFLXG7E8FRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998" { + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } // 构建请求数据,将项目规范的字段名转换为 XingweiService 需要的字段名 diff --git a/internal/domains/api/services/processors/flxg/flxgdea9_processor.go b/internal/domains/api/services/processors/flxg/flxgdea9_processor.go index ad2f9a9..e63f2f5 100644 --- a/internal/domains/api/services/processors/flxg/flxgdea9_processor.go +++ b/internal/domains/api/services/processors/flxg/flxgdea9_processor.go @@ -25,7 +25,7 @@ func ProcessFLXGDEA9Request(ctx context.Context, params []byte, deps *processors if err != nil { return nil, errors.Join(processors.ErrSystem, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"|| paramsDto.IDCard == "640102198708020925"{ + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard) diff --git a/internal/shared/pdf/LOG_VIEWING_GUIDE.md b/internal/shared/pdf/LOG_VIEWING_GUIDE.md deleted file mode 100644 index cecdaed..0000000 --- a/internal/shared/pdf/LOG_VIEWING_GUIDE.md +++ /dev/null @@ -1,168 +0,0 @@ -# 📋 PDF表格转换日志查看指南 - -## 📍 日志文件位置 - -### 1. 开发环境 -日志文件存储在项目根目录的 `logs/` 目录下: - -``` -tyapi-server/ -└── logs/ - ├── 2024-12-02/ # 按日期分包(如果启用) - │ ├── debug.log # Debug级别日志(包含JSON转换详情) - │ ├── info.log # Info级别日志(包含转换流程) - │ └── error.log # Error级别日志(包含错误信息) - └── app.log # 传统模式(如果未启用按日分包) -``` - -### 2. 生产环境(Docker) -日志文件存储在容器的 `/app/logs/` 目录,映射到宿主机的 `./logs/` 目录: - -```bash -# 查看宿主机日志 -./logs/2024-12-02/info.log -./logs/2024-12-02/debug.log -``` - -## 🔍 如何查看转换日志 - -### 方法1:实时查看日志(推荐) - -```bash -# 查看Info级别日志(转换流程) -tail -f logs/2024-12-02/info.log | grep "表格\|JSON\|markdown" - -# 查看Debug级别日志(详细JSON数据) -tail -f logs/2024-12-02/debug.log | grep "JSON\|表格" - -# 查看所有PDF相关日志 -tail -f logs/2024-12-02/*.log | grep -i "pdf\|table\|json" -``` - -### 方法2:使用Docker查看 - -```bash -# 查看容器实时日志 -docker logs -f tyapi-app-prod | grep -i "表格\|json\|markdown" - -# 查看最近的100行日志 -docker logs --tail 100 tyapi-app-prod | grep -i "表格\|json" -``` - -### 方法3:搜索特定字段类型 - -```bash -# 查看请求参数的转换日志 -grep "request_params" logs/2024-12-02/info.log - -# 查看响应字段的转换日志 -grep "response_fields" logs/2024-12-02/info.log - -# 查看错误代码的转换日志 -grep "error_codes" logs/2024-12-02/info.log -``` - -## 📊 日志级别说明 - -### Info级别日志(info.log) -包含转换流程的关键步骤: -- ✅ 数据格式检测(JSON/Markdown) -- ✅ Markdown表格解析开始 -- ✅ 表格解析成功(表头数量、行数) -- ✅ JSON转换完成 - -**示例日志:** -``` -2024-12-02T10:30:15Z INFO 开始解析markdown表格并转换为JSON {"field_type": "request_params", "content_length": 1234} -2024-12-02T10:30:15Z INFO markdown表格解析成功 {"field_type": "request_params", "header_count": 3, "row_count": 5} -2024-12-02T10:30:15Z INFO 表格数据已转换为JSON格式 {"field_type": "request_params", "json_array_length": 5} -``` - -### Debug级别日志(debug.log) -包含详细的转换数据: -- 🔍 原始内容预览 -- 🔍 解析后的表头列表 -- 🔍 转换后的完整JSON数据(前1000字符) -- 🔍 每行的转换详情 - -**示例日志:** -``` -2024-12-02T10:30:15Z DEBUG 转换后的JSON数据预览 {"field_type": "request_params", "json_length": 2345, "json_preview": "[{\"字段名\":\"name\",\"类型\":\"string\",\"说明\":\"姓名\"}...]"} -``` - -### Error级别日志(error.log) -包含转换过程中的错误: -- ❌ Markdown解析失败 -- ❌ JSON序列化失败 -- ❌ 数据格式错误 - -**示例日志:** -``` -2024-12-02T10:30:15Z ERROR 解析markdown表格失败 {"field_type": "request_params", "error": "无法解析表格:未找到表头", "content_preview": "..."} -``` - -## 🔎 日志关键词搜索 - -### 转换流程关键词 -- `开始解析markdown表格` - 转换开始 -- `markdown表格解析成功` - 解析完成 -- `表格数据已转换为JSON格式` - JSON转换完成 -- `转换后的JSON数据预览` - JSON数据详情 - -### 数据格式关键词 -- `数据已经是JSON格式` - 数据源是JSON -- `从JSON对象中提取数组数据` - 从JSON对象提取 -- `解析markdown表格并转换为JSON` - Markdown转JSON - -### 错误关键词 -- `解析markdown表格失败` - 解析错误 -- `JSON序列化失败` - JSON错误 -- `字段内容为空` - 空数据 - -## 📝 日志配置 - -确保日志级别设置为 `debug` 才能看到详细的JSON转换日志: - -```yaml -# config.yaml 或 configs/env.development.yaml -logger: - level: "debug" # 开发环境使用debug级别 - format: "console" # 或 "json" - output: "file" # 输出到文件 - log_dir: "logs" # 日志目录 - use_daily: true # 启用按日分包 -``` - -## 🛠️ 常用命令 - -```bash -# 查看今天的Info日志 -cat logs/$(date +%Y-%m-%d)/info.log | grep "表格\|JSON" - -# 查看最近的转换日志(最后50行) -tail -n 50 logs/$(date +%Y-%m-%d)/info.log - -# 搜索特定产品的转换日志 -grep "product_id.*xxx" logs/$(date +%Y-%m-%d)/info.log - -# 查看所有错误 -grep "ERROR" logs/$(date +%Y-%m-%d)/error.log - -# 统计转换次数 -grep "表格数据已转换为JSON格式" logs/$(date +%Y-%m-%d)/info.log | wc -l -``` - -## 💡 调试技巧 - -1. **查看完整JSON数据**:如果JSON数据超过1000字符,查看debug.log获取完整内容 -2. **追踪转换流程**:使用 `field_type` 字段过滤特定字段的转换日志 -3. **定位错误**:查看error.log中的 `content_preview` 字段了解原始数据 -4. **性能监控**:统计转换次数和耗时,优化转换逻辑 - -## 📌 注意事项 - -- Debug级别日志可能包含大量数据,注意日志文件大小 -- 生产环境建议使用 `info` 级别,减少日志量 -- JSON预览限制在1000字符,完整数据请查看debug日志 -- 日志文件按日期自动分包,便于管理和查找 - diff --git a/internal/shared/pdf/database_table_renderer.go b/internal/shared/pdf/database_table_renderer.go index 9fb7fe3..1394d86 100644 --- a/internal/shared/pdf/database_table_renderer.go +++ b/internal/shared/pdf/database_table_renderer.go @@ -87,6 +87,7 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa if currentY+estimatedHeaderHeight > pageHeight-bottomMargin { r.logger.Debug("表头前需要分页", zap.Float64("current_y", currentY)) pdf.AddPage() + pdf.SetY(ContentStartYBelowHeader) } // 绘制表头 @@ -448,6 +449,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co zap.Float64("current_y", currentY), zap.Float64("page_height", pageHeight)) pdf.AddPage() + pdf.SetY(ContentStartYBelowHeader) // 在新页面上重新绘制表头 if len(headers) > 0 && len(headerColWidths) > 0 { newHeaderStartY := pdf.GetY() @@ -523,6 +525,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co zap.Int("row_index", rowIndex), zap.Float64("row_height", maxCellHeight)) pdf.AddPage() + pdf.SetY(ContentStartYBelowHeader) startY = pdf.GetY() } diff --git a/internal/shared/pdf/font_manager.go b/internal/shared/pdf/font_manager.go index fa85ef9..88aca87 100644 --- a/internal/shared/pdf/font_manager.go +++ b/internal/shared/pdf/font_manager.go @@ -205,8 +205,10 @@ func (fm *FontManager) getChineseFontPaths() []string { func (fm *FontManager) getWatermarkFontPaths() []string { // 水印字体文件名(尝试大小写变体) fontNames := []string{ - "YunFengFeiYunTi-2.ttf", // 优先尝试大写版本 - "yunfengfeiyunti-2.ttf", // 小写版本(兼容) + // "XuanZongTi-v0.1.otf", //玄宗字体不支持otf + "WenYuanSerifSC-Bold.ttf", //文渊雅黑 + // "YunFengFeiYunTi-2.ttf", // 毛笔字体 + // "yunfengfeiyunti-2.ttf", // 毛笔字体小写版本(兼容) } return fm.buildFontPaths(fontNames) diff --git a/internal/shared/pdf/page_builder.go b/internal/shared/pdf/page_builder.go index 0cb061b..f1eb17f 100644 --- a/internal/shared/pdf/page_builder.go +++ b/internal/shared/pdf/page_builder.go @@ -56,109 +56,142 @@ func NewPageBuilder( } } +// 封面页底部为价格预留的高度(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() - // 添加页眉(logo和文字) - pb.addHeader(pdf, chineseFontAvailable) + pageWidth, pageHeight := pdf.GetPageSize() + _, _, _, bottomMargin := pdf.GetMargins() + // 内容区最大 Y:超出则不再绘制,留出底部给价格,避免价格单独一页 + maxContentY := pageHeight - bottomMargin - firstPagePriceReservedHeight - // 添加水印 - pb.addWatermark(pdf, chineseFontAvailable) - - // 封面页布局 - 居中显示 - pageWidth, _ := pdf.GetPageSize() - - // 标题区域(页面中上部) - pdf.SetY(80) + // 标题区域(在页眉下方留足间距,避免与 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(10) + pdf.Ln(6) pb.fontManager.SetFont(pdf, "", 18) _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "") - // 分隔线 - pdf.Ln(20) - pdf.SetLineWidth(0.5) - pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY()) - - // 产品编码(居中) - pdf.Ln(30) + // 产品编码 + 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(25) - desc := pb.textProcessor.StripHTML(product.Description) + pdf.Ln(10) + desc := pb.textProcessor.HTMLToPlainWithBreaks(product.Description) desc = pb.textProcessor.CleanText(desc) pb.fontManager.SetFont(pdf, "", 14) _, lineHt = pdf.GetFontSize() - // 居中对齐的MultiCell(通过计算宽度实现) - descWidth := pageWidth * 0.7 - descLines := pb.safeSplitText(pdf, desc, descWidth, chineseFontAvailable) - currentX := (pageWidth - descWidth) / 2 - for _, line := range descLines { - pdf.SetX(currentX) - pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "") - } + pb.drawRichTextBlock(pdf, desc, pageWidth*0.7, lineHt*1.5, maxContentY, "C", true, chineseFontAvailable) } - // 产品详情(如果存在) - if product.Content != "" { - pdf.Ln(20) - content := pb.textProcessor.StripHTML(product.Content) - content = pb.textProcessor.CleanText(content) - pb.fontManager.SetFont(pdf, "", 12) - _, lineHt = pdf.GetFontSize() - contentWidth := pageWidth * 0.7 - contentLines := pb.safeSplitText(pdf, content, contentWidth, chineseFontAvailable) - currentX := (pageWidth - contentWidth) / 2 - for _, line := range contentLines { - pdf.SetX(currentX) - pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "") - } - } - - // 价格信息(右下角,在产品详情之后) + // 产品详情已移至单独一页,见 AddProductContentPage if !product.Price.IsZero() { - // 获取产品详情结束后的Y坐标,稍微下移显示价格 - contentEndY := pdf.GetY() - pdf.SetY(contentEndY + 5) - pdf.SetTextColor(0, 0, 0) 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) - // 右对齐:从页面宽度减去文本宽度和右边距(15mm) pdf.SetX(pageWidth - textWidth - 15) pdf.CellFormat(textWidth, priceLineHt, priceText, "", 0, "R", false, 0, "") } +} +// AddProductContentPage 添加产品详情页(另起一页,左对齐,段前两空格) +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) + 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) +} + +// 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 { + 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 在 AddPage 时自动绘制 func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { - // 创建自定义的AddPage函数,确保每页都有水印 - addPageWithWatermark := func() { - pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 - } + pdf.AddPage() - addPageWithWatermark() - - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) pb.fontManager.SetFont(pdf, "B", 18) _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "") @@ -212,10 +245,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") - // JSON中可能包含中文值,使用黑体字体 - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文) - pdf.SetTextColor(0, 0, 0) - pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt) } } @@ -227,7 +257,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") - // 优先尝试提取和格式化JSON + // 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐) jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample) if jsonContent != "" { // 格式化JSON @@ -235,9 +265,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro if err == nil { jsonContent = formattedJSON } - pdf.SetTextColor(0, 0, 0) // 确保深黑色 - pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文) - pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt) } else { // 如果没有JSON,尝试使用表格方式处理 if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil { @@ -253,8 +281,9 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro } } - // 返回字段说明 + // 返回字段说明(确保在页眉下方,避免与 logo 重叠) if doc.ResponseFields != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -306,18 +335,11 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro } // AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明) -// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明 +// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc 自动绘制。 func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { - // 创建自定义的AddPage函数,确保每页都有水印 - addPageWithWatermark := func() { - pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 - } + pdf.AddPage() - addPageWithWatermark() - - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) pb.fontManager.SetFont(pdf, "B", 18) _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "") @@ -371,10 +393,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") - // JSON中可能包含中文值,使用黑体字体 - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文) - pdf.SetTextColor(0, 0, 0) - pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt) } } @@ -386,7 +405,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") - // 优先尝试提取和格式化JSON + // 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐) jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample) if jsonContent != "" { // 格式化JSON @@ -394,9 +413,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp if err == nil { jsonContent = formattedJSON } - pdf.SetTextColor(0, 0, 0) // 确保深黑色 - pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文) - pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt) } else { // 如果没有JSON,尝试使用表格方式处理 if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil { @@ -412,8 +429,9 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp } } - // 返回字段说明 + // 返回字段说明(确保在页眉下方,避免与 logo 重叠) if doc.ResponseFields != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -463,17 +481,11 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp } // AddSubProductDocumentationPages 添加子产品的接口文档页面(用于组合包) +// 每页页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制 func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProduct *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool, isLastSubProduct bool) { - // 创建自定义的AddPage函数,确保每页都有水印 - addPageWithWatermark := func() { - pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 - } + pdf.AddPage() - addPageWithWatermark() - - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) pb.fontManager.SetFont(pdf, "B", 18) _, lineHt := pdf.GetFontSize() @@ -533,10 +545,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") - // JSON中可能包含中文值,使用黑体字体 - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文) - pdf.SetTextColor(0, 0, 0) - pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt) } } @@ -548,7 +557,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") - // 优先尝试提取和格式化JSON + // 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐) jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample) if jsonContent != "" { // 格式化JSON @@ -556,9 +565,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd if err == nil { jsonContent = formattedJSON } - pdf.SetTextColor(0, 0, 0) // 确保深黑色 - pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文) - pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt) } else { // 如果没有JSON,尝试使用表格方式处理 if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil { @@ -574,8 +581,9 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd } } - // 返回字段说明 + // 返回字段说明(确保在页眉下方,避免与 logo 重叠) if doc.ResponseFields != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -979,73 +987,121 @@ func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { // 绘制下横线(优化位置,左边距是15mm) pdf.Line(15, 22, 75, 22) + + // 所有自动分页后的正文统一从页眉下方固定位置开始,避免内容顶到 logo 或水印 + pdf.SetY(ContentStartYBelowHeader) } -// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域) +// 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() - // 获取页面尺寸和边距 - _, pageHeight := pdf.GetPageSize() + pageWidth, pageHeight := pdf.GetPageSize() leftMargin, topMargin, _, bottomMargin := pdf.GetMargins() - - // 计算实际可用区域高度 usableHeight := pageHeight - topMargin - bottomMargin + usableWidth := pageWidth - leftMargin*2 - // 设置水印样式(使用水印字体,非黑体) - fontSize := 45.0 - + fontSize := 42.0 pb.fontManager.SetWatermarkFont(pdf, "", fontSize) - // 设置灰色和透明度(加深水印,使其更明显) - pdf.SetTextColor(180, 180, 180) // 深一点的灰色 - pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显 + // 加深水印:更深的灰与更高不透明度,保证可见 + pdf.SetTextColor(150, 150, 150) + pdf.SetAlpha(0.32, "Normal") - // 计算文字宽度 textWidth := pdf.GetStringWidth(pb.watermarkText) if textWidth == 0 { - // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm) textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0 } - // 从左边开始,计算起始位置 - // 起始X:左边距 - // 起始Y:考虑水印文字长度和旋转后需要的空间 - startX := leftMargin - startY := topMargin + textWidth*0.5 // 为旋转留出空间 - - // 移动到起始位置 - pdf.TransformTranslate(startX, startY) - - // 向上倾斜45度(顺时针旋转45度,即-45度,或逆时针315度) - pdf.TransformRotate(-45, 0, 0) - - // 检查文字是否会超出可用区域(旋转后的对角线长度) + // 旋转后对角线长度,用于缩放与定位 rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize) - if rotatedDiagonal > usableHeight*0.8 { - // 如果太大,缩小字体 - fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal + 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) // 恢复为黑色 + pdf.SetTextColor(0, 0, 0) +} + +// 段前缩进宽度(约两字符,mm) +const paragraphIndentMM = 7.0 + +// drawRichTextBlock 按段落与换行绘制文本块(还原 HTML 换行),超出 maxContentY 截断并显示 … +// 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 获取内容预览(用于日志记录) @@ -1057,6 +1113,49 @@ func (pb *PageBuilder) getContentPreview(content string, maxLen int) string { return content[:maxLen] + "..." } +// drawJSONInCenteredTable 在居中表格中绘制 JSON 文本(表格居中,内容左对齐);空间不足时自动换页避免压住 logo +func (pb *PageBuilder) drawJSONInCenteredTable(pdf *gofpdf.Fpdf, jsonContent string, lineHt float64) { + jsonContent = strings.TrimSpace(jsonContent) + if jsonContent == "" { + return + } + pageWidth, pageHeight := pdf.GetPageSize() + leftMargin, _, rightMargin, bottomMargin := pdf.GetMargins() + usableWidth := pageWidth - leftMargin - rightMargin + // 表格宽度取可用宽度的 85%,居中 + tableWidth := usableWidth * 0.85 + 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 + + // 当前页剩余高度不足时先换页,避免表格被 logo 或底部裁掉 + currentY := pdf.GetY() + if currentY+totalHeight > pageHeight-bottomMargin { + pdf.AddPage() + currentY = ContentStartYBelowHeader + pdf.SetY(currentY) + } + + 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, "") + } + pdf.SetY(startY + totalHeight) +} + // safeSplitText 安全地分割文本,避免在没有中文字体时调用SplitText导致panic func (pb *PageBuilder) safeSplitText(pdf *gofpdf.Fpdf, text string, width float64, chineseFontAvailable bool) []string { // 检查文本是否包含中文字符 @@ -1167,12 +1266,10 @@ func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.Product currentY := pdf.GetY() remainingHeight := pageHeight - currentY - bottomMargin - // 如果剩余空间不足,添加新页 + // 如果剩余空间不足,添加新页(页眉与水印由 SetHeaderFunc 自动绘制) if remainingHeight < 100 { pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) } // 添加分隔线 @@ -1327,9 +1424,7 @@ func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductD // 检查是否需要换页(为二维码预留空间) if pageHeight-currentY-bottomMargin < 120 { pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) } // 添加二维码标题 @@ -1381,9 +1476,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF qrSize := 40.0 if pageHeight-currentY-bottomMargin < qrSize+20 { pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) } // 生成二维码 diff --git a/internal/shared/pdf/pdf_cache_manager.go b/internal/shared/pdf/pdf_cache_manager.go index bd671ce..091db5b 100644 --- a/internal/shared/pdf/pdf_cache_manager.go +++ b/internal/shared/pdf/pdf_cache_manager.go @@ -434,4 +434,3 @@ func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) { "max_size": m.maxSize, }, nil } - diff --git a/internal/shared/pdf/pdf_generator.go b/internal/shared/pdf/pdf_generator.go index 0b8c1d8..849c5b5 100644 --- a/internal/shared/pdf/pdf_generator.go +++ b/internal/shared/pdf/pdf_generator.go @@ -2078,69 +2078,55 @@ func (g *PDFGenerator) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { pdf.Line(15, 22, 75, 22) } -// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域) +// addWatermark 添加水印:自左下角往右上角倾斜 45°,单条水印居中于页面,样式柔和 func (g *PDFGenerator) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { - // 如果中文字体不可用,跳过水印(避免显示乱码) if !chineseFontAvailable { return } - // 保存当前图形状态 pdf.TransformBegin() defer pdf.TransformEnd() - // 获取页面尺寸和边距 - _, pageHeight := pdf.GetPageSize() + pageWidth, pageHeight := pdf.GetPageSize() leftMargin, topMargin, _, bottomMargin := pdf.GetMargins() - - // 计算实际可用区域高度 usableHeight := pageHeight - topMargin - bottomMargin + usableWidth := pageWidth - leftMargin*2 - // 设置水印样式(使用中文字体) - fontSize := 45.0 - + fontSize := 42.0 pdf.SetFont("ChineseFont", "", fontSize) - // 设置灰色和透明度(加深水印,使其更明显) - pdf.SetTextColor(180, 180, 180) // 深一点的灰色 - pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显 + pdf.SetTextColor(150, 150, 150) + pdf.SetAlpha(0.32, "Normal") - // 计算文字宽度 textWidth := pdf.GetStringWidth(g.watermarkText) if textWidth == 0 { - // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm) textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 } - // 从左边开始,计算起始位置 - // 起始X:左边距 - // 起始Y:考虑水印文字长度和旋转后需要的空间 - startX := leftMargin - startY := topMargin + textWidth*0.5 // 为旋转留出空间 - - // 移动到起始位置 - pdf.TransformTranslate(startX, startY) - - // 向上倾斜45度(顺时针旋转45度,即-45度,或逆时针315度) - pdf.TransformRotate(-45, 0, 0) - - // 检查文字是否会超出可用区域(旋转后的对角线长度) rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize) - if rotatedDiagonal > usableHeight*0.8 { - // 如果太大,缩小字体 - fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal + if rotatedDiagonal > usableHeight*0.75 { + fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal pdf.SetFont("ChineseFont", "", fontSize) textWidth = pdf.GetStringWidth(g.watermarkText) if textWidth == 0 { textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 } + rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize) } - // 从左边开始绘制水印文字 + startX := leftMargin + startY := pageHeight - bottomMargin + 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, g.watermarkText, "", 0, "L", false, 0, "") - // 恢复透明度和颜色 pdf.SetAlpha(1.0, "Normal") - pdf.SetTextColor(0, 0, 0) // 恢复为黑色 + pdf.SetTextColor(0, 0, 0) } diff --git a/internal/shared/pdf/pdf_generator_refactored.go b/internal/shared/pdf/pdf_generator_refactored.go index c82a0ff..8b2ec06 100644 --- a/internal/shared/pdf/pdf_generator_refactored.go +++ b/internal/shared/pdf/pdf_generator_refactored.go @@ -151,8 +151,8 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent // 创建PDF文档 (A4大小,gofpdf v2 默认支持UTF-8) pdf := gofpdf.New("P", "mm", "A4", "") - // 优化边距,减少空白 - pdf.SetMargins(15, 25, 15) + // 上边距与 ContentStartYBelowHeader 一致,这样自动分页后新页内容从 logo 下方开始,不被遮挡 + pdf.SetMargins(15, ContentStartYBelowHeader, 15) // 加载黑体字体(用于所有内容,除了水印) // 注意:此时工作目录应该是根目录(/),这样gofpdf处理路径时就能正确解析 @@ -176,9 +176,22 @@ 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) - // 添加第一页(产品信息) + // 页眉只画 logo+横线。水印用 SetFooterFunc 画:gofpdf 在每页内容画完后再调 Footer,水印最后画,z 层在最上,不会被表格等盖住 + pdf.SetHeaderFunc(func() { + pageBuilder.addHeader(pdf, chineseFontAvailable) + }) + pdf.SetFooterFunc(func() { + pageBuilder.addWatermark(pdf, chineseFontAvailable) + }) + + // 添加第一页(封面:产品信息 + 产品描述 + 价格) pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable) + // 产品详情单独一页(左对齐,段前两空格) + if product.Content != "" { + pageBuilder.AddProductContentPage(pdf, product, chineseFontAvailable) + } + // 如果是组合包,需要特殊处理:先渲染所有文档,最后统一添加二维码 if product.IsPackage { // 如果有关联的文档,添加接口文档页面(但不包含二维码和说明,后面统一添加) diff --git a/internal/shared/pdf/text_processor.go b/internal/shared/pdf/text_processor.go index 47a784f..b32c8ad 100644 --- a/internal/shared/pdf/text_processor.go +++ b/internal/shared/pdf/text_processor.go @@ -90,6 +90,22 @@ func (tp *TextProcessor) StripHTML(text string) string { return text } +// HTMLToPlainWithBreaks 将 HTML 转为纯文本并保留富文本换行效果(