Files
tyapi-server/internal/shared/pdf/database_table_renderer.go
2025-12-03 12:03:42 +08:00

536 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pdf
import (
"math"
"strings"
"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
}
// 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]
headerLines := pdf.SplitText(header, colW-6) // 增加边距从4增加到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.SetFont(pdf, "B", 9)
currentX := 15.0
for i, header := range headers {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 清理表头数据移除任何残留的HTML标签和样式
header = strings.TrimSpace(header)
// 移除HTML标签使用简单的替换
header = strings.ReplaceAll(header, "<br>", " ")
header = strings.ReplaceAll(header, "<br/>", " ")
header = strings.ReplaceAll(header, "<br />", " ")
// 绘制表头背景
pdf.Rect(currentX, startY, colW, maxHeaderHeight, "FD")
// 绘制表头文本
if header != "" {
// 计算文本的实际高度(减少内边距,给文本更多空间)
headerLines := pdf.SplitText(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.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++
// 计算这一行的最大高度
maxCellHeight := lineHt * 2.0 // 使用合理的最小高度
for j := 0; j < numCols && j < len(row); j++ {
cell := row[j]
cellWidth := colWidths[j] - 4 // 减少内边距到4mm给文本更多空间
// 计算文本实际宽度,判断是否需要换行
textWidth := r.getTextWidth(pdf, cell)
var lines []string
// 只有当文本宽度超过单元格宽度时才换行
if textWidth > cellWidth {
// 文本需要换行
func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Warn("SplitText失败使用估算",
zap.Any("error", rec),
zap.Int("row_index", rowIndex),
zap.Int("col_index", j))
// 使用估算值
charCount := len([]rune(cell))
estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
lines = make([]string, int(estimatedLines))
for i := range lines {
lines[i] = ""
}
}
}()
lines = pdf.SplitText(cell, cellWidth)
}()
} else {
// 文本不需要换行,单行显示
lines = []string{cell}
}
cellHeight := float64(len(lines)) * lineHt
// 添加上下内边距
cellHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if cellHeight < lineHt*2.0 {
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()
startY = pdf.GetY()
}
// 绘制这一行的所有单元格
currentX := 15.0
for j := 0; j < numCols && j < len(row); j++ {
colW := colWidths[j]
cell := row[j]
// 清理单元格数据移除任何残留的HTML标签和样式
cell = strings.TrimSpace(cell)
// 移除HTML标签使用简单的正则表达式
cell = strings.ReplaceAll(cell, "<br>", " ")
cell = strings.ReplaceAll(cell, "<br/>", " ")
cell = strings.ReplaceAll(cell, "<br />", " ")
// 绘制单元格背景
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 {
// 文本需要换行
func() {
defer func() {
if rec := recover(); rec != nil {
// 如果SplitText失败使用估算
charCount := len([]rune(cell))
estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
textLines = make([]string, int(estimatedLines))
for i := range textLines {
textLines[i] = ""
}
}
}()
textLines = pdf.SplitText(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.SetFont(pdf, "", 9)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
pdf.SetTextColor(0, 0, 0)
// 安全地渲染文本,使用正常的行高
func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Warn("MultiCell渲染失败",
zap.Any("error", rec),
zap.Int("row_index", rowIndex),
zap.Int("col_index", j))
}
}()
// 使用正常的行高,文本已经垂直居中
pdf.MultiCell(cellWidth, lineHt, cell, "", "L", false)
}()
}
// 重置Y坐标
pdf.SetXY(currentX+colW, startY)
currentX += colW
}
// 移动到下一行
pdf.SetXY(15.0, startY+maxCellHeight)
}
}
// 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
}