This commit is contained in:
2026-04-21 22:36:48 +08:00
commit 488c695fdf
748 changed files with 266838 additions and 0 deletions

View File

@@ -0,0 +1,601 @@
package pdf
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"hyapi-server/internal/domains/product/entities"
"go.uber.org/zap"
)
// DatabaseTableReader 数据库表格数据读取器
type DatabaseTableReader struct {
logger *zap.Logger
}
// NewDatabaseTableReader 创建数据库表格数据读取器
func NewDatabaseTableReader(logger *zap.Logger) *DatabaseTableReader {
return &DatabaseTableReader{
logger: logger,
}
}
// TableData 表格数据
type TableData struct {
Headers []string
Rows [][]string
}
// TableWithTitle 带标题的表格
type TableWithTitle struct {
Title string // 表格标题markdown标题
Table *TableData // 表格数据
}
// ReadTableFromDocumentation 从产品文档中读取表格数据
// 先将markdown表格转换为JSON格式然后再转换为表格数据
func (r *DatabaseTableReader) ReadTableFromDocumentation(ctx context.Context, doc *entities.ProductDocumentation, fieldType string) (*TableData, error) {
var content string
switch fieldType {
case "request_params":
content = doc.RequestParams
case "response_fields":
content = doc.ResponseFields
case "response_example":
content = doc.ResponseExample
case "error_codes":
content = doc.ErrorCodes
default:
return nil, fmt.Errorf("未知的字段类型: %s", fieldType)
}
// 检查内容是否为空(去除空白字符后)
trimmedContent := strings.TrimSpace(content)
if trimmedContent == "" {
return nil, fmt.Errorf("字段 %s 内容为空", fieldType)
}
// 先尝试解析为JSON数组如果已经是JSON格式
var jsonArray []map[string]interface{}
if err := json.Unmarshal([]byte(content), &jsonArray); err == nil && len(jsonArray) > 0 {
r.logger.Info("数据已经是JSON格式直接使用",
zap.String("field_type", fieldType),
zap.Int("json_array_length", len(jsonArray)))
return r.convertJSONArrayToTable(jsonArray), nil
}
// 尝试解析为单个JSON对象包含数组字段
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(content), &jsonObj); err == nil {
// 查找包含数组的字段
for _, value := range jsonObj {
if arr, ok := value.([]interface{}); ok && len(arr) > 0 {
// 转换为map数组
mapArray := make([]map[string]interface{}, 0, len(arr))
for _, item := range arr {
if itemMap, ok := item.(map[string]interface{}); ok {
mapArray = append(mapArray, itemMap)
}
}
if len(mapArray) > 0 {
r.logger.Info("从JSON对象中提取数组数据", zap.String("field_type", fieldType))
return r.convertJSONArrayToTable(mapArray), nil
}
}
}
}
// 如果不是JSON格式先解析为markdown表格然后转换为JSON格式
tableData, err := r.parseMarkdownTable(content)
if err != nil {
// 错误已返回,不记录日志
return nil, fmt.Errorf("解析markdown表格失败: %w", err)
}
// 将markdown表格数据转换为JSON格式保持列顺序
r.logger.Debug("开始将表格数据转换为JSON格式", zap.String("field_type", fieldType))
jsonArray = r.convertTableDataToJSON(tableData)
// 记录转换后的JSON用于调试
jsonBytes, marshalErr := json.MarshalIndent(jsonArray, "", " ")
if marshalErr != nil {
r.logger.Warn("JSON序列化失败",
zap.String("field_type", fieldType),
zap.Error(marshalErr))
} else {
previewLen := len(jsonBytes)
if previewLen > 1000 {
previewLen = 1000
}
r.logger.Debug("转换后的JSON数据预览",
zap.String("field_type", fieldType),
zap.Int("json_length", len(jsonBytes)),
zap.String("json_preview", string(jsonBytes[:previewLen])))
// 如果JSON数据较大记录完整路径提示
if len(jsonBytes) > 1000 {
r.logger.Info("JSON数据较大完整内容请查看debug级别日志",
zap.String("field_type", fieldType),
zap.Int("json_length", len(jsonBytes)))
}
}
// 将JSON数据转换回表格数据用于渲染使用原始表头顺序保持列顺序
return r.convertJSONArrayToTableWithOrder(jsonArray, tableData.Headers), nil
}
// convertJSONArrayToTable 将JSON数组转换为表格数据用于已经是JSON格式的数据
func (r *DatabaseTableReader) convertJSONArrayToTable(data []map[string]interface{}) *TableData {
if len(data) == 0 {
return &TableData{
Headers: []string{},
Rows: [][]string{},
}
}
// 收集所有列名(按第一次出现的顺序)
columnSet := make(map[string]bool)
columns := make([]string, 0)
// 从第一行开始收集列名,保持第一次出现的顺序
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
// 只从第一行收集,保持顺序
if len(columns) > 0 {
break
}
}
// 如果第一行没有收集到所有列,继续收集(但顺序可能不稳定)
if len(columns) == 0 {
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
}
}
// 构建表头
headers := make([]string, len(columns))
copy(headers, columns)
// 构建数据行
rows := make([][]string, 0, len(data))
for _, row := range data {
rowData := make([]string, len(columns))
for i, col := range columns {
value := row[col]
rowData[i] = r.formatValue(value)
}
rows = append(rows, rowData)
}
return &TableData{
Headers: headers,
Rows: rows,
}
}
// convertJSONArrayToTableWithOrder 将JSON数组转换为表格数据使用指定的列顺序
func (r *DatabaseTableReader) convertJSONArrayToTableWithOrder(data []map[string]interface{}, originalHeaders []string) *TableData {
if len(data) == 0 {
return &TableData{
Headers: originalHeaders,
Rows: [][]string{},
}
}
// 使用原始表头顺序
headers := make([]string, len(originalHeaders))
copy(headers, originalHeaders)
// 构建数据行,按照原始表头顺序
rows := make([][]string, 0, len(data))
for _, row := range data {
rowData := make([]string, len(headers))
for i, header := range headers {
value := row[header]
rowData[i] = r.formatValue(value)
}
rows = append(rows, rowData)
}
r.logger.Debug("JSON转表格完成保持列顺序",
zap.Int("header_count", len(headers)),
zap.Int("row_count", len(rows)),
zap.Strings("headers", headers))
return &TableData{
Headers: headers,
Rows: rows,
}
}
// parseMarkdownTablesWithTitles 解析markdown格式的表格支持多个表格保留标题
func (r *DatabaseTableReader) parseMarkdownTablesWithTitles(content string) ([]TableWithTitle, error) {
lines := strings.Split(content, "\n")
var result []TableWithTitle
var currentTitle string
var currentHeaders []string
var currentRows [][]string
inTable := false
hasValidHeader := false
nonTableLineCount := 0
maxNonTableLines := 3 // 允许最多3个连续非表格行
for _, line := range lines {
line = strings.TrimSpace(line)
// 处理markdown标题行以#开头)- 保存标题
if strings.HasPrefix(line, "#") {
// 如果当前有表格,先保存
if inTable && len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
currentHeaders = nil
currentRows = nil
inTable = false
hasValidHeader = false
}
// 提取标题(移除#和空格)
currentTitle = strings.TrimSpace(strings.TrimPrefix(line, "#"))
currentTitle = strings.TrimSpace(strings.TrimPrefix(currentTitle, "#"))
currentTitle = strings.TrimSpace(strings.TrimPrefix(currentTitle, "#"))
nonTableLineCount = 0
continue
}
// 跳过空行
if line == "" {
if inTable {
nonTableLineCount++
if nonTableLineCount > maxNonTableLines {
// 当前表格结束,保存并重置
if len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
currentHeaders = nil
currentRows = nil
currentTitle = ""
}
inTable = false
hasValidHeader = false
nonTableLineCount = 0
}
}
continue
}
// 检查是否是markdown表格行
if !strings.Contains(line, "|") {
// 如果已经在表格中,遇到非表格行则计数
if inTable {
nonTableLineCount++
// 如果连续非表格行过多,表格结束
if nonTableLineCount > maxNonTableLines {
// 当前表格结束,保存并重置
if len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
currentHeaders = nil
currentRows = nil
currentTitle = ""
}
inTable = false
hasValidHeader = false
nonTableLineCount = 0
}
}
continue
}
// 重置非表格行计数(遇到表格行了)
nonTableLineCount = 0
// 跳过分隔行
if r.isSeparatorLine(line) {
// 分隔行后应该开始数据行
if hasValidHeader {
continue
}
// 如果还没有表头,跳过分隔行
continue
}
// 解析表格行
cells := strings.Split(line, "|")
// 清理首尾空元素
if len(cells) > 0 && strings.TrimSpace(cells[0]) == "" {
cells = cells[1:]
}
if len(cells) > 0 && strings.TrimSpace(cells[len(cells)-1]) == "" {
cells = cells[:len(cells)-1]
}
// 清理每个单元格,过滤空字符
cleanedCells := make([]string, 0, len(cells))
for _, cell := range cells {
cleaned := strings.TrimSpace(cell)
// 移除HTML标签如<br>
cleaned = r.removeHTMLTags(cleaned)
cleanedCells = append(cleanedCells, cleaned)
}
// 检查这一行是否有有效内容
hasContent := false
for _, cell := range cleanedCells {
if strings.TrimSpace(cell) != "" {
hasContent = true
break
}
}
if !hasContent || len(cleanedCells) == 0 {
continue
}
if !inTable {
// 第一行作为表头
currentHeaders = cleanedCells
inTable = true
hasValidHeader = true
} else {
// 数据行,确保列数与表头一致
row := make([]string, len(currentHeaders))
for i := range row {
if i < len(cleanedCells) {
row[i] = cleanedCells[i]
} else {
row[i] = ""
}
}
// 检查数据行是否有有效内容(至少有一个非空单元格)
hasData := false
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
hasData = true
break
}
}
// 只添加有有效内容的数据行
if hasData {
currentRows = append(currentRows, row)
}
}
}
// 处理最后一个表格
if len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
}
if len(result) == 0 {
return nil, fmt.Errorf("无法解析表格:未找到表头")
}
return result, nil
}
// parseMarkdownTable 解析markdown格式的表格兼容方法调用新方法
func (r *DatabaseTableReader) parseMarkdownTable(content string) (*TableData, error) {
tablesWithTitles, err := r.parseMarkdownTablesWithTitles(content)
if err != nil {
return nil, err
}
if len(tablesWithTitles) == 0 {
return nil, fmt.Errorf("未找到任何表格")
}
// 返回第一个表格(向后兼容)
return tablesWithTitles[0].Table, nil
}
// mergeTables 合并多个表格(使用最宽的表头)
func (r *DatabaseTableReader) mergeTables(existingHeaders []string, existingRows [][]string, newHeaders []string, newRows [][]string) ([]string, [][]string) {
// 如果这是第一个表格,直接返回
if len(existingHeaders) == 0 {
return newHeaders, newRows
}
// 使用最宽的表头(列数最多的)
var finalHeaders []string
if len(newHeaders) > len(existingHeaders) {
finalHeaders = make([]string, len(newHeaders))
copy(finalHeaders, newHeaders)
} else {
finalHeaders = make([]string, len(existingHeaders))
copy(finalHeaders, existingHeaders)
}
// 合并所有行,确保列数与最终表头一致
mergedRows := make([][]string, 0, len(existingRows)+len(newRows))
// 添加已有行
for _, row := range existingRows {
adjustedRow := make([]string, len(finalHeaders))
copy(adjustedRow, row)
mergedRows = append(mergedRows, adjustedRow)
}
// 添加新行
for _, row := range newRows {
adjustedRow := make([]string, len(finalHeaders))
for i := range adjustedRow {
if i < len(row) {
adjustedRow[i] = row[i]
} else {
adjustedRow[i] = ""
}
}
mergedRows = append(mergedRows, adjustedRow)
}
return finalHeaders, mergedRows
}
// removeHTMLTags 移除HTML标签如<br>)和样式信息
func (r *DatabaseTableReader) removeHTMLTags(text string) string {
// 先移除所有HTML标签包括带样式的标签如 <span style="color:red">
// 使用正则表达式移除所有HTML标签及其内容
re := regexp.MustCompile(`<[^>]+>`)
text = re.ReplaceAllString(text, "")
// 替换常见的HTML换行标签为空格
text = strings.ReplaceAll(text, "<br>", " ")
text = strings.ReplaceAll(text, "<br/>", " ")
text = strings.ReplaceAll(text, "<br />", " ")
text = strings.ReplaceAll(text, "\n", " ")
// 移除HTML实体
text = strings.ReplaceAll(text, "&nbsp;", " ")
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
return strings.TrimSpace(text)
}
// isSeparatorLine 检查是否是markdown表格的分隔行
func (r *DatabaseTableReader) isSeparatorLine(line string) bool {
if !strings.Contains(line, "-") {
return false
}
for _, r := range line {
if r != '|' && r != '-' && r != ':' && r != ' ' {
return false
}
}
return true
}
// convertTableDataToJSON 将表格数据转换为JSON数组格式
func (r *DatabaseTableReader) convertTableDataToJSON(tableData *TableData) []map[string]interface{} {
if tableData == nil || len(tableData.Headers) == 0 {
r.logger.Warn("表格数据为空无法转换为JSON")
return []map[string]interface{}{}
}
jsonArray := make([]map[string]interface{}, 0, len(tableData.Rows))
validRowCount := 0
for rowIndex, row := range tableData.Rows {
rowObj := make(map[string]interface{})
for i, header := range tableData.Headers {
// 获取对应的单元格值
var cellValue string
if i < len(row) {
cellValue = strings.TrimSpace(row[i])
}
// 将表头作为key单元格值作为value
header = strings.TrimSpace(header)
if header != "" {
rowObj[header] = cellValue
}
}
// 只添加有有效数据的行
if len(rowObj) > 0 {
jsonArray = append(jsonArray, rowObj)
validRowCount++
} else {
r.logger.Debug("跳过空行",
zap.Int("row_index", rowIndex))
}
}
r.logger.Debug("表格转JSON完成",
zap.Int("total_rows", len(tableData.Rows)),
zap.Int("valid_rows", validRowCount),
zap.Int("json_array_length", len(jsonArray)))
return jsonArray
}
// getContentPreview 获取内容预览(用于日志记录)
func (r *DatabaseTableReader) getContentPreview(content string, maxLen int) string {
content = strings.TrimSpace(content)
if len(content) <= maxLen {
return content
}
if maxLen > len(content) {
maxLen = len(content)
}
return content[:maxLen] + "..."
}
// formatValue 格式化值为字符串
func (r *DatabaseTableReader) formatValue(value interface{}) string {
if value == nil {
return ""
}
var result string
switch v := value.(type) {
case string:
result = strings.TrimSpace(v)
// 如果去除空白后为空,返回空字符串
if result == "" {
return ""
}
// 移除HTML标签和样式确保数据干净
result = r.removeHTMLTags(result)
return result
case bool:
if v {
return "是"
}
return "否"
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
result = fmt.Sprintf("%v", v)
// 去除空白字符
result = strings.TrimSpace(result)
if result == "" {
return ""
}
return result
}
}

View File

