Files
tyapi-server/internal/shared/pdf/database_table_renderer.go

654 lines
19 KiB
Go
Raw Normal View History

2025-12-03 12:03:42 +08:00
package pdf
import (
"math"
2025-12-03 18:02:49 +08:00
"regexp"
2025-12-03 12:03:42 +08:00
"strings"
2025-12-03 18:02:49 +08:00
"unicode"
2025-12-03 12:03:42 +08:00
"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
}
r.logger.Info("开始渲染表格",
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Strings("headers", tableData.Headers))
// 检查表头是否有有效内容
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)))
// 即使没有数据行,也渲染表头(单行表格)
// 但如果没有表头也没有数据,则不渲染
// 设置字体
r.fontManager.SetFont(pdf, "", 9)
_, 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()
}
// 绘制表头
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("没有数据行,只渲染表头")
}
r.logger.Info("表格渲染完成",
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Float64("current_y", pdf.GetY()))
return nil
}
// calculateColumnWidths 计算每列的宽度
// 策略:先确保每列最短内容能完整显示(不换行),然后根据内容长度分配剩余空间
func (r *DatabaseTableRenderer) calculateColumnWidths(pdf *gofpdf.Fpdf, tableData *TableData, availableWidth float64) []float64 {
numCols := len(tableData.Headers)
r.fontManager.SetFont(pdf, "", 9)
// 第一步:找到每列中最短的内容(包括表头),计算其完整显示所需的最小宽度
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
}
2025-12-03 18:02:49 +08:00
// 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 {
2025-12-04 10:35:11 +08:00
if text == "" {
return []string{""}
}
return []string{text}
}
// 检查文本长度,如果太短可能不需要分割
if len([]rune(text)) <= 1 {
2025-12-03 18:02:49 +08:00
return []string{text}
}
// 使用匿名函数和recover保护SplitText调用
var lines []string
2025-12-04 10:35:11 +08:00
var panicOccurred bool
2025-12-03 18:02:49 +08:00
func() {
defer func() {
if rec := recover(); rec != nil {
2025-12-04 10:35:11 +08:00
panicOccurred = true
// 静默处理,不记录日志
2025-12-03 18:02:49 +08:00
// 如果panic发生使用估算值
charCount := len([]rune(text))
2025-12-04 10:35:11 +08:00
if charCount == 0 {
lines = []string{""}
return
}
2025-12-03 18:02:49 +08:00
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)
}()
2025-12-04 10:35:11 +08:00
// 如果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 {
2025-12-03 18:02:49 +08:00
return []string{text}
}
2025-12-04 10:35:11 +08:00
return nonEmptyLines
2025-12-03 18:02:49 +08:00
}
2025-12-03 12:03:42 +08:00
// 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]
2025-12-03 18:02:49 +08:00
// 使用安全的SplitText方法
headerLines := r.safeSplitText(pdf, header, colW-6)
2025-12-03 12:03:42 +08:00
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.SetFont(pdf, "B", 9)
currentX := 15.0
for i, header := range headers {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 清理表头数据移除任何残留的HTML标签和样式
2025-12-03 18:02:49 +08:00
header = r.cleanTextForPDF(header)
2025-12-03 12:03:42 +08:00
// 绘制表头背景
pdf.Rect(currentX, startY, colW, maxHeaderHeight, "FD")
// 绘制表头文本
if header != "" {
// 计算文本的实际高度(减少内边距,给文本更多空间)
2025-12-03 18:02:49 +08:00
// 使用安全的SplitText方法
headerLines := r.safeSplitText(pdf, header, colW-4)
2025-12-03 12:03:42 +08:00
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.SetFont(pdf, "B", 9)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
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.SetFont(pdf, "", 9)
// 获取页面尺寸和边距
_, 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()
// 在新页面上重新绘制表头
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++
// 计算这一行的最大高度
2025-12-04 10:35:11 +08:00
// 确保lineHt有效
if lineHt <= 0 {
lineHt = 5.0 // 默认行高
}
2025-12-03 12:03:42 +08:00
maxCellHeight := lineHt * 2.0 // 使用合理的最小高度
for j := 0; j < numCols && j < len(row); j++ {
cell := row[j]
cellWidth := colWidths[j] - 4 // 减少内边距到4mm给文本更多空间
2025-12-03 18:02:49 +08:00
// 先清理单元格文本(在计算宽度之前)
cell = r.cleanTextForPDF(cell)
2025-12-03 12:03:42 +08:00
// 计算文本实际宽度,判断是否需要换行
textWidth := r.getTextWidth(pdf, cell)
var lines []string
// 只有当文本宽度超过单元格宽度时才换行
if textWidth > cellWidth {
2025-12-03 18:02:49 +08:00
// 文本需要换行使用安全的SplitText方法
lines = r.safeSplitText(pdf, cell, cellWidth)
2025-12-03 12:03:42 +08:00
} else {
// 文本不需要换行,单行显示
lines = []string{cell}
}
2025-12-04 10:35:11 +08:00
// 确保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
2025-12-03 12:03:42 +08:00
// 添加上下内边距
cellHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if cellHeight < lineHt*2.0 {
cellHeight = lineHt * 2.0
}
2025-12-04 10:35:11 +08:00
// 检查是否为有效数值
if math.IsInf(cellHeight, 0) || math.IsNaN(cellHeight) {
cellHeight = lineHt * 2.0
}
2025-12-03 12:03:42 +08:00
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()
startY = pdf.GetY()
}
// 绘制这一行的所有单元格
currentX := 15.0
for j := 0; j < numCols && j < len(row); j++ {
colW := colWidths[j]
cell := row[j]
// 清理单元格数据移除任何残留的HTML标签和样式
2025-12-03 18:02:49 +08:00
cell = r.cleanTextForPDF(cell)
2025-12-03 12:03:42 +08:00
// 绘制单元格背景
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 {
2025-12-03 18:02:49 +08:00
// 文本需要换行使用安全的SplitText方法
textLines = r.safeSplitText(pdf, cell, cellWidth)
2025-12-03 12:03:42 +08:00
} 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.SetFont(pdf, "", 9)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
pdf.SetTextColor(0, 0, 0)
// 安全地渲染文本,使用正常的行高
func() {
defer func() {
if rec := recover(); rec != nil {
2025-12-04 10:35:11 +08:00
// 静默处理,不记录日志
2025-12-03 12:03:42 +08:00
}
}()
// 使用正常的行高,文本已经垂直居中
pdf.MultiCell(cellWidth, lineHt, cell, "", "L", false)
}()
}
// 重置Y坐标
pdf.SetXY(currentX+colW, startY)
currentX += colW
}
2025-12-04 10:35:11 +08:00
// 检查maxCellHeight是否为有效数值
if math.IsInf(maxCellHeight, 0) || math.IsNaN(maxCellHeight) {
maxCellHeight = lineHt * 2.0
}
if maxCellHeight <= 0 {
maxCellHeight = lineHt * 2.0
}
2025-12-03 12:03:42 +08:00
// 移动到下一行
2025-12-04 10:35:11 +08:00
nextY := startY + maxCellHeight
if math.IsInf(nextY, 0) || math.IsNaN(nextY) {
nextY = pdf.GetY() + lineHt*2.0
}
pdf.SetXY(15.0, nextY)
2025-12-03 12:03:42 +08:00
}
}
// 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
}