Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+608
View File
@@ -0,0 +1,608 @@
package renderer
import (
"io"
"strings"
"github.com/olekukonko/ll"
"github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/olekukonko/tablewriter/tw"
)
// Blueprint implements a primary table rendering engine with customizable borders and alignments.
type Blueprint struct {
config tw.Rendition // Rendering configuration for table borders and symbols
logger *ll.Logger // Logger for debug trace messages
w io.Writer
}
// NewBlueprint creates a new Blueprint instance with optional custom configurations.
func NewBlueprint(configs ...tw.Rendition) *Blueprint {
// Initialize with default configuration
cfg := defaultBlueprint()
if len(configs) > 0 {
userCfg := configs[0]
// Override default borders if provided
if userCfg.Borders.Left != 0 {
cfg.Borders.Left = userCfg.Borders.Left
}
if userCfg.Borders.Right != 0 {
cfg.Borders.Right = userCfg.Borders.Right
}
if userCfg.Borders.Top != 0 {
cfg.Borders.Top = userCfg.Borders.Top
}
if userCfg.Borders.Bottom != 0 {
cfg.Borders.Bottom = userCfg.Borders.Bottom
}
// Override symbols if provided
if userCfg.Symbols != nil {
cfg.Symbols = userCfg.Symbols
}
// Merge user settings with default settings
cfg.Settings = mergeSettings(cfg.Settings, userCfg.Settings)
}
return &Blueprint{config: cfg, logger: ll.New("blueprint").Disable()}
}
// Close performs cleanup (no-op in this implementation).
func (f *Blueprint) Close() error {
f.logger.Debug("Blueprint.Close() called (no-op).")
return nil
}
// Config returns the renderer's current configuration.
func (f *Blueprint) Config() tw.Rendition {
return f.config
}
// Footer renders the table footer section with configured formatting.
func (f *Blueprint) Footer(footers [][]string, ctx tw.Formatting) {
f.logger.Debugf("Starting Footer render: IsSubRow=%v, Location=%v, Pos=%s", ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position)
// Render the footer line
f.renderLine(ctx)
f.logger.Debug("Completed Footer render")
}
// Header renders the table header section with configured formatting.
func (f *Blueprint) Header(headers [][]string, ctx tw.Formatting) {
f.logger.Debugf("Starting Header render: IsSubRow=%v, Location=%v, Pos=%s, lines=%d, widths=%v",
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, len(ctx.Row.Current), ctx.Row.Widths)
// Render the header line
f.renderLine(ctx)
f.logger.Debug("Completed Header render")
}
// Line renders a full horizontal row line with junctions and segments.
func (f *Blueprint) Line(ctx tw.Formatting) {
// Initialize junction renderer
jr := NewJunction(JunctionContext{
Symbols: f.config.Symbols,
Ctx: ctx,
ColIdx: 0,
Logger: f.logger,
BorderTint: Tint{},
SeparatorTint: Tint{},
})
var line strings.Builder
totalLineWidth := 0 // Track total display width
// Get sorted column indices
sortedKeys := ctx.Row.Widths.SortedKeys()
numCols := 0
if len(sortedKeys) > 0 {
numCols = sortedKeys[len(sortedKeys)-1] + 1
}
// Handle empty row case
if numCols == 0 {
prefix := tw.Empty
suffix := tw.Empty
if f.config.Borders.Left.Enabled() {
prefix = jr.RenderLeft()
}
if f.config.Borders.Right.Enabled() {
suffix = jr.RenderRight(-1)
}
if prefix != tw.Empty || suffix != tw.Empty {
line.WriteString(prefix + suffix + tw.NewLine)
totalLineWidth = twwidth.Width(prefix) + twwidth.Width(suffix)
f.w.Write([]byte(line.String()))
}
f.logger.Debugf("Line: Handled empty row/widths case (total width %d)", totalLineWidth)
return
}
// Calculate target total width based on data rows
targetTotalWidth := 0
for _, colIdx := range sortedKeys {
targetTotalWidth += ctx.Row.Widths.Get(colIdx)
}
if f.config.Borders.Left.Enabled() {
targetTotalWidth += twwidth.Width(f.config.Symbols.Column())
}
if f.config.Borders.Right.Enabled() {
targetTotalWidth += twwidth.Width(f.config.Symbols.Column())
}
if f.config.Settings.Separators.BetweenColumns.Enabled() && len(sortedKeys) > 1 {
targetTotalWidth += twwidth.Width(f.config.Symbols.Column()) * (len(sortedKeys) - 1)
}
// Add left border if enabled
leftBorderWidth := 0
if f.config.Borders.Left.Enabled() {
leftBorder := jr.RenderLeft()
line.WriteString(leftBorder)
leftBorderWidth = twwidth.Width(leftBorder)
totalLineWidth += leftBorderWidth
f.logger.Debugf("Line: Left border='%s' (f.width %d)", leftBorder, leftBorderWidth)
}
visibleColIndices := make([]int, 0)
// Calculate visible columns
for _, colIdx := range sortedKeys {
colWidth := ctx.Row.Widths.Get(colIdx)
if colWidth > 0 {
visibleColIndices = append(visibleColIndices, colIdx)
}
}
f.logger.Debugf("Line: sortedKeys=%v, Widths=%v, visibleColIndices=%v, targetTotalWidth=%d", sortedKeys, ctx.Row.Widths, visibleColIndices, targetTotalWidth)
// Render each column segment
for keyIndex, currentColIdx := range visibleColIndices {
jr.colIdx = currentColIdx
segment := jr.GetSegment()
colWidth := ctx.Row.Widths.Get(currentColIdx)
// Adjust colWidth to account for wider borders
adjustedColWidth := colWidth
if f.config.Borders.Left.Enabled() && keyIndex == 0 {
adjustedColWidth -= leftBorderWidth - twwidth.Width(f.config.Symbols.Column())
}
if f.config.Borders.Right.Enabled() && keyIndex == len(visibleColIndices)-1 {
rightBorderWidth := twwidth.Width(jr.RenderRight(currentColIdx))
adjustedColWidth -= rightBorderWidth - twwidth.Width(f.config.Symbols.Column())
}
if adjustedColWidth < 0 {
adjustedColWidth = 0
}
f.logger.Debugf("Line: colIdx=%d, segment='%s', adjusted colWidth=%d", currentColIdx, segment, adjustedColWidth)
if segment == tw.Empty {
spaces := strings.Repeat(tw.Space, adjustedColWidth)
line.WriteString(spaces)
totalLineWidth += adjustedColWidth
f.logger.Debugf("Line: Rendered spaces='%s' (f.width %d) for col %d", spaces, adjustedColWidth, currentColIdx)
} else {
segmentWidth := twwidth.Width(segment)
if segmentWidth == 0 {
segmentWidth = 1 // Avoid division by zero
f.logger.Warnf("Line: Segment='%s' has zero width, using 1", segment)
}
// Calculate how many full segments fit
repeat := adjustedColWidth / segmentWidth
if repeat < 1 && adjustedColWidth > 0 {
repeat = 1
}
repeatedSegment := strings.Repeat(segment, repeat)
actualWidth := twwidth.Width(repeatedSegment)
if actualWidth > adjustedColWidth {
// Truncate if too long
repeatedSegment = twwidth.Truncate(repeatedSegment, adjustedColWidth)
actualWidth = twwidth.Width(repeatedSegment)
f.logger.Debugf("Line: Truncated segment='%s' to width %d", repeatedSegment, actualWidth)
} else if actualWidth < adjustedColWidth {
// Pad with segment character to match adjustedColWidth
remainingWidth := adjustedColWidth - actualWidth
for i := 0; i < remainingWidth/segmentWidth; i++ {
repeatedSegment += segment
}
actualWidth = twwidth.Width(repeatedSegment)
if actualWidth < adjustedColWidth {
repeatedSegment = tw.PadRight(repeatedSegment, tw.Space, adjustedColWidth)
actualWidth = adjustedColWidth
f.logger.Debugf("Line: Padded segment with spaces='%s' to width %d", repeatedSegment, actualWidth)
}
f.logger.Debugf("Line: Padded segment='%s' to width %d", repeatedSegment, actualWidth)
}
line.WriteString(repeatedSegment)
totalLineWidth += actualWidth
f.logger.Debugf("Line: Rendered segment='%s' (f.width %d) for col %d", repeatedSegment, actualWidth, currentColIdx)
}
// Add junction between columns if not the last column
isLast := keyIndex == len(visibleColIndices)-1
if !isLast && f.config.Settings.Separators.BetweenColumns.Enabled() {
nextColIdx := visibleColIndices[keyIndex+1]
junction := jr.RenderJunction(currentColIdx, nextColIdx)
// Use center symbol (❀) or column separator (|) to match data rows
if twwidth.Width(junction) != twwidth.Width(f.config.Symbols.Column()) {
junction = f.config.Symbols.Center()
if twwidth.Width(junction) != twwidth.Width(f.config.Symbols.Column()) {
junction = f.config.Symbols.Column()
}
}
junctionWidth := twwidth.Width(junction)
line.WriteString(junction)
totalLineWidth += junctionWidth
f.logger.Debugf("Line: Junction between %d and %d: '%s' (f.width %d)", currentColIdx, nextColIdx, junction, junctionWidth)
}
}
// Add right border
rightBorderWidth := 0
if f.config.Borders.Right.Enabled() && len(visibleColIndices) > 0 {
lastIdx := visibleColIndices[len(visibleColIndices)-1]
rightBorder := jr.RenderRight(lastIdx)
rightBorderWidth = twwidth.Width(rightBorder)
line.WriteString(rightBorder)
totalLineWidth += rightBorderWidth
f.logger.Debugf("Line: Right border='%s' (f.width %d)", rightBorder, rightBorderWidth)
}
// Write the final line
line.WriteString(tw.NewLine)
f.w.Write([]byte(line.String()))
f.logger.Debugf("Line rendered: '%s' (total width %d, target %d)", strings.TrimSuffix(line.String(), tw.NewLine), totalLineWidth, targetTotalWidth)
}
// Logger sets the logger for the Blueprint instance.
func (f *Blueprint) Logger(logger *ll.Logger) {
f.logger = logger.Namespace("blueprint")
}
// Row renders a table data row with configured formatting.
func (f *Blueprint) Row(row []string, ctx tw.Formatting) {
f.logger.Debugf("Starting Row render: IsSubRow=%v, Location=%v, Pos=%s, hasFooter=%v",
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, ctx.HasFooter)
// Render the row line
f.renderLine(ctx)
f.logger.Debug("Completed Row render")
}
// Start initializes the rendering process (no-op in this implementation).
func (f *Blueprint) Start(w io.Writer) error {
f.w = w
f.logger.Debug("Blueprint.Start() called (no-op).")
return nil
}
// formatCell formats a cell's content with specified width, padding, and alignment, returning an empty string if width is non-positive.
func (f *Blueprint) formatCell(content string, width int, padding tw.Padding, align tw.Align) string {
if width <= 0 {
return tw.Empty
}
f.logger.Debugf("Formatting cell: content='%s', width=%d, align=%s, padding={L:'%s' R:'%s'}",
content, width, align, padding.Left, padding.Right)
// Calculate display width of content
runeWidth := twwidth.Width(content)
// Set default padding characters
leftPadChar := padding.Left
rightPadChar := padding.Right
// if f.config.Settings.Cushion.Enabled() || f.config.Settings.Cushion.Default() {
// if leftPadChar == tw.Empty {
// leftPadChar = tw.Space
// }
// if rightPadChar == tw.Empty {
// rightPadChar = tw.Space
// }
//}
// Calculate padding widths
padLeftWidth := twwidth.Width(leftPadChar)
padRightWidth := twwidth.Width(rightPadChar)
// Calculate available width for content
availableContentWidth := max(width-padLeftWidth-padRightWidth, 0)
f.logger.Debugf("Available content width: %d", availableContentWidth)
// Truncate content if it exceeds available width
if runeWidth > availableContentWidth {
content = twwidth.Truncate(content, availableContentWidth)
runeWidth = twwidth.Width(content)
f.logger.Debugf("Truncated content to fit %d: '%s' (new width %d)", availableContentWidth, content, runeWidth)
}
// Calculate total padding needed
totalPaddingWidth := max(width-runeWidth, 0)
f.logger.Debugf("Total padding width: %d", totalPaddingWidth)
var result strings.Builder
var leftPaddingWidth, rightPaddingWidth int
// Apply alignment and padding
switch align {
case tw.AlignLeft:
result.WriteString(leftPadChar)
result.WriteString(content)
rightPaddingWidth = totalPaddingWidth - padLeftWidth
if rightPaddingWidth > 0 {
padChar := rightPadChar
if padChar == tw.Empty {
padChar = tw.Space
}
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth))
f.logger.Debugf("Applied right padding: '%s' for %d width", padChar, rightPaddingWidth)
}
case tw.AlignRight:
leftPaddingWidth = totalPaddingWidth - padRightWidth
if leftPaddingWidth > 0 {
padChar := leftPadChar
if padChar == tw.Empty {
padChar = tw.Space
}
result.WriteString(tw.PadLeft(tw.Empty, padChar, leftPaddingWidth))
f.logger.Debugf("Applied left padding: '%s' for %d width", padChar, leftPaddingWidth)
}
result.WriteString(content)
result.WriteString(rightPadChar)
case tw.AlignCenter:
leftPaddingWidth = (totalPaddingWidth-padLeftWidth-padRightWidth)/2 + padLeftWidth
rightPaddingWidth = totalPaddingWidth - leftPaddingWidth
if leftPaddingWidth > padLeftWidth {
padChar := leftPadChar
if padChar == tw.Empty {
padChar = tw.Space
}
result.WriteString(tw.PadLeft(tw.Empty, padChar, leftPaddingWidth-padLeftWidth))
f.logger.Debugf("Applied left centering padding: '%s' for %d width", padChar, leftPaddingWidth-padLeftWidth)
}
result.WriteString(leftPadChar)
result.WriteString(content)
result.WriteString(rightPadChar)
if rightPaddingWidth > padRightWidth {
padChar := rightPadChar
if padChar == tw.Empty {
padChar = tw.Space
}
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth-padRightWidth))
f.logger.Debugf("Applied right centering padding: '%s' for %d width", padChar, rightPaddingWidth-padRightWidth)
}
default:
// Default to left alignment
result.WriteString(leftPadChar)
result.WriteString(content)
rightPaddingWidth = totalPaddingWidth - padLeftWidth
if rightPaddingWidth > 0 {
padChar := rightPadChar
if padChar == tw.Empty {
padChar = tw.Space
}
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth))
f.logger.Debugf("Applied right padding: '%s' for %d width", padChar, rightPaddingWidth)
}
}
output := result.String()
finalWidth := twwidth.Width(output)
// Adjust output to match target width
if finalWidth > width {
output = twwidth.Truncate(output, width)
f.logger.Debugf("formatCell: Truncated output to width %d", width)
} else if finalWidth < width {
output = tw.PadRight(output, tw.Space, width)
f.logger.Debugf("formatCell: Padded output to meet width %d", width)
}
// Log warning if final width doesn't match target
if f.logger.Enabled() && twwidth.Width(output) != width {
f.logger.Debugf("formatCell Warning: Final width %d does not match target %d for result '%s'",
twwidth.Width(output), width, output)
}
f.logger.Debugf("Formatted cell final result: '%s' (target width %d)", output, width)
return output
}
// renderLine renders a single line (header, row, or footer) with borders, separators, and merge handling.
func (f *Blueprint) renderLine(ctx tw.Formatting) {
// Get sorted column indices
sortedKeys := ctx.Row.Widths.SortedKeys()
numCols := 0
if len(sortedKeys) > 0 {
numCols = sortedKeys[len(sortedKeys)-1] + 1
}
// Set column separator and borders
columnSeparator := f.config.Symbols.Column()
prefix := tw.Empty
if f.config.Borders.Left.Enabled() {
prefix = columnSeparator
}
suffix := tw.Empty
if f.config.Borders.Right.Enabled() {
suffix = columnSeparator
}
var output strings.Builder
totalLineWidth := 0 // Track total display width
if prefix != tw.Empty {
output.WriteString(prefix)
totalLineWidth += twwidth.Width(prefix)
f.logger.Debugf("renderLine: Prefix='%s' (f.width %d)", prefix, twwidth.Width(prefix))
}
colIndex := 0
separatorDisplayWidth := 0
if f.config.Settings.Separators.BetweenColumns.Enabled() {
separatorDisplayWidth = twwidth.Width(columnSeparator)
}
// Process each column
for colIndex < numCols {
visualWidth := ctx.Row.Widths.Get(colIndex)
cellCtx, ok := ctx.Row.Current[colIndex]
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
if visualWidth == 0 && !isHMergeStart {
f.logger.Debugf("renderLine: Skipping col %d (zero width, not HMerge start)", colIndex)
colIndex++
continue
}
// Determine if a separator is needed
shouldAddSeparator := false
if colIndex > 0 && f.config.Settings.Separators.BetweenColumns.Enabled() {
prevWidth := ctx.Row.Widths.Get(colIndex - 1)
prevCellCtx, prevOk := ctx.Row.Current[colIndex-1]
prevIsHMergeEnd := prevOk && prevCellCtx.Merge.Horizontal.Present && prevCellCtx.Merge.Horizontal.End
if (prevWidth > 0 || prevIsHMergeEnd) && (!ok || (!cellCtx.Merge.Horizontal.Present || cellCtx.Merge.Horizontal.Start)) {
shouldAddSeparator = true
}
}
if shouldAddSeparator {
output.WriteString(columnSeparator)
totalLineWidth += separatorDisplayWidth
f.logger.Debugf("renderLine: Added separator '%s' before col %d (f.width %d)", columnSeparator, colIndex, separatorDisplayWidth)
} else if colIndex > 0 {
f.logger.Debugf("renderLine: Skipped separator before col %d due to zero-width prev col or HMerge continuation", colIndex)
}
// Handle merged cells
span := 1
if isHMergeStart {
span = cellCtx.Merge.Horizontal.Span
if ctx.Row.Position == tw.Row {
dynamicTotalWidth := 0
for k := 0; k < span && colIndex+k < numCols; k++ {
normWidth := max(ctx.NormalizedWidths.Get(colIndex+k), 0)
dynamicTotalWidth += normWidth
if k > 0 && separatorDisplayWidth > 0 && ctx.NormalizedWidths.Get(colIndex+k) > 0 {
dynamicTotalWidth += separatorDisplayWidth
}
}
visualWidth = dynamicTotalWidth
f.logger.Debugf("renderLine: Row HMerge col %d, span %d, dynamic visualWidth %d", colIndex, span, visualWidth)
} else {
visualWidth = ctx.Row.Widths.Get(colIndex)
f.logger.Debugf("renderLine: H/F HMerge col %d, span %d, pre-adjusted visualWidth %d", colIndex, span, visualWidth)
}
} else {
visualWidth = ctx.Row.Widths.Get(colIndex)
f.logger.Debugf("renderLine: Regular col %d, visualWidth %d", colIndex, visualWidth)
}
if visualWidth < 0 {
visualWidth = 0
}
// Skip processing for non-start merged cells
if ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
f.logger.Debugf("renderLine: Skipping col %d processing (part of HMerge)", colIndex)
colIndex++
continue
}
// Handle empty cell context
if !ok {
if visualWidth > 0 {
spaces := strings.Repeat(tw.Space, visualWidth)
output.WriteString(spaces)
totalLineWidth += visualWidth
f.logger.Debugf("renderLine: No cell context for col %d, writing %d spaces (f.width %d)", colIndex, visualWidth, visualWidth)
} else {
f.logger.Debugf("renderLine: No cell context for col %d, visualWidth is 0, writing nothing", colIndex)
}
colIndex += span
continue
}
// Set cell padding and alignment
padding := cellCtx.Padding
align := cellCtx.Align
switch align {
case tw.AlignNone:
switch ctx.Row.Position {
case tw.Header:
align = tw.AlignCenter
case tw.Footer:
align = tw.AlignRight
default:
align = tw.AlignLeft
}
f.logger.Debugf("renderLine: col %d (data: '%s') using renderer default align '%s' for position %s.", colIndex, cellCtx.Data, align, ctx.Row.Position)
case tw.Skip:
switch ctx.Row.Position {
case tw.Header:
align = tw.AlignCenter
case tw.Footer:
align = tw.AlignRight
default:
align = tw.AlignLeft
}
f.logger.Debugf("renderLine: col %d (data: '%s') cellCtx.Align was Skip/empty, falling back to basic default '%s'.", colIndex, cellCtx.Data, align)
}
isTotalPattern := false
// Case-insensitive check for "total"
if isHMergeStart && colIndex > 0 {
if prevCellCtx, ok := ctx.Row.Current[colIndex-1]; ok {
if strings.Contains(strings.ToLower(prevCellCtx.Data), "total") {
isTotalPattern = true
f.logger.Debugf("renderLine: total pattern in row in %d", colIndex)
}
}
}
// Get the alignment from the configuration
align = cellCtx.Align
// Override alignment for footer merged cells
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
if align == tw.AlignNone {
f.logger.Debugf("renderLine: Applying AlignRight HMerge/TOTAL override for Footer col %d. Original/default align was: %s", colIndex, align)
align = tw.AlignRight
}
}
// Handle vertical/hierarchical merges
cellData := cellCtx.Data
if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) ||
(cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
cellData = tw.Empty
f.logger.Debugf("renderLine: Blanked data for col %d (non-start V/Hierarchical)", colIndex)
}
// Format and render the cell
formattedCell := f.formatCell(cellData, visualWidth, padding, align)
if len(formattedCell) > 0 {
output.WriteString(formattedCell)
cellWidth := twwidth.Width(formattedCell)
totalLineWidth += cellWidth
f.logger.Debugf("renderLine: Rendered col %d, formattedCell='%s' (f.width %d), totalLineWidth=%d", colIndex, formattedCell, cellWidth, totalLineWidth)
}
// Log rendering details
if isHMergeStart {
f.logger.Debugf("renderLine: Rendered HMerge START col %d (span %d, visualWidth %d, align %v): '%s'",
colIndex, span, visualWidth, align, formattedCell)
} else {
f.logger.Debugf("renderLine: Rendered regular col %d (visualWidth %d, align %v): '%s'",
colIndex, visualWidth, align, formattedCell)
}
colIndex += span
}
// Add suffix and adjust total width
if output.Len() > len(prefix) || f.config.Borders.Right.Enabled() {
output.WriteString(suffix)
totalLineWidth += twwidth.Width(suffix)
f.logger.Debugf("renderLine: Suffix='%s' (f.width %d)", suffix, twwidth.Width(suffix))
}
output.WriteString(tw.NewLine)
f.w.Write([]byte(output.String()))
f.logger.Debugf("renderLine: Final rendered line: '%s' (total width %d)", strings.TrimSuffix(output.String(), tw.NewLine), totalLineWidth)
}
// Rendition updates the Blueprint's configuration.
func (f *Blueprint) Rendition(config tw.Rendition) {
f.config = mergeRendition(f.config, config)
f.logger.Debugf("Blueprint.Rendition updated. New config: %+v", f.config)
}
// Ensure Blueprint implements tw.Renditioning
var _ tw.Renditioning = (*Blueprint)(nil)
+702
View File
@@ -0,0 +1,702 @@
package renderer
import (
"io"
"strings"
"github.com/olekukonko/ll"
"github.com/olekukonko/ll/lh"
"github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/olekukonko/tablewriter/tw"
)
// ColorizedConfig holds configuration for the Colorized table renderer.
type ColorizedConfig struct {
Borders tw.Border // Border visibility settings
Settings tw.Settings // Rendering behavior settings (e.g., separators, whitespace)
Header Tint // Colors for header cells
Column Tint // Colors for row cells
Footer Tint // Colors for footer cells
Border Tint // Colors for borders and lines
Separator Tint // Colors for column separators
Symbols tw.Symbols // Symbols for table drawing (e.g., corners, lines)
}
// Colorized renders colored ASCII tables with customizable borders, colors, and alignments.
type Colorized struct {
config ColorizedConfig // Renderer configuration
trace []string // Debug trace messages
newLine string // Newline character
defaultAlign map[tw.Position]tw.Align // Default alignments for header, row, and footer
logger *ll.Logger // Logger for debug messages
w io.Writer
}
// NewColorized creates a Colorized renderer with the specified configuration, falling back to defaults if none provided.
// Only the first config is used if multiple are passed.
func NewColorized(configs ...ColorizedConfig) *Colorized {
// Initialize with default configuration
baseCfg := defaultColorized()
if len(configs) > 0 {
userCfg := configs[0]
// Override border settings if provided
if userCfg.Borders.Left != 0 {
baseCfg.Borders.Left = userCfg.Borders.Left
}
if userCfg.Borders.Right != 0 {
baseCfg.Borders.Right = userCfg.Borders.Right
}
if userCfg.Borders.Top != 0 {
baseCfg.Borders.Top = userCfg.Borders.Top
}
if userCfg.Borders.Bottom != 0 {
baseCfg.Borders.Bottom = userCfg.Borders.Bottom
}
// Merge separator and line settings
baseCfg.Settings.Separators = mergeSeparators(baseCfg.Settings.Separators, userCfg.Settings.Separators)
baseCfg.Settings.Lines = mergeLines(baseCfg.Settings.Lines, userCfg.Settings.Lines)
// Override compact mode if specified
if userCfg.Settings.CompactMode != 0 {
baseCfg.Settings.CompactMode = userCfg.Settings.CompactMode
}
// Override color settings for various table elements
if len(userCfg.Header.FG) > 0 || len(userCfg.Header.BG) > 0 || userCfg.Header.Columns != nil {
baseCfg.Header = userCfg.Header
}
if len(userCfg.Column.FG) > 0 || len(userCfg.Column.BG) > 0 || userCfg.Column.Columns != nil {
baseCfg.Column = userCfg.Column
}
if len(userCfg.Footer.FG) > 0 || len(userCfg.Footer.BG) > 0 || userCfg.Footer.Columns != nil {
baseCfg.Footer = userCfg.Footer
}
if len(userCfg.Border.FG) > 0 || len(userCfg.Border.BG) > 0 || userCfg.Border.Columns != nil {
baseCfg.Border = userCfg.Border
}
if len(userCfg.Separator.FG) > 0 || len(userCfg.Separator.BG) > 0 || userCfg.Separator.Columns != nil {
baseCfg.Separator = userCfg.Separator
}
// Override symbols if provided
if userCfg.Symbols != nil {
baseCfg.Symbols = userCfg.Symbols
}
}
cfg := baseCfg
// Ensure symbols are initialized
if cfg.Symbols == nil {
cfg.Symbols = tw.NewSymbols(tw.StyleLight)
}
// Initialize the Colorized renderer
f := &Colorized{
config: cfg,
newLine: tw.NewLine,
defaultAlign: map[tw.Position]tw.Align{
tw.Header: tw.AlignCenter,
tw.Row: tw.AlignLeft,
tw.Footer: tw.AlignRight,
},
logger: ll.New("colorized", ll.WithHandler(lh.NewMemoryHandler())).Disable(),
}
// Log initialization details
f.logger.Debugf("Initialized Colorized renderer with symbols: Center=%q, Row=%q, Column=%q", f.config.Symbols.Center(), f.config.Symbols.Row(), f.config.Symbols.Column())
f.logger.Debugf("Final ColorizedConfig.Settings.Lines: %+v", f.config.Settings.Lines)
f.logger.Debugf("Final ColorizedConfig.Borders: %+v", f.config.Borders)
return f
}
// Close performs cleanup (no-op in this implementation).
func (c *Colorized) Close() error {
c.logger.Debug("Colorized.Close() called (no-op).")
return nil
}
// Config returns the renderer's configuration as a Rendition.
func (c *Colorized) Config() tw.Rendition {
return tw.Rendition{
Borders: c.config.Borders,
Settings: c.config.Settings,
Symbols: c.config.Symbols,
Streaming: true,
}
}
// Debug returns the accumulated debug trace messages.
func (c *Colorized) Debug() []string {
return c.trace
}
// Footer renders the table footer with configured colors and formatting.
func (c *Colorized) Footer(footers [][]string, ctx tw.Formatting) {
c.logger.Debugf("Starting Footer render: IsSubRow=%v, Location=%v, Pos=%s",
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position)
// Check if there are footers to render
if len(footers) == 0 || len(footers[0]) == 0 {
c.logger.Debug("Footer: No footers to render")
return
}
// Render the footer line
c.renderLine(ctx, footers[0], c.config.Footer)
c.logger.Debug("Completed Footer render")
}
// Header renders the table header with configured colors and formatting.
func (c *Colorized) Header(headers [][]string, ctx tw.Formatting) {
c.logger.Debugf("Starting Header render: IsSubRow=%v, Location=%v, Pos=%s, lines=%d, widths=%v",
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, len(headers), ctx.Row.Widths)
// Check if there are headers to render
if len(headers) == 0 || len(headers[0]) == 0 {
c.logger.Debug("Header: No headers to render")
return
}
// Render the header line
c.renderLine(ctx, headers[0], c.config.Header)
c.logger.Debug("Completed Header render")
}
// Line renders a horizontal row line with colored junctions and segments, skipping zero-width columns.
func (c *Colorized) Line(ctx tw.Formatting) {
c.logger.Debugf("Line: Starting with Level=%v, Location=%v, IsSubRow=%v, Widths=%v", ctx.Level, ctx.Row.Location, ctx.IsSubRow, ctx.Row.Widths)
// Initialize junction renderer
jr := NewJunction(JunctionContext{
Symbols: c.config.Symbols,
Ctx: ctx,
ColIdx: 0,
BorderTint: c.config.Border,
SeparatorTint: c.config.Separator,
Logger: c.logger,
})
var line strings.Builder
// Get sorted column indices and filter out zero-width columns
allSortedKeys := ctx.Row.Widths.SortedKeys()
effectiveKeys := []int{}
keyWidthMap := make(map[int]int)
for _, k := range allSortedKeys {
width := ctx.Row.Widths.Get(k)
keyWidthMap[k] = width
if width > 0 {
effectiveKeys = append(effectiveKeys, k)
}
}
c.logger.Debugf("Line: All keys=%v, Effective keys (width>0)=%v", allSortedKeys, effectiveKeys)
// Handle case with no effective columns
if len(effectiveKeys) == 0 {
prefix := tw.Empty
suffix := tw.Empty
if c.config.Borders.Left.Enabled() {
prefix = jr.RenderLeft()
}
if c.config.Borders.Right.Enabled() {
originalLastColIdx := -1
if len(allSortedKeys) > 0 {
originalLastColIdx = allSortedKeys[len(allSortedKeys)-1]
}
suffix = jr.RenderRight(originalLastColIdx)
}
if prefix != tw.Empty || suffix != tw.Empty {
line.WriteString(prefix + suffix + tw.NewLine)
c.w.Write([]byte(line.String()))
}
c.logger.Debug("Line: Handled empty row/widths case (no effective keys)")
return
}
// Add left border if enabled
if c.config.Borders.Left.Enabled() {
line.WriteString(jr.RenderLeft())
}
// Render segments for each effective column
for keyIndex, currentColIdx := range effectiveKeys {
jr.colIdx = currentColIdx
segment := jr.GetSegment()
colWidth := keyWidthMap[currentColIdx]
c.logger.Debugf("Line: Drawing segment for Effective colIdx=%d, segment='%s', width=%d", currentColIdx, segment, colWidth)
if segment == tw.Empty {
line.WriteString(strings.Repeat(tw.Space, colWidth))
} else {
// Calculate how many times to repeat the segment
segmentWidth := twwidth.Width(segment)
if segmentWidth <= 0 {
segmentWidth = 1
}
repeat := 0
if colWidth > 0 && segmentWidth > 0 {
repeat = colWidth / segmentWidth
}
drawnSegment := strings.Repeat(segment, repeat)
line.WriteString(drawnSegment)
// Adjust for width discrepancies
actualDrawnWidth := twwidth.Width(drawnSegment)
if actualDrawnWidth < colWidth {
missingWidth := colWidth - actualDrawnWidth
spaces := strings.Repeat(tw.Space, missingWidth)
if len(c.config.Border.BG) > 0 {
line.WriteString(Tint{BG: c.config.Border.BG}.Apply(spaces))
} else {
line.WriteString(spaces)
}
c.logger.Debugf("Line: colIdx=%d corrected segment width, added %d spaces", currentColIdx, missingWidth)
} else if actualDrawnWidth > colWidth {
c.logger.Debugf("Line: WARNING colIdx=%d segment draw width %d > target %d", currentColIdx, actualDrawnWidth, colWidth)
}
}
// Add junction between columns if not the last visible column
isLastVisible := keyIndex == len(effectiveKeys)-1
if !isLastVisible && c.config.Settings.Separators.BetweenColumns.Enabled() {
nextVisibleColIdx := effectiveKeys[keyIndex+1]
originalPrecedingCol := -1
foundCurrent := false
for _, k := range allSortedKeys {
if k == currentColIdx {
foundCurrent = true
}
if foundCurrent && k < nextVisibleColIdx {
originalPrecedingCol = k
}
if k >= nextVisibleColIdx {
break
}
}
if originalPrecedingCol != -1 {
jr.colIdx = originalPrecedingCol
junction := jr.RenderJunction(originalPrecedingCol, nextVisibleColIdx)
c.logger.Debugf("Line: Junction between visible %d (orig preceding %d) and next visible %d: '%s'", currentColIdx, originalPrecedingCol, nextVisibleColIdx, junction)
line.WriteString(junction)
} else {
c.logger.Debugf("Line: Could not determine original preceding column for junction before visible %d", nextVisibleColIdx)
line.WriteString(c.config.Separator.Apply(jr.sym.Center()))
}
}
}
// Add right border if enabled
if c.config.Borders.Right.Enabled() {
originalLastColIdx := -1
if len(allSortedKeys) > 0 {
originalLastColIdx = allSortedKeys[len(allSortedKeys)-1]
}
jr.colIdx = originalLastColIdx
line.WriteString(jr.RenderRight(originalLastColIdx))
}
// Write the final line
line.WriteString(c.newLine)
c.w.Write([]byte(line.String()))
c.logger.Debugf("Line rendered: %s", strings.TrimSuffix(line.String(), c.newLine))
}
// Logger sets the logger for the Colorized instance.
func (c *Colorized) Logger(logger *ll.Logger) {
c.logger = logger.Namespace("colorized")
}
// Reset clears the renderer's internal state, including debug traces.
func (c *Colorized) Reset() {
c.trace = nil
c.logger.Debugf("Reset: Cleared debug trace")
}
// Row renders a table data row with configured colors and formatting.
func (c *Colorized) Row(row []string, ctx tw.Formatting) {
c.logger.Debugf("Starting Row render: IsSubRow=%v, Location=%v, Pos=%s, hasFooter=%v",
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, ctx.HasFooter)
// Check if there is data to render
if len(row) == 0 {
c.logger.Debugf("Row: No data to render")
return
}
// Render the row line
c.renderLine(ctx, row, c.config.Column)
c.logger.Debugf("Completed Row render")
}
// Start initializes the rendering process (no-op in this implementation).
func (c *Colorized) Start(w io.Writer) error {
c.w = w
c.logger.Debugf("Colorized.Start() called (no-op).")
return nil
}
// formatCell formats a cell's content with color, width, padding, and alignment, handling whitespace trimming and truncation.
func (c *Colorized) formatCell(content string, width int, padding tw.Padding, align tw.Align, tint Tint) string {
c.logger.Debugf("Formatting cell: content='%s', width=%d, align=%s, paddingL='%s', paddingR='%s', tintFG=%v, tintBG=%v",
content, width, align, padding.Left, padding.Right, tint.FG, tint.BG)
// Return empty string if width is non-positive
if width <= 0 {
c.logger.Debugf("formatCell: width %d <= 0, returning empty string", width)
return tw.Empty
}
// Calculate visual width of content
contentVisualWidth := twwidth.Width(content)
// Set padding characters
padLeftCharStr := padding.Left
padRightCharStr := padding.Right
// Determine the character to use for alignment filling.
// We default to the padding character defined for that side.
// If the padding character is empty (e.g. Overwrite: true), we MUST fallback to Space
// for the alignment calculation to prevent the content from shifting incorrectly.
alignFillLeft := padLeftCharStr
if alignFillLeft == tw.Empty {
alignFillLeft = tw.Space
}
alignFillRight := padRightCharStr
if alignFillRight == tw.Empty {
alignFillRight = tw.Space
}
// Calculate padding widths
definedPadLeftWidth := twwidth.Width(padLeftCharStr)
definedPadRightWidth := twwidth.Width(padRightCharStr)
// Calculate available width for content and alignment
availableForContentAndAlign := max(width-definedPadLeftWidth-definedPadRightWidth, 0)
// Truncate content if it exceeds available width
if contentVisualWidth > availableForContentAndAlign {
content = twwidth.Truncate(content, availableForContentAndAlign)
contentVisualWidth = twwidth.Width(content)
c.logger.Debugf("Truncated content to fit %d: '%s' (new width %d)", availableForContentAndAlign, content, contentVisualWidth)
}
// Calculate remaining space for alignment
remainingSpaceForAlignment := max(availableForContentAndAlign-contentVisualWidth, 0)
// Apply alignment padding
// Note: We use tw.Pad* helpers here instead of strings.Repeat to handle multi-byte fill chars correctly.
leftAlignmentPadSpaces := tw.Empty
rightAlignmentPadSpaces := tw.Empty
switch align {
case tw.AlignLeft:
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, remainingSpaceForAlignment)
case tw.AlignRight:
leftAlignmentPadSpaces = tw.PadLeft(tw.Empty, alignFillLeft, remainingSpaceForAlignment)
case tw.AlignCenter:
leftSpacesCount := remainingSpaceForAlignment / 2
rightSpacesCount := remainingSpaceForAlignment - leftSpacesCount
if leftSpacesCount > 0 {
leftAlignmentPadSpaces = tw.PadLeft(tw.Empty, alignFillLeft, leftSpacesCount)
}
if rightSpacesCount > 0 {
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, rightSpacesCount)
}
default:
// Default to left alignment
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, remainingSpaceForAlignment)
}
// Apply colors to content and padding
coloredContent := tint.Apply(content)
coloredPadLeft := padLeftCharStr
coloredPadRight := padRightCharStr
coloredAlignPadLeft := leftAlignmentPadSpaces
coloredAlignPadRight := rightAlignmentPadSpaces
if len(tint.BG) > 0 {
bgTint := Tint{BG: tint.BG}
// Apply foreground color to non-space padding if foreground is defined
if len(tint.FG) > 0 && padLeftCharStr != tw.Space {
coloredPadLeft = tint.Apply(padLeftCharStr)
} else {
coloredPadLeft = bgTint.Apply(padLeftCharStr)
}
if len(tint.FG) > 0 && padRightCharStr != tw.Space {
coloredPadRight = tint.Apply(padRightCharStr)
} else {
coloredPadRight = bgTint.Apply(padRightCharStr)
}
// Apply background color to alignment padding
if leftAlignmentPadSpaces != tw.Empty {
coloredAlignPadLeft = bgTint.Apply(leftAlignmentPadSpaces)
}
if rightAlignmentPadSpaces != tw.Empty {
coloredAlignPadRight = bgTint.Apply(rightAlignmentPadSpaces)
}
} else if len(tint.FG) > 0 {
// Apply foreground color to non-space padding
if padLeftCharStr != tw.Space {
coloredPadLeft = tint.Apply(padLeftCharStr)
}
if padRightCharStr != tw.Space {
coloredPadRight = tint.Apply(padRightCharStr)
}
}
// Build final cell string
var sb strings.Builder
sb.WriteString(coloredPadLeft)
sb.WriteString(coloredAlignPadLeft)
sb.WriteString(coloredContent)
sb.WriteString(coloredAlignPadRight)
sb.WriteString(coloredPadRight)
output := sb.String()
// Adjust output width if necessary (safety check)
currentVisualWidth := twwidth.Width(output)
if currentVisualWidth != width {
c.logger.Debugf("formatCell MISMATCH: content='%s', target_w=%d. Calculated parts width = %d. String: '%s'",
content, width, currentVisualWidth, output)
if currentVisualWidth > width {
output = twwidth.Truncate(output, width)
} else {
paddingSpacesStr := strings.Repeat(tw.Space, width-currentVisualWidth)
if len(tint.BG) > 0 {
output += Tint{BG: tint.BG}.Apply(paddingSpacesStr)
} else {
output += paddingSpacesStr
}
}
c.logger.Debugf("formatCell Post-Correction: Target %d, New Visual width %d. Output: '%s'", width, twwidth.Width(output), output)
}
c.logger.Debugf("Formatted cell final result: '%s' (target width %d, display width %d)", output, width, twwidth.Width(output))
return output
}
// renderLine renders a single line (header, row, or footer) with colors, handling merges and separators.
func (c *Colorized) renderLine(ctx tw.Formatting, line []string, tint Tint) {
// Determine number of columns
numCols := 0
if len(ctx.Row.Current) > 0 {
maxKey := -1
for k := range ctx.Row.Current {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
} else {
maxKey := -1
for k := range ctx.Row.Widths {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
}
var output strings.Builder
// Add left border if enabled
prefix := tw.Empty
if c.config.Borders.Left.Enabled() {
prefix = c.config.Border.Apply(c.config.Symbols.Column())
}
output.WriteString(prefix)
// Set up separator
separatorDisplayWidth := 0
separatorString := tw.Empty
if c.config.Settings.Separators.BetweenColumns.Enabled() {
separatorString = c.config.Separator.Apply(c.config.Symbols.Column())
separatorDisplayWidth = twwidth.Width(c.config.Symbols.Column())
}
// Process each column
for i := 0; i < numCols; {
// Determine if a separator is needed
shouldAddSeparator := false
if i > 0 && c.config.Settings.Separators.BetweenColumns.Enabled() {
cellCtx, ok := ctx.Row.Current[i]
if !ok || (!cellCtx.Merge.Horizontal.Present || cellCtx.Merge.Horizontal.Start) {
shouldAddSeparator = true
}
}
if shouldAddSeparator {
output.WriteString(separatorString)
c.logger.Debugf("renderLine: Added separator '%s' before col %d", separatorString, i)
} else if i > 0 {
c.logger.Debugf("renderLine: Skipped separator before col %d due to HMerge continuation", i)
}
// Get cell context, use default if not present
cellCtx, ok := ctx.Row.Current[i]
if !ok {
cellCtx = tw.CellContext{
Data: tw.Empty,
Align: c.defaultAlign[ctx.Row.Position],
Padding: tw.Padding{Left: tw.Space, Right: tw.Space},
Width: ctx.Row.Widths.Get(i),
Merge: tw.MergeState{},
}
}
// Handle merged cells
visualWidth := 0
span := 1
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
if isHMergeStart {
span = cellCtx.Merge.Horizontal.Span
if ctx.Row.Position == tw.Row {
// Calculate dynamic width for row merges
dynamicTotalWidth := 0
for k := 0; k < span && i+k < numCols; k++ {
colToSum := i + k
normWidth := max(ctx.NormalizedWidths.Get(colToSum), 0)
dynamicTotalWidth += normWidth
if k > 0 && separatorDisplayWidth > 0 {
dynamicTotalWidth += separatorDisplayWidth
}
}
visualWidth = dynamicTotalWidth
c.logger.Debugf("renderLine: Row HMerge col %d, span %d, dynamic visualWidth %d", i, span, visualWidth)
} else {
visualWidth = ctx.Row.Widths.Get(i)
c.logger.Debugf("renderLine: H/F HMerge col %d, span %d, pre-adjusted visualWidth %d", i, span, visualWidth)
}
} else {
visualWidth = ctx.Row.Widths.Get(i)
c.logger.Debugf("renderLine: Regular col %d, visualWidth %d", i, visualWidth)
}
if visualWidth < 0 {
visualWidth = 0
}
// Skip processing for non-start merged cells
if ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
c.logger.Debugf("renderLine: Skipping col %d processing (part of HMerge)", i)
i++
continue
}
// Handle empty cell context with non-zero width
if !ok && visualWidth > 0 {
spaces := strings.Repeat(tw.Space, visualWidth)
if len(tint.BG) > 0 {
output.WriteString(Tint{BG: tint.BG}.Apply(spaces))
} else {
output.WriteString(spaces)
}
c.logger.Debugf("renderLine: No cell context for col %d, writing %d spaces", i, visualWidth)
i += span
continue
}
// Set cell alignment
padding := cellCtx.Padding
align := cellCtx.Align
if align == tw.AlignNone {
align = c.defaultAlign[ctx.Row.Position]
c.logger.Debugf("renderLine: col %d using default renderer align '%s' for position %s because cellCtx.Align was AlignNone", i, align, ctx.Row.Position)
}
// Detect and handle TOTAL pattern
isTotalPattern := false
if i == 0 && isHMergeStart && cellCtx.Merge.Horizontal.Span >= 3 && strings.TrimSpace(cellCtx.Data) == "TOTAL" {
isTotalPattern = true
c.logger.Debugf("renderLine: Detected 'TOTAL' HMerge pattern at col 0")
}
// Override alignment for footer merges or TOTAL pattern
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
if align == tw.AlignNone {
c.logger.Debugf("renderLine: Applying AlignRight override for Footer HMerge/TOTAL pattern at col %d. Original/default align was: %s", i, align)
align = tw.AlignRight
}
}
// Handle vertical/hierarchical merges
content := cellCtx.Data
if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) ||
(cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
content = tw.Empty
c.logger.Debugf("renderLine: Blanked data for col %d (non-start V/Hierarchical)", i)
}
// Apply per-column tint if available
cellTint := tint
if i < len(tint.Columns) {
columnTint := tint.Columns[i]
if len(columnTint.FG) > 0 || len(columnTint.BG) > 0 {
cellTint = columnTint
}
}
// Format and render the cell
formattedCell := c.formatCell(content, visualWidth, padding, align, cellTint)
if len(formattedCell) > 0 {
output.WriteString(formattedCell)
} else if visualWidth == 0 && isHMergeStart {
c.logger.Debugf("renderLine: Rendered HMerge START col %d resulted in 0 visual width, wrote nothing.", i)
} else if visualWidth == 0 {
c.logger.Debugf("renderLine: Rendered regular col %d resulted in 0 visual width, wrote nothing.", i)
}
// Log rendering details
if isHMergeStart {
c.logger.Debugf("renderLine: Rendered HMerge START col %d (span %d, visualWidth %d, align %s): '%s'",
i, span, visualWidth, align, formattedCell)
} else {
c.logger.Debugf("renderLine: Rendered regular col %d (visualWidth %d, align %s): '%s'",
i, visualWidth, align, formattedCell)
}
i += span
}
// Add right border if enabled
suffix := tw.Empty
if c.config.Borders.Right.Enabled() {
suffix = c.config.Border.Apply(c.config.Symbols.Column())
}
output.WriteString(suffix)
// Write the final line
output.WriteString(c.newLine)
c.w.Write([]byte(output.String()))
c.logger.Debugf("renderLine: Final rendered line: %s", strings.TrimSuffix(output.String(), c.newLine))
}
// Rendition updates the parts of ColorizedConfig that correspond to tw.Rendition
// by merging the provided newRendition. Color-specific Tints are not modified.
func (c *Colorized) Rendition(newRendition tw.Rendition) { // Method name matches interface
c.logger.Debug("Colorized.Rendition called. Current B/Sym/Set: B:%+v, Sym:%T, S:%+v. Override: %+v", c.config.Borders, c.config.Symbols, c.config.Settings, newRendition)
currentRenditionPart := tw.Rendition{
Borders: c.config.Borders,
Symbols: c.config.Symbols,
Settings: c.config.Settings,
}
mergedRenditionPart := mergeRendition(currentRenditionPart, newRendition)
c.config.Borders = mergedRenditionPart.Borders
c.config.Symbols = mergedRenditionPart.Symbols
if c.config.Symbols == nil {
c.config.Symbols = tw.NewSymbols(tw.StyleLight)
}
c.config.Settings = mergedRenditionPart.Settings
c.logger.Debugf("Colorized.Rendition updated. New B/Sym/Set: B:%+v, Sym:%T, S:%+v",
c.config.Borders, c.config.Symbols, c.config.Settings)
}
// Ensure Colorized implements tw.Renditioning
var _ tw.Renditioning = (*Colorized)(nil)
+236
View File
@@ -0,0 +1,236 @@
package renderer
import (
"fmt"
"github.com/fatih/color"
"github.com/olekukonko/tablewriter/tw"
)
// defaultBlueprint returns a default Rendition for ASCII table rendering with borders and light symbols.
func defaultBlueprint() tw.Rendition {
return tw.Rendition{
Borders: tw.Border{
Left: tw.On,
Right: tw.On,
Top: tw.On,
Bottom: tw.On,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.On,
BetweenRows: tw.Off,
BetweenColumns: tw.On,
},
Lines: tw.Lines{
ShowTop: tw.On,
ShowBottom: tw.On,
ShowHeaderLine: tw.On,
ShowFooterLine: tw.On,
},
CompactMode: tw.Off,
// Cushion: tw.On,
},
Symbols: tw.NewSymbols(tw.StyleLight),
Streaming: true,
}
}
// defaultColorized returns a default ColorizedConfig optimized for dark terminal backgrounds with colored headers, rows, and borders.
func defaultColorized() ColorizedConfig {
return ColorizedConfig{
Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.On,
BetweenRows: tw.Off,
BetweenColumns: tw.On,
},
Lines: tw.Lines{
ShowTop: tw.On,
ShowBottom: tw.On,
ShowHeaderLine: tw.On,
ShowFooterLine: tw.On,
},
CompactMode: tw.Off,
},
Header: Tint{
FG: Colors{color.FgWhite, color.Bold},
BG: Colors{color.BgBlack},
},
Column: Tint{
FG: Colors{color.FgCyan},
BG: Colors{color.BgBlack},
},
Footer: Tint{
FG: Colors{color.FgYellow},
BG: Colors{color.BgBlack},
},
Border: Tint{
FG: Colors{color.FgWhite},
BG: Colors{color.BgBlack},
},
Separator: Tint{
FG: Colors{color.FgWhite},
BG: Colors{color.BgBlack},
},
Symbols: tw.NewSymbols(tw.StyleLight),
}
}
// defaultOceanRendererConfig returns a base tw.Rendition for the Ocean renderer.
func defaultOceanRendererConfig() tw.Rendition {
return tw.Rendition{
Borders: tw.Border{
Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.Off,
BetweenRows: tw.Off,
BetweenColumns: tw.On,
},
Lines: tw.Lines{
ShowTop: tw.On,
ShowBottom: tw.On,
ShowHeaderLine: tw.On,
ShowFooterLine: tw.Off,
},
CompactMode: tw.Off,
},
Symbols: tw.NewSymbols(tw.StyleDefault),
Streaming: true,
}
}
// getHTMLStyle remains the same
func getHTMLStyle(align tw.Align) string {
styleContent := tw.Empty
switch align {
case tw.AlignRight:
styleContent = "text-align: right;"
case tw.AlignCenter:
styleContent = "text-align: center;"
case tw.AlignLeft:
styleContent = "text-align: left;"
}
if styleContent != tw.Empty {
return fmt.Sprintf(` style="%s"`, styleContent)
}
return tw.Empty
}
// mergeLines combines default and override line settings, preserving defaults for unset (zero) overrides.
func mergeLines(defaults, overrides tw.Lines) tw.Lines {
if overrides.ShowTop != 0 {
defaults.ShowTop = overrides.ShowTop
}
if overrides.ShowBottom != 0 {
defaults.ShowBottom = overrides.ShowBottom
}
if overrides.ShowHeaderLine != 0 {
defaults.ShowHeaderLine = overrides.ShowHeaderLine
}
if overrides.ShowFooterLine != 0 {
defaults.ShowFooterLine = overrides.ShowFooterLine
}
return defaults
}
// mergeSeparators combines default and override separator settings, preserving defaults for unset (zero) overrides.
func mergeSeparators(defaults, overrides tw.Separators) tw.Separators {
if overrides.ShowHeader != 0 {
defaults.ShowHeader = overrides.ShowHeader
}
if overrides.ShowFooter != 0 {
defaults.ShowFooter = overrides.ShowFooter
}
if overrides.BetweenRows != 0 {
defaults.BetweenRows = overrides.BetweenRows
}
if overrides.BetweenColumns != 0 {
defaults.BetweenColumns = overrides.BetweenColumns
}
return defaults
}
// mergeSettings combines default and override settings, preserving defaults for unset (zero) overrides.
func mergeSettings(defaults, overrides tw.Settings) tw.Settings {
if overrides.Separators.ShowHeader != tw.Unknown {
defaults.Separators.ShowHeader = overrides.Separators.ShowHeader
}
if overrides.Separators.ShowFooter != tw.Unknown {
defaults.Separators.ShowFooter = overrides.Separators.ShowFooter
}
if overrides.Separators.BetweenRows != tw.Unknown {
defaults.Separators.BetweenRows = overrides.Separators.BetweenRows
}
if overrides.Separators.BetweenColumns != tw.Unknown {
defaults.Separators.BetweenColumns = overrides.Separators.BetweenColumns
}
if overrides.Lines.ShowTop != tw.Unknown {
defaults.Lines.ShowTop = overrides.Lines.ShowTop
}
if overrides.Lines.ShowBottom != tw.Unknown {
defaults.Lines.ShowBottom = overrides.Lines.ShowBottom
}
if overrides.Lines.ShowHeaderLine != tw.Unknown {
defaults.Lines.ShowHeaderLine = overrides.Lines.ShowHeaderLine
}
if overrides.Lines.ShowFooterLine != tw.Unknown {
defaults.Lines.ShowFooterLine = overrides.Lines.ShowFooterLine
}
if overrides.CompactMode != tw.Unknown {
defaults.CompactMode = overrides.CompactMode
}
// if overrides.Cushion != tw.Unknown {
// defaults.Cushion = overrides.Cushion
//}
return defaults
}
// MergeRendition merges the 'override' rendition into the 'current' rendition.
// It only updates fields in 'current' if they are explicitly set (non-zero/non-nil) in 'override'.
// This allows for partial updates to a renderer's configuration.
func mergeRendition(current, override tw.Rendition) tw.Rendition {
// Merge Borders: Only update if override border states are explicitly set (not 0).
// A tw.State's zero value is 0, which is distinct from tw.On (1) or tw.Off (-1).
// So, if override.Borders.Left is 0, it means "not specified", so we keep current.
if override.Borders.Left != 0 {
current.Borders.Left = override.Borders.Left
}
if override.Borders.Right != 0 {
current.Borders.Right = override.Borders.Right
}
if override.Borders.Top != 0 {
current.Borders.Top = override.Borders.Top
}
if override.Borders.Bottom != 0 {
current.Borders.Bottom = override.Borders.Bottom
}
// Merge Symbols: Only update if override.Symbols is not nil.
if override.Symbols != nil {
current.Symbols = override.Symbols
}
// Merge Settings: Use the existing mergeSettings for granular control.
// mergeSettings already handles preserving defaults for unset (zero) overrides.
current.Settings = mergeSettings(current.Settings, override.Settings)
// Streaming flag: typically set at renderer creation, but can be overridden if needed.
// For now, let's assume it's not commonly changed post-creation by a generic rendition merge.
// If override provides a different streaming capability, it might indicate a fundamental
// change that a simple merge shouldn't handle without more context.
// current.Streaming = override.Streaming // Or keep current.Streaming
return current
}
+442
View File
@@ -0,0 +1,442 @@
package renderer
import (
"errors"
"fmt"
"html"
"io"
"strings"
"github.com/olekukonko/ll"
"github.com/olekukonko/tablewriter/tw"
)
// HTMLConfig defines settings for the HTML table renderer.
type HTMLConfig struct {
EscapeContent bool // Whether to escape cell content
AddLinesTag bool // Whether to wrap multiline content in <lines> tags
TableClass string // CSS class for <table>
HeaderClass string // CSS class for <thead>
BodyClass string // CSS class for <tbody>
FooterClass string // CSS class for <tfoot>
RowClass string // CSS class for <tr> in body
HeaderRowClass string // CSS class for <tr> in header
FooterRowClass string // CSS class for <tr> in footer
}
// HTML renders tables in HTML format with customizable classes and content handling.
type HTML struct {
config HTMLConfig // Renderer configuration
w io.Writer // Output w
trace []string // Debug trace messages
debug bool // Enables debug logging
tableStarted bool // Tracks if <table> tag is open
tbodyStarted bool // Tracks if <tbody> tag is open
tfootStarted bool // Tracks if <tfoot> tag is open
vMergeTrack map[int]int // Tracks vertical merge spans by column index
logger *ll.Logger
}
// NewHTML initializes an HTML renderer with the given w, debug setting, and optional configuration.
// It panics if the w is nil and applies defaults for unset config fields.
// Update: see https://github.com/olekukonko/tablewriter/issues/258
func NewHTML(configs ...HTMLConfig) *HTML {
cfg := HTMLConfig{
EscapeContent: true,
AddLinesTag: false,
}
if len(configs) > 0 {
userCfg := configs[0]
cfg.EscapeContent = userCfg.EscapeContent
cfg.AddLinesTag = userCfg.AddLinesTag
cfg.TableClass = userCfg.TableClass
cfg.HeaderClass = userCfg.HeaderClass
cfg.BodyClass = userCfg.BodyClass
cfg.FooterClass = userCfg.FooterClass
cfg.RowClass = userCfg.RowClass
cfg.HeaderRowClass = userCfg.HeaderRowClass
cfg.FooterRowClass = userCfg.FooterRowClass
}
return &HTML{
config: cfg,
vMergeTrack: make(map[int]int),
tableStarted: false,
tbodyStarted: false,
tfootStarted: false,
logger: ll.New("html").Disable(),
}
}
func (h *HTML) Logger(logger *ll.Logger) {
h.logger = logger
}
// Config returns a Rendition representation of the current configuration.
func (h *HTML) Config() tw.Rendition {
return tw.Rendition{
Borders: tw.BorderNone,
Symbols: tw.NewSymbols(tw.StyleNone),
Settings: tw.Settings{},
Streaming: false,
}
}
// debugLog appends a formatted message to the debug trace if debugging is enabled.
// func (h *HTML) debugLog(format string, a ...interface{}) {
// if h.debug {
// msg := fmt.Sprintf(format, a...)
// h.trace = append(h.trace, fmt.Sprintf("[HTML] %s", msg))
// }
//}
// Debug returns the accumulated debug trace messages.
func (h *HTML) Debug() []string {
return h.trace
}
// Start begins the HTML table rendering by opening the <table> tag.
func (h *HTML) Start(w io.Writer) error {
h.w = w
h.Reset()
h.logger.Debug("HTML.Start() called.")
classAttr := tw.Empty
if h.config.TableClass != tw.Empty {
classAttr = fmt.Sprintf(` class="%s"`, h.config.TableClass)
}
h.logger.Debugf("Writing opening <table%s> tag", classAttr)
_, err := fmt.Fprintf(h.w, "<table%s>\n", classAttr)
if err != nil {
return err
}
h.tableStarted = true
return nil
}
// closePreviousSection closes any open <tbody> or <tfoot> sections.
func (h *HTML) closePreviousSection() {
if h.tbodyStarted {
h.logger.Debug("Closing <tbody> tag")
fmt.Fprintln(h.w, "</tbody>")
h.tbodyStarted = false
}
if h.tfootStarted {
h.logger.Debug("Closing <tfoot> tag")
fmt.Fprintln(h.w, "</tfoot>")
h.tfootStarted = false
}
}
// Header renders the <thead> section with header rows, supporting horizontal merges.
func (h *HTML) Header(headers [][]string, ctx tw.Formatting) {
if !h.tableStarted {
h.logger.Debug("WARN: Header called before Start")
return
}
if len(headers) == 0 || len(headers[0]) == 0 {
h.logger.Debug("Header: No headers")
return
}
h.closePreviousSection()
classAttr := tw.Empty
if h.config.HeaderClass != tw.Empty {
classAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderClass)
}
fmt.Fprintf(h.w, "<thead%s>\n", classAttr)
headerRow := headers[0]
numCols := 0
if len(ctx.Row.Current) > 0 {
maxKey := -1
for k := range ctx.Row.Current {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
} else if len(headerRow) > 0 {
numCols = len(headerRow)
}
indent := " "
rowClassAttr := tw.Empty
if h.config.HeaderRowClass != tw.Empty {
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderRowClass)
}
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
renderedCols := 0
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
// Skip columns consumed by vertical merges
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
h.logger.Debugf("Header: Skipping col %d due to vmerge", colIdx)
h.vMergeTrack[colIdx]--
if h.vMergeTrack[colIdx] <= 1 {
delete(h.vMergeTrack, colIdx)
}
colIdx++
continue
}
// Render cell
cellCtx, ok := ctx.Row.Current[colIdx]
if !ok {
cellCtx = tw.CellContext{Align: tw.AlignCenter}
}
originalContent := tw.Empty
if colIdx < len(headerRow) {
originalContent = headerRow[colIdx]
}
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, true, colIdx)
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
renderedCols++
// Handle horizontal merges
hSpan := 1
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
hSpan = cellCtx.Merge.Horizontal.Span
renderedCols += (hSpan - 1)
}
colIdx += hSpan
}
fmt.Fprintf(h.w, "</tr>\n")
fmt.Fprintln(h.w, "</thead>")
}
// Row renders a <tr> element within <tbody>, supporting horizontal and vertical merges.
func (h *HTML) Row(row []string, ctx tw.Formatting) {
if !h.tableStarted {
h.logger.Debug("WARN: Row called before Start")
return
}
if !h.tbodyStarted {
h.closePreviousSection()
classAttr := tw.Empty
if h.config.BodyClass != tw.Empty {
classAttr = fmt.Sprintf(` class="%s"`, h.config.BodyClass)
}
h.logger.Debugf("Writing opening <tbody%s> tag", classAttr)
fmt.Fprintf(h.w, "<tbody%s>\n", classAttr)
h.tbodyStarted = true
}
h.logger.Debugf("Rendering row data: %v", row)
numCols := 0
if len(ctx.Row.Current) > 0 {
maxKey := -1
for k := range ctx.Row.Current {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
} else if len(row) > 0 {
numCols = len(row)
}
indent := " "
rowClassAttr := tw.Empty
if h.config.RowClass != tw.Empty {
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.RowClass)
}
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
renderedCols := 0
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
// Skip columns consumed by vertical merges
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
h.logger.Debugf("Row: Skipping render for col %d due to vertical merge (remaining %d)", colIdx, remainingSpan-1)
h.vMergeTrack[colIdx]--
if h.vMergeTrack[colIdx] <= 1 {
delete(h.vMergeTrack, colIdx)
}
colIdx++
continue
}
// Render cell
cellCtx, ok := ctx.Row.Current[colIdx]
if !ok {
cellCtx = tw.CellContext{Align: tw.AlignLeft}
}
originalContent := tw.Empty
if colIdx < len(row) {
originalContent = row[colIdx]
}
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
renderedCols++
// Handle horizontal merges
hSpan := 1
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
hSpan = cellCtx.Merge.Horizontal.Span
renderedCols += (hSpan - 1)
}
colIdx += hSpan
}
fmt.Fprintf(h.w, "</tr>\n")
}
// Footer renders the <tfoot> section with footer rows, supporting horizontal merges.
func (h *HTML) Footer(footers [][]string, ctx tw.Formatting) {
if !h.tableStarted {
h.logger.Debug("WARN: Footer called before Start")
return
}
if len(footers) == 0 || len(footers[0]) == 0 {
h.logger.Debug("Footer: No footers")
return
}
h.closePreviousSection()
classAttr := tw.Empty
if h.config.FooterClass != tw.Empty {
classAttr = fmt.Sprintf(` class="%s"`, h.config.FooterClass)
}
fmt.Fprintf(h.w, "<tfoot%s>\n", classAttr)
h.tfootStarted = true
footerRow := footers[0]
numCols := 0
if len(ctx.Row.Current) > 0 {
maxKey := -1
for k := range ctx.Row.Current {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
} else if len(footerRow) > 0 {
numCols = len(footerRow)
}
indent := " "
rowClassAttr := tw.Empty
if h.config.FooterRowClass != tw.Empty {
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.FooterRowClass)
}
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
renderedCols := 0
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
cellCtx, ok := ctx.Row.Current[colIdx]
if !ok {
cellCtx = tw.CellContext{Align: tw.AlignRight}
}
originalContent := tw.Empty
if colIdx < len(footerRow) {
originalContent = footerRow[colIdx]
}
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
renderedCols++
hSpan := 1
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
hSpan = cellCtx.Merge.Horizontal.Span
renderedCols += (hSpan - 1)
}
colIdx += hSpan
}
fmt.Fprintf(h.w, "</tr>\n")
fmt.Fprintln(h.w, "</tfoot>")
h.tfootStarted = false
}
// renderRowCell generates HTML for a single cell, handling content escaping, merges, and alignment.
func (h *HTML) renderRowCell(originalContent string, cellCtx tw.CellContext, isHeader bool, colIdx int) (tag, attributes, processedContent string) {
tag = "td"
if isHeader {
tag = "th"
}
// Process content
processedContent = originalContent
containsNewline := strings.Contains(originalContent, "\n")
if h.config.EscapeContent {
if containsNewline {
const newlinePlaceholder = "[[--HTML_RENDERER_BR_PLACEHOLDER--]]"
tempContent := strings.ReplaceAll(originalContent, "\n", newlinePlaceholder)
escapedContent := html.EscapeString(tempContent)
processedContent = strings.ReplaceAll(escapedContent, newlinePlaceholder, "<br>")
} else {
processedContent = html.EscapeString(originalContent)
}
} else if containsNewline {
processedContent = strings.ReplaceAll(originalContent, "\n", "<br>")
}
if containsNewline && h.config.AddLinesTag {
processedContent = "<lines>" + processedContent + "</lines>"
}
// Build attributes
var attrBuilder strings.Builder
merge := cellCtx.Merge
if merge.Horizontal.Present && merge.Horizontal.Start && merge.Horizontal.Span > 1 {
fmt.Fprintf(&attrBuilder, ` colspan="%d"`, merge.Horizontal.Span)
}
vSpan := 0
if !isHeader {
if merge.Vertical.Present && merge.Vertical.Start {
vSpan = merge.Vertical.Span
} else if merge.Hierarchical.Present && merge.Hierarchical.Start {
vSpan = merge.Hierarchical.Span
}
if vSpan > 1 {
fmt.Fprintf(&attrBuilder, ` rowspan="%d"`, vSpan)
h.vMergeTrack[colIdx] = vSpan
h.logger.Debugf("renderRowCell: Tracking rowspan=%d for col %d", vSpan, colIdx)
}
}
if style := getHTMLStyle(cellCtx.Align); style != tw.Empty {
attrBuilder.WriteString(style)
}
attributes = attrBuilder.String()
return
}
// Line is a no-op for HTML rendering, as structural lines are handled by tags.
func (h *HTML) Line(ctx tw.Formatting) {}
// Reset clears the renderer's internal state, including debug traces and merge tracking.
func (h *HTML) Reset() {
h.logger.Debug("HTML.Reset() called.")
h.tableStarted = false
h.tbodyStarted = false
h.tfootStarted = false
h.vMergeTrack = make(map[int]int)
h.trace = nil
}
// Close ensures all open HTML tags (<table>, <tbody>, <tfoot>) are properly closed.
func (h *HTML) Close() error {
if h.w == nil {
return errors.New("HTML Renderer Close called on nil internal w")
}
if h.tableStarted {
h.logger.Debug("HTML.Close() called.")
h.closePreviousSection()
h.logger.Debug("Closing <table> tag.")
_, err := fmt.Fprintln(h.w, "</table>")
h.tableStarted = false
h.tbodyStarted = false
h.tfootStarted = false
h.vMergeTrack = make(map[int]int)
return err
}
h.logger.Debug("HTML.Close() called, but table was not started (no-op).")
return nil
}
+273
View File
@@ -0,0 +1,273 @@
package renderer
import (
"github.com/olekukonko/ll"
"github.com/olekukonko/tablewriter/tw"
)
// Junction handles rendering of table junction points (corners, intersections) with color support.
type Junction struct {
sym tw.Symbols // Symbols used for rendering junctions and lines
ctx tw.Formatting // Current table formatting context
colIdx int // Index of the column being processed
debugging bool // Enables debug logging
borderTint Tint // Colors for border symbols
separatorTint Tint // Colors for separator symbols
logger *ll.Logger
}
type JunctionContext struct {
Symbols tw.Symbols
Ctx tw.Formatting
ColIdx int
Logger *ll.Logger
BorderTint Tint
SeparatorTint Tint
}
// NewJunction initializes a Junction with the given symbols, context, and tints.
// If debug is nil, a no-op debug function is used.
func NewJunction(ctx JunctionContext) *Junction {
return &Junction{
sym: ctx.Symbols,
ctx: ctx.Ctx,
colIdx: ctx.ColIdx,
logger: ctx.Logger.Namespace("junction"),
borderTint: ctx.BorderTint,
separatorTint: ctx.SeparatorTint,
}
}
// getMergeState retrieves the merge state for a specific column in a row, returning an empty state if not found.
func (jr *Junction) getMergeState(row map[int]tw.CellContext, colIdx int) tw.MergeState {
if row == nil || colIdx < 0 {
return tw.MergeState{}
}
return row[colIdx].Merge
}
// GetSegment determines whether to render a colored horizontal line or an empty space based on merge states.
func (jr *Junction) GetSegment() string {
currentMerge := jr.getMergeState(jr.ctx.Row.Current, jr.colIdx)
nextMerge := jr.getMergeState(jr.ctx.Row.Next, jr.colIdx)
vPassThruStrict := (currentMerge.Vertical.Present && nextMerge.Vertical.Present && !currentMerge.Vertical.End && !nextMerge.Vertical.Start) ||
(currentMerge.Hierarchical.Present && nextMerge.Hierarchical.Present && !currentMerge.Hierarchical.End && !nextMerge.Hierarchical.Start)
if vPassThruStrict {
jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Empty segment", jr.colIdx, vPassThruStrict)
return tw.Empty
}
symbol := jr.sym.Row()
coloredSymbol := jr.borderTint.Apply(symbol)
jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Colored row symbol '%s'", jr.colIdx, vPassThruStrict, coloredSymbol)
return coloredSymbol
}
// RenderLeft selects and colors the leftmost junction symbol for the current row line based on position and merges.
func (jr *Junction) RenderLeft() string {
mergeAbove := jr.getMergeState(jr.ctx.Row.Current, 0)
mergeBelow := jr.getMergeState(jr.ctx.Row.Next, 0)
jr.logger.Debugf("RenderLeft: Level=%v, Location=%v, Previous=%v", jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous)
isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil)
if isTopBorder {
symbol := jr.sym.TopLeft()
return jr.borderTint.Apply(symbol)
}
isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter
isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd
if isBottom || isFooter {
symbol := jr.sym.BottomLeft()
return jr.borderTint.Apply(symbol)
}
isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) ||
(mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start)
if isVPassThruStrict {
symbol := jr.sym.Column()
return jr.separatorTint.Apply(symbol)
}
symbol := jr.sym.MidLeft()
return jr.borderTint.Apply(symbol)
}
// RenderRight selects and colors the rightmost junction symbol for the row line based on position, merges, and last column index.
func (jr *Junction) RenderRight(lastColIdx int) string {
jr.logger.Debugf("RenderRight: lastColIdx=%d, Level=%v, Location=%v, Previous=%v", lastColIdx, jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous)
if lastColIdx < 0 {
switch jr.ctx.Level {
case tw.LevelHeader:
symbol := jr.sym.TopRight()
return jr.borderTint.Apply(symbol)
case tw.LevelFooter:
symbol := jr.sym.BottomRight()
return jr.borderTint.Apply(symbol)
default:
if jr.ctx.Row.Location == tw.LocationFirst {
symbol := jr.sym.TopRight()
return jr.borderTint.Apply(symbol)
}
if jr.ctx.Row.Location == tw.LocationEnd {
symbol := jr.sym.BottomRight()
return jr.borderTint.Apply(symbol)
}
symbol := jr.sym.MidRight()
return jr.borderTint.Apply(symbol)
}
}
mergeAbove := jr.getMergeState(jr.ctx.Row.Current, lastColIdx)
mergeBelow := jr.getMergeState(jr.ctx.Row.Next, lastColIdx)
isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil)
if isTopBorder {
symbol := jr.sym.TopRight()
return jr.borderTint.Apply(symbol)
}
isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter
isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd
if isBottom || isFooter {
symbol := jr.sym.BottomRight()
return jr.borderTint.Apply(symbol)
}
isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) ||
(mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start)
if isVPassThruStrict {
symbol := jr.sym.Column()
return jr.separatorTint.Apply(symbol)
}
symbol := jr.sym.MidRight()
return jr.borderTint.Apply(symbol)
}
// RenderJunction selects and colors the junction symbol between two adjacent columns based on merge states and table position.
func (jr *Junction) RenderJunction(leftColIdx, rightColIdx int) string {
mergeCurrentL := jr.getMergeState(jr.ctx.Row.Current, leftColIdx)
mergeCurrentR := jr.getMergeState(jr.ctx.Row.Current, rightColIdx)
mergeNextL := jr.getMergeState(jr.ctx.Row.Next, leftColIdx)
mergeNextR := jr.getMergeState(jr.ctx.Row.Next, rightColIdx)
isSpannedCurrent := mergeCurrentL.Horizontal.Present && !mergeCurrentL.Horizontal.End
isSpannedNext := mergeNextL.Horizontal.Present && !mergeNextL.Horizontal.End
vPassThruLStrict := (mergeCurrentL.Vertical.Present && mergeNextL.Vertical.Present && !mergeCurrentL.Vertical.End && !mergeNextL.Vertical.Start) ||
(mergeCurrentL.Hierarchical.Present && mergeNextL.Hierarchical.Present && !mergeCurrentL.Hierarchical.End && !mergeNextL.Hierarchical.Start)
vPassThruRStrict := (mergeCurrentR.Vertical.Present && mergeNextR.Vertical.Present && !mergeCurrentR.Vertical.End && !mergeNextR.Vertical.Start) ||
(mergeCurrentR.Hierarchical.Present && mergeNextR.Hierarchical.Present && !mergeCurrentR.Hierarchical.End && !mergeNextR.Hierarchical.Start)
isTop := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && len(jr.ctx.Row.Previous) == 0)
isBottom := (jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd) ||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter)
isPreFooter := jr.ctx.Level == tw.LevelFooter && (jr.ctx.Row.Position == tw.Row || jr.ctx.Row.Position == tw.Header)
if isTop {
if isSpannedNext {
symbol := jr.sym.Row()
return jr.borderTint.Apply(symbol)
}
symbol := jr.sym.TopMid()
return jr.borderTint.Apply(symbol)
}
if isBottom {
if vPassThruLStrict && vPassThruRStrict {
symbol := jr.sym.Column()
return jr.separatorTint.Apply(symbol)
}
if vPassThruLStrict {
symbol := jr.sym.MidLeft()
return jr.borderTint.Apply(symbol)
}
if vPassThruRStrict {
symbol := jr.sym.MidRight()
return jr.borderTint.Apply(symbol)
}
if isSpannedCurrent {
symbol := jr.sym.Row()
return jr.borderTint.Apply(symbol)
}
symbol := jr.sym.BottomMid()
return jr.borderTint.Apply(symbol)
}
if isPreFooter {
if vPassThruLStrict && vPassThruRStrict {
symbol := jr.sym.Column()
return jr.separatorTint.Apply(symbol)
}
if vPassThruLStrict {
symbol := jr.sym.MidLeft()
return jr.borderTint.Apply(symbol)
}
if vPassThruRStrict {
symbol := jr.sym.MidRight()
return jr.borderTint.Apply(symbol)
}
if mergeCurrentL.Horizontal.Present {
if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && !mergeCurrentR.Horizontal.End {
jr.logger.Debugf("Footer separator: H-merge continues from col %d to %d (mid-span), using BottomMid", leftColIdx, rightColIdx)
symbol := jr.sym.BottomMid()
return jr.borderTint.Apply(symbol)
}
if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && mergeCurrentR.Horizontal.End {
jr.logger.Debugf("Footer separator: H-merge ends at col %d, using BottomMid", rightColIdx)
symbol := jr.sym.BottomMid()
return jr.borderTint.Apply(symbol)
}
if mergeCurrentL.Horizontal.End && !mergeCurrentR.Horizontal.Present {
jr.logger.Debugf("Footer separator: H-merge ends at col %d, next col %d not merged, using Center", leftColIdx, rightColIdx)
symbol := jr.sym.Center()
return jr.borderTint.Apply(symbol)
}
}
if isSpannedNext {
symbol := jr.sym.BottomMid()
return jr.borderTint.Apply(symbol)
}
if isSpannedCurrent {
symbol := jr.sym.TopMid()
return jr.borderTint.Apply(symbol)
}
symbol := jr.sym.Center()
return jr.borderTint.Apply(symbol)
}
if vPassThruLStrict && vPassThruRStrict {
symbol := jr.sym.Column()
return jr.separatorTint.Apply(symbol)
}
if vPassThruLStrict {
symbol := jr.sym.MidLeft()
return jr.borderTint.Apply(symbol)
}
if vPassThruRStrict {
symbol := jr.sym.MidRight()
return jr.borderTint.Apply(symbol)
}
if isSpannedCurrent && isSpannedNext {
symbol := jr.sym.Row()
return jr.borderTint.Apply(symbol)
}
if isSpannedCurrent {
symbol := jr.sym.TopMid()
return jr.borderTint.Apply(symbol)
}
if isSpannedNext {
symbol := jr.sym.BottomMid()
return jr.borderTint.Apply(symbol)
}
symbol := jr.sym.Center()
return jr.borderTint.Apply(symbol)
}
+415
View File
@@ -0,0 +1,415 @@
package renderer
import (
"io"
"strings"
"github.com/olekukonko/ll"
"github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/olekukonko/tablewriter/tw"
)
// Markdown renders tables in Markdown format with customizable settings.
type Markdown struct {
config tw.Rendition // Rendering configuration
logger *ll.Logger // Debug trace messages
alignment tw.Alignment // alias of []tw.Align
w io.Writer
}
// NewMarkdown initializes a Markdown renderer with defaults tailored for Markdown (e.g., pipes, header separator).
// Only the first config is used if multiple are provided.
func NewMarkdown(configs ...tw.Rendition) *Markdown {
cfg := defaultBlueprint()
// Configure Markdown-specific defaults
cfg.Symbols = tw.NewSymbols(tw.StyleMarkdown)
cfg.Borders = tw.Border{Left: tw.On, Right: tw.On, Top: tw.Off, Bottom: tw.Off}
cfg.Settings.Separators.BetweenColumns = tw.On
cfg.Settings.Separators.BetweenRows = tw.Off
cfg.Settings.Lines.ShowHeaderLine = tw.On
cfg.Settings.Lines.ShowTop = tw.Off
cfg.Settings.Lines.ShowBottom = tw.Off
cfg.Settings.Lines.ShowFooterLine = tw.Off
// cfg.Settings.TrimWhitespace = tw.On
// Apply user overrides
if len(configs) > 0 {
cfg = mergeMarkdownConfig(cfg, configs[0])
}
return &Markdown{config: cfg, logger: ll.New("markdown").Disable()}
}
// mergeMarkdownConfig combines user-provided config with Markdown defaults, enforcing Markdown-specific settings.
func mergeMarkdownConfig(defaults, overrides tw.Rendition) tw.Rendition {
if overrides.Borders.Left != 0 {
defaults.Borders.Left = overrides.Borders.Left
}
if overrides.Borders.Right != 0 {
defaults.Borders.Right = overrides.Borders.Right
}
if overrides.Symbols != nil {
defaults.Symbols = overrides.Symbols
}
defaults.Settings = mergeSettings(defaults.Settings, overrides.Settings)
// Enforce Markdown requirements
defaults.Settings.Lines.ShowHeaderLine = tw.On
defaults.Settings.Separators.BetweenColumns = tw.On
// defaults.Settings.TrimWhitespace = tw.On
return defaults
}
func (m *Markdown) Logger(logger *ll.Logger) {
m.logger = logger.Namespace("markdown")
}
// Config returns the renderer's current configuration.
func (m *Markdown) Config() tw.Rendition {
return m.config
}
// Header renders the Markdown table header and its separator line.
func (m *Markdown) Header(headers [][]string, ctx tw.Formatting) {
m.resolveAlignment(ctx)
if len(headers) == 0 || len(headers[0]) == 0 {
m.logger.Debug("Header: No headers to render")
return
}
m.logger.Debugf("Rendering header with %d lines, widths=%v, current=%v, next=%v", len(headers), ctx.Row.Widths, ctx.Row.Current, ctx.Row.Next)
// Render header content
m.renderMarkdownLine(headers[0], ctx, false)
// Render separator if enabled
if m.config.Settings.Lines.ShowHeaderLine.Enabled() {
sepCtx := ctx
sepCtx.Row.Widths = ctx.Row.Widths
sepCtx.Row.Current = ctx.Row.Current
sepCtx.Row.Previous = ctx.Row.Current
sepCtx.IsSubRow = true
m.renderMarkdownLine(nil, sepCtx, true)
}
}
// Row renders a Markdown table data row.
func (m *Markdown) Row(row []string, ctx tw.Formatting) {
m.resolveAlignment(ctx)
m.logger.Debugf("Rendering row with data=%v, widths=%v, previous=%v, current=%v, next=%v", row, ctx.Row.Widths, ctx.Row.Previous, ctx.Row.Current, ctx.Row.Next)
m.renderMarkdownLine(row, ctx, false)
}
// Footer renders the Markdown table footer.
func (m *Markdown) Footer(footers [][]string, ctx tw.Formatting) {
m.resolveAlignment(ctx)
if len(footers) == 0 || len(footers[0]) == 0 {
m.logger.Debug("Footer: No footers to render")
return
}
m.logger.Debugf("Rendering footer with %d lines, widths=%v, previous=%v, current=%v, next=%v",
len(footers), ctx.Row.Widths, ctx.Row.Previous, ctx.Row.Current, ctx.Row.Next)
m.renderMarkdownLine(footers[0], ctx, false)
}
// Line is a no-op for Markdown, as only the header separator is rendered (handled by Header).
func (m *Markdown) Line(ctx tw.Formatting) {
m.logger.Debugf("Line: Generic Line call received (pos: %s, loc: %s). Markdown ignores these.", ctx.Row.Position, ctx.Row.Location)
}
// Reset clears the renderer's internal state, including debug traces.
func (m *Markdown) Reset() {
m.logger.Info("Reset: Cleared debug trace")
}
func (m *Markdown) Start(w io.Writer) error {
m.w = w
m.logger.Warn("Markdown.Start() called (no-op).")
return nil
}
func (m *Markdown) Close() error {
m.logger.Warn("Markdown.Close() called (no-op).")
return nil
}
func (m *Markdown) resolveAlignment(ctx tw.Formatting) tw.Alignment {
if len(m.alignment) != 0 {
return m.alignment
}
// get total columns
total := len(ctx.Row.Current)
// build default alignment
for i := 0; i < total; i++ {
m.alignment = append(m.alignment, tw.AlignNone) // Default to AlignNone
}
// add per column alignment if it exists
for i := 0; i < total; i++ {
m.alignment[i] = ctx.Row.Current[i].Align
}
m.logger.Debugf(" → Align Resolved %s", m.alignment)
return m.alignment
}
// formatCell formats a Markdown cell's content with padding and alignment, ensuring at least 3 characters wide.
func (m *Markdown) formatCell(content string, width int, align tw.Align, padding tw.Padding) string {
// if m.config.Settings.TrimWhitespace.Enabled() {
// content = strings.TrimSpace(content)
//}
contentVisualWidth := twwidth.Width(content)
// Use specified padding characters or default to spaces
padLeftChar := padding.Left
if padLeftChar == tw.Empty {
padLeftChar = tw.Space
}
padRightChar := padding.Right
if padRightChar == tw.Empty {
padRightChar = tw.Space
}
// Calculate padding widths
padLeftCharWidth := twwidth.Width(padLeftChar)
padRightCharWidth := twwidth.Width(padRightChar)
minWidth := tw.Max(3, contentVisualWidth+padLeftCharWidth+padRightCharWidth)
targetWidth := tw.Max(width, minWidth)
// Calculate padding
totalPaddingNeeded := max(targetWidth-contentVisualWidth, 0)
var leftPadStr, rightPadStr string
switch align {
case tw.AlignRight:
leftPadCount := tw.Max(0, totalPaddingNeeded-padRightCharWidth)
rightPadCount := totalPaddingNeeded - leftPadCount
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
case tw.AlignCenter:
leftPadCount := totalPaddingNeeded / 2
rightPadCount := totalPaddingNeeded - leftPadCount
if leftPadCount < padLeftCharWidth && totalPaddingNeeded >= padLeftCharWidth+padRightCharWidth {
leftPadCount = padLeftCharWidth
rightPadCount = totalPaddingNeeded - leftPadCount
}
if rightPadCount < padRightCharWidth && totalPaddingNeeded >= padLeftCharWidth+padRightCharWidth {
rightPadCount = padRightCharWidth
leftPadCount = totalPaddingNeeded - rightPadCount
}
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
default: // AlignLeft
rightPadCount := tw.Max(0, totalPaddingNeeded-padLeftCharWidth)
leftPadCount := totalPaddingNeeded - rightPadCount
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
}
// Build result
result := leftPadStr + content + rightPadStr
// Adjust width if needed
finalWidth := twwidth.Width(result)
if finalWidth != targetWidth {
m.logger.Debugf("Markdown formatCell MISMATCH: content='%s', target_w=%d, paddingL='%s', paddingR='%s', align=%s -> result='%s', result_w=%d",
content, targetWidth, padding.Left, padding.Right, align, result, finalWidth)
adjNeeded := targetWidth - finalWidth
if adjNeeded > 0 {
adjStr := strings.Repeat(tw.Space, adjNeeded)
switch align {
case tw.AlignRight:
result = adjStr + result
case tw.AlignCenter:
leftAdj := adjNeeded / 2
rightAdj := adjNeeded - leftAdj
result = strings.Repeat(tw.Space, leftAdj) + result + strings.Repeat(tw.Space, rightAdj)
default:
result += adjStr
}
} else {
result = twwidth.Truncate(result, targetWidth)
}
m.logger.Debugf("Markdown formatCell Corrected: target_w=%d, result='%s', new_w=%d", targetWidth, result, twwidth.Width(result))
}
m.logger.Debugf("Markdown formatCell: content='%s', width=%d, align=%s, paddingL='%s', paddingR='%s' -> '%s' (target %d)",
content, width, align, padding.Left, padding.Right, result, targetWidth)
return result
}
// formatSeparator generates a Markdown separator (e.g., `---`, `:--`, `:-:`) with alignment indicators.
func (m *Markdown) formatSeparator(width int, align tw.Align) string {
targetWidth := tw.Max(3, width)
var sb strings.Builder
switch align {
case tw.AlignLeft:
sb.WriteRune(':')
sb.WriteString(strings.Repeat("-", targetWidth-1))
case tw.AlignRight:
sb.WriteString(strings.Repeat("-", targetWidth-1))
sb.WriteRune(':')
case tw.AlignCenter:
sb.WriteRune(':')
sb.WriteString(strings.Repeat("-", targetWidth-2))
sb.WriteRune(':')
case tw.AlignNone:
sb.WriteString(strings.Repeat("-", targetWidth))
default:
sb.WriteString(strings.Repeat("-", targetWidth)) // Fallback
}
result := sb.String()
currentLen := twwidth.Width(result)
if currentLen < targetWidth {
result += strings.Repeat("-", targetWidth-currentLen)
} else if currentLen > targetWidth {
result = twwidth.Truncate(result, targetWidth)
}
m.logger.Debugf("Markdown formatSeparator: width=%d, align=%s -> '%s'", width, align, result)
return result
}
// renderMarkdownLine renders a single Markdown line (header, row, footer, or separator) with pipes and alignment.
func (m *Markdown) renderMarkdownLine(line []string, ctx tw.Formatting, isHeaderSep bool) {
numCols := 0
if len(ctx.Row.Widths) > 0 {
maxKey := -1
for k := range ctx.Row.Widths {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
} else if len(ctx.Row.Current) > 0 {
maxKey := -1
for k := range ctx.Row.Current {
if k > maxKey {
maxKey = k
}
}
numCols = maxKey + 1
} else if len(line) > 0 && !isHeaderSep {
numCols = len(line)
}
if numCols == 0 && !isHeaderSep {
m.logger.Debug("renderMarkdownLine: Skipping line with zero columns.")
return
}
var output strings.Builder
prefix := m.config.Symbols.Column()
if m.config.Borders.Left == tw.Off {
prefix = tw.Empty
}
suffix := m.config.Symbols.Column()
if m.config.Borders.Right == tw.Off {
suffix = tw.Empty
}
separator := m.config.Symbols.Column()
output.WriteString(prefix)
colIndex := 0
separatorWidth := twwidth.Width(separator)
for colIndex < numCols {
cellCtx, ok := ctx.Row.Current[colIndex]
align := m.alignment[colIndex]
defaultPadding := tw.Padding{Left: tw.Space, Right: tw.Space}
if !ok {
cellCtx = tw.CellContext{
Data: tw.Empty, Align: align, Padding: defaultPadding,
Width: ctx.Row.Widths.Get(colIndex), Merge: tw.MergeState{},
}
} else if !cellCtx.Padding.Paddable() {
cellCtx.Padding = defaultPadding
}
// Add separator
isContinuation := ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start
if colIndex > 0 && !isContinuation {
output.WriteString(separator)
m.logger.Debugf("renderMarkdownLine: Added separator '%s' before col %d", separator, colIndex)
}
// Calculate width and span
span := 1
visualWidth := 0
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
if isHMergeStart {
span = cellCtx.Merge.Horizontal.Span
totalWidth := 0
for k := 0; k < span && colIndex+k < numCols; k++ {
colWidth := max(ctx.NormalizedWidths.Get(colIndex+k), 0)
totalWidth += colWidth
if k > 0 && separatorWidth > 0 {
totalWidth += separatorWidth
}
}
visualWidth = totalWidth
m.logger.Debugf("renderMarkdownLine: HMerge col %d, span %d, visualWidth %d", colIndex, span, visualWidth)
} else {
visualWidth = ctx.Row.Widths.Get(colIndex)
m.logger.Debugf("renderMarkdownLine: Regular col %d, visualWidth %d", colIndex, visualWidth)
}
if visualWidth < 0 {
visualWidth = 0
}
// Render segment
if isContinuation {
m.logger.Debugf("renderMarkdownLine: Skipping col %d (HMerge continuation)", colIndex)
} else {
var formattedSegment string
if isHeaderSep {
// Use header's alignment from ctx.Row.Previous
headerAlign := align
if headerCellCtx, headerOK := ctx.Row.Previous[colIndex]; headerOK {
headerAlign = headerCellCtx.Align
// Preserve tw.AlignNone for separator
if headerAlign != tw.AlignNone && (headerAlign == tw.Empty || headerAlign == tw.Skip) {
headerAlign = tw.AlignCenter
}
}
formattedSegment = m.formatSeparator(visualWidth, headerAlign)
} else {
content := tw.Empty
if colIndex < len(line) {
content = line[colIndex]
}
// For rows, use the header's alignment if specified
rowAlign := align
if headerCellCtx, headerOK := ctx.Row.Previous[colIndex]; headerOK && !isHeaderSep {
if headerCellCtx.Align != tw.AlignNone && headerCellCtx.Align != tw.Empty {
rowAlign = headerCellCtx.Align
}
}
if rowAlign == tw.AlignNone || rowAlign == tw.Empty {
switch ctx.Row.Position {
case tw.Header:
rowAlign = tw.AlignCenter
case tw.Footer:
rowAlign = tw.AlignRight
default:
rowAlign = tw.AlignLeft
}
m.logger.Debugf("renderMarkdownLine: Col %d using default align '%s'", colIndex, rowAlign)
}
formattedSegment = m.formatCell(content, visualWidth, rowAlign, cellCtx.Padding)
}
output.WriteString(formattedSegment)
m.logger.Debugf("renderMarkdownLine: Wrote col %d (span %d, width %d): '%s'", colIndex, span, visualWidth, formattedSegment)
}
colIndex += span
}
output.WriteString(suffix)
output.WriteString(tw.NewLine)
m.w.Write([]byte(output.String()))
m.logger.Debugf("renderMarkdownLine: Final line: %s", strings.TrimSuffix(output.String(), tw.NewLine))
}
+462
View File
@@ -0,0 +1,462 @@
package renderer
import (
"io"
"slices"
"strings"
"github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/olekukonko/ll"
"github.com/olekukonko/tablewriter/tw"
)
// OceanConfig defines configuration specific to the Ocean renderer.
type OceanConfig struct{}
// Ocean is a streaming table renderer that writes ASCII tables.
type Ocean struct {
config tw.Rendition
oceanConfig OceanConfig
fixedWidths tw.Mapper[int, int]
widthsFinalized bool
tableOutputStarted bool
headerContentRendered bool // True if actual header *content* has been rendered by Ocean.Header
logger *ll.Logger
w io.Writer
}
func NewOcean(oceanConfig ...OceanConfig) *Ocean {
cfg := defaultOceanRendererConfig()
oCfg := OceanConfig{}
if len(oceanConfig) > 0 {
// Apply user-provided OceanConfig if necessary
}
r := &Ocean{
config: cfg,
oceanConfig: oCfg,
fixedWidths: tw.NewMapper[int, int](),
logger: ll.New("ocean").Disable(),
}
r.resetState()
return r
}
func (o *Ocean) resetState() {
o.fixedWidths = tw.NewMapper[int, int]()
o.widthsFinalized = false
o.tableOutputStarted = false
o.headerContentRendered = false
o.logger.Debug("State reset.")
}
func (o *Ocean) Logger(logger *ll.Logger) {
o.logger = logger.Namespace("ocean")
}
func (o *Ocean) Config() tw.Rendition {
return o.config
}
func (o *Ocean) tryFinalizeWidths(ctx tw.Formatting) {
if o.widthsFinalized {
return
}
if ctx.Row.Widths != nil && ctx.Row.Widths.Len() > 0 {
o.fixedWidths = ctx.Row.Widths.Clone()
o.widthsFinalized = true
o.logger.Debugf("Widths finalized from context: %v", o.fixedWidths)
} else {
o.logger.Warn("Attempted to finalize widths, but no width data in context.")
}
}
func (o *Ocean) Start(w io.Writer) error {
o.w = w
o.logger.Debug("Start() called.")
o.resetState()
// Top border is drawn by the first component (Header or Row) that has widths
// OR by an explicit Line() call from table.go's batch renderer.
return nil
}
// renderTopBorderIfNeeded is called by Header or Row if it's the first to render
// and tableOutputStarted is false.
func (o *Ocean) renderTopBorderIfNeeded(currentPosition tw.Position, ctx tw.Formatting) {
if !o.tableOutputStarted && o.widthsFinalized {
// This renderer's config for Top border
if o.config.Borders.Top.Enabled() && o.config.Settings.Lines.ShowTop.Enabled() {
o.logger.Debugf("Ocean itself rendering top border (triggered by %s)", currentPosition)
lineCtx := tw.Formatting{ // Construct specific context for this line
Row: tw.RowContext{
Widths: o.fixedWidths,
Location: tw.LocationFirst,
Position: currentPosition,
Next: ctx.Row.Current, // The actual first content is "Next" to the top border
},
Level: tw.LevelHeader,
}
o.Line(lineCtx)
o.tableOutputStarted = true
}
}
}
func (o *Ocean) Header(headers [][]string, ctx tw.Formatting) {
o.logger.Debugf("Ocean.Header called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(headers))
if !o.widthsFinalized {
o.tryFinalizeWidths(ctx)
}
if !o.widthsFinalized {
o.logger.Error("Ocean.Header: Cannot render content, widths are not finalized.")
return
}
if len(headers) > 0 && len(headers[0]) > 0 {
for i, headerLineData := range headers {
currentLineCtx := ctx
currentLineCtx.Row.Widths = o.fixedWidths
if i > 0 {
currentLineCtx.IsSubRow = true
}
o.renderContentLine(currentLineCtx, headerLineData)
o.tableOutputStarted = true // Content was written
}
o.headerContentRendered = true
} else {
o.logger.Debug("Ocean.Header: No actual header content lines to render.")
}
}
func (o *Ocean) Row(row []string, ctx tw.Formatting) {
o.logger.Debugf("Ocean.Row called: IsSubRow=%v, Location=%v, DataItems=%d", ctx.IsSubRow, ctx.Row.Location, len(row))
if !o.widthsFinalized {
o.tryFinalizeWidths(ctx)
}
if !o.widthsFinalized {
o.logger.Error("Ocean.Row: Cannot render content, widths are not finalized.")
return
}
ctx.Row.Widths = o.fixedWidths
o.renderContentLine(ctx, row)
o.tableOutputStarted = true
}
func (o *Ocean) Footer(footers [][]string, ctx tw.Formatting) {
o.logger.Debugf("Ocean.Footer called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(footers))
if !o.widthsFinalized {
o.tryFinalizeWidths(ctx)
o.logger.Warn("Ocean.Footer: Widths finalized at Footer stage (unusual).")
}
if !o.widthsFinalized {
o.logger.Error("Ocean.Footer: Cannot render content, widths are not finalized.")
return
}
if len(footers) > 0 && len(footers[0]) > 0 {
for i, footerLineData := range footers {
currentLineCtx := ctx
currentLineCtx.Row.Widths = o.fixedWidths
if i > 0 {
currentLineCtx.IsSubRow = true
}
o.renderContentLine(currentLineCtx, footerLineData)
o.tableOutputStarted = true
}
} else {
o.logger.Debug("Ocean.Footer: No actual footer content lines to render.")
}
}
func (o *Ocean) Line(ctx tw.Formatting) {
if !o.widthsFinalized {
o.tryFinalizeWidths(ctx)
if !o.widthsFinalized {
o.logger.Error("Ocean.Line: Called but widths could not be finalized. Skipping line rendering.")
return
}
}
// Ensure Line uses the consistent fixedWidths for drawing
ctx.Row.Widths = o.fixedWidths
o.logger.Debugf("Ocean.Line DRAWING: Level=%v, Loc=%s, Pos=%s, IsSubRow=%t, WidthsLen=%d", ctx.Level, ctx.Row.Location, ctx.Row.Position, ctx.IsSubRow, ctx.Row.Widths.Len())
jr := NewJunction(JunctionContext{
Symbols: o.config.Symbols,
Ctx: ctx,
ColIdx: 0,
Logger: o.logger,
BorderTint: Tint{},
SeparatorTint: Tint{},
})
var line strings.Builder
sortedColIndices := o.fixedWidths.SortedKeys()
if len(sortedColIndices) == 0 {
drewEmptyBorders := false
if o.config.Borders.Left.Enabled() {
line.WriteString(jr.RenderLeft())
drewEmptyBorders = true
}
if o.config.Borders.Right.Enabled() {
line.WriteString(jr.RenderRight(-1))
drewEmptyBorders = true
}
if drewEmptyBorders {
line.WriteString(tw.NewLine)
o.w.Write([]byte(line.String()))
o.logger.Debug("Line: Drew empty table borders based on Line call.")
} else {
o.logger.Debug("Line: Handled empty table case (no columns, no borders).")
}
o.tableOutputStarted = drewEmptyBorders // A line counts as output
return
}
if o.config.Borders.Left.Enabled() {
line.WriteString(jr.RenderLeft())
}
for i, colIdx := range sortedColIndices {
jr.colIdx = colIdx
segmentChar := jr.GetSegment()
colVisualWidth := o.fixedWidths.Get(colIdx)
if colVisualWidth <= 0 {
// Still need to consider separators after zero-width columns
} else {
if segmentChar == tw.Empty {
segmentChar = o.config.Symbols.Row()
}
segmentDisplayWidth := twwidth.Width(segmentChar)
if segmentDisplayWidth <= 0 {
segmentDisplayWidth = 1
}
repeatCount := 0
if colVisualWidth > 0 {
repeatCount = colVisualWidth / segmentDisplayWidth
if repeatCount == 0 {
repeatCount = 1
}
}
line.WriteString(strings.Repeat(segmentChar, repeatCount))
}
if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
nextColIdx := sortedColIndices[i+1]
line.WriteString(jr.RenderJunction(colIdx, nextColIdx))
}
}
if o.config.Borders.Right.Enabled() {
lastColIdx := sortedColIndices[len(sortedColIndices)-1]
line.WriteString(jr.RenderRight(lastColIdx))
}
line.WriteString(tw.NewLine)
o.w.Write([]byte(line.String()))
o.tableOutputStarted = true
o.logger.Debugf("Line rendered by explicit call: %s", strings.TrimSuffix(line.String(), tw.NewLine))
}
func (o *Ocean) Close() error {
o.logger.Debug("Ocean.Close() called.")
o.resetState()
return nil
}
func (o *Ocean) renderContentLine(ctx tw.Formatting, lineData []string) {
if !o.widthsFinalized || o.fixedWidths.Len() == 0 {
o.logger.Error("renderContentLine: Cannot render, fixedWidths not set or empty.")
return
}
var output strings.Builder
if o.config.Borders.Left.Enabled() {
output.WriteString(o.config.Symbols.Column())
}
sortedColIndices := o.fixedWidths.SortedKeys()
for i, colIdx := range sortedColIndices {
cellVisualWidth := o.fixedWidths.Get(colIdx)
cellContent := tw.Empty
align := tw.AlignDefault
padding := tw.Padding{Left: tw.Space, Right: tw.Space}
switch ctx.Row.Position {
case tw.Header:
align = tw.AlignCenter
case tw.Footer:
align = tw.AlignRight
default:
align = tw.AlignLeft
}
cellCtx, hasCellCtx := ctx.Row.Current[colIdx]
if hasCellCtx {
cellContent = cellCtx.Data
if cellCtx.Align.Validate() == nil && cellCtx.Align != tw.AlignNone {
align = cellCtx.Align
}
if cellCtx.Padding.Paddable() {
padding = cellCtx.Padding
}
} else if colIdx < len(lineData) {
cellContent = lineData[colIdx]
}
actualCellWidthToRender := cellVisualWidth
isHMergeContinuation := false
if hasCellCtx && cellCtx.Merge.Horizontal.Present {
if cellCtx.Merge.Horizontal.Start {
hSpan := cellCtx.Merge.Horizontal.Span
if hSpan <= 0 {
hSpan = 1
}
currentMergeTotalRenderWidth := 0
for k := 0; k < hSpan; k++ {
idxInMergeSpan := colIdx + k
// Check if idxInMergeSpan is a defined column in fixedWidths
foundInFixedWidths := false
if slices.Contains(sortedColIndices, idxInMergeSpan) {
currentMergeTotalRenderWidth += o.fixedWidths.Get(idxInMergeSpan)
foundInFixedWidths = true
}
if !foundInFixedWidths && idxInMergeSpan <= sortedColIndices[len(sortedColIndices)-1] {
o.logger.Debugf("Col %d in HMerge span not found in fixedWidths, assuming 0-width contribution.", idxInMergeSpan)
}
if k < hSpan-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
currentMergeTotalRenderWidth += twwidth.Width(o.config.Symbols.Column())
}
}
actualCellWidthToRender = currentMergeTotalRenderWidth
} else {
isHMergeContinuation = true
}
}
if isHMergeContinuation {
o.logger.Debugf("renderContentLine: Col %d is HMerge continuation, skipping content render.", colIdx)
// The separator logic below needs to handle this correctly.
// If the *previous* column was the start of a merge that spans *this* column,
// then the separator after the previous column should have been suppressed.
} else if actualCellWidthToRender > 0 {
formattedCell := o.formatCellContent(cellContent, actualCellWidthToRender, padding, align)
output.WriteString(formattedCell)
} else {
o.logger.Debugf("renderContentLine: col %d has 0 render width, writing no content.", colIdx)
}
// Add column separator if:
// 1. It's not the last column in sortedColIndices
// 2. Separators are enabled
// 3. This cell is NOT a horizontal merge start that spans over the next column.
if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
shouldAddSeparator := true
if hasCellCtx && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
// If this merge start spans beyond the current colIdx into the next sortedColIndex
if colIdx+cellCtx.Merge.Horizontal.Span > sortedColIndices[i+1] {
shouldAddSeparator = false // Separator is part of the merged cell's width
o.logger.Debugf("renderContentLine: Suppressed separator after HMerge col %d.", colIdx)
}
}
if shouldAddSeparator {
output.WriteString(o.config.Symbols.Column())
}
}
}
if o.config.Borders.Right.Enabled() {
output.WriteString(o.config.Symbols.Column())
}
output.WriteString(tw.NewLine)
o.w.Write([]byte(output.String()))
o.logger.Debugf("Content line rendered: %s", strings.TrimSuffix(output.String(), tw.NewLine))
}
func (o *Ocean) formatCellContent(content string, cellVisualWidth int, padding tw.Padding, align tw.Align) string {
if cellVisualWidth <= 0 {
return tw.Empty
}
contentDisplayWidth := twwidth.Width(content)
padLeftChar := padding.Left
if padLeftChar == tw.Empty {
padLeftChar = tw.Space
}
padRightChar := padding.Right
if padRightChar == tw.Empty {
padRightChar = tw.Space
}
padLeftDisplayWidth := twwidth.Width(padLeftChar)
padRightDisplayWidth := twwidth.Width(padRightChar)
spaceForContentAndAlignment := max(cellVisualWidth-padLeftDisplayWidth-padRightDisplayWidth, 0)
if contentDisplayWidth > spaceForContentAndAlignment {
content = twwidth.Truncate(content, spaceForContentAndAlignment)
contentDisplayWidth = twwidth.Width(content)
}
remainingSpace := max(spaceForContentAndAlignment-contentDisplayWidth, 0)
var PL, PR string
switch align {
case tw.AlignRight:
PL = strings.Repeat(tw.Space, remainingSpace)
case tw.AlignCenter:
leftSpaces := remainingSpace / 2
rightSpaces := remainingSpace - leftSpaces
PL = strings.Repeat(tw.Space, leftSpaces)
PR = strings.Repeat(tw.Space, rightSpaces)
default:
PR = strings.Repeat(tw.Space, remainingSpace)
}
var sb strings.Builder
sb.WriteString(padLeftChar)
sb.WriteString(PL)
sb.WriteString(content)
sb.WriteString(PR)
sb.WriteString(padRightChar)
currentFormattedWidth := twwidth.Width(sb.String())
if currentFormattedWidth < cellVisualWidth {
if align == tw.AlignRight {
prefixSpaces := strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth)
finalStr := prefixSpaces + sb.String()
sb.Reset()
sb.WriteString(finalStr)
} else {
sb.WriteString(strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth))
}
} else if currentFormattedWidth > cellVisualWidth {
tempStr := sb.String()
sb.Reset()
sb.WriteString(twwidth.Truncate(tempStr, cellVisualWidth))
o.logger.Warnf("formatCellContent: Final string '%s' (width %d) exceeded target %d. Force truncated.", tempStr, currentFormattedWidth, cellVisualWidth)
}
return sb.String()
}
func (o *Ocean) Rendition(config tw.Rendition) {
o.config = mergeRendition(o.config, config)
o.logger.Debugf("Blueprint.Rendition updated. New internal config: %+v", o.config)
}
// Ensure Blueprint implements tw.Renditioning
var _ tw.Renditioning = (*Ocean)(nil)
+702
View File
@@ -0,0 +1,702 @@
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, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2f" height="%.2f"></svg>`, 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, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2f" height="%.2f" font-family="%s" font-size="%.2f">`,
totalWidth, totalHeight, html.EscapeString(s.config.FontFamily), s.config.FontSize)
fmt.Fprintln(s.w)
fmt.Fprintln(s.w, "<style>text { stroke: none; }</style>")
if _, err := io.WriteString(s.w, s.svgElements.String()); err != nil {
fmt.Fprintln(s.w, `</svg>`)
return fmt.Errorf("failed to write SVG elements: %w", err)
}
if s.maxCols > 0 || s.numVisualRowsDrawn > 0 {
fmt.Fprintf(s.w, ` <g class="table-borders" stroke="%s" stroke-width="%.2f" stroke-linecap="square">`,
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, ` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />%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, ` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />%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, " </g>")
}
fmt.Fprintln(s.w, `</svg>`)
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, ` <rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>%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, ` <text x="%.2f" y="%.2f" fill="%s" text-anchor="%s" dominant-baseline="middle">%s</text>%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)
}
+25
View File
@@ -0,0 +1,25 @@
package renderer
import "github.com/fatih/color"
// Colors is a slice of color attributes for use with fatih/color, such as color.FgWhite or color.Bold.
type Colors []color.Attribute
// Tint defines foreground and background color settings for table elements, with optional per-column overrides.
type Tint struct {
FG Colors // Foreground color attributes
BG Colors // Background color attributes
Columns []Tint // Per-column color settings
}
// Apply applies the Tint's foreground and background colors to the given text, returning the text unchanged if no colors are set.
func (t Tint) Apply(text string) string {
if len(t.FG) == 0 && len(t.BG) == 0 {
return text
}
// Combine foreground and background colors
combinedColors := append(t.FG, t.BG...)
// Create a color function and apply it to the text
c := color.New(combinedColors...).SprintFunc()
return c(text)
}