This commit is contained in:
535
internal/shared/pdf/database_table_renderer.go
Normal file
535
internal/shared/pdf/database_table_renderer.go
Normal file
@@ -0,0 +1,535 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user