f
This commit is contained in:
601
internal/shared/pdf/database_table_reader.go
Normal file
601
internal/shared/pdf/database_table_reader.go
Normal 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, " ", " ")
|
||||
text = strings.ReplaceAll(text, "&", "&")
|
||||
text = strings.ReplaceAll(text, "<", "<")
|
||||
text = strings.ReplaceAll(text, ">", ">")
|
||||
text = strings.ReplaceAll(text, """, "\"")
|
||||
text = strings.ReplaceAll(text, "'", "'")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
653
internal/shared/pdf/database_table_renderer.go
Normal file
653
internal/shared/pdf/database_table_renderer.go
Normal 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
|
||||
}
|
||||
355
internal/shared/pdf/font_manager.go
Normal file
355
internal/shared/pdf/font_manager.go
Normal 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
|
||||
}
|
||||
88
internal/shared/pdf/html_pdf_generator.go
Normal file
88
internal/shared/pdf/html_pdf_generator.go
Normal 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
|
||||
}
|
||||
|
||||
155
internal/shared/pdf/json_processor.go
Normal file
155
internal/shared/pdf/json_processor.go
Normal 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)
|
||||
}
|
||||
658
internal/shared/pdf/markdown_converter.go
Normal file
658
internal/shared/pdf/markdown_converter.go
Normal 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]
|
||||
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")
|
||||
}
|
||||
355
internal/shared/pdf/markdown_processor.go
Normal file
355
internal/shared/pdf/markdown_processor.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1667
internal/shared/pdf/page_builder.go
Normal file
1667
internal/shared/pdf/page_builder.go
Normal file
File diff suppressed because it is too large
Load Diff
436
internal/shared/pdf/pdf_cache_manager.go
Normal file
436
internal/shared/pdf/pdf_cache_manager.go
Normal 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
|
||||
}
|
||||
265
internal/shared/pdf/pdf_debug_tool.go
Normal file
265
internal/shared/pdf/pdf_debug_tool.go
Normal 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,
|
||||
)
|
||||
}
|
||||
226
internal/shared/pdf/pdf_finder.go
Normal file
226
internal/shared/pdf/pdf_finder.go
Normal 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
|
||||
}
|
||||
2132
internal/shared/pdf/pdf_generator.go
Normal file
2132
internal/shared/pdf/pdf_generator.go
Normal file
File diff suppressed because it is too large
Load Diff
324
internal/shared/pdf/pdf_generator_refactored.go
Normal file
324
internal/shared/pdf/pdf_generator_refactored.go
Normal 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.ttf,gofpdf会去掉开头的/
|
||||
// 变成相对路径 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
|
||||
}
|
||||
1842
internal/shared/pdf/qygl_report_pdf.go
Normal file
1842
internal/shared/pdf/qygl_report_pdf.go
Normal file
File diff suppressed because it is too large
Load Diff
177
internal/shared/pdf/qygl_report_pdf_pregen.go
Normal file
177
internal/shared/pdf/qygl_report_pdf_pregen.go
Normal 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 异步预渲染企业报告 PDF(headless 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 != ""
|
||||
}
|
||||
90
internal/shared/pdf/resources_path.go
Normal file
90
internal/shared/pdf/resources_path.go
Normal 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 ""
|
||||
}
|
||||
198
internal/shared/pdf/table_parser.go
Normal file
198
internal/shared/pdf/table_parser.go
Normal 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
|
||||
}
|
||||
340
internal/shared/pdf/table_renderer.go
Normal file
340
internal/shared/pdf/table_renderer.go
Normal 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)
|
||||
}
|
||||
}
|
||||
283
internal/shared/pdf/text_processor.go
Normal file
283
internal/shared/pdf/text_processor.go
Normal 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-h3,0 表示正文
|
||||
}
|
||||
|
||||
// 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
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user