@@ -0,0 +1,653 @@
package pdf
import (
"math"
"regexp"
"strings"
"unicode"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// DatabaseTableRenderer 数据库表格渲染器
type DatabaseTableRenderer struct {
logger *zap.Logger
fontManager *FontManager
}
// NewDatabaseTableRenderer 创建数据库表格渲染器
func NewDatabaseTableRenderer(logger *zap.Logger, fontManager *FontManager) *DatabaseTableRenderer {
return &DatabaseTableRenderer{
logger: logger,
fontManager: fontManager,
}
}
// RenderTable 渲染表格到PDF
func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableData) error {
if tableData == nil || len(tableData.Headers) == 0 {
r.logger.Warn("表格数据为空,跳过渲染")
return nil
}
// 避免表格绘制在页眉区,防止遮挡 logo
if pdf.GetY() < ContentStartYBelowHeader {
pdf.SetY(ContentStartYBelowHeader)
}
// 检查表头是否有有效内容
hasValidHeader := false
for _, header := range tableData.Headers {
if strings.TrimSpace(header) != "" {
hasValidHeader = true
break
}
}
if !hasValidHeader {
r.logger.Warn("表头内容为空,跳过渲染")
return nil
}
// 检查是否有有效的数据行
hasValidRows := false
for _, row := range tableData.Rows {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
hasValidRows = true
break
}
}
if hasValidRows {
break
}
}
r.logger.Debug("表格验证通过",
zap.Bool("has_valid_header", hasValidHeader),
zap.Bool("has_valid_rows", hasValidRows),
zap.Int("row_count", len(tableData.Rows)))
// 即使没有数据行,也渲染表头(单行表格)
// 但如果没有表头也没有数据,则不渲染
// 表格线细线(返回字段说明等表格线不要太粗)
savedLineWidth := pdf.GetLineWidth()
pdf.SetLineWidth(0.2)
defer pdf.SetLineWidth(savedLineWidth)
// 正文字体:宋体小四 12pt
r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
_, lineHt := pdf.GetFontSize()
// 计算页面可用宽度
pageWidth, _ := pdf.GetPageSize()
availableWidth := pageWidth - 30 // 减去左右边距15mm * 2
// 计算每列宽度
colWidths := r.calculateColumnWidths(pdf, tableData, availableWidth)
// 检查是否需要分页(在绘制表头前)
_, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
estimatedHeaderHeight := lineHt * 2.5 // 估算表头高度
if currentY+estimatedHeaderHeight > pageHeight-bottomMargin {
r.logger.Debug("表头前需要分页", zap.Float64("current_y", currentY))
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
}
// 绘制表头
headerStartY := pdf.GetY()
headerHeight := r.renderHeader(pdf, tableData.Headers, colWidths, headerStartY)
// 移动到表头下方
pdf.SetXY(15.0, headerStartY+headerHeight)
// 绘制数据行(如果有数据行)
if len(tableData.Rows) > 0 {
r.logger.Debug("开始渲染数据行", zap.Int("row_count", len(tableData.Rows)))
r.renderRows(pdf, tableData.Rows, colWidths, lineHt, tableData.Headers, colWidths)
} else {
r.logger.Debug("没有数据行,只渲染表头")
}
return nil
}
// calculateColumnWidths 计算每列的宽度
// 策略:先确保每列最短内容能完整显示(不换行),然后根据内容长度分配剩余空间
func (r *DatabaseTableRenderer) calculateColumnWidths(pdf *gofpdf.Fpdf, tableData *TableData, availableWidth float64) []float64 {
numCols := len(tableData.Headers)
r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
// 第一步:找到每列中最短的内容(包括表头),计算其完整显示所需的最小宽度
colMinWidths := make([]float64, numCols)
colMaxWidths := make([]float64, numCols)
colContentLengths := make([]float64, numCols) // 用于记录内容总长度,用于分配剩余空间
for i := 0; i < numCols; i++ {
minWidth := math.MaxFloat64
maxWidth := 0.0
totalLength := 0.0
count := 0
// 检查表头
if i < len(tableData.Headers) {
header := tableData.Headers[i]
textWidth := r.getTextWidth(pdf, header)
cellWidth := textWidth + 8 // 加上内边距
if cellWidth < minWidth {
minWidth = cellWidth
}
if cellWidth > maxWidth {
maxWidth = cellWidth
}
totalLength += textWidth
count++
}
// 检查所有数据行
for _, row := range tableData.Rows {
if i < len(row) {
cell := row[i]
textWidth := r.getTextWidth(pdf, cell)
cellWidth := textWidth + 8 // 加上内边距
if cellWidth < minWidth {
minWidth = cellWidth
}
if cellWidth > maxWidth {
maxWidth = cellWidth
}
totalLength += textWidth
count++
}
}
// 设置最小宽度(确保最短内容能完整显示)
if minWidth == math.MaxFloat64 {
colMinWidths[i] = 30.0 // 默认最小宽度
} else {
colMinWidths[i] = math.Max(minWidth, 25.0) // 至少25mm
}
colMaxWidths[i] = maxWidth
if count > 0 {
colContentLengths[i] = totalLength / float64(count) // 平均内容长度
} else {
colContentLengths[i] = colMinWidths[i]
}
}
// 第二步:计算总的最小宽度(确保所有最短内容都能显示)
totalMinWidth := 0.0
for _, w := range colMinWidths {
totalMinWidth += w
}
// 第三步:分配宽度
colWidths := make([]float64, numCols)
if totalMinWidth >= availableWidth {
// 如果最小宽度已经超过可用宽度,按比例缩放,但确保每列至少能显示最短内容
scale := availableWidth / totalMinWidth
for i := range colWidths {
colWidths[i] = colMinWidths[i] * scale
// 确保最小宽度,但允许稍微压缩
if colWidths[i] < colMinWidths[i]*0.8 {
colWidths[i] = colMinWidths[i] * 0.8
}
}
} else {
// 如果最小宽度小于可用宽度,先分配最小宽度,然后根据内容长度分配剩余空间
extraWidth := availableWidth - totalMinWidth
// 计算总的内容长度(用于按比例分配)
totalContentLength := 0.0
for _, length := range colContentLengths {
totalContentLength += length
}
// 如果总内容长度为0平均分配
if totalContentLength < 0.1 {
extraPerCol := extraWidth / float64(numCols)
for i := range colWidths {
colWidths[i] = colMinWidths[i] + extraPerCol
}
} else {
// 根据内容长度按比例分配剩余空间
for i := range colWidths {
// 计算这一列应该分配多少额外空间(基于内容长度)
ratio := colContentLengths[i] / totalContentLength
extraForCol := extraWidth * ratio
colWidths[i] = colMinWidths[i] + extraForCol
}
}
}
return colWidths
}
// cleanTextForPDF 清理文本移除可能导致PDF生成问题的字符
func (r *DatabaseTableRenderer) cleanTextForPDF(text string) string {
// 先移除HTML标签
text = strings.TrimSpace(text)
text = strings.ReplaceAll(text, "<br>", " ")
text = strings.ReplaceAll(text, "<br/>", " ")
text = strings.ReplaceAll(text, "<br />", " ")
// 移除控制字符(除了换行符和制表符)
var cleaned strings.Builder
for _, r := range text {
// 保留可打印字符、空格、换行符、制表符
if unicode.IsPrint(r) || r == '\n' || r == '\t' || r == '\r' {
cleaned.WriteRune(r)
} else if unicode.IsSpace(r) {
// 将其他空白字符转换为空格
cleaned.WriteRune(' ')
}
}
text = cleaned.String()
// 移除多余的空白字符
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// 确保文本不为空且长度合理
if len([]rune(text)) > 10000 {
// 如果文本过长,截断
runes := []rune(text)
text = string(runes[:10000]) + "..."
}
return strings.TrimSpace(text)
}
// safeSplitText 安全地调用SplitText带错误恢复
func (r *DatabaseTableRenderer) safeSplitText(pdf *gofpdf.Fpdf, text string, width float64) []string {
// 先清理文本
text = r.cleanTextForPDF(text)
// 如果文本为空或宽度无效,返回单行
if text == "" || width <= 0 {
if text == "" {
return []string{""}
}
return []string{text}
}
// 检查文本长度,如果太短可能不需要分割
if len([]rune(text)) <= 1 {
return []string{text}
}
// 使用匿名函数和recover保护SplitText调用
var lines []string
var panicOccurred bool
func() {
defer func() {
if rec := recover(); rec != nil {
panicOccurred = true
// 静默处理,不记录日志
// 如果panic发生使用估算值
charCount := len([]rune(text))
if charCount == 0 {
lines = []string{""}
return
}
estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
lines = make([]string, int(estimatedLines))
if estimatedLines == 1 {
lines[0] = text
} else {
// 简单分割文本
runes := []rune(text)
charsPerLine := int(math.Ceil(float64(len(runes)) / estimatedLines))
for i := 0; i < int(estimatedLines); i++ {
start := i * charsPerLine
end := start + charsPerLine
if end > len(runes) {
end = len(runes)
}
if start < len(runes) {
lines[i] = string(runes[start:end])
} else {
lines[i] = ""
}
}
}
}
}()
// 尝试调用SplitText
lines = pdf.SplitText(text, width)
}()
// 如果panic发生或lines为nil或空使用后备方案
if panicOccurred || lines == nil || len(lines) == 0 {
// 如果文本不为空,至少返回一行
if text != "" {
return []string{text}
}
return []string{""}
}
// 过滤掉空行(但保留至少一行)
nonEmptyLines := make([]string, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(line) != "" {
nonEmptyLines = append(nonEmptyLines, line)
}
}
if len(nonEmptyLines) == 0 {
return []string{text}
}
return nonEmptyLines
}
// renderHeader 渲染表头
func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, colWidths []float64, startY float64) float64 {
_, lineHt := pdf.GetFontSize()
// 计算表头的最大高度
maxHeaderHeight := lineHt * 2.0 // 使用合理的表头高度
for i, header := range headers {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 使用安全的SplitText方法
headerLines := r.safeSplitText(pdf, header, colW-6)
headerHeight := float64(len(headerLines)) * lineHt
// 添加上下内边距
headerHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if headerHeight < lineHt*2.0 {
headerHeight = lineHt * 2.0
}
if headerHeight > maxHeaderHeight {
maxHeaderHeight = headerHeight
}
}
// 绘制表头背景和文本
pdf.SetFillColor(74, 144, 226) // 蓝色背景
pdf.SetTextColor(0, 0, 0) // 黑色文字
r.fontManager.SetBodyFont(pdf, "B", BodyFontSizeXiaosi)
currentX := 15.0
for i, header := range headers {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 清理表头数据移除任何残留的HTML标签和样式
header = r.cleanTextForPDF(header)
// 绘制表头背景
pdf.Rect(currentX, startY, colW, maxHeaderHeight, "FD")
// 绘制表头文本
if header != "" {
// 计算文本的实际高度(减少内边距,给文本更多空间)
// 使用安全的SplitText方法
headerLines := r.safeSplitText(pdf, header, colW-4)
textHeight := float64(len(headerLines)) * lineHt
if textHeight < lineHt {
textHeight = lineHt
}
// 计算垂直居中的Y位置
cellCenterY := startY + maxHeaderHeight/2
textStartY := cellCenterY - textHeight/2
// 设置文本位置(水平居中,垂直居中,减少左边距)
pdf.SetXY(currentX+2, textStartY)
// 确保颜色为深黑色(在渲染前再次设置,防止被覆盖)
pdf.SetTextColor(0, 0, 0) // 表头是黑色文字
r.fontManager.SetBodyFont(pdf, "B", BodyFontSizeXiaosi)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
pdf.SetTextColor(0, 0, 0)
// 使用正常的行高,文本已经垂直居中(减少内边距,给文本更多空间)
pdf.MultiCell(colW-4, lineHt, header, "", "C", false)
}
// 重置Y坐标
pdf.SetXY(currentX+colW, startY)
currentX += colW
}
return maxHeaderHeight
}
// renderRows 渲染数据行(支持自动分页)
func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, colWidths []float64, lineHt float64, headers []string, headerColWidths []float64) {
numCols := len(colWidths)
pdf.SetFillColor(245, 245, 220) // 米色背景
pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰
r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
// 获取页面尺寸和边距
_, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
minSpaceForRow := lineHt * 3 // 至少需要3倍行高的空间
validRowIndex := 0 // 用于交替填充的有效行索引
for rowIndex, row := range rows {
// 检查这一行是否有有效内容
hasContent := false
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
hasContent = true
break
}
}
// 跳过完全为空的行
if !hasContent {
continue
}
// 检查是否需要分页
currentY := pdf.GetY()
if currentY+minSpaceForRow > pageHeight-bottomMargin {
// 需要分页
r.logger.Debug("表格需要分页",
zap.Int("row_index", rowIndex),
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()
headerHeight := r.renderHeader(pdf, headers, headerColWidths, newHeaderStartY)
pdf.SetXY(15.0, newHeaderStartY+headerHeight)
}
}
startY := pdf.GetY()
fill := (validRowIndex % 2) == 0 // 交替填充
validRowIndex++
// 计算这一行的最大高度
// 确保lineHt有效
if lineHt <= 0 {
lineHt = 5.0 // 默认行高
}
maxCellHeight := lineHt * 2.0 // 使用合理的最小高度
for j := 0; j < numCols && j < len(row); j++ {
cell := row[j]
cellWidth := colWidths[j] - 4 // 减少内边距到4mm给文本更多空间
// 先清理单元格文本(在计算宽度之前)
cell = r.cleanTextForPDF(cell)
// 计算文本实际宽度,判断是否需要换行
textWidth := r.getTextWidth(pdf, cell)
var lines []string
// 只有当文本宽度超过单元格宽度时才换行
if textWidth > cellWidth {
// 文本需要换行使用安全的SplitText方法
lines = r.safeSplitText(pdf, cell, cellWidth)
} else {
// 文本不需要换行,单行显示
lines = []string{cell}
}
// 确保lines不为空且有效
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
lines = []string{cell}
if lines[0] == "" {
lines[0] = " " // 至少保留一个空格避免高度为0
}
}
// 计算单元格高度确保不会出现Inf或NaN
lineCount := float64(len(lines))
if lineCount <= 0 {
lineCount = 1
}
if lineHt <= 0 {
lineHt = 5.0 // 默认行高
}
cellHeight := lineCount * lineHt
// 添加上下内边距
cellHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if cellHeight < lineHt*2.0 {
cellHeight = lineHt * 2.0
}
// 检查是否为有效数值
if math.IsInf(cellHeight, 0) || math.IsNaN(cellHeight) {
cellHeight = lineHt * 2.0
}
if cellHeight > maxCellHeight {
maxCellHeight = cellHeight
}
}
// 再次检查分页(在计算完行高后)
if startY+maxCellHeight > pageHeight-bottomMargin {
r.logger.Debug("行高度超出页面,需要分页",
zap.Int("row_index", rowIndex),
zap.Float64("row_height", maxCellHeight))
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
startY = pdf.GetY()
}
// 绘制这一行的所有单元格
currentX := 15.0
for j := 0; j < numCols && j < len(row); j++ {
colW := colWidths[j]
cell := row[j]
// 清理单元格数据移除任何残留的HTML标签和样式
cell = r.cleanTextForPDF(cell)
// 绘制单元格背景
if fill {
pdf.SetFillColor(250, 250, 235) // 稍深的米色
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.Rect(currentX, startY, colW, maxCellHeight, "FD")
// 绘制单元格文本(只绘制非空内容)
if cell != "" {
// 计算文本的实际宽度和单元格可用宽度
cellWidth := colW - 4
textWidth := r.getTextWidth(pdf, cell)
var textLines []string
// 只有当文本宽度超过单元格宽度时才换行
if textWidth > cellWidth {
// 文本需要换行使用安全的SplitText方法
textLines = r.safeSplitText(pdf, cell, cellWidth)
} else {
// 文本不需要换行,单行显示
textLines = []string{cell}
}
textHeight := float64(len(textLines)) * lineHt
if textHeight < lineHt {
textHeight = lineHt
}
// 计算垂直居中的Y位置
cellCenterY := startY + maxCellHeight/2
textStartY := cellCenterY - textHeight/2
// 设置文本位置(水平左对齐,垂直居中,减少左边距)
pdf.SetXY(currentX+2, textStartY)
// 再次确保颜色为深黑色(防止被其他设置覆盖)
pdf.SetTextColor(0, 0, 0)
r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
pdf.SetTextColor(0, 0, 0)
// 安全地渲染文本,使用正常的行高
func() {
defer func() {
if rec := recover(); rec != nil {
// 静默处理,不记录日志
}
}()
// 使用正常的行高,文本已经垂直居中
pdf.MultiCell(cellWidth, lineHt, cell, "", "L", false)
}()
}
// 重置Y坐标
pdf.SetXY(currentX+colW, startY)
currentX += colW
}
// 检查maxCellHeight是否为有效数值
if math.IsInf(maxCellHeight, 0) || math.IsNaN(maxCellHeight) {
maxCellHeight = lineHt * 2.0
}
if maxCellHeight <= 0 {
maxCellHeight = lineHt * 2.0
}
// 移动到下一行
nextY := startY + maxCellHeight
if math.IsInf(nextY, 0) || math.IsNaN(nextY) {
nextY = pdf.GetY() + lineHt*2.0
}
pdf.SetXY(15.0, nextY)
}
}
// getTextWidth 获取文本宽度
func (r *DatabaseTableRenderer) getTextWidth(pdf *gofpdf.Fpdf, text string) float64 {
if r.fontManager.IsChineseFontAvailable() {
width := pdf.GetStringWidth(text)
// 如果宽度为0或太小使用更准确的估算
if width < 0.1 {
return r.estimateTextWidth(text)
}
return width
}
// 估算宽度
return r.estimateTextWidth(text)
}
// estimateTextWidth 估算文本宽度(处理中英文混合)
func (r *DatabaseTableRenderer) estimateTextWidth(text string) float64 {
charCount := 0.0
for _, r := range text {
// 中文字符通常比英文字符宽
if r >= 0x4E00 && r <= 0x9FFF {
charCount += 1.8 // 中文字符约1.8倍宽度
} else if r >= 0x3400 && r <= 0x4DBF {
charCount += 1.8 // 扩展A
} else if r >= 0x20000 && r <= 0x2A6DF {
charCount += 1.8 // 扩展B
} else {
charCount += 1.0 // 英文字符和数字
}
}
return charCount * 3.0 // 基础宽度3mm
}

View File

@@ -0,0 +1,355 @@
package pdf
import (
"os"
"path/filepath"
"runtime"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// FontManager 字体管理器
type FontManager struct {
logger *zap.Logger
chineseFontName string
chineseFontLoaded bool
watermarkFontName string
watermarkFontLoaded bool
bodyFontName string
bodyFontLoaded bool
}
// NewFontManager 创建字体管理器
func NewFontManager(logger *zap.Logger) *FontManager {
return &FontManager{
logger: logger,
chineseFontName: "ChineseFont",
watermarkFontName: "WatermarkFont",
bodyFontName: "BodyFont",
}
}
// LoadChineseFont 加载中文字体到PDF
func (fm *FontManager) LoadChineseFont(pdf *gofpdf.Fpdf) bool {
if fm.chineseFontLoaded {
return true
}
fontPaths := fm.getChineseFontPaths()
if len(fontPaths) == 0 {
// 字体文件不存在,使用系统默认字体,不记录警告
return false
}
// 尝试加载字体
for _, fontPath := range fontPaths {
if fm.tryAddFont(pdf, fontPath, fm.chineseFontName) {
fm.chineseFontLoaded = true
return true
}
}
// 无法加载字体,使用系统默认字体,不记录警告
return false
}
// LoadWatermarkFont 加载水印字体到PDF
func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool {
if fm.watermarkFontLoaded {
return true
}
fontPaths := fm.getWatermarkFontPaths()
if len(fontPaths) == 0 {
return false
}
// 尝试加载字体
for _, fontPath := range fontPaths {
if fm.tryAddFont(pdf, fontPath, fm.watermarkFontName) {
fm.watermarkFontLoaded = true
return true
}
}
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() {
if r := recover(); r != nil {
fm.logger.Error("添加字体时发生panic",
zap.String("font_path", fontPath),
zap.String("font_name", fontName),
zap.Any("panic_value", r),
)
}
}()
// 确保路径是绝对路径
absFontPath, err := filepath.Abs(fontPath)
if err != nil {
fm.logger.Warn("无法获取字体文件绝对路径",
zap.String("font_path", fontPath),
zap.Error(err),
)
absFontPath = fontPath
}
fm.logger.Debug("尝试添加字体",
zap.String("font_path", absFontPath),
zap.String("font_name", fontName),
)
// 检查文件是否存在
fileInfo, err := os.Stat(absFontPath)
if err != nil {
fm.logger.Debug("字体文件不存在",
zap.String("font_path", absFontPath),
zap.Error(err),
)
return false
}
if !fileInfo.Mode().IsRegular() {
fm.logger.Warn("字体路径不是普通文件",
zap.String("font_path", absFontPath),
)
return false
}
// 确保路径是绝对路径gofpdf在Output时需要绝对路径
// 首先确保absFontPath是绝对路径
if !filepath.IsAbs(absFontPath) {
fm.logger.Warn("字体路径不是绝对路径,重新转换",
zap.String("original_path", absFontPath),
)
if newAbsPath, err := filepath.Abs(absFontPath); err == nil {
absFontPath = newAbsPath
}
}
// 使用filepath.ToSlash统一路径分隔符Linux下使用/
// 注意ToSlash不会改变路径的绝对/相对性质,只统一分隔符
normalizedPath := filepath.ToSlash(absFontPath)
// 在 Linux 下,绝对路径通常以 / 开头;在 Windows 下则可能以盘符 (C:/...) 开头。
// 这里只要保证 normalizedPath 非空即可,具体格式交给 gofpdf 处理,避免在 Windows 下误判。
if len(normalizedPath) == 0 {
fm.logger.Error("字体路径转换后为空无法添加到PDF",
zap.String("abs_font_path", absFontPath),
zap.String("font_name", fontName),
)
return false
}
// 额外记录当前平台,方便排查路径格式问题
fm.logger.Debug("字体路径平台信息",
zap.String("goos", runtime.GOOS),
zap.String("normalized_path", normalizedPath),
)
fm.logger.Debug("准备添加字体到gofpdf",
zap.String("original_path", fontPath),
zap.String("abs_path", absFontPath),
zap.String("normalized_path", normalizedPath),
zap.String("font_name", fontName),
)
// gofpdf v2使用AddUTF8Font添加支持UTF-8的字体
// 注意gofpdf在Output时可能会重新解析路径必须确保路径格式正确
// 记录传递给gofpdf的实际路径
fm.logger.Info("添加字体到gofpdf",
zap.String("font_path", normalizedPath),
zap.String("font_name", fontName),
zap.Bool("is_absolute", len(normalizedPath) > 0 && normalizedPath[0] == '/'),
)
pdf.AddUTF8Font(fontName, "", normalizedPath) // 常规样式
pdf.AddUTF8Font(fontName, "B", normalizedPath) // 粗体样式
// 验证字体是否可用
// 注意gofpdf可能在AddUTF8Font时不会立即加载字体而是在Output时才加载
// 所以这里验证可能失败,但不一定代表字体无法使用
pdf.SetFont(fontName, "", 12)
testWidth := pdf.GetStringWidth("测试")
if testWidth == 0 {
fm.logger.Warn("字体添加后验证失败测试文本宽度为0但会在Output时重新尝试",
zap.String("font_path", normalizedPath),
zap.String("font_name", fontName),
)
// 注意即使验证失败也返回true因为gofpdf在Output时才会真正加载字体文件
// 这里的验证可能不准确
}
fm.logger.Info("字体已添加到PDF将在Output时加载",
zap.String("font_path", normalizedPath),
zap.String("font_name", fontName),
zap.Float64("test_width", testWidth),
)
return true
}
// getChineseFontPaths 获取中文字体路径列表仅TTF格式
func (fm *FontManager) getChineseFontPaths() []string {
// 按优先级排序的字体文件列表
fontNames := []string{
"simhei.ttf", // 黑体(默认)
"simkai.ttf", // 楷体(备选)
"simfang.ttf", // 仿宋(备选)
}
return fm.buildFontPaths(fontNames)
}
// getWatermarkFontPaths 获取水印字体路径列表仅TTF格式
func (fm *FontManager) getWatermarkFontPaths() []string {
// 水印字体文件名(尝试大小写变体)
fontNames := []string{
// "XuanZongTi-v0.1.otf", //玄宗字体不支持otf
"WenYuanSerifSC-Bold.ttf", //文渊雅黑
// "YunFengFeiYunTi-2.ttf", // 毛笔字体
// "yunfengfeiyunti-2.ttf", // 毛笔字体小写版本(兼容)
}
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 {
// 获取resources/pdf目录已返回绝对路径
resourcesPDFDir := GetResourcesPDFDir()
if resourcesPDFDir == "" {
fm.logger.Error("无法获取resources/pdf目录路径")
return []string{}
}
// 构建字体目录路径resourcesPDFDir已经是绝对路径
fontsDir := filepath.Join(resourcesPDFDir, "fonts")
fm.logger.Debug("查找字体文件",
zap.String("resources_pdf_dir", resourcesPDFDir),
zap.String("fonts_dir", fontsDir),
zap.Strings("font_names", fontNames),
)
// 构建字体文件路径列表(都是绝对路径)
var fontPaths []string
for _, fontName := range fontNames {
fontPath := filepath.Join(fontsDir, fontName)
// 确保是绝对路径
if absPath, err := filepath.Abs(fontPath); err == nil {
fontPaths = append(fontPaths, absPath)
} else {
fm.logger.Warn("无法获取字体文件绝对路径",
zap.String("font_path", fontPath),
zap.Error(err),
)
}
}
// 过滤出实际存在的字体文件
var existingFonts []string
for _, fontPath := range fontPaths {
if info, err := os.Stat(fontPath); err == nil && info.Mode().IsRegular() {
existingFonts = append(existingFonts, fontPath)
fm.logger.Debug("找到字体文件", zap.String("font_path", fontPath))
} else {
fm.logger.Debug("字体文件不存在",
zap.String("font_path", fontPath),
zap.Error(err),
)
}
}
if len(existingFonts) == 0 {
fm.logger.Warn("未找到任何字体文件",
zap.String("fonts_dir", fontsDir),
zap.Strings("attempted_fonts", fontPaths),
)
} else {
fm.logger.Info("找到字体文件",
zap.Int("count", len(existingFonts)),
zap.Strings("font_paths", existingFonts),
)
}
return existingFonts
}
// SetFont 设置中文字体
func (fm *FontManager) SetFont(pdf *gofpdf.Fpdf, style string, size float64) {
if fm.chineseFontLoaded {
pdf.SetFont(fm.chineseFontName, style, size)
} else {
// 如果没有中文字体使用Arial作为后备
pdf.SetFont("Arial", style, size)
}
}
// SetWatermarkFont 设置水印字体
func (fm *FontManager) SetWatermarkFont(pdf *gofpdf.Fpdf, style string, size float64) {
if fm.watermarkFontLoaded {
pdf.SetFont(fm.watermarkFontName, style, size)
} else {
// 如果水印字体不可用,使用主字体作为后备
fm.SetFont(pdf, style, size)
}
}
// 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
}

View File

@@ -0,0 +1,88 @@
package pdf
import (
"context"
"fmt"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"go.uber.org/zap"
)
// HTMLPDFGenerator 使用 headless Chrome 将 HTML 页面渲染为 PDF
type HTMLPDFGenerator struct {
logger *zap.Logger
}
// NewHTMLPDFGenerator 创建 HTMLPDFGenerator
func NewHTMLPDFGenerator(logger *zap.Logger) *HTMLPDFGenerator {
if logger == nil {
logger = zap.NewNop()
}
return &HTMLPDFGenerator{
logger: logger,
}
}
// GenerateFromURL 使用 headless Chrome 打开指定 URL并导出为 PDF 字节流
// 这里固定使用 A4 纵向纸张,开启背景打印
func (g *HTMLPDFGenerator) GenerateFromURL(ctx context.Context, url string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
// 整个生成过程增加超时时间,避免长时间卡死
timeoutCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
// 创建 Chrome 上下文(使用系统默认的 headless Chrome/Chromium
chromeCtx, cancelChrome := chromedp.NewContext(timeoutCtx)
defer cancelChrome()
var pdfBuf []byte
tasks := chromedp.Tasks{
chromedp.Navigate(url),
// 等待页面主体和报告容器就绪,确保数据渲染完成
chromedp.WaitReady("body", chromedp.ByQuery),
chromedp.WaitVisible(".page", chromedp.ByQuery),
chromedp.ActionFunc(func(ctx context.Context) error {
g.logger.Info("开始通过 headless Chrome 生成企业报告 PDF", zap.String("url", url))
var (
buf []byte
err error
)
buf, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithPaperWidth(8.27). // A4 宽度(英寸 -> 约 210mm
WithPaperHeight(11.69). // A4 高度(英寸 -> 约 297mm
WithMarginTop(0.4).
WithMarginBottom(0.4).
WithMarginLeft(0.4).
WithMarginRight(0.4).
Do(ctx)
if err == nil {
pdfBuf = buf
}
return err
}),
}
if err := chromedp.Run(chromeCtx, tasks); err != nil {
g.logger.Error("使用 headless Chrome 生成 HTML 报告 PDF 失败", zap.String("url", url), zap.Error(err))
return nil, err
}
if len(pdfBuf) == 0 {
return nil, fmt.Errorf("生成的 PDF 内容为空")
}
g.logger.Info("通过 headless Chrome 生成企业报告 PDF 成功",
zap.String("url", url),
zap.Int("pdf_size", len(pdfBuf)),
)
return pdfBuf, nil
}

View File

@@ -0,0 +1,155 @@
package pdf
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// JSONProcessor JSON处理器
type JSONProcessor struct{}
// NewJSONProcessor 创建JSON处理器
func NewJSONProcessor() *JSONProcessor {
return &JSONProcessor{}
}
// FormatJSON 格式化JSON字符串以便更好地显示
func (jp *JSONProcessor) FormatJSON(jsonStr string) (string, error) {
var jsonObj interface{}
if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err != nil {
return jsonStr, err // 如果解析失败,返回原始字符串
}
// 重新格式化JSON使用缩进
formatted, err := json.MarshalIndent(jsonObj, "", " ")
if err != nil {
return jsonStr, err
}
return string(formatted), nil
}
// ExtractJSON 从文本中提取JSON
func (jp *JSONProcessor) ExtractJSON(text string) string {
// 查找 ```json 代码块
re := regexp.MustCompile("(?s)```json\\s*\n(.*?)\n```")
matches := re.FindStringSubmatch(text)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
// 查找普通代码块
re = regexp.MustCompile("(?s)```\\s*\n(.*?)\n```")
matches = re.FindStringSubmatch(text)
if len(matches) > 1 {
content := strings.TrimSpace(matches[1])
// 检查是否是JSON
if strings.HasPrefix(content, "{") || strings.HasPrefix(content, "[") {
return content
}
}
return ""
}
// GenerateJSONExample 从请求参数表格生成JSON示例
func (jp *JSONProcessor) GenerateJSONExample(requestParams string, tableParser *TableParser) string {
tableData := tableParser.ParseMarkdownTable(requestParams)
if len(tableData) < 2 {
return ""
}
// 查找字段名列和类型列
var fieldCol, typeCol int = -1, -1
header := tableData[0]
for i, h := range header {
hLower := strings.ToLower(h)
if strings.Contains(hLower, "字段") || strings.Contains(hLower, "参数") || strings.Contains(hLower, "field") {
fieldCol = i
}
if strings.Contains(hLower, "类型") || strings.Contains(hLower, "type") {
typeCol = i
}
}
if fieldCol == -1 {
return ""
}
// 生成JSON结构
jsonMap := make(map[string]interface{})
for i := 1; i < len(tableData); i++ {
row := tableData[i]
if fieldCol >= len(row) {
continue
}
fieldName := strings.TrimSpace(row[fieldCol])
if fieldName == "" {
continue
}
// 跳过表头行
if strings.Contains(strings.ToLower(fieldName), "字段") || strings.Contains(strings.ToLower(fieldName), "参数") {
continue
}
// 获取类型
fieldType := "string"
if typeCol >= 0 && typeCol < len(row) {
fieldType = strings.ToLower(strings.TrimSpace(row[typeCol]))
}
// 设置示例值
var value interface{}
if strings.Contains(fieldType, "int") || strings.Contains(fieldType, "number") {
value = 0
} else if strings.Contains(fieldType, "bool") {
value = true
} else if strings.Contains(fieldType, "array") || strings.Contains(fieldType, "list") {
value = []interface{}{}
} else if strings.Contains(fieldType, "object") || strings.Contains(fieldType, "dict") {
value = map[string]interface{}{}
} else {
// 根据字段名设置合理的示例值
fieldLower := strings.ToLower(fieldName)
if strings.Contains(fieldLower, "name") || strings.Contains(fieldName, "姓名") {
value = "张三"
} else if strings.Contains(fieldLower, "id_card") || strings.Contains(fieldLower, "idcard") || strings.Contains(fieldName, "身份证") {
value = "110101199001011234"
} else if strings.Contains(fieldLower, "phone") || strings.Contains(fieldLower, "mobile") || strings.Contains(fieldName, "手机") {
value = "13800138000"
} else if strings.Contains(fieldLower, "card") || strings.Contains(fieldName, "银行卡") {
value = "6222021234567890123"
} else {
value = "string"
}
}
// 处理嵌套字段(如 baseInfo.phone
if strings.Contains(fieldName, ".") {
parts := strings.Split(fieldName, ".")
current := jsonMap
for j := 0; j < len(parts)-1; j++ {
if _, ok := current[parts[j]].(map[string]interface{}); !ok {
current[parts[j]] = make(map[string]interface{})
}
current = current[parts[j]].(map[string]interface{})
}
current[parts[len(parts)-1]] = value
} else {
jsonMap[fieldName] = value
}
}
// 使用encoding/json正确格式化JSON
jsonBytes, err := json.MarshalIndent(jsonMap, "", " ")
if err != nil {
// 如果JSON序列化失败返回简单的字符串表示
return fmt.Sprintf("%v", jsonMap)
}
return string(jsonBytes)
}

View File

@@ -0,0 +1,658 @@
package pdf
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// MarkdownConverter Markdown转换器 - 将各种格式的markdown内容标准化
type MarkdownConverter struct {
textProcessor *TextProcessor
}
// NewMarkdownConverter 创建Markdown转换器
func NewMarkdownConverter(textProcessor *TextProcessor) *MarkdownConverter {
return &MarkdownConverter{
textProcessor: textProcessor,
}
}
// ConvertToStandardMarkdown 将各种格式的内容转换为标准的markdown格式
// 这是第一步:预处理和标准化
func (mc *MarkdownConverter) ConvertToStandardMarkdown(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
// 1. 先清理HTML标签保留内容
content = mc.textProcessor.StripHTML(content)
// 2. 处理代码块 - 确保代码块格式正确
content = mc.normalizeCodeBlocks(content)
// 3. 处理表格 - 确保表格格式正确
content = mc.normalizeTables(content)
// 4. 处理列表 - 统一列表格式
content = mc.normalizeLists(content)
// 5. 处理JSON内容 - 尝试识别并格式化JSON
content = mc.normalizeJSONContent(content)
// 6. 处理链接和图片 - 转换为文本
content = mc.convertLinksToText(content)
content = mc.convertImagesToText(content)
// 7. 处理引用块
content = mc.normalizeBlockquotes(content)
// 8. 处理水平线
content = mc.normalizeHorizontalRules(content)
// 9. 清理多余空行(保留代码块内的空行)
content = mc.cleanupExtraBlankLines(content)
return content
}
// normalizeCodeBlocks 规范化代码块
func (mc *MarkdownConverter) normalizeCodeBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
codeBlockLang := ""
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是代码块开始
if strings.HasPrefix(trimmed, "```") {
if inCodeBlock {
// 代码块结束
result = append(result, line)
inCodeBlock = false
codeBlockLang = ""
} else {
// 代码块开始
inCodeBlock = true
// 提取语言标识
if len(trimmed) > 3 {
codeBlockLang = strings.TrimSpace(trimmed[3:])
if codeBlockLang != "" {
result = append(result, fmt.Sprintf("```%s", codeBlockLang))
} else {
result = append(result, "```")
}
} else {
result = append(result, "```")
}
}
} else if inCodeBlock {
// 在代码块中,保留原样
result = append(result, line)
} else {
// 不在代码块中,处理其他内容
result = append(result, line)
}
// 如果代码块没有正确关闭,在文件末尾自动关闭
if i == len(lines)-1 && inCodeBlock {
result = append(result, "```")
}
}
return strings.Join(result, "\n")
}
// normalizeTables 规范化表格格式
func (mc *MarkdownConverter) normalizeTables(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
// 代码块中的内容不处理
result = append(result, line)
continue
}
// 检查是否是表格行
if strings.Contains(trimmed, "|") {
// 检查是否是分隔行
isSeparator := mc.isTableSeparator(trimmed)
if isSeparator {
// 确保分隔行格式正确
cells := strings.Split(trimmed, "|")
// 清理首尾空元素
if len(cells) > 0 && cells[0] == "" {
cells = cells[1:]
}
if len(cells) > 0 && cells[len(cells)-1] == "" {
cells = cells[:len(cells)-1]
}
// 构建标准分隔行
separator := "|"
for range cells {
separator += " --- |"
}
result = append(result, separator)
} else {
// 普通表格行,确保格式正确
normalizedLine := mc.normalizeTableRow(line)
result = append(result, normalizedLine)
}
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// isTableSeparator 检查是否是表格分隔行
func (mc *MarkdownConverter) isTableSeparator(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "-") {
return false
}
// 检查是否只包含 |、-、:、空格
for _, r := range trimmed {
if r != '|' && r != '-' && r != ':' && r != ' ' {
return false
}
}
return true
}
// normalizeTableRow 规范化表格行
func (mc *MarkdownConverter) normalizeTableRow(line string) string {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "|") {
return line
}
cells := strings.Split(trimmed, "|")
// 清理首尾空元素
if len(cells) > 0 && cells[0] == "" {
cells = cells[1:]
}
if len(cells) > 0 && cells[len(cells)-1] == "" {
cells = cells[:len(cells)-1]
}
// 清理每个单元格
normalizedCells := make([]string, 0, len(cells))
for _, cell := range cells {
cell = strings.TrimSpace(cell)
// 移除markdown格式但保留内容
cell = mc.textProcessor.RemoveMarkdownSyntax(cell)
normalizedCells = append(normalizedCells, cell)
}
// 重新构建表格行
return "| " + strings.Join(normalizedCells, " | ") + " |"
}
// normalizeLists 规范化列表格式
func (mc *MarkdownConverter) normalizeLists(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
result = append(result, line)
continue
}
// 处理有序列表
if matched, _ := regexp.MatchString(`^\d+\.\s+`, trimmed); matched {
// 确保格式统一:数字. 空格
re := regexp.MustCompile(`^(\d+)\.\s*`)
trimmed = re.ReplaceAllString(trimmed, "$1. ")
result = append(result, trimmed)
} else if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
// 处理无序列表,统一使用 -
re := regexp.MustCompile(`^[-*+]\s*`)
trimmed = re.ReplaceAllString(trimmed, "- ")
result = append(result, trimmed)
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// normalizeJSONContent 规范化JSON内容
func (mc *MarkdownConverter) normalizeJSONContent(content string) string {
// 尝试识别并格式化JSON代码块
jsonBlockRegex := regexp.MustCompile("(?s)```(?:json)?\\s*\n(.*?)\n```")
content = jsonBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
// 提取JSON内容
submatch := jsonBlockRegex.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
jsonStr := strings.TrimSpace(submatch[1])
// 尝试格式化JSON
var jsonObj interface{}
if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err == nil {
// 格式化成功
formatted, err := json.MarshalIndent(jsonObj, "", " ")
if err == nil {
return fmt.Sprintf("```json\n%s\n```", string(formatted))
}
}
return match
})
return content
}
// convertLinksToText 将链接转换为文本
func (mc *MarkdownConverter) convertLinksToText(content string) string {
// [text](url) -> text (url)
linkRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^\)]+)\)`)
content = linkRegex.ReplaceAllString(content, "$1 ($2)")
// [text][ref] -> text
refLinkRegex := regexp.MustCompile(`\[([^\]]+)\]\[[^\]]+\]`)
content = refLinkRegex.ReplaceAllString(content, "$1")
return content
}
// convertImagesToText 将图片转换为文本
func (mc *MarkdownConverter) convertImagesToText(content string) string {
// ![alt](url) -> [图片: alt]
imageRegex := regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`)
content = imageRegex.ReplaceAllString(content, "[图片: $1]")
return content
}
// normalizeBlockquotes 规范化引用块
func (mc *MarkdownConverter) normalizeBlockquotes(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
result = append(result, line)
continue
}
// 处理引用块 > text -> > text
if strings.HasPrefix(trimmed, ">") {
// 确保格式统一
quoteText := strings.TrimSpace(trimmed[1:])
if quoteText != "" {
result = append(result, "> "+quoteText)
} else {
result = append(result, ">")
}
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// normalizeHorizontalRules 规范化水平线
func (mc *MarkdownConverter) normalizeHorizontalRules(content string) string {
// 统一水平线格式为 ---
hrRegex := regexp.MustCompile(`^[-*_]{3,}\s*$`)
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
result = append(result, line)
continue
}
// 如果是水平线,统一格式
if hrRegex.MatchString(trimmed) {
result = append(result, "---")
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// cleanupExtraBlankLines 清理多余空行(保留代码块内的空行)
func (mc *MarkdownConverter) cleanupExtraBlankLines(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
lastWasBlank := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
lastWasBlank = false
continue
}
if inCodeBlock {
// 代码块中的内容全部保留
result = append(result, line)
lastWasBlank = (trimmed == "")
continue
}
// 不在代码块中
if trimmed == "" {
// 空行:最多保留一个连续空行
if !lastWasBlank {
result = append(result, "")
lastWasBlank = true
}
} else {
result = append(result, line)
lastWasBlank = false
}
}
return strings.Join(result, "\n")
}
// PreprocessContent 预处理内容 - 这是主要的转换入口
// 先转换,再解析
func (mc *MarkdownConverter) PreprocessContent(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
// 第一步转换为标准markdown
content = mc.ConvertToStandardMarkdown(content)
// 第二步尝试识别并转换JSON数组为表格
content = mc.convertJSONArrayToTable(content)
// 第三步:确保所有表格都有正确的分隔行
content = mc.ensureTableSeparators(content)
return content
}
// convertJSONArrayToTable 将JSON数组转换为markdown表格
func (mc *MarkdownConverter) convertJSONArrayToTable(content string) string {
// 如果内容已经是表格格式,不处理
if strings.Contains(content, "|") {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "```") {
// 已经有表格,不转换
return content
}
}
}
// 尝试解析为JSON数组
trimmedContent := strings.TrimSpace(content)
if strings.HasPrefix(trimmedContent, "[") {
var jsonArray []map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &jsonArray); err == nil && len(jsonArray) > 0 {
// 转换为markdown表格
return mc.jsonArrayToMarkdownTable(jsonArray)
}
}
// 尝试解析为JSON对象包含params或fields字段
if strings.HasPrefix(trimmedContent, "{") {
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &jsonObj); err == nil {
// 检查是否有params字段
if params, ok := jsonObj["params"].([]interface{}); ok {
paramMaps := make([]map[string]interface{}, 0, len(params))
for _, p := range params {
if pm, ok := p.(map[string]interface{}); ok {
paramMaps = append(paramMaps, pm)
}
}
if len(paramMaps) > 0 {
return mc.jsonArrayToMarkdownTable(paramMaps)
}
}
// 检查是否有fields字段
if fields, ok := jsonObj["fields"].([]interface{}); ok {
fieldMaps := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
if fm, ok := f.(map[string]interface{}); ok {
fieldMaps = append(fieldMaps, fm)
}
}
if len(fieldMaps) > 0 {
return mc.jsonArrayToMarkdownTable(fieldMaps)
}
}
}
}
return content
}
// jsonArrayToMarkdownTable 将JSON数组转换为markdown表格
func (mc *MarkdownConverter) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
if len(data) == 0 {
return ""
}
var result strings.Builder
// 收集所有可能的列名(保持原始顺序)
// 使用map记录是否已添加使用slice保持顺序
columnSet := make(map[string]bool)
columns := make([]string, 0)
// 遍历所有数据行,按第一次出现的顺序收集列名
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
}
if len(columns) == 0 {
return ""
}
// 构建表头(直接使用原始列名,不做映射)
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
result.WriteString(col) // 直接使用原始列名
result.WriteString(" |")
}
result.WriteString("\n")
// 构建分隔行
result.WriteString("|")
for range columns {
result.WriteString(" --- |")
}
result.WriteString("\n")
// 构建数据行
for _, row := range data {
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
value := mc.formatCellValue(row[col])
result.WriteString(value)
result.WriteString(" |")
}
result.WriteString("\n")
}
return result.String()
}
// formatColumnName 格式化列名(直接返回原始列名,不做映射)
// 保持数据库原始数据的列名,不进行转换
func (mc *MarkdownConverter) formatColumnName(name string) string {
// 直接返回原始列名,保持数据库数据的原始格式
return name
}
// formatCellValue 格式化单元格值
func (mc *MarkdownConverter) formatCellValue(value interface{}) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
v = strings.ReplaceAll(v, "\n", " ")
v = strings.ReplaceAll(v, "\r", " ")
v = strings.TrimSpace(v)
v = strings.ReplaceAll(v, "|", "\\|")
return v
case bool:
if v {
return "是"
}
return "否"
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
str := fmt.Sprintf("%v", v)
str = strings.ReplaceAll(str, "\n", " ")
str = strings.ReplaceAll(str, "\r", " ")
str = strings.ReplaceAll(str, "|", "\\|")
return strings.TrimSpace(str)
}
}
// ensureTableSeparators 确保所有表格都有正确的分隔行
func (mc *MarkdownConverter) ensureTableSeparators(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
lastLineWasTableHeader := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
lastLineWasTableHeader = false
continue
}
if inCodeBlock {
result = append(result, line)
lastLineWasTableHeader = false
continue
}
// 检查是否是表格行
if strings.Contains(trimmed, "|") {
// 检查是否是分隔行
if mc.isTableSeparator(trimmed) {
result = append(result, line)
lastLineWasTableHeader = false
} else {
// 普通表格行
result = append(result, line)
// 检查上一行是否是表头
if lastLineWasTableHeader {
// 在表头后插入分隔行
cells := strings.Split(trimmed, "|")
if len(cells) > 0 && cells[0] == "" {
cells = cells[1:]
}
if len(cells) > 0 && cells[len(cells)-1] == "" {
cells = cells[:len(cells)-1]
}
separator := "|"
for range cells {
separator += " --- |"
}
// 在当前位置插入分隔行
result = append(result[:len(result)-1], separator, line)
} else {
// 检查是否是表头(第一行表格)
if i > 0 {
prevLine := strings.TrimSpace(lines[i-1])
if !strings.Contains(prevLine, "|") || mc.isTableSeparator(prevLine) {
// 这可能是表头
lastLineWasTableHeader = true
}
} else {
lastLineWasTableHeader = true
}
}
}
} else {
result = append(result, line)
lastLineWasTableHeader = false
}
}
return strings.Join(result, "\n")
}

View File

@@ -0,0 +1,355 @@
package pdf
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// MarkdownProcessor Markdown处理器
type MarkdownProcessor struct {
textProcessor *TextProcessor
markdownConverter *MarkdownConverter
}
// NewMarkdownProcessor 创建Markdown处理器
func NewMarkdownProcessor(textProcessor *TextProcessor) *MarkdownProcessor {
converter := NewMarkdownConverter(textProcessor)
return &MarkdownProcessor{
textProcessor: textProcessor,
markdownConverter: converter,
}
}
// MarkdownSection 表示一个markdown章节
type MarkdownSection struct {
Title string // 标题(包含#号)
Level int // 标题级别(## 是2, ### 是3, #### 是4
Content string // 该章节的内容
}
// SplitByMarkdownHeaders 按markdown标题分割内容
func (mp *MarkdownProcessor) SplitByMarkdownHeaders(content string) []MarkdownSection {
lines := strings.Split(content, "\n")
var sections []MarkdownSection
var currentSection MarkdownSection
var currentContent []string
// 标题正则:匹配 #, ##, ###, #### 等
headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
// 检查是否是标题行
if matches := headerRegex.FindStringSubmatch(trimmedLine); matches != nil {
// 如果之前有内容,先保存之前的章节
if currentSection.Title != "" || len(currentContent) > 0 {
if currentSection.Title != "" {
currentSection.Content = strings.Join(currentContent, "\n")
sections = append(sections, currentSection)
}
}
// 开始新章节
level := len(matches[1]) // #号的数量
currentSection = MarkdownSection{
Title: trimmedLine,
Level: level,
Content: "",
}
currentContent = []string{}
} else {
// 普通内容行,添加到当前章节
currentContent = append(currentContent, line)
}
}
// 保存最后一个章节
if currentSection.Title != "" || len(currentContent) > 0 {
if currentSection.Title != "" {
currentSection.Content = strings.Join(currentContent, "\n")
sections = append(sections, currentSection)
} else if len(currentContent) > 0 {
// 如果没有标题,但开头有内容,作为第一个章节
sections = append(sections, MarkdownSection{
Title: "",
Level: 0,
Content: strings.Join(currentContent, "\n"),
})
}
}
return sections
}
// FormatContentAsMarkdownTable 将数据库中的数据格式化为标准的markdown表格格式
// 先进行预处理转换,再进行解析
func (mp *MarkdownProcessor) FormatContentAsMarkdownTable(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
// 第一步预处理和转换标准化markdown格式
content = mp.markdownConverter.PreprocessContent(content)
// 如果内容已经是markdown表格格式包含|符号),检查格式是否正确
if strings.Contains(content, "|") {
// 检查是否已经是有效的markdown表格
lines := strings.Split(content, "\n")
hasTableFormat := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 跳过代码块中的内容
if strings.HasPrefix(trimmed, "```") {
continue
}
if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "#") {
hasTableFormat = true
break
}
}
if hasTableFormat {
return content
}
}
// 提取代码块(保留代码块不变)
codeBlocks := mp.ExtractCodeBlocks(content)
// 移除代码块,只处理非代码块部分
contentWithoutCodeBlocks := mp.RemoveCodeBlocks(content)
// 如果移除代码块后内容为空,说明只有代码块,直接返回原始内容
if strings.TrimSpace(contentWithoutCodeBlocks) == "" {
return content
}
// 尝试解析非代码块部分为JSON数组仅当内容看起来像JSON时
trimmedContent := strings.TrimSpace(contentWithoutCodeBlocks)
// 检查是否看起来像JSON以[或{开头)
if strings.HasPrefix(trimmedContent, "[") || strings.HasPrefix(trimmedContent, "{") {
// 尝试解析为JSON数组
var requestParams []map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &requestParams); err == nil && len(requestParams) > 0 {
// 成功解析为JSON数组转换为markdown表格
tableContent := mp.jsonArrayToMarkdownTable(requestParams)
// 如果有代码块,在表格后添加代码块
if len(codeBlocks) > 0 {
return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
}
return tableContent
}
// 尝试解析为单个JSON对象
var singleObj map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &singleObj); err == nil {
// 检查是否是包含数组字段的对象
if params, ok := singleObj["params"].([]interface{}); ok {
// 转换为map数组
paramMaps := make([]map[string]interface{}, 0, len(params))
for _, p := range params {
if pm, ok := p.(map[string]interface{}); ok {
paramMaps = append(paramMaps, pm)
}
}
if len(paramMaps) > 0 {
tableContent := mp.jsonArrayToMarkdownTable(paramMaps)
// 如果有代码块,在表格后添加代码块
if len(codeBlocks) > 0 {
return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
}
return tableContent
}
}
if fields, ok := singleObj["fields"].([]interface{}); ok {
// 转换为map数组
fieldMaps := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
if fm, ok := f.(map[string]interface{}); ok {
fieldMaps = append(fieldMaps, fm)
}
}
if len(fieldMaps) > 0 {
tableContent := mp.jsonArrayToMarkdownTable(fieldMaps)
// 如果有代码块,在表格后添加代码块
if len(codeBlocks) > 0 {
return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
}
return tableContent
}
}
}
}
// 如果无法解析为JSON返回原始内容保留代码块
return content
}
// ExtractCodeBlocks 提取内容中的所有代码块
func (mp *MarkdownProcessor) ExtractCodeBlocks(content string) []string {
var codeBlocks []string
lines := strings.Split(content, "\n")
inCodeBlock := false
var currentBlock []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是代码块开始
if strings.HasPrefix(trimmed, "```") {
if inCodeBlock {
// 代码块结束
currentBlock = append(currentBlock, line)
codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
currentBlock = []string{}
inCodeBlock = false
} else {
// 代码块开始
inCodeBlock = true
currentBlock = []string{line}
}
} else if inCodeBlock {
// 在代码块中
currentBlock = append(currentBlock, line)
}
}
// 如果代码块没有正确关闭,也添加进去
if inCodeBlock && len(currentBlock) > 0 {
codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
}
return codeBlocks
}
// RemoveCodeBlocks 移除内容中的所有代码块
func (mp *MarkdownProcessor) RemoveCodeBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是代码块开始或结束
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
continue // 跳过代码块的标记行
}
// 如果不在代码块中,保留这一行
if !inCodeBlock {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// jsonArrayToMarkdownTable 将JSON数组转换为标准的markdown表格
func (mp *MarkdownProcessor) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
if len(data) == 0 {
return ""
}
var result strings.Builder
// 收集所有可能的列名(保持原始顺序)
// 使用map记录是否已添加使用slice保持顺序
columnSet := make(map[string]bool)
columns := make([]string, 0)
// 遍历所有数据行,按第一次出现的顺序收集列名
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
}
if len(columns) == 0 {
return ""
}
// 构建表头(直接使用原始列名,不做映射)
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
result.WriteString(col) // 直接使用原始列名
result.WriteString(" |")
}
result.WriteString("\n")
// 构建分隔行
result.WriteString("|")
for range columns {
result.WriteString(" --- |")
}
result.WriteString("\n")
// 构建数据行
for _, row := range data {
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
value := mp.formatCellValue(row[col])
result.WriteString(value)
result.WriteString(" |")
}
result.WriteString("\n")
}
return result.String()
}
// formatColumnName 格式化列名(直接返回原始列名,不做映射)
// 保持数据库原始数据的列名,不进行转换
func (mp *MarkdownProcessor) formatColumnName(name string) string {
// 直接返回原始列名,保持数据库数据的原始格式
return name
}
// formatCellValue 格式化单元格值
func (mp *MarkdownProcessor) formatCellValue(value interface{}) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
// 清理字符串,移除换行符和多余空格
v = strings.ReplaceAll(v, "\n", " ")
v = strings.ReplaceAll(v, "\r", " ")
v = strings.TrimSpace(v)
// 转义markdown特殊字符
v = strings.ReplaceAll(v, "|", "\\|")
return v
case bool:
if v {
return "是"
}
return "否"
case float64:
// 如果是整数,不显示小数点
if v == float64(int64(v)) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
// 对于其他类型,转换为字符串
str := fmt.Sprintf("%v", v)
str = strings.ReplaceAll(str, "\n", " ")
str = strings.ReplaceAll(str, "\r", " ")
str = strings.ReplaceAll(str, "|", "\\|")
return strings.TrimSpace(str)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,436 @@
package pdf
import (
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/zap"
)
// PDFCacheManager PDF缓存管理器统一实现
// 支持两种缓存键生成方式:
// 1. 基于姓名+身份证用于PDFG报告
// 2. 基于产品ID+版本(用于产品文档)
type PDFCacheManager struct {
logger *zap.Logger
cacheDir string
ttl time.Duration // 缓存过期时间
maxSize int64 // 最大缓存大小字节0表示不限制
mu sync.RWMutex // 保护并发访问
cleanupOnce sync.Once // 确保清理任务只启动一次
}
// NewPDFCacheManager 创建PDF缓存管理器
// cacheDir: 缓存目录(空则使用默认目录)
// ttl: 缓存过期时间
// maxSize: 最大缓存大小字节0表示不限制
func NewPDFCacheManager(logger *zap.Logger, cacheDir string, ttl time.Duration, maxSize int64) (*PDFCacheManager, error) {
// 如果缓存目录为空使用项目根目录的storage/pdfg-cache目录
if cacheDir == "" {
wd, err := os.Getwd()
if err != nil {
cacheDir = filepath.Join(os.TempDir(), "hyapi_pdfg_cache")
} else {
cacheDir = filepath.Join(wd, "storage", "pdfg-cache")
}
}
// 确保缓存目录存在
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, fmt.Errorf("创建缓存目录失败: %w", err)
}
manager := &PDFCacheManager{
logger: logger,
cacheDir: cacheDir,
ttl: ttl,
maxSize: maxSize,
}
// 启动定期清理任务
manager.startCleanupTask()
logger.Info("PDF缓存管理器已初始化",
zap.String("cache_dir", cacheDir),
zap.Duration("ttl", ttl),
zap.Int64("max_size", maxSize),
)
return manager, nil
}
// GetCacheKey 生成缓存键(基于姓名+身份证)
func (m *PDFCacheManager) GetCacheKey(name, idCard string) string {
key := fmt.Sprintf("%s:%s", name, idCard)
hash := md5.Sum([]byte(key))
return hex.EncodeToString(hash[:])
}
// GetCacheKeyByProduct 生成缓存键基于产品ID+版本)
func (m *PDFCacheManager) GetCacheKeyByProduct(productID, version string) string {
key := fmt.Sprintf("%s:%s", productID, version)
hash := md5.Sum([]byte(key))
return hex.EncodeToString(hash[:])
}
// GetCacheKeyByReportID 生成缓存键基于报告ID
// 文件名格式MD5(report_id).pdf
// report_id 本身已经包含时间戳和随机数,所以 MD5 后就是唯一的
func (m *PDFCacheManager) GetCacheKeyByReportID(reportID string) string {
hash := md5.Sum([]byte(reportID))
return hex.EncodeToString(hash[:])
}
// GetCachePath 获取缓存文件路径
func (m *PDFCacheManager) GetCachePath(cacheKey string) string {
return filepath.Join(m.cacheDir, fmt.Sprintf("%s.pdf", cacheKey))
}
// Get 从缓存获取PDF文件基于姓名+身份证)
// 返回PDF字节流、是否命中缓存、文件创建时间、错误
func (m *PDFCacheManager) Get(name, idCard string) ([]byte, bool, time.Time, error) {
cacheKey := m.GetCacheKey(name, idCard)
return m.getByKey(cacheKey, name, idCard)
}
// GetByProduct 从缓存获取PDF文件基于产品ID+版本)
// 返回PDF字节流、是否命中缓存、错误
func (m *PDFCacheManager) GetByProduct(productID, version string) ([]byte, bool, error) {
cacheKey := m.GetCacheKeyByProduct(productID, version)
pdfBytes, hit, _, err := m.getByKey(cacheKey, productID, version)
return pdfBytes, hit, err
}
// GetByCacheKey 通过缓存键直接获取PDF文件
// 适用于已经持久化了缓存键例如作为报告ID的场景
// 返回PDF字节流、是否命中缓存、文件创建时间、错误
func (m *PDFCacheManager) GetByCacheKey(cacheKey string) ([]byte, bool, time.Time, error) {
return m.getByKey(cacheKey, "", "")
}
// SetByReportID 将PDF文件保存到缓存基于报告ID
func (m *PDFCacheManager) SetByReportID(reportID string, pdfBytes []byte) error {
cacheKey := m.GetCacheKeyByReportID(reportID)
return m.setByKey(cacheKey, pdfBytes, reportID, "")
}
// GetByReportID 从缓存获取PDF文件基于报告ID
// 直接通过 report_id 的 MD5 计算文件名,无需遍历
// 返回PDF字节流、是否命中缓存、文件创建时间、错误
func (m *PDFCacheManager) GetByReportID(reportID string) ([]byte, bool, time.Time, error) {
cacheKey := m.GetCacheKeyByReportID(reportID)
return m.getByKey(cacheKey, reportID, "")
}
// getByKey 内部方法:根据缓存键获取文件
func (m *PDFCacheManager) getByKey(cacheKey string, key1, key2 string) ([]byte, bool, time.Time, error) {
cachePath := m.GetCachePath(cacheKey)
m.mu.RLock()
defer m.mu.RUnlock()
// 检查文件是否存在
info, err := os.Stat(cachePath)
if err != nil {
if os.IsNotExist(err) {
return nil, false, time.Time{}, nil // 缓存未命中
}
return nil, false, time.Time{}, fmt.Errorf("检查缓存文件失败: %w", err)
}
// 检查文件是否过期从文件生成时间开始算24小时
createdAt := info.ModTime()
expiresAt := createdAt.Add(m.ttl)
if time.Now().After(expiresAt) {
// 缓存已过期,删除文件
m.logger.Debug("缓存已过期,删除文件",
zap.String("key1", key1),
zap.String("key2", key2),
zap.String("cache_key", cacheKey),
zap.Time("expires_at", expiresAt),
)
_ = os.Remove(cachePath)
return nil, false, time.Time{}, nil
}
// 读取缓存文件
pdfBytes, err := os.ReadFile(cachePath)
if err != nil {
return nil, false, time.Time{}, fmt.Errorf("读取缓存文件失败: %w", err)
}
m.logger.Debug("缓存命中",
zap.String("key1", key1),
zap.String("key2", key2),
zap.String("cache_key", cacheKey),
zap.Int64("file_size", int64(len(pdfBytes))),
zap.Time("expires_at", expiresAt),
)
return pdfBytes, true, createdAt, nil
}
// Set 将PDF文件保存到缓存基于姓名+身份证)
func (m *PDFCacheManager) Set(name, idCard string, pdfBytes []byte) error {
cacheKey := m.GetCacheKey(name, idCard)
return m.setByKey(cacheKey, pdfBytes, name, idCard)
}
// SetByProduct 将PDF文件保存到缓存基于产品ID+版本)
func (m *PDFCacheManager) SetByProduct(productID, version string, pdfBytes []byte) error {
cacheKey := m.GetCacheKeyByProduct(productID, version)
return m.setByKey(cacheKey, pdfBytes, productID, version)
}
// setByKey 内部方法:根据缓存键保存文件
func (m *PDFCacheManager) setByKey(cacheKey string, pdfBytes []byte, key1, key2 string) error {
cachePath := m.GetCachePath(cacheKey)
m.mu.Lock()
defer m.mu.Unlock()
// 检查缓存大小限制
if m.maxSize > 0 {
currentSize, err := m.getCacheDirSize()
if err != nil {
m.logger.Warn("获取缓存目录大小失败", zap.Error(err))
} else {
// 检查是否已存在文件
var oldFileSize int64
if info, err := os.Stat(cachePath); err == nil {
oldFileSize = info.Size()
}
sizeToAdd := int64(len(pdfBytes)) - oldFileSize
if currentSize+sizeToAdd > m.maxSize {
// 缓存空间不足,清理过期文件
m.logger.Warn("缓存空间不足,开始清理过期文件",
zap.Int64("current_size", currentSize),
zap.Int64("max_size", m.maxSize),
zap.Int64("required_size", sizeToAdd),
)
if err := m.cleanExpiredFiles(); err != nil {
m.logger.Warn("清理过期文件失败", zap.Error(err))
}
// 再次检查
currentSize, _ = m.getCacheDirSize()
if currentSize+sizeToAdd > m.maxSize {
m.logger.Error("缓存空间不足,无法保存文件",
zap.Int64("current_size", currentSize),
zap.Int64("max_size", m.maxSize),
zap.Int64("required_size", sizeToAdd),
)
return fmt.Errorf("缓存空间不足,无法保存文件")
}
}
}
}
// 写入文件
if err := os.WriteFile(cachePath, pdfBytes, 0644); err != nil {
return fmt.Errorf("写入缓存文件失败: %w", err)
}
m.logger.Debug("PDF已保存到缓存",
zap.String("key1", key1),
zap.String("key2", key2),
zap.String("cache_key", cacheKey),
zap.Int64("file_size", int64(len(pdfBytes))),
)
return nil
}
// getCacheDirSize 获取缓存目录总大小
func (m *PDFCacheManager) getCacheDirSize() (int64, error) {
var totalSize int64
err := filepath.Walk(m.cacheDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
totalSize += info.Size()
}
return nil
})
return totalSize, err
}
// startCleanupTask 启动定期清理任务
func (m *PDFCacheManager) startCleanupTask() {
m.cleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(1 * time.Hour) // 每小时清理一次
defer ticker.Stop()
for range ticker.C {
if err := m.cleanExpiredFiles(); err != nil {
m.logger.Warn("清理过期缓存文件失败", zap.Error(err))
}
}
}()
})
}
// cleanExpiredFiles 清理过期的缓存文件
func (m *PDFCacheManager) cleanExpiredFiles() error {
m.mu.Lock()
defer m.mu.Unlock()
entries, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
now := time.Now()
cleanedCount := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
// 只处理PDF文件
if filepath.Ext(entry.Name()) != ".pdf" {
continue
}
filePath := filepath.Join(m.cacheDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// 检查文件是否过期
createdAt := info.ModTime()
expiresAt := createdAt.Add(m.ttl)
if now.After(expiresAt) {
if err := os.Remove(filePath); err != nil {
m.logger.Warn("删除过期缓存文件失败",
zap.String("file_path", filePath),
zap.Error(err),
)
} else {
cleanedCount++
}
}
}
if cleanedCount > 0 {
m.logger.Info("清理过期缓存文件完成",
zap.Int("cleaned_count", cleanedCount),
)
}
return nil
}
// Invalidate 使缓存失效基于产品ID+版本)
func (m *PDFCacheManager) Invalidate(productID, version string) error {
cacheKey := m.GetCacheKeyByProduct(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.mu.Lock()
defer m.mu.Unlock()
if err := os.Remove(cachePath); err != nil {
if os.IsNotExist(err) {
return nil // 文件不存在,视为已失效
}
return fmt.Errorf("删除缓存文件失败: %w", err)
}
return nil
}
// InvalidateByNameIDCard 使缓存失效(基于姓名+身份证)
func (m *PDFCacheManager) InvalidateByNameIDCard(name, idCard string) error {
cacheKey := m.GetCacheKey(name, idCard)
cachePath := m.GetCachePath(cacheKey)
m.mu.Lock()
defer m.mu.Unlock()
if err := os.Remove(cachePath); err != nil {
if os.IsNotExist(err) {
return nil // 文件不存在,视为已失效
}
return fmt.Errorf("删除缓存文件失败: %w", err)
}
return nil
}
// Clear 清空所有缓存
func (m *PDFCacheManager) Clear() error {
m.mu.Lock()
defer m.mu.Unlock()
entries, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
count := 0
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, entry.Name())
if err := os.Remove(filePath); err == nil {
count++
}
}
}
m.logger.Info("已清空所有缓存", zap.Int("deleted_count", count))
return nil
}
// GetCacheStats 获取缓存统计信息
func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) {
m.mu.RLock()
defer m.mu.RUnlock()
entries, err := os.ReadDir(m.cacheDir)
if err != nil {
return nil, fmt.Errorf("读取缓存目录失败: %w", err)
}
var totalSize int64
var fileCount int
var expiredCount int
now := time.Now()
for _, entry := range entries {
if entry.IsDir() {
continue
}
if filepath.Ext(entry.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, entry.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
totalSize += info.Size()
fileCount++
// 检查是否过期
expiresAt := info.ModTime().Add(m.ttl)
if now.After(expiresAt) {
expiredCount++
}
}
}
return map[string]interface{}{
"total_size": totalSize,
"file_count": fileCount,
"expired_count": expiredCount,
"cache_dir": m.cacheDir,
"ttl": m.ttl.String(),
"max_size": m.maxSize,
}, nil
}

View File

@@ -0,0 +1,265 @@
package pdf
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"hyapi-server/internal/domains/product/entities"
"go.uber.org/zap"
)
// PDFDebugTool PDF调试工具 - 用于输出转换前后的文档
type PDFDebugTool struct {
logger *zap.Logger
pdfGenerator *PDFGenerator
markdownConverter *MarkdownConverter
textProcessor *TextProcessor
outputDir string
}
// NewPDFDebugTool 创建PDF调试工具
func NewPDFDebugTool(logger *zap.Logger, outputDir string) *PDFDebugTool {
if outputDir == "" {
outputDir = "./pdf_debug_output"
}
textProcessor := NewTextProcessor()
markdownConverter := NewMarkdownConverter(textProcessor)
pdfGenerator := NewPDFGenerator(logger)
return &PDFDebugTool{
logger: logger,
pdfGenerator: pdfGenerator,
markdownConverter: markdownConverter,
textProcessor: textProcessor,
outputDir: outputDir,
}
}
// GenerateDebugDocuments 生成调试文档转换前的markdown和转换后的PDF
func (tool *PDFDebugTool) GenerateDebugDocuments(
ctx context.Context,
productID string,
productName, productCode, description, content string,
price float64,
doc *entities.ProductDocumentation,
) error {
// 创建输出目录
if err := os.MkdirAll(tool.outputDir, 0755); err != nil {
return fmt.Errorf("创建输出目录失败: %w", err)
}
timestamp := time.Now().Format("20060102_150405")
baseName := fmt.Sprintf("%s_%s", productID, timestamp)
// 1. 保存转换前的markdown数据
if err := tool.saveOriginalMarkdown(baseName, doc); err != nil {
tool.logger.Error("保存原始markdown失败", zap.Error(err))
return fmt.Errorf("保存原始markdown失败: %w", err)
}
// 2. 保存转换后的markdown数据预处理后
if err := tool.saveProcessedMarkdown(baseName, doc); err != nil {
tool.logger.Error("保存处理后的markdown失败", zap.Error(err))
return fmt.Errorf("保存处理后的markdown失败: %w", err)
}
// 3. 生成PDF文件
pdfBytes, err := tool.pdfGenerator.GenerateProductPDF(
ctx,
productID,
productName,
productCode,
description,
content,
price,
doc,
)
if err != nil {
tool.logger.Error("生成PDF失败", zap.Error(err))
return fmt.Errorf("生成PDF失败: %w", err)
}
// 4. 保存PDF文件
pdfPath := filepath.Join(tool.outputDir, fmt.Sprintf("%s.pdf", baseName))
if err := os.WriteFile(pdfPath, pdfBytes, 0644); err != nil {
tool.logger.Error("保存PDF文件失败", zap.Error(err))
return fmt.Errorf("保存PDF文件失败: %w", err)
}
tool.logger.Info("调试文档生成成功",
zap.String("product_id", productID),
zap.String("output_dir", tool.outputDir),
zap.String("base_name", baseName),
zap.Int("pdf_size", len(pdfBytes)),
)
return nil
}
// saveOriginalMarkdown 保存原始markdown数据
func (tool *PDFDebugTool) saveOriginalMarkdown(baseName string, doc *entities.ProductDocumentation) error {
if doc == nil {
return nil
}
var content strings.Builder
content.WriteString("# 原始Markdown数据\n\n")
content.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
content.WriteString("---\n\n")
// 请求URL
if doc.RequestURL != "" {
content.WriteString("## 请求URL\n\n")
content.WriteString(fmt.Sprintf("```\n%s\n```\n\n", doc.RequestURL))
}
// 请求方法
if doc.RequestMethod != "" {
content.WriteString("## 请求方法\n\n")
content.WriteString(fmt.Sprintf("%s\n\n", doc.RequestMethod))
}
// 基本信息
if doc.BasicInfo != "" {
content.WriteString("## 基本信息\n\n")
content.WriteString(doc.BasicInfo)
content.WriteString("\n\n")
}
// 请求参数
if doc.RequestParams != "" {
content.WriteString("## 请求参数(原始)\n\n")
content.WriteString("```markdown\n")
content.WriteString(doc.RequestParams)
content.WriteString("\n```\n\n")
}
// 响应示例
if doc.ResponseExample != "" {
content.WriteString("## 响应示例(原始)\n\n")
content.WriteString("```markdown\n")
content.WriteString(doc.ResponseExample)
content.WriteString("\n```\n\n")
}
// 返回字段
if doc.ResponseFields != "" {
content.WriteString("## 返回字段(原始)\n\n")
content.WriteString("```markdown\n")
content.WriteString(doc.ResponseFields)
content.WriteString("\n```\n\n")
}
// 错误代码
if doc.ErrorCodes != "" {
content.WriteString("## 错误代码(原始)\n\n")
content.WriteString("```markdown\n")
content.WriteString(doc.ErrorCodes)
content.WriteString("\n```\n\n")
}
// 保存文件
filePath := filepath.Join(tool.outputDir, fmt.Sprintf("%s_original.md", baseName))
return os.WriteFile(filePath, []byte(content.String()), 0644)
}
// saveProcessedMarkdown 保存处理后的markdown数据
func (tool *PDFDebugTool) saveProcessedMarkdown(baseName string, doc *entities.ProductDocumentation) error {
if doc == nil {
return nil
}
var content strings.Builder
content.WriteString("# 转换后的Markdown数据\n\n")
content.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
content.WriteString("---\n\n")
// 请求URL
if doc.RequestURL != "" {
content.WriteString("## 请求URL\n\n")
content.WriteString(fmt.Sprintf("```\n%s\n```\n\n", doc.RequestURL))
}
// 请求方法
if doc.RequestMethod != "" {
content.WriteString("## 请求方法\n\n")
content.WriteString(fmt.Sprintf("%s\n\n", doc.RequestMethod))
}
// 基本信息
if doc.BasicInfo != "" {
content.WriteString("## 基本信息\n\n")
processedBasicInfo := tool.markdownConverter.PreprocessContent(doc.BasicInfo)
content.WriteString(processedBasicInfo)
content.WriteString("\n\n")
}
// 请求参数(转换后)
if doc.RequestParams != "" {
content.WriteString("## 请求参数(转换后)\n\n")
processedParams := tool.markdownConverter.PreprocessContent(doc.RequestParams)
content.WriteString("```markdown\n")
content.WriteString(processedParams)
content.WriteString("\n```\n\n")
}
// 响应示例(转换后)
if doc.ResponseExample != "" {
content.WriteString("## 响应示例(转换后)\n\n")
processedExample := tool.markdownConverter.PreprocessContent(doc.ResponseExample)
content.WriteString("```markdown\n")
content.WriteString(processedExample)
content.WriteString("\n```\n\n")
}
// 返回字段(转换后)
if doc.ResponseFields != "" {
content.WriteString("## 返回字段(转换后)\n\n")
processedFields := tool.markdownConverter.PreprocessContent(doc.ResponseFields)
content.WriteString("```markdown\n")
content.WriteString(processedFields)
content.WriteString("\n```\n\n")
}
// 错误代码(转换后)
if doc.ErrorCodes != "" {
content.WriteString("## 错误代码(转换后)\n\n")
processedErrorCodes := tool.markdownConverter.PreprocessContent(doc.ErrorCodes)
content.WriteString("```markdown\n")
content.WriteString(processedErrorCodes)
content.WriteString("\n```\n\n")
}
// 保存文件
filePath := filepath.Join(tool.outputDir, fmt.Sprintf("%s_processed.md", baseName))
return os.WriteFile(filePath, []byte(content.String()), 0644)
}
// GenerateDebugDocumentsFromEntity 从实体生成调试文档
func (tool *PDFDebugTool) GenerateDebugDocumentsFromEntity(
ctx context.Context,
product *entities.Product,
doc *entities.ProductDocumentation,
) error {
var price float64
if !product.Price.IsZero() {
price, _ = product.Price.Float64()
}
return tool.GenerateDebugDocuments(
ctx,
product.ID,
product.Name,
product.Code,
product.Description,
product.Content,
price,
doc,
)
}

View File

@@ -0,0 +1,226 @@
package pdf
import (
"fmt"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
)
// GetDocumentationDir 获取接口文档文件夹路径
// 会在当前目录及其父目录中查找"接口文档"文件夹
func GetDocumentationDir() (string, error) {
// 获取当前工作目录
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取工作目录失败: %w", err)
}
// 搜索策略:从当前目录开始,向上查找"接口文档"文件夹
currentDir := wd
maxDepth := 10 // 增加搜索深度,确保能找到
var checkedDirs []string
for i := 0; i < maxDepth; i++ {
docDir := filepath.Join(currentDir, "接口文档")
checkedDirs = append(checkedDirs, docDir)
if info, err := os.Stat(docDir); err == nil && info.IsDir() {
// 直接返回相对路径,不转换为绝对路径
return docDir, nil
}
// 尝试父目录
parentDir := filepath.Dir(currentDir)
if parentDir == currentDir {
break // 已到达根目录
}
currentDir = parentDir
}
return "", fmt.Errorf("未找到接口文档文件夹。已检查的路径: %v当前工作目录: %s", checkedDirs, wd)
}
// PDFFinder PDF文件查找服务
type PDFFinder struct {
documentationDir string
logger *zap.Logger
}
// NewPDFFinder 创建PDF查找服务
func NewPDFFinder(documentationDir string, logger *zap.Logger) *PDFFinder {
return &PDFFinder{
documentationDir: documentationDir,
logger: logger,
}
}
// FindPDFByProductCode 根据产品代码查找PDF文件
// 会在接口文档文件夹中递归搜索匹配的PDF文件
// 文件名格式应为: *_{产品代码}.pdf
func (f *PDFFinder) FindPDFByProductCode(productCode string) (string, error) {
if productCode == "" {
return "", fmt.Errorf("产品代码不能为空")
}
// 构建搜索模式:文件名以 _{产品代码}.pdf 结尾
searchPattern := fmt.Sprintf("*_%s.pdf", productCode)
f.logger.Info("开始搜索PDF文件",
zap.String("product_code", productCode),
zap.String("search_pattern", searchPattern),
zap.String("documentation_dir", f.documentationDir),
)
// 验证接口文档文件夹是否存在
if info, err := os.Stat(f.documentationDir); err != nil || !info.IsDir() {
f.logger.Error("接口文档文件夹不存在或无法访问",
zap.String("documentation_dir", f.documentationDir),
zap.Error(err),
)
return "", fmt.Errorf("接口文档文件夹不存在或无法访问: %s", f.documentationDir)
}
var foundPath string
var checkedFiles []string
err := filepath.Walk(f.documentationDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
f.logger.Debug("访问文件/目录时出错,跳过",
zap.String("path", path),
zap.Error(err),
)
return nil // 忽略访问错误,继续搜索
}
// 只处理PDF文件
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".pdf") {
return nil
}
// 获取文件名(不包含路径)
fileName := info.Name()
checkedFiles = append(checkedFiles, fileName)
// 转换为小写进行大小写不敏感匹配
fileNameLower := strings.ToLower(fileName)
productCodeLower := strings.ToLower(productCode)
// 方式1: 检查文件名是否以 _{产品代码}.pdf 结尾(大小写不敏感)
suffixPattern := fmt.Sprintf("_%s.pdf", productCodeLower)
if strings.HasSuffix(fileNameLower, suffixPattern) {
foundPath = path
f.logger.Info("找到匹配的PDF文件后缀匹配",
zap.String("product_code", productCode),
zap.String("file_name", fileName),
zap.String("file_path", path),
)
return filepath.SkipAll // 找到后停止搜索
}
// 方式2: 使用filepath.Match进行模式匹配作为备用
matched, matchErr := filepath.Match(searchPattern, fileName)
if matchErr == nil && matched {
foundPath = path
f.logger.Info("找到匹配的PDF文件模式匹配",
zap.String("product_code", productCode),
zap.String("file_name", fileName),
zap.String("file_path", path),
)
return filepath.SkipAll // 找到后停止搜索
}
return nil
})
if err != nil {
f.logger.Error("搜索PDF文件时出错",
zap.String("product_code", productCode),
zap.Error(err),
)
return "", fmt.Errorf("搜索PDF文件时出错: %w", err)
}
if foundPath == "" {
// 查找包含产品编码前缀的类似文件,用于调试
var similarFiles []string
if len(productCode) >= 4 {
productCodePrefix := productCode[:4] // 取前4个字符作为前缀如JRZQ
for _, fileName := range checkedFiles {
fileNameLower := strings.ToLower(fileName)
if strings.Contains(fileNameLower, strings.ToLower(productCodePrefix)) {
similarFiles = append(similarFiles, fileName)
if len(similarFiles) >= 5 {
break // 只显示最多5个类似文件
}
}
}
}
f.logger.Warn("未找到匹配的PDF文件",
zap.String("product_code", productCode),
zap.String("search_pattern", searchPattern),
zap.String("documentation_dir", f.documentationDir),
zap.Int("checked_files_count", len(checkedFiles)),
zap.Strings("similar_files_with_same_prefix", similarFiles),
zap.Strings("sample_files", func() []string {
if len(checkedFiles) > 10 {
return checkedFiles[:10]
}
return checkedFiles
}()),
)
return "", fmt.Errorf("未找到产品代码为 %s 的PDF文档", productCode)
}
// 直接返回相对路径,不转换为绝对路径
f.logger.Info("成功找到PDF文件",
zap.String("product_code", productCode),
zap.String("file_path", foundPath),
)
return foundPath, nil
}
// FindPDFByProductCodeWithFallback 根据产品代码查找PDF文件支持多个可能的命名格式
func (f *PDFFinder) FindPDFByProductCodeWithFallback(productCode string) (string, error) {
// 尝试多种可能的文件命名格式
patterns := []string{
fmt.Sprintf("*_%s.pdf", productCode), // 标准格式: 产品名称_{代码}.pdf
fmt.Sprintf("%s*.pdf", productCode), // 以代码开头
fmt.Sprintf("*%s*.pdf", productCode), // 包含代码
}
var foundPath string
for _, pattern := range patterns {
err := filepath.Walk(f.documentationDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".pdf") {
return nil
}
fileName := info.Name()
if matched, _ := filepath.Match(pattern, fileName); matched {
foundPath = path
return filepath.SkipAll
}
return nil
})
if err == nil && foundPath != "" {
break
}
}
if foundPath == "" {
return "", fmt.Errorf("未找到产品代码为 %s 的PDF文档", productCode)
}
// 直接返回相对路径,不转换为绝对路径
return foundPath, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,324 @@
package pdf
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"hyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
"github.com/shopspring/decimal"
"go.uber.org/zap"
)
// PDFGeneratorRefactored 重构后的PDF生成器
type PDFGeneratorRefactored struct {
logger *zap.Logger
fontManager *FontManager
textProcessor *TextProcessor
markdownProc *MarkdownProcessor
tableParser *TableParser
tableRenderer *TableRenderer
jsonProcessor *JSONProcessor
logoPath string
watermarkText string
}
// NewPDFGeneratorRefactored 创建重构后的PDF生成器
func NewPDFGeneratorRefactored(logger *zap.Logger) *PDFGeneratorRefactored {
// 设置全局logger用于资源路径查找
SetGlobalLogger(logger)
// 初始化各个模块
textProcessor := NewTextProcessor()
fontManager := NewFontManager(logger)
markdownProc := NewMarkdownProcessor(textProcessor)
tableParser := NewTableParser(logger, fontManager)
tableRenderer := NewTableRenderer(logger, fontManager, textProcessor)
jsonProcessor := NewJSONProcessor()
gen := &PDFGeneratorRefactored{
logger: logger,
fontManager: fontManager,
textProcessor: textProcessor,
markdownProc: markdownProc,
tableParser: tableParser,
tableRenderer: tableRenderer,
jsonProcessor: jsonProcessor,
watermarkText: "海南海宇大数据有限公司",
}
// 查找logo文件
gen.findLogo()
return gen
}
// findLogo 查找logo文件仅从resources/pdf加载
func (g *PDFGeneratorRefactored) findLogo() {
// 获取resources/pdf目录使用统一的资源路径查找函数
resourcesPDFDir := GetResourcesPDFDir()
logoPath := filepath.Join(resourcesPDFDir, "logo.png")
// 检查文件是否存在
if _, err := os.Stat(logoPath); err == nil {
g.logoPath = logoPath
return
}
// 只记录关键错误
g.logger.Warn("未找到logo文件", zap.String("path", logoPath))
}
// GenerateProductPDF 为产品生成PDF文档接受响应类型内部转换
func (g *PDFGeneratorRefactored) GenerateProductPDF(ctx context.Context, productID string, productName, productCode, description, content string, price float64, doc *entities.ProductDocumentation) ([]byte, error) {
// 构建临时的 Product entity仅用于PDF生成
product := &entities.Product{
ID: productID,
Name: productName,
Code: productCode,
Description: description,
Content: content,
}
// 如果有价格信息,设置价格
if price > 0 {
product.Price = decimal.NewFromFloat(price)
}
return g.generatePDF(product, doc, nil)
}
// GenerateProductPDFFromEntity 从entity类型生成PDF推荐使用
func (g *PDFGeneratorRefactored) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) {
return g.generatePDF(product, doc, nil)
}
// GenerateProductPDFWithSubProducts 从entity类型生成PDF支持组合包子产品文档
func (g *PDFGeneratorRefactored) GenerateProductPDFWithSubProducts(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation, subProductDocs []*entities.ProductDocumentation) ([]byte, error) {
return g.generatePDF(product, doc, subProductDocs)
}
// generatePDF 内部PDF生成方法
func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *entities.ProductDocumentation, subProductDocs []*entities.ProductDocumentation) (result []byte, err error) {
defer func() {
if r := recover(); r != nil {
// 将panic转换为error而不是重新抛出
if e, ok := r.(error); ok {
err = fmt.Errorf("PDF生成panic: %w", e)
} else {
err = fmt.Errorf("PDF生成panic: %v", r)
}
result = nil
}
}()
// 保存当前工作目录(用于后续恢复)
originalWorkDir, _ := os.Getwd()
// 关键修复gofpdf在AddUTF8Font和Output时都会处理字体路径
// 如果路径是绝对路径 /app/resources/pdf/fonts/simhei.ttfgofpdf会去掉开头的/
// 变成相对路径 app/resources/pdf/fonts/simhei.ttf
// 解决方案在AddUTF8Font之前就切换工作目录到根目录/
resourcesDir := GetResourcesPDFDir()
workDirChanged := false
if resourcesDir != "" && len(resourcesDir) > 0 && resourcesDir[0] == '/' {
// 切换到根目录,这样 gofpdf 转换后的相对路径 app/resources 就能解析为 /app/resources
if err := os.Chdir("/"); err == nil {
workDirChanged = true
g.logger.Info("切换工作目录到根目录在AddUTF8Font之前以修复gofpdf路径问题",
zap.String("reason", "gofpdf在AddUTF8Font时就会处理路径需要在加载字体前切换工作目录"),
zap.String("original_work_dir", originalWorkDir),
zap.String("new_work_dir", "/"),
zap.String("resources_dir", resourcesDir),
)
defer func() {
// 恢复原始工作目录
if originalWorkDir != "" {
if err := os.Chdir(originalWorkDir); err == nil {
g.logger.Debug("已恢复原始工作目录", zap.String("work_dir", originalWorkDir))
}
}
}()
} else {
g.logger.Warn("无法切换到根目录", zap.Error(err))
}
}
// 创建PDF文档 (A4大小gofpdf v2 默认支持UTF-8)
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),
)
// 设置文档信息
pdf.SetTitle("Product Documentation", true)
pdf.SetAuthor("HYAPI Server", true)
pdf.SetCreator("HYAPI Server", true)
// 创建页面构建器
pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText)
// 页眉只绘制 logo 和横线;水印改到页脚绘制,确保水印在最上层不被表格等内容遮挡
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 {
// 如果有关联的文档,添加接口文档页面(但不包含二维码和说明,后面统一添加)
if doc != nil {
pageBuilder.AddDocumentationPagesWithoutAdditionalInfo(pdf, doc, chineseFontAvailable)
}
// 如果有子产品文档,为每个子产品添加接口文档页面
if len(subProductDocs) > 0 {
for i, subDoc := range subProductDocs {
// 获取子产品信息从文档中获取ProductID然后查找对应的产品信息
// 注意这里我们需要从product.PackageItems中查找对应的子产品信息
var subProduct *entities.Product
if product.PackageItems != nil && i < len(product.PackageItems) {
if product.PackageItems[i].Product != nil {
subProduct = product.PackageItems[i].Product
}
}
// 如果找不到子产品信息,创建一个基本的子产品实体
if subProduct == nil {
subProduct = &entities.Product{
ID: subDoc.ProductID,
Code: subDoc.ProductID, // 使用ProductID作为临时Code
Name: fmt.Sprintf("子产品 %d", i+1),
}
}
pageBuilder.AddSubProductDocumentationPages(pdf, subProduct, subDoc, chineseFontAvailable, false)
}
}
// 在所有接口文档渲染完成后,统一添加二维码和后勤服务说明
// 使用主产品文档(如果存在),否则使用第一个子产品文档
var finalDoc *entities.ProductDocumentation
if doc != nil {
finalDoc = doc
} else if len(subProductDocs) > 0 {
finalDoc = subProductDocs[0]
}
if finalDoc != nil {
pageBuilder.AddAdditionalInfo(pdf, finalDoc, chineseFontAvailable)
}
} else {
// 普通产品:使用原来的方法(包含二维码和说明)
if doc != nil {
pageBuilder.AddDocumentationPages(pdf, doc, chineseFontAvailable)
}
}
// 生成PDF字节流
// 注意工作目录已经在AddUTF8Font之前切换到了根目录/
// 这样gofpdf在Output时使用相对路径 app/resources/pdf/fonts 就能正确解析
var buf bytes.Buffer
// 在Output前验证字体文件路径此时工作目录应该是根目录/
if workDir, err := os.Getwd(); err == nil {
// 验证绝对路径
fontAbsPath := filepath.Join(resourcesDir, "fonts", "simhei.ttf")
if _, err := os.Stat(fontAbsPath); err == nil {
g.logger.Debug("Output前验证字体文件存在绝对路径",
zap.String("font_abs_path", fontAbsPath),
zap.String("work_dir", workDir),
)
}
// 验证相对路径gofpdf可能使用的路径
fontRelPath := "app/resources/pdf/fonts/simhei.ttf"
if relAbsPath, err := filepath.Abs(fontRelPath); err == nil {
if _, err := os.Stat(relAbsPath); err == nil {
g.logger.Debug("Output前验证字体文件存在相对路径解析",
zap.String("font_rel_path", fontRelPath),
zap.String("resolved_abs_path", relAbsPath),
zap.String("work_dir", workDir),
)
} else {
g.logger.Warn("Output前相对路径解析的字体文件不存在",
zap.String("font_rel_path", fontRelPath),
zap.String("resolved_abs_path", relAbsPath),
zap.String("work_dir", workDir),
zap.Error(err),
)
}
}
g.logger.Debug("准备生成PDF",
zap.String("work_dir", workDir),
zap.String("resources_pdf_dir", resourcesDir),
zap.Bool("work_dir_changed", workDirChanged),
)
}
err = pdf.Output(&buf)
if err != nil {
// 记录详细的错误信息
currentWorkDir := ""
if wd, e := os.Getwd(); e == nil {
currentWorkDir = wd
}
// 尝试分析错误:如果是路径问题,记录更多信息
errStr := err.Error()
if strings.Contains(errStr, "stat ") && strings.Contains(errStr, ": no such file") {
g.logger.Error("PDF Output失败字体文件路径问题",
zap.Error(err),
zap.String("current_work_dir", currentWorkDir),
zap.String("resources_pdf_dir", resourcesDir),
zap.String("error_message", errStr),
)
} else {
g.logger.Error("PDF Output失败",
zap.Error(err),
zap.String("current_work_dir", currentWorkDir),
zap.String("resources_pdf_dir", resourcesDir),
)
}
return nil, fmt.Errorf("生成PDF失败: %w", err)
}
pdfBytes := buf.Bytes()
return pdfBytes, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
package pdf
import (
"context"
"net/url"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
// QYGLReportPDFStatus 企业报告 PDF 预生成状态(供前端轮询)
type QYGLReportPDFStatus string
const (
QYGLReportPDFStatusNone QYGLReportPDFStatus = "none"
QYGLReportPDFStatusPending QYGLReportPDFStatus = "pending"
QYGLReportPDFStatusGenerating QYGLReportPDFStatus = "generating"
QYGLReportPDFStatusReady QYGLReportPDFStatus = "ready"
QYGLReportPDFStatusFailed QYGLReportPDFStatus = "failed"
)
// QYGLReportPDFPregen 异步预渲染企业报告 PDFheadless Chrome 访问公网可访问的报告 HTML
type QYGLReportPDFPregen struct {
logger *zap.Logger
cache *PDFCacheManager
baseURL string // 已 trim无尾斜杠空则禁用
mu sync.RWMutex
states map[string]*qyglPDFState
}
type qyglPDFState struct {
Status QYGLReportPDFStatus
Message string
UpdatedAt time.Time
}
// NewQYGLReportPDFPregen baseURL 须为外部可访问基址(如 https://api.example.com为空时不预生成
func NewQYGLReportPDFPregen(logger *zap.Logger, cache *PDFCacheManager, baseURL string) *QYGLReportPDFPregen {
if logger == nil {
logger = zap.NewNop()
}
u := strings.TrimSpace(baseURL)
u = strings.TrimRight(u, "/")
return &QYGLReportPDFPregen{
logger: logger,
cache: cache,
baseURL: u,
states: make(map[string]*qyglPDFState),
}
}
// ScheduleQYGLReportPDF 实现 processors.QYGLReportPDFScheduler
func (p *QYGLReportPDFPregen) ScheduleQYGLReportPDF(ctx context.Context, reportID string) {
p.schedule(ctx, reportID)
}
func (p *QYGLReportPDFPregen) schedule(_ context.Context, reportID string) {
if p == nil || p.baseURL == "" || reportID == "" || p.cache == nil {
return
}
if b, hit, _, err := p.cache.GetByReportID(reportID); hit && err == nil && len(b) > 0 {
p.setState(reportID, QYGLReportPDFStatusReady, "")
return
}
p.mu.Lock()
if st, ok := p.states[reportID]; ok {
if st.Status == QYGLReportPDFStatusGenerating || st.Status == QYGLReportPDFStatusPending {
p.mu.Unlock()
return
}
if st.Status == QYGLReportPDFStatusReady {
p.mu.Unlock()
return
}
}
p.states[reportID] = &qyglPDFState{
Status: QYGLReportPDFStatusPending,
Message: "",
UpdatedAt: time.Now(),
}
p.mu.Unlock()
go p.runGeneration(reportID)
}
func (p *QYGLReportPDFPregen) runGeneration(reportID string) {
p.setState(reportID, QYGLReportPDFStatusGenerating, "")
fullURL := p.baseURL + "/reports/qygl/" + url.PathEscape(reportID)
gen := NewHTMLPDFGenerator(p.logger)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pdfBytes, err := gen.GenerateFromURL(ctx, fullURL)
if err != nil {
p.logger.Error("企业报告 PDF 预生成失败",
zap.String("report_id", reportID),
zap.String("url", fullURL),
zap.Error(err),
)
p.setState(reportID, QYGLReportPDFStatusFailed, "生成失败:"+err.Error())
return
}
if len(pdfBytes) == 0 {
p.setState(reportID, QYGLReportPDFStatusFailed, "生成的 PDF 为空")
return
}
if err := p.cache.SetByReportID(reportID, pdfBytes); err != nil {
p.logger.Error("企业报告 PDF 写入缓存失败",
zap.String("report_id", reportID),
zap.Error(err),
)
p.setState(reportID, QYGLReportPDFStatusFailed, "写入缓存失败")
return
}
p.setState(reportID, QYGLReportPDFStatusReady, "")
p.logger.Info("企业报告 PDF 预生成完成",
zap.String("report_id", reportID),
zap.Int("size", len(pdfBytes)),
)
}
func (p *QYGLReportPDFPregen) setState(reportID string, st QYGLReportPDFStatus, msg string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if p.states == nil {
p.states = make(map[string]*qyglPDFState)
}
p.states[reportID] = &qyglPDFState{
Status: st,
Message: msg,
UpdatedAt: time.Now(),
}
}
// Status 返回当前状态;若磁盘缓存已命中则始终为 ready
func (p *QYGLReportPDFPregen) Status(reportID string) (QYGLReportPDFStatus, string) {
if p == nil || reportID == "" {
return QYGLReportPDFStatusNone, ""
}
if p.cache != nil {
if b, hit, _, err := p.cache.GetByReportID(reportID); hit && err == nil && len(b) > 0 {
return QYGLReportPDFStatusReady, ""
}
}
p.mu.RLock()
defer p.mu.RUnlock()
st := p.states[reportID]
if st == nil {
if p.baseURL == "" {
return QYGLReportPDFStatusNone, "未启用预生成"
}
return QYGLReportPDFStatusNone, ""
}
return st.Status, st.Message
}
// MarkReadyAfterUpload 同步生成成功后与预生成状态对齐(可选)
func (p *QYGLReportPDFPregen) MarkReadyAfterUpload(reportID string) {
if p == nil || reportID == "" {
return
}
p.setState(reportID, QYGLReportPDFStatusReady, "")
}
// Enabled 是否配置了预生成基址
func (p *QYGLReportPDFPregen) Enabled() bool {
return p != nil && p.baseURL != ""
}

View File

@@ -0,0 +1,90 @@
package pdf
import (
"os"
"path/filepath"
"go.uber.org/zap"
)
var globalLogger *zap.Logger
// SetGlobalLogger 设置全局logger用于资源路径查找
func SetGlobalLogger(logger *zap.Logger) {
globalLogger = logger
}
// GetResourcesPDFDir 获取resources/pdf目录路径绝对路径
// resources目录和可执行文件同级例如
// 生产环境:/app/hyapi-server (可执行文件) 和 /app/resources/pdf (资源文件)
// 开发环境:工作目录下的 resources/pdf 或 hyapi-server/resources/pdf
func GetResourcesPDFDir() string {
// 候选路径列表(按优先级排序)
var candidatePaths []string
// 优先级1: 从可执行文件所在目录查找(生产环境和开发环境都适用)
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
// 处理符号链接
if realPath, err := filepath.EvalSymlinks(execPath); err == nil {
execDir = filepath.Dir(realPath)
}
candidatePaths = append(candidatePaths, filepath.Join(execDir, "resources", "pdf"))
}
// 优先级2: 从工作目录查找(开发环境)
if workDir, err := os.Getwd(); err == nil {
candidatePaths = append(candidatePaths,
filepath.Join(workDir, "resources", "pdf"),
filepath.Join(workDir, "hyapi-server", "resources", "pdf"),
)
}
// 尝试每个候选路径
for _, candidatePath := range candidatePaths {
absPath, err := filepath.Abs(candidatePath)
if err != nil {
continue
}
if globalLogger != nil {
globalLogger.Debug("尝试查找resources/pdf目录", zap.String("path", absPath))
}
// 检查目录是否存在
if info, err := os.Stat(absPath); err == nil && info.IsDir() {
if globalLogger != nil {
globalLogger.Info("找到resources/pdf目录", zap.String("path", absPath))
}
return absPath
}
}
// 所有候选路径都不存在,返回第一个候选路径的绝对路径(作为后备)
// 这样至少保证返回的是绝对路径,即使目录不存在
if len(candidatePaths) > 0 {
if absPath, err := filepath.Abs(candidatePaths[0]); err == nil {
if globalLogger != nil {
globalLogger.Warn("未找到resources/pdf目录返回后备绝对路径", zap.String("path", absPath))
}
return absPath
}
}
// 最后的最后:从工作目录构建绝对路径
if workDir, err := os.Getwd(); err == nil {
absPath, err := filepath.Abs(filepath.Join(workDir, "resources", "pdf"))
if err == nil {
if globalLogger != nil {
globalLogger.Error("无法确定resources/pdf目录返回工作目录下的绝对路径", zap.String("path", absPath))
}
return absPath
}
}
// 完全无法确定路径
if globalLogger != nil {
globalLogger.Error("完全无法确定resources/pdf目录路径")
}
return ""
}

View File

@@ -0,0 +1,198 @@
package pdf
import (
"context"
"fmt"
"strings"
"hyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// TableBlock 表格块(用于向后兼容)
type TableBlock struct {
BeforeText string
TableData [][]string
AfterText string
}
// TableParser 表格解析器
// 从数据库读取数据并转换为表格格式
type TableParser struct {
logger *zap.Logger
fontManager *FontManager
databaseReader *DatabaseTableReader
databaseRenderer *DatabaseTableRenderer
}
// NewTableParser 创建表格解析器
func NewTableParser(logger *zap.Logger, fontManager *FontManager) *TableParser {
reader := NewDatabaseTableReader(logger)
renderer := NewDatabaseTableRenderer(logger, fontManager)
return &TableParser{
logger: logger,
fontManager: fontManager,
databaseReader: reader,
databaseRenderer: renderer,
}
}
// ParseAndRenderTable 从产品文档中解析并渲染表格(支持多个表格,带标题)
func (tp *TableParser) ParseAndRenderTable(ctx context.Context, pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, fieldType string) error {
// 从数据库读取表格数据(支持多个表格)
tableData, err := tp.databaseReader.ReadTableFromDocumentation(ctx, doc, fieldType)
if err != nil {
// 如果内容为空,不渲染,也不报错(静默跳过)
if strings.Contains(err.Error(), "内容为空") {
tp.logger.Debug("表格内容为空,跳过渲染", zap.String("field_type", fieldType))
return nil
}
return fmt.Errorf("读取表格数据失败: %w", err)
}
// 检查表格数据是否有效
if tableData == nil || len(tableData.Headers) == 0 {
tp.logger.Warn("表格数据无效,跳过渲染",
zap.String("field_type", fieldType),
zap.Bool("is_nil", tableData == nil))
return nil
}
// 渲染表格到PDF
if err := tp.databaseRenderer.RenderTable(pdf, tableData); err != nil {
// 错误已返回,不记录日志
return fmt.Errorf("渲染表格失败: %w", err)
}
return nil
}
// ParseAndRenderTablesWithTitles 从产品文档中解析并渲染多个表格(带标题)
func (tp *TableParser) ParseAndRenderTablesWithTitles(ctx context.Context, pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, fieldType string) error {
var content string
switch fieldType {
case "request_params":
content = doc.RequestParams
case "response_fields":
content = doc.ResponseFields
case "response_example":
content = doc.ResponseExample
case "error_codes":
content = doc.ErrorCodes
default:
return fmt.Errorf("未知的字段类型: %s", fieldType)
}
if strings.TrimSpace(content) == "" {
return nil
}
// 解析多个表格(带标题)
tablesWithTitles, err := tp.databaseReader.parseMarkdownTablesWithTitles(content)
if err != nil {
tp.logger.Warn("解析表格失败,回退到单个表格", zap.Error(err))
// 回退到单个表格渲染
return tp.ParseAndRenderTable(ctx, pdf, doc, fieldType)
}
if len(tablesWithTitles) == 0 {
return nil
}
// 分别渲染每个表格,并在表格前显示标题
_, lineHt := pdf.GetFontSize()
for i, twt := range tablesWithTitles {
if twt.Table == nil || len(twt.Table.Headers) == 0 {
continue
}
// 如果不是第一个表格,添加间距
if i > 0 {
pdf.Ln(5)
}
// 如果有标题,显示标题
if strings.TrimSpace(twt.Title) != "" {
pdf.SetTextColor(0, 0, 0)
tp.fontManager.SetFont(pdf, "B", 12)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, twt.Title, "", 1, "L", false, 0, "")
pdf.Ln(2)
}
// 渲染表格
if err := tp.databaseRenderer.RenderTable(pdf, twt.Table); err != nil {
tp.logger.Warn("渲染表格失败", zap.Error(err), zap.String("title", twt.Title))
continue
}
}
return nil
}
// ParseTableData 仅解析表格数据,不渲染
func (tp *TableParser) ParseTableData(ctx context.Context, doc *entities.ProductDocumentation, fieldType string) (*TableData, error) {
return tp.databaseReader.ReadTableFromDocumentation(ctx, doc, fieldType)
}
// ParseMarkdownTable 解析Markdown表格兼容方法
func (tp *TableParser) ParseMarkdownTable(text string) [][]string {
// 使用数据库读取器的markdown解析功能
tableData, err := tp.databaseReader.parseMarkdownTable(text)
if err != nil {
tp.logger.Warn("解析markdown表格失败", zap.Error(err))
return nil
}
// 转换为旧格式 [][]string
result := make([][]string, 0, len(tableData.Rows)+1)
result = append(result, tableData.Headers)
result = append(result, tableData.Rows...)
return result
}
// ExtractAllTables 提取所有表格块(兼容方法)
func (tp *TableParser) ExtractAllTables(content string) []TableBlock {
// 使用数据库读取器解析markdown表格
tableData, err := tp.databaseReader.parseMarkdownTable(content)
if err != nil {
return []TableBlock{}
}
// 转换为TableBlock格式
if len(tableData.Headers) > 0 {
rows := make([][]string, 0, len(tableData.Rows)+1)
rows = append(rows, tableData.Headers)
rows = append(rows, tableData.Rows...)
return []TableBlock{
{
BeforeText: "",
TableData: rows,
AfterText: "",
},
}
}
return []TableBlock{}
}
// IsValidTable 验证表格是否有效(兼容方法)
func (tp *TableParser) IsValidTable(tableData [][]string) bool {
if len(tableData) == 0 {
return false
}
if len(tableData[0]) == 0 {
return false
}
// 检查表头是否有有效内容
for _, cell := range tableData[0] {
if strings.TrimSpace(cell) != "" {
return true
}
}
return false
}

View File

@@ -0,0 +1,340 @@
package pdf
import (
"math"
"strings"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// TableRenderer 表格渲染器
type TableRenderer struct {
logger *zap.Logger
fontManager *FontManager
textProcessor *TextProcessor
}
// NewTableRenderer 创建表格渲染器
func NewTableRenderer(logger *zap.Logger, fontManager *FontManager, textProcessor *TextProcessor) *TableRenderer {
return &TableRenderer{
logger: logger,
fontManager: fontManager,
textProcessor: textProcessor,
}
}
// RenderTable 渲染表格
func (tr *TableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData [][]string) {
if len(tableData) == 0 {
return
}
// 支持只有表头的表格(单行表格)
if len(tableData) == 1 {
tr.logger.Info("渲染单行表格(只有表头)", zap.Int("cols", len(tableData[0])))
}
_, lineHt := pdf.GetFontSize()
tr.fontManager.SetFont(pdf, "", 9)
// 计算列宽(根据内容动态计算,确保所有列都能显示)
pageWidth, _ := pdf.GetPageSize()
availableWidth := pageWidth - 30 // 减去左右边距15mm * 2
numCols := len(tableData[0])
// 计算每列的最小宽度(根据内容)
colMinWidths := make([]float64, numCols)
tr.fontManager.SetFont(pdf, "", 9)
// 遍历所有行,计算每列的最大内容宽度
for i, row := range tableData {
for j := 0; j < numCols && j < len(row); j++ {
cell := tr.textProcessor.CleanTextPreservingMarkdown(row[j])
// 计算文本宽度
var textWidth float64
if tr.fontManager.IsChineseFontAvailable() {
textWidth = pdf.GetStringWidth(cell)
} else {
// 估算宽度
charCount := len([]rune(cell))
textWidth = float64(charCount) * 3.0 // 估算每个字符3mm
}
// 加上边距左右各4mm进一步增加边距让内容更舒适
cellWidth := textWidth + 8
// 最小宽度(表头可能需要更多空间)
if i == 0 {
cellWidth = math.Max(cellWidth, 30) // 表头最小30mm从25mm增加
} else {
cellWidth = math.Max(cellWidth, 25) // 数据行最小25mm从20mm增加
}
if cellWidth > colMinWidths[j] {
colMinWidths[j] = cellWidth
}
}
}
// 确保所有列的最小宽度一致(避免宽度差异过大)
minColWidth := 25.0
for i := range colMinWidths {
if colMinWidths[i] < minColWidth {
colMinWidths[i] = minColWidth
}
}
// 计算总的最小宽度
totalMinWidth := 0.0
for _, w := range colMinWidths {
totalMinWidth += w
}
// 计算每列的实际宽度
colWidths := make([]float64, numCols)
if totalMinWidth <= availableWidth {
// 如果总宽度不超过可用宽度,使用计算的最小宽度,剩余空间平均分配
extraWidth := availableWidth - totalMinWidth
extraPerCol := extraWidth / float64(numCols)
for i := range colWidths {
colWidths[i] = colMinWidths[i] + extraPerCol
}
} else {
// 如果总宽度超过可用宽度,按比例缩放
scale := availableWidth / totalMinWidth
for i := range colWidths {
colWidths[i] = colMinWidths[i] * scale
// 确保最小宽度
if colWidths[i] < 10 {
colWidths[i] = 10
}
}
// 重新调整以确保总宽度不超过可用宽度
actualTotal := 0.0
for _, w := range colWidths {
actualTotal += w
}
if actualTotal > availableWidth {
scale = availableWidth / actualTotal
for i := range colWidths {
colWidths[i] *= scale
}
}
}
// 绘制表头
header := tableData[0]
pdf.SetFillColor(74, 144, 226) // 蓝色背景
pdf.SetTextColor(0, 0, 0) // 黑色文字
tr.fontManager.SetFont(pdf, "B", 9)
// 清理表头文本只清理无效字符保留markdown格式
for i, cell := range header {
header[i] = tr.textProcessor.CleanTextPreservingMarkdown(cell)
}
// 先计算表头的最大高度
headerStartY := pdf.GetY()
maxHeaderHeight := lineHt * 2.5 // 进一步增加表头高度从2.0倍增加到2.5倍
for i, cell := range header {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
headerLines := pdf.SplitText(cell, colW-6) // 增加边距从4增加到6
headerHeight := float64(len(headerLines)) * lineHt * 2.5 // 进一步增加表头行高
if headerHeight < lineHt*2.5 {
headerHeight = lineHt * 2.5
}
if headerHeight > maxHeaderHeight {
maxHeaderHeight = headerHeight
}
}
// 绘制表头(使用动态计算的列宽)
currentX := 15.0
for i, cell := range header {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 绘制表头背景
pdf.Rect(currentX, headerStartY, colW, maxHeaderHeight, "FD")
// 绘制表头文本不使用ClipRect直接使用MultiCell它会自动处理换行
// 确保文本不为空
if strings.TrimSpace(cell) != "" {
// 增加内边距从2增加到3
pdf.SetXY(currentX+3, headerStartY+3)
// 确保表头文字为黑色
pdf.SetTextColor(0, 0, 0)
// 进一步增加表头行高从2.0倍增加到2.5倍
pdf.MultiCell(colW-6, lineHt*2.5, cell, "", "C", false)
} else {
// 如果单元格为空,记录警告
tr.logger.Warn("表头单元格为空", zap.Int("col_index", i), zap.String("header", strings.Join(header, ",")))
}
// 重置Y坐标确保下一列从同一行开始
pdf.SetXY(currentX+colW, headerStartY)
currentX += colW
}
// 移动到下一行(使用计算好的最大表头高度)
pdf.SetXY(15.0, headerStartY+maxHeaderHeight)
// 绘制数据行
pdf.SetFillColor(245, 245, 220) // 米色背景
pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰
tr.fontManager.SetFont(pdf, "", 9)
_, lineHt = pdf.GetFontSize()
for i := 1; i < len(tableData); i++ {
row := tableData[i]
fill := (i % 2) == 0 // 交替填充
// 计算这一行的起始Y坐标
startY := pdf.GetY()
// 设置字体以计算文本宽度和高度
tr.fontManager.SetFont(pdf, "", 9)
_, cellLineHt := pdf.GetFontSize()
// 先遍历一次,计算每列需要的最大高度
maxCellHeight := cellLineHt * 2.5 // 进一步增加最小高度从2.0倍增加到2.5倍
for j, cell := range row {
if j >= numCols || j >= len(colWidths) {
break
}
// 清理单元格文本只清理无效字符保留markdown格式
cleanCell := tr.textProcessor.CleanTextPreservingMarkdown(cell)
cellWidth := colWidths[j] - 6 // 使用动态计算的列宽减去左右边距从4增加到6
// 使用SplitText准确计算需要的行数
var lines []string
if tr.fontManager.IsChineseFontAvailable() {
// 对于中文字体使用SplitText
lines = pdf.SplitText(cleanCell, cellWidth)
} else {
// 对于Arial字体如果包含中文可能失败使用估算
charCount := len([]rune(cleanCell))
if charCount == 0 {
lines = []string{""}
} else {
// 中文字符宽度大约是英文字符的2倍
estimatedWidth := 0.0
for _, r := range cleanCell {
if r >= 0x4E00 && r <= 0x9FFF {
estimatedWidth += 6.0 // 中文字符宽度
} else {
estimatedWidth += 3.0 // 英文字符宽度
}
}
estimatedLines := math.Ceil(estimatedWidth / cellWidth)
if estimatedLines < 1 {
estimatedLines = 1
}
lines = make([]string, int(estimatedLines))
// 简单分割文本
charsPerLine := int(math.Ceil(float64(charCount) / estimatedLines))
for k := 0; k < int(estimatedLines); k++ {
start := k * charsPerLine
end := start + charsPerLine
if end > charCount {
end = charCount
}
if start < charCount {
runes := []rune(cleanCell)
if start < len(runes) {
if end > len(runes) {
end = len(runes)
}
lines[k] = string(runes[start:end])
}
}
}
}
}
// 计算单元格高度
numLines := float64(len(lines))
if numLines == 0 {
numLines = 1
}
cellHeight := numLines * cellLineHt * 2.5 // 进一步增加行高从2.0倍增加到2.5倍
if cellHeight < cellLineHt*2.5 {
cellHeight = cellLineHt * 2.5
}
// 为多行内容添加额外间距
if len(lines) > 1 {
cellHeight += cellLineHt * 0.5 // 多行时额外增加0.5倍行高
}
if cellHeight > maxCellHeight {
maxCellHeight = cellHeight
}
}
// 绘制这一行的所有单元格左边距是15mm
currentX := 15.0
for j, cell := range row {
if j >= numCols || j >= len(colWidths) {
break
}
colW := colWidths[j] // 使用动态计算的列宽
// 绘制单元格边框和背景
if fill {
pdf.SetFillColor(250, 250, 235) // 稍深的米色
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.Rect(currentX, startY, colW, maxCellHeight, "FD")
// 绘制文本使用MultiCell支持换行并限制在单元格内
pdf.SetTextColor(0, 0, 0) // 确保深黑色
// 只清理无效字符保留markdown格式
cleanCell := tr.textProcessor.CleanTextPreservingMarkdown(cell)
// 确保文本不为空才渲染
if strings.TrimSpace(cleanCell) != "" {
// 设置到单元格内增加边距从2增加到3让内容更舒适
pdf.SetXY(currentX+3, startY+3)
// 使用MultiCell自动换行左对齐
tr.fontManager.SetFont(pdf, "", 9)
// 再次确保颜色为深黑色(防止被其他设置覆盖)
pdf.SetTextColor(0, 0, 0)
// 设置字体后再次确保颜色
pdf.SetTextColor(0, 0, 0)
// 使用MultiCell会自动处理换行使用统一的行高
// MultiCell会自动处理换行不需要ClipRect
// 进一步增加行高从2.0倍增加到2.5倍,让内容更舒适
pdf.MultiCell(colW-6, cellLineHt*2.5, cleanCell, "", "L", false)
} else if strings.TrimSpace(cell) != "" {
// 如果原始单元格不为空但清理后为空,记录警告
tr.logger.Warn("单元格文本清理后为空",
zap.Int("row", i),
zap.Int("col", j),
zap.String("original", cell[:min(len(cell), 50)]))
}
// MultiCell后Y坐标已经改变必须重置以便下一列从同一行开始
// 这是关键确保所有列都从同一个startY开始
pdf.SetXY(currentX+colW, startY)
// 移动到下一列
currentX += colW
}
// 移动到下一行的起始位置(使用计算好的最大高度)
pdf.SetXY(15.0, startY+maxCellHeight)
}
}

View File

@@ -0,0 +1,283 @@
package pdf
import (
"html"
"regexp"
"strings"
)
// TextProcessor 文本处理器
type TextProcessor struct{}
// NewTextProcessor 创建文本处理器
func NewTextProcessor() *TextProcessor {
return &TextProcessor{}
}
// CleanText 清理文本中的无效字符和乱码
func (tp *TextProcessor) CleanText(text string) string {
// 先解码HTML实体
text = html.UnescapeString(text)
// 移除或替换无效的UTF-8字符
var result strings.Builder
for _, r := range text {
// 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等
if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
(r >= 0x3400 && r <= 0x4DBF) || // 扩展A
(r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
(r >= 'A' && r <= 'Z') || // 大写字母
(r >= 'a' && r <= 'z') || // 小写字母
(r >= '0' && r <= '9') || // 数字
(r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符
(r == '\n' || r == '\r' || r == '\t') || // 换行和制表符
(r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
(r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
result.WriteRune(r)
} else if r > 0x007F && r < 0x00A0 {
// 无效的控制字符,替换为空格
result.WriteRune(' ')
}
// 其他字符(如乱码)直接跳过
}
return result.String()
}
// CleanTextPreservingMarkdown 清理文本但保留markdown语法字符
func (tp *TextProcessor) CleanTextPreservingMarkdown(text string) string {
// 先解码HTML实体
text = html.UnescapeString(text)
// 移除或替换无效的UTF-8字符但保留markdown语法字符
var result strings.Builder
for _, r := range text {
// 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等
// 特别保留markdown语法字符* _ ` [ ] ( ) # - | : !
if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
(r >= 0x3400 && r <= 0x4DBF) || // 扩展A
(r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
(r >= 'A' && r <= 'Z') || // 大写字母
(r >= 'a' && r <= 'z') || // 小写字母
(r >= '0' && r <= '9') || // 数字
(r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符包括markdown语法字符
(r == '\n' || r == '\r' || r == '\t') || // 换行和制表符
(r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
(r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
result.WriteRune(r)
} else if r > 0x007F && r < 0x00A0 {
// 无效的控制字符,替换为空格
result.WriteRune(' ')
}
// 其他字符(如乱码)直接跳过
}
return result.String()
}
// StripHTML 去除HTML标签不转换换行直接移除标签
func (tp *TextProcessor) StripHTML(text string) string {
// 解码HTML实体
text = html.UnescapeString(text)
// 直接移除所有HTML标签不进行换行转换
re := regexp.MustCompile(`<[^>]+>`)
text = re.ReplaceAllString(text, "")
// 清理多余空白
text = strings.TrimSpace(text)
return text
}
// HTMLToPlainWithBreaks 将 HTML 转为纯文本并保留富文本换行效果(<p><br><div> 等变为换行)
// 用于在 PDF 中还原段落与换行,避免内容挤成一团
func (tp *TextProcessor) HTMLToPlainWithBreaks(text string) string {
text = html.UnescapeString(text)
// 块级结束标签转为换行
text = regexp.MustCompile(`(?i)</(p|div|br|tr|li|h[1-6])>\s*`).ReplaceAllString(text, "\n")
// <br> 自闭合
text = regexp.MustCompile(`(?i)<br\s*/?>\s*`).ReplaceAllString(text, "\n")
// 剩余标签移除
text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "")
// 连续空白/换行压缩为最多两个换行(段间距)
text = regexp.MustCompile(`[ \t]+`).ReplaceAllString(text, " ")
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// HTMLSegment 用于 PDF 绘制的 HTML 片段:支持段落、换行、加粗、标题
type HTMLSegment struct {
Text string // 纯文本(已去标签、已解码实体)
Bold bool // 是否加粗
NewLine bool // 是否换行(如 <br>
NewParagraph bool // 是否新段落(如 </p>、</div>
HeadingLevel int // 1-3 表示 h1-h30 表示正文
}
// ParseHTMLToSegments 将 HTML 解析为用于 PDF 绘制的片段序列,保留段落、换行、加粗与标题
func (tp *TextProcessor) ParseHTMLToSegments(htmlStr string) []HTMLSegment {
htmlStr = html.UnescapeString(htmlStr)
var out []HTMLSegment
blockSplit := regexp.MustCompile(`(?i)(</p>|</div>|</h[1-6]>|<br\s*/?>)\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, "</p>") || strings.Contains(prevTag, "</div>") ||
strings.HasPrefix(prevTag, "</h")
isNewLine := strings.Contains(prevTag, "<br")
headingLevel := 0
if strings.HasPrefix(prevTag, "</h1") {
headingLevel = 1
} else if strings.HasPrefix(prevTag, "</h2") {
headingLevel = 2
} else if strings.HasPrefix(prevTag, "</h3") {
headingLevel = 3
}
segments := tp.parseInlineSegments(block)
// 块前先输出段落/换行/标题标记(仅在第一段文本前输出一次)
if i > 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 解析块内文本,按 <strong>/<b> 拆成片段
func (tp *TextProcessor) parseInlineSegments(block string) []inlineSeg {
var segs []inlineSeg
// 移除所有标签并收集加粗区间(按字符偏移)
reBoldOpen := regexp.MustCompile(`(?i)<(strong|b)>`)
reBoldClose := regexp.MustCompile(`(?i)</(strong|b)>`)
plain := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(block, "")
plain = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plain, " ")
plain = strings.TrimSpace(plain)
if plain == "" {
return segs
}
// 在 block 上找加粗区间,再映射到 plain去掉标签后的位置
// 注意work 每次循环被截断,必须用相对 work 的索引切片,避免 work[:endInWork] 越界
work := block
var boldRanges [][2]int
plainOffset := 0
for {
idxOpen := reBoldOpen.FindStringIndex(work)
if idxOpen == nil {
break
}
afterOpen := work[idxOpen[1]:]
idxClose := reBoldClose.FindStringIndex(afterOpen)
if idxClose == nil {
break
}
closeLen := len(reBoldClose.FindString(afterOpen))
// 使用相对当前 work 的字节偏移,保证 work[:endInWork] 不越界
endInWork := idxOpen[1] + idxClose[0]
workBefore := work[:idxOpen[1]]
plainBefore := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(workBefore, "")
plainBefore = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plainBefore, " ")
startPlain := plainOffset + len([]rune(plainBefore))
workUntil := work[:endInWork]
plainUntil := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(workUntil, "")
plainUntil = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plainUntil, " ")
endPlain := plainOffset + len([]rune(plainUntil))
boldRanges = append(boldRanges, [2]int{startPlain, endPlain})
consumed := work[:endInWork+closeLen]
strippedConsumed := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(consumed, "")
strippedConsumed = regexp.MustCompile(`[ \t]+`).ReplaceAllString(strippedConsumed, " ")
plainOffset += len([]rune(strippedConsumed))
work = work[endInWork+closeLen:]
}
// 按 boldRanges 切分 plain限制区间在 [0,len(runes)] 内,防止越界)
runes := []rune(plain)
nr := len(runes)
inBold := false
var start int
for i := 0; i <= nr; i++ {
nowBold := false
for _, r := range boldRanges {
r0, r1 := r[0], r[1]
if r0 < 0 {
r0 = 0
}
if r1 > nr {
r1 = nr
}
if r0 < r1 && i >= r0 && i < r1 {
nowBold = true
break
}
}
if nowBold != inBold || i == nr {
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__
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1")
// 移除斜体标记 *text* 或 _text_
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1")
// 移除代码标记 `code`
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
// 移除链接标记 [text](url) -> text
text = regexp.MustCompile(`\[([^\]]+)\]\([^\)]+\)`).ReplaceAllString(text, "$1")
// 移除图片标记 ![alt](url) -> alt
text = regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`).ReplaceAllString(text, "$1")
// 移除标题标记 # text -> text
text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
return text
}
// RemoveNonASCII 移除非ASCII字符保留ASCII字符和常见符号
func (tp *TextProcessor) RemoveNonASCII(text string) string {
var result strings.Builder
for _, r := range text {
// 保留ASCII字符0-127
if r < 128 {
result.WriteRune(r)
} else {
// 中文字符替换为空格或跳过
result.WriteRune(' ')
}
}
return result.String()
}