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 } 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 } // cleanTextForPDF 清理文本,移除可能导致PDF生成问题的字符 func (r *DatabaseTableRenderer) cleanTextForPDF(text string) string { // 先移除HTML标签 text = strings.TrimSpace(text) text = strings.ReplaceAll(text, "
", " ") text = strings.ReplaceAll(text, "
", " ") text = strings.ReplaceAll(text, "
", " ") // 移除控制字符(除了换行符和制表符) 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 { return []string{text} } // 使用匿名函数和recover保护SplitText调用 var lines []string func() { defer func() { if rec := recover(); rec != nil { r.logger.Warn("SplitText发生panic,使用估算值", zap.Any("error", rec), zap.String("text_preview", func() string { if len(text) > 50 { return text[:50] + "..." } return text }()), zap.Float64("width", width)) // 如果panic发生,使用估算值 charCount := len([]rune(text)) 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) }() // 如果lines为nil或空,返回单行 if lines == nil || len(lines) == 0 { return []string{text} } return lines } // 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.SetFont(pdf, "B", 9) 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.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,给文本更多空间 // 先清理单元格文本(在计算宽度之前) 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} } 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 = 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.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 }