package renderer import ( "fmt" "html" "io" "strings" "github.com/olekukonko/ll" "github.com/olekukonko/tablewriter/tw" ) // SVGConfig holds configuration for the SVG renderer. // Fields include font, colors, padding, and merge rendering options. // Used to customize SVG output appearance and behavior. type SVGConfig struct { FontFamily string // e.g., "Arial, sans-serif" FontSize float64 // Base font size in SVG units LineHeightFactor float64 // Factor for line height (e.g., 1.2) Padding float64 // Padding inside cells StrokeWidth float64 // Line width for borders StrokeColor string // Color for strokes (e.g., "black") HeaderBG string // Background color for header RowBG string // Background color for rows RowAltBG string // Alternating row background color FooterBG string // Background color for footer HeaderColor string // Text color for header RowColor string // Text color for rows FooterColor string // Text color for footer ApproxCharWidthFactor float64 // Char width relative to FontSize MinColWidth float64 // Minimum column width RenderTWConfigOverrides bool // Override SVG alignments with tablewriter Debug bool // Enable debug logging ScaleFactor float64 // Scaling factor for SVG } // SVG implements tw.Renderer for SVG output. // Manages SVG element generation and merge tracking. type SVG struct { config SVGConfig trace []string allVisualLineData [][][]string // [section][line][cell] allVisualLineCtx [][]tw.Formatting // [section][line]Formatting maxCols int calculatedColWidths []float64 svgElements strings.Builder currentY float64 dataRowCounter int vMergeTrack map[int]int // Tracks vertical merge spans numVisualRowsDrawn int logger *ll.Logger w io.Writer } const ( sectionTypeHeader = 0 sectionTypeRow = 1 sectionTypeFooter = 2 ) // NewSVG creates a new SVG renderer with configuration. // Parameter configs provides optional SVGConfig; defaults used if empty. // Returns a configured SVG instance. func NewSVG(configs ...SVGConfig) *SVG { cfg := SVGConfig{ FontFamily: "sans-serif", FontSize: 12.0, LineHeightFactor: 1.4, Padding: 5.0, StrokeWidth: 1.0, StrokeColor: "black", HeaderBG: "#F0F0F0", RowBG: "white", RowAltBG: "#F9F9F9", FooterBG: "#F0F0F0", HeaderColor: "black", RowColor: "black", FooterColor: "black", ApproxCharWidthFactor: 0.6, MinColWidth: 30.0, ScaleFactor: 1.0, RenderTWConfigOverrides: true, Debug: false, } if len(configs) > 0 { userCfg := configs[0] if userCfg.FontFamily != tw.Empty { cfg.FontFamily = userCfg.FontFamily } if userCfg.FontSize > 0 { cfg.FontSize = userCfg.FontSize } if userCfg.LineHeightFactor > 0 { cfg.LineHeightFactor = userCfg.LineHeightFactor } if userCfg.Padding >= 0 { cfg.Padding = userCfg.Padding } if userCfg.StrokeWidth > 0 { cfg.StrokeWidth = userCfg.StrokeWidth } if userCfg.StrokeColor != tw.Empty { cfg.StrokeColor = userCfg.StrokeColor } if userCfg.HeaderBG != tw.Empty { cfg.HeaderBG = userCfg.HeaderBG } if userCfg.RowBG != tw.Empty { cfg.RowBG = userCfg.RowBG } cfg.RowAltBG = userCfg.RowAltBG if userCfg.FooterBG != tw.Empty { cfg.FooterBG = userCfg.FooterBG } if userCfg.HeaderColor != tw.Empty { cfg.HeaderColor = userCfg.HeaderColor } if userCfg.RowColor != tw.Empty { cfg.RowColor = userCfg.RowColor } if userCfg.FooterColor != tw.Empty { cfg.FooterColor = userCfg.FooterColor } if userCfg.ApproxCharWidthFactor > 0 { cfg.ApproxCharWidthFactor = userCfg.ApproxCharWidthFactor } if userCfg.MinColWidth >= 0 { cfg.MinColWidth = userCfg.MinColWidth } cfg.RenderTWConfigOverrides = userCfg.RenderTWConfigOverrides cfg.Debug = userCfg.Debug } r := &SVG{ config: cfg, trace: make([]string, 0, 50), allVisualLineData: make([][][]string, 3), allVisualLineCtx: make([][]tw.Formatting, 3), vMergeTrack: make(map[int]int), logger: ll.New("svg").Disable(), } for i := 0; i < 3; i++ { r.allVisualLineData[i] = make([][]string, 0) r.allVisualLineCtx[i] = make([]tw.Formatting, 0) } return r } // calculateAllColumnWidths computes column widths based on content and merges. // Uses content length and merge spans; handles horizontal merges by distributing width. func (s *SVG) calculateAllColumnWidths() { s.debug("Calculating column widths") tempMaxCols := 0 for sectionIdx := 0; sectionIdx < 3; sectionIdx++ { for lineIdx, lineCtx := range s.allVisualLineCtx[sectionIdx] { if lineCtx.Row.Current != nil { visualColCount := 0 for colIdx := 0; colIdx < len(lineCtx.Row.Current); { cellCtx := lineCtx.Row.Current[colIdx] if cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start { colIdx++ // Skip non-start merged cells continue } visualColCount++ span := 1 if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start { span = cellCtx.Merge.Horizontal.Span if span <= 0 { span = 1 } } colIdx += span } s.debug("Section %d, line %d: Visual columns = %d", sectionIdx, lineIdx, visualColCount) if visualColCount > tempMaxCols { tempMaxCols = visualColCount } } else if lineIdx < len(s.allVisualLineData[sectionIdx]) { if rawDataLen := len(s.allVisualLineData[sectionIdx][lineIdx]); rawDataLen > tempMaxCols { tempMaxCols = rawDataLen } } } } s.maxCols = tempMaxCols s.debug("Max columns: %d", s.maxCols) if s.maxCols == 0 { s.calculatedColWidths = []float64{} return } s.calculatedColWidths = make([]float64, s.maxCols) for i := range s.calculatedColWidths { s.calculatedColWidths[i] = s.config.MinColWidth } // Structure to track max width for each merge group type mergeKey struct { startCol int span int } maxMergeWidths := make(map[mergeKey]float64) processSectionForWidth := func(sectionIdx int) { for lineIdx, visualLineData := range s.allVisualLineData[sectionIdx] { if lineIdx >= len(s.allVisualLineCtx[sectionIdx]) { s.debug("Warning: Missing context for section %d line %d", sectionIdx, lineIdx) continue } lineCtx := s.allVisualLineCtx[sectionIdx][lineIdx] currentTableCol := 0 currentVisualCol := 0 for currentVisualCol < len(visualLineData) && currentTableCol < s.maxCols { cellContent := visualLineData[currentVisualCol] cellCtx := tw.CellContext{} if lineCtx.Row.Current != nil { if c, ok := lineCtx.Row.Current[currentTableCol]; ok { cellCtx = c } } hSpan := 1 if cellCtx.Merge.Horizontal.Present { if cellCtx.Merge.Horizontal.Start { hSpan = cellCtx.Merge.Horizontal.Span if hSpan <= 0 { hSpan = 1 } } else { currentTableCol++ continue } } textPixelWidth := s.estimateTextWidth(cellContent) contentAndPaddingWidth := textPixelWidth + (2 * s.config.Padding) if hSpan == 1 { if currentTableCol < len(s.calculatedColWidths) && contentAndPaddingWidth > s.calculatedColWidths[currentTableCol] { s.calculatedColWidths[currentTableCol] = contentAndPaddingWidth } } else { totalMergedWidth := contentAndPaddingWidth + (float64(hSpan-1) * s.config.Padding * 2) if totalMergedWidth < s.config.MinColWidth*float64(hSpan) { totalMergedWidth = s.config.MinColWidth * float64(hSpan) } if currentTableCol < len(s.calculatedColWidths) { key := mergeKey{currentTableCol, hSpan} if currentWidth, ok := maxMergeWidths[key]; ok { if totalMergedWidth > currentWidth { maxMergeWidths[key] = totalMergedWidth } } else { maxMergeWidths[key] = totalMergedWidth } s.debug("Horizontal merge at col %d, span %d: Total width %.2f", currentTableCol, hSpan, totalMergedWidth) } } currentTableCol += hSpan currentVisualCol++ } } } processSectionForWidth(sectionTypeHeader) processSectionForWidth(sectionTypeRow) processSectionForWidth(sectionTypeFooter) // Apply maximum widths for merged cells for key, width := range maxMergeWidths { s.calculatedColWidths[key.startCol] = width for i := 1; i < key.span && (key.startCol+i) < len(s.calculatedColWidths); i++ { s.calculatedColWidths[key.startCol+i] = 0 } } for i := range s.calculatedColWidths { if s.calculatedColWidths[i] < s.config.MinColWidth && s.calculatedColWidths[i] != 0 { s.calculatedColWidths[i] = s.config.MinColWidth } } s.debug("Column widths: %v", s.calculatedColWidths) } // Close finalizes SVG rendering and writes output. // Parameter w is the output w. // Returns an error if writing fails. func (s *SVG) Close() error { s.debug("Finalizing SVG output") s.calculateAllColumnWidths() s.renderBufferedData() if s.numVisualRowsDrawn == 0 && s.maxCols == 0 { fmt.Fprintf(s.w, ``, s.config.StrokeWidth*2, s.config.StrokeWidth*2) return nil } totalWidth := s.config.StrokeWidth if len(s.calculatedColWidths) > 0 { for _, cw := range s.calculatedColWidths { colWidth := cw if colWidth <= 0 { colWidth = s.config.MinColWidth } totalWidth += colWidth + s.config.StrokeWidth } } else if s.maxCols > 0 { for i := 0; i < s.maxCols; i++ { totalWidth += s.config.MinColWidth + s.config.StrokeWidth } } else { totalWidth = s.config.StrokeWidth * 2 } totalHeight := s.currentY singleVisualRowHeight := s.config.FontSize*s.config.LineHeightFactor + (2 * s.config.Padding) if s.numVisualRowsDrawn == 0 { if s.maxCols > 0 { totalHeight = s.config.StrokeWidth + singleVisualRowHeight + s.config.StrokeWidth } else { totalHeight = s.config.StrokeWidth * 2 } } fmt.Fprintf(s.w, ``, totalWidth, totalHeight, html.EscapeString(s.config.FontFamily), s.config.FontSize) fmt.Fprintln(s.w) fmt.Fprintln(s.w, "") if _, err := io.WriteString(s.w, s.svgElements.String()); err != nil { fmt.Fprintln(s.w, ``) return fmt.Errorf("failed to write SVG elements: %w", err) } if s.maxCols > 0 || s.numVisualRowsDrawn > 0 { fmt.Fprintf(s.w, ` `, html.EscapeString(s.config.StrokeColor), s.config.StrokeWidth) fmt.Fprintln(s.w) yPos := s.config.StrokeWidth / 2.0 borderRowsToDraw := s.numVisualRowsDrawn if borderRowsToDraw == 0 && s.maxCols > 0 { borderRowsToDraw = 1 } lineStartX := s.config.StrokeWidth / 2.0 lineEndX := s.config.StrokeWidth / 2.0 for _, width := range s.calculatedColWidths { lineEndX += width + s.config.StrokeWidth } for i := 0; i <= borderRowsToDraw; i++ { fmt.Fprintf(s.w, ` %s`, lineStartX, yPos, lineEndX, yPos, "\n") if i < borderRowsToDraw { yPos += singleVisualRowHeight + s.config.StrokeWidth } } xPos := s.config.StrokeWidth / 2.0 borderLineStartY := s.config.StrokeWidth / 2.0 borderLineEndY := totalHeight - (s.config.StrokeWidth / 2.0) for visualColIdx := 0; visualColIdx <= s.maxCols; visualColIdx++ { fmt.Fprintf(s.w, ` %s`, xPos, borderLineStartY, xPos, borderLineEndY, "\n") if visualColIdx < s.maxCols { colWidth := s.config.MinColWidth if visualColIdx < len(s.calculatedColWidths) && s.calculatedColWidths[visualColIdx] > 0 { colWidth = s.calculatedColWidths[visualColIdx] } xPos += colWidth + s.config.StrokeWidth } } fmt.Fprintln(s.w, " ") } fmt.Fprintln(s.w, ``) return nil } // Config returns the renderer's configuration. // No parameters are required. // Returns a Rendition with border and debug settings. func (s *SVG) Config() tw.Rendition { return tw.Rendition{ Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On}, Settings: tw.Settings{}, Streaming: false, } } // Debug returns the renderer's debug trace. // No parameters are required. // Returns a slice of debug messages. func (s *SVG) Debug() []string { return s.trace } // estimateTextWidth estimates text width in SVG units. // Parameter text is the input string to measure. // Returns the estimated width based on font size and char factor. func (s *SVG) estimateTextWidth(text string) float64 { runeCount := float64(len([]rune(text))) return runeCount * s.config.FontSize * s.config.ApproxCharWidthFactor } // Footer buffers footer lines for SVG rendering. // Parameters include w (w), footers (lines), and ctx (formatting). // No return value; stores data for later rendering. func (s *SVG) Footer(footers [][]string, ctx tw.Formatting) { s.debug("Buffering %d footer lines", len(footers)) for i, line := range footers { currentCtx := ctx currentCtx.IsSubRow = (i > 0) s.storeVisualLine(sectionTypeFooter, line, currentCtx) } } // getSVGAnchorFromTW maps tablewriter alignment to SVG text-anchor. // Parameter align is the tablewriter alignment setting. // Returns the corresponding SVG text-anchor value or empty string. func (s *SVG) getSVGAnchorFromTW(align tw.Align) string { switch align { case tw.AlignLeft: return "start" case tw.AlignCenter: return "middle" case tw.AlignRight: return "end" case tw.AlignNone, tw.Skip: return tw.Empty } return tw.Empty } // Header buffers header lines for SVG rendering. // Parameters include w (w), headers (lines), and ctx (formatting). // No return value; stores data for later rendering. func (s *SVG) Header(headers [][]string, ctx tw.Formatting) { s.debug("Buffering %d header lines", len(headers)) for i, line := range headers { currentCtx := ctx currentCtx.IsSubRow = i > 0 s.storeVisualLine(sectionTypeHeader, line, currentCtx) } } // Line handles border rendering (ignored in SVG renderer). // Parameters include w (w) and ctx (formatting). // No return value; SVG borders are drawn in Close. func (s *SVG) Line(ctx tw.Formatting) { s.debug("Line rendering ignored") } // padLineSVG pads a line to the specified column count. // Parameters include line (input strings) and numCols (target length). // Returns the padded line with empty strings as needed. func padLineSVG(line []string, numCols int) []string { if numCols <= 0 { return []string{} } currentLen := len(line) if currentLen == numCols { return line } if currentLen > numCols { return line[:numCols] } padded := make([]string, numCols) copy(padded, line) return padded } // renderBufferedData renders all buffered lines to SVG elements. // No parameters are required. // No return value; populates svgElements buffer. func (s *SVG) renderBufferedData() { s.debug("Rendering buffered data") s.currentY = s.config.StrokeWidth s.dataRowCounter = 0 s.vMergeTrack = make(map[int]int) s.numVisualRowsDrawn = 0 renderSection := func(sectionIdx int, position tw.Position) { for visualLineIdx, visualLineData := range s.allVisualLineData[sectionIdx] { if visualLineIdx >= len(s.allVisualLineCtx[sectionIdx]) { s.debug("Error: Missing context for section %d line %d", sectionIdx, visualLineIdx) continue } s.renderVisualLine(visualLineData, s.allVisualLineCtx[sectionIdx][visualLineIdx], position) } } renderSection(sectionTypeHeader, tw.Header) renderSection(sectionTypeRow, tw.Row) renderSection(sectionTypeFooter, tw.Footer) } // renderVisualLine renders a single visual line as SVG elements. // Parameters include lineData (cell content), ctx (formatting), and position (section type). // No return value; handles horizontal and vertical merges. func (s *SVG) renderVisualLine(visualLineData []string, ctx tw.Formatting, position tw.Position) { if s.maxCols == 0 || len(s.calculatedColWidths) == 0 { s.debug("Skipping line rendering: maxCols=%d, widths=%d", s.maxCols, len(s.calculatedColWidths)) return } s.numVisualRowsDrawn++ s.debug("Rendering visual row %d", s.numVisualRowsDrawn) singleVisualRowHeight := s.config.FontSize*s.config.LineHeightFactor + (2 * s.config.Padding) bgColor := tw.Empty textColor := tw.Empty defaultTextAnchor := "start" switch position { case tw.Header: bgColor = s.config.HeaderBG textColor = s.config.HeaderColor defaultTextAnchor = "middle" case tw.Footer: bgColor = s.config.FooterBG textColor = s.config.FooterColor defaultTextAnchor = "end" default: textColor = s.config.RowColor if !ctx.IsSubRow { if s.config.RowAltBG != tw.Empty && s.dataRowCounter%2 != 0 { bgColor = s.config.RowAltBG } else { bgColor = s.config.RowBG } s.dataRowCounter++ } else { parentDataRowStripeIndex := max(s.dataRowCounter-1, 0) if s.config.RowAltBG != tw.Empty && parentDataRowStripeIndex%2 != 0 { bgColor = s.config.RowAltBG } else { bgColor = s.config.RowBG } } } currentX := s.config.StrokeWidth currentVisualCellIdx := 0 for tableColIdx := 0; tableColIdx < s.maxCols; { if tableColIdx >= len(s.calculatedColWidths) { s.debug("Table Col %d out of bounds for widths", tableColIdx) tableColIdx++ continue } if remainingVSpan, isMerging := s.vMergeTrack[tableColIdx]; isMerging && remainingVSpan > 1 { s.vMergeTrack[tableColIdx]-- if s.vMergeTrack[tableColIdx] <= 1 { delete(s.vMergeTrack, tableColIdx) } currentX += s.calculatedColWidths[tableColIdx] + s.config.StrokeWidth tableColIdx++ continue } cellContentFromVisualLine := tw.Empty if currentVisualCellIdx < len(visualLineData) { cellContentFromVisualLine = visualLineData[currentVisualCellIdx] } cellCtx := tw.CellContext{} if ctx.Row.Current != nil { if c, ok := ctx.Row.Current[tableColIdx]; ok { cellCtx = c } } textToRender := cellContentFromVisualLine if cellCtx.Data != tw.Empty { if !((cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) || (cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start)) { textToRender = cellCtx.Data } else { textToRender = tw.Empty } } else if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) || (cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) { textToRender = tw.Empty } hSpan := 1 if cellCtx.Merge.Horizontal.Present { if cellCtx.Merge.Horizontal.Start { hSpan = cellCtx.Merge.Horizontal.Span if hSpan <= 0 { hSpan = 1 } } else { currentX += s.calculatedColWidths[tableColIdx] + s.config.StrokeWidth tableColIdx++ continue } } vSpan := 1 isVSpanStart := false if cellCtx.Merge.Vertical.Present && cellCtx.Merge.Vertical.Start { vSpan = cellCtx.Merge.Vertical.Span isVSpanStart = true } else if cellCtx.Merge.Hierarchical.Present && cellCtx.Merge.Hierarchical.Start { vSpan = cellCtx.Merge.Hierarchical.Span isVSpanStart = true } if vSpan <= 0 { vSpan = 1 } rectWidth := 0.0 for hs := 0; hs < hSpan && (tableColIdx+hs) < s.maxCols; hs++ { if (tableColIdx + hs) < len(s.calculatedColWidths) { rectWidth += s.calculatedColWidths[tableColIdx+hs] } else { rectWidth += s.config.MinColWidth } } if hSpan > 1 { rectWidth += float64(hSpan-1) * s.config.StrokeWidth } if rectWidth <= 0 { tableColIdx += hSpan if hSpan > 0 { currentVisualCellIdx++ } continue } rectHeight := singleVisualRowHeight if isVSpanStart && vSpan > 1 { rectHeight = float64(vSpan)*singleVisualRowHeight + float64(vSpan-1)*s.config.StrokeWidth for hs := 0; hs < hSpan && (tableColIdx+hs) < s.maxCols; hs++ { s.vMergeTrack[tableColIdx+hs] = vSpan } s.debug("Vertical merge at col %d, span %d, height %.2f", tableColIdx, vSpan, rectHeight) } else if remainingVSpan, isMerging := s.vMergeTrack[tableColIdx]; isMerging && remainingVSpan > 1 { rectHeight = singleVisualRowHeight textToRender = tw.Empty } fmt.Fprintf(&s.svgElements, ` %s`, currentX, s.currentY, rectWidth, rectHeight, html.EscapeString(bgColor), "\n") cellTextAnchor := defaultTextAnchor if s.config.RenderTWConfigOverrides { if al := s.getSVGAnchorFromTW(cellCtx.Align); al != tw.Empty { cellTextAnchor = al } } textX := currentX + s.config.Padding switch cellTextAnchor { case "middle": textX = currentX + s.config.Padding + (rectWidth-2*s.config.Padding)/2.0 case "end": textX = currentX + rectWidth - s.config.Padding } textY := s.currentY + rectHeight/2.0 escapedCell := html.EscapeString(textToRender) fmt.Fprintf(&s.svgElements, ` %s%s`, textX, textY, html.EscapeString(textColor), cellTextAnchor, escapedCell, "\n") currentX += rectWidth + s.config.StrokeWidth tableColIdx += hSpan currentVisualCellIdx++ } s.currentY += singleVisualRowHeight + s.config.StrokeWidth } // Reset clears the renderer's internal state. // No parameters are required. // No return value; prepares for new rendering. func (s *SVG) Reset() { s.debug("Resetting state") s.trace = make([]string, 0, 50) for i := 0; i < 3; i++ { s.allVisualLineData[i] = s.allVisualLineData[i][:0] s.allVisualLineCtx[i] = s.allVisualLineCtx[i][:0] } s.maxCols = 0 s.calculatedColWidths = nil s.svgElements.Reset() s.currentY = 0 s.dataRowCounter = 0 s.vMergeTrack = make(map[int]int) s.numVisualRowsDrawn = 0 } // Row buffers a row line for SVG rendering. // Parameters include w (w), rowLine (cells), and ctx (formatting). // No return value; stores data for later rendering. func (s *SVG) Row(rowLine []string, ctx tw.Formatting) { s.debug("Buffering row line, IsSubRow: %v", ctx.IsSubRow) s.storeVisualLine(sectionTypeRow, rowLine, ctx) } func (s *SVG) Logger(logger *ll.Logger) { s.logger = logger.Namespace("svg") } // Start initializes SVG rendering. // Parameter w is the output w. // Returns nil; prepares internal state. func (s *SVG) Start(w io.Writer) error { s.w = w s.debug("Starting SVG rendering") s.Reset() return nil } // debug logs a message if debugging is enabled. // Parameters include format string and variadic arguments. // No return value; appends to trace. func (s *SVG) debug(format string, a ...interface{}) { if s.config.Debug { msg := fmt.Sprintf(format, a...) s.trace = append(s.trace, "[SVG] "+msg) } } // storeVisualLine stores a visual line for rendering. // Parameters include sectionIdx, lineData (cells), and ctx (formatting). // No return value; buffers data and context. func (s *SVG) storeVisualLine(sectionIdx int, lineData []string, ctx tw.Formatting) { copiedLineData := make([]string, len(lineData)) copy(copiedLineData, lineData) s.allVisualLineData[sectionIdx] = append(s.allVisualLineData[sectionIdx], copiedLineData) s.allVisualLineCtx[sectionIdx] = append(s.allVisualLineCtx[sectionIdx], ctx) hasCurrent := ctx.Row.Current != nil s.debug("Stored line in section %d, has context: %v", sectionIdx, hasCurrent) }