Files
opencloud/vendor/github.com/olekukonko/tablewriter/tablewriter.go
dependabot[bot] f4eaa8bd5b build(deps): bump github.com/olekukonko/tablewriter from 1.0.9 to 1.1.0
Bumps [github.com/olekukonko/tablewriter](https://github.com/olekukonko/tablewriter) from 1.0.9 to 1.1.0.
- [Commits](https://github.com/olekukonko/tablewriter/compare/v1.0.9...v1.1.0)

---
updated-dependencies:
- dependency-name: github.com/olekukonko/tablewriter
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-30 13:07:46 +02:00

2273 lines
83 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tablewriter
import (
"bytes"
"io"
"math"
"os"
"reflect"
"runtime"
"strings"
"sync"
"github.com/olekukonko/errors"
"github.com/olekukonko/ll"
"github.com/olekukonko/ll/lh"
"github.com/olekukonko/tablewriter/pkg/twwarp"
"github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
)
// Table represents a table instance with content and rendering capabilities.
type Table struct {
writer io.Writer // Destination for table output
counters []tw.Counter // Counters for indices
rows [][][]string // Row data, supporting multi-line cells
headers [][]string // Header content
footers [][]string // Footer content
headerWidths tw.Mapper[int, int] // Computed widths for header columns
rowWidths tw.Mapper[int, int] // Computed widths for row columns
footerWidths tw.Mapper[int, int] // Computed widths for footer columns
renderer tw.Renderer // Engine for rendering the table
config Config // Table configuration settings
stringer any // Function to convert rows to strings
newLine string // Newline character (e.g., "\n")
hasPrinted bool // Indicates if the table has been rendered
logger *ll.Logger // Debug trace log
trace *bytes.Buffer // Debug trace log
// Caption fields
caption tw.Caption
// streaming
streamWidths tw.Mapper[int, int] // Fixed column widths for streaming mode, calculated once
streamFooterLines [][]string // Processed footer lines for streaming, stored until Close().
headerRendered bool // Tracks if header has been rendered in streaming mode
firstRowRendered bool // Tracks if the first data row has been rendered in streaming mode
lastRenderedLineContent []string // Content of the very last line rendered (for Previous context in streaming)
lastRenderedMergeState tw.Mapper[int, tw.MergeState] // Merge state of the very last line rendered (for Previous context in streaming)
lastRenderedPosition tw.Position // Position (Header/Row/Footer/Separator) of the last line rendered (for Previous context in streaming)
streamNumCols int // The derived number of columns in streaming mode
streamRowCounter int // Counter for rows rendered in streaming mode (0-indexed logical rows)
// cache
stringerCache map[reflect.Type]reflect.Value // Cache for stringer reflection
stringerCacheMu sync.RWMutex // Mutex for thread-safe cache access
stringerCacheEnabled bool // Flag to enable/disable caching
batchRenderNumCols int
isBatchRenderNumColsSet bool
}
// renderContext holds the core state for rendering the table.
type renderContext struct {
table *Table // Reference to the table instance
renderer tw.Renderer // Renderer instance
cfg tw.Rendition // Renderer configuration
numCols int // Total number of columns
headerLines [][]string // Processed header lines
rowLines [][][]string // Processed row lines
footerLines [][]string // Processed footer lines
widths tw.Mapper[tw.Position, tw.Mapper[int, int]] // Widths per section
footerPrepared bool // Tracks if footer is prepared
emptyColumns []bool // Tracks which original columns are empty (true if empty)
visibleColCount int // Count of columns that are NOT empty
logger *ll.Logger // Debug trace log
}
// mergeContext holds state related to cell merging.
type mergeContext struct {
headerMerges map[int]tw.MergeState // Merge states for header columns
rowMerges []map[int]tw.MergeState // Merge states for each row
footerMerges map[int]tw.MergeState // Merge states for footer columns
horzMerges map[tw.Position]map[int]bool // Tracks horizontal merges (unused)
}
// helperContext holds additional data for rendering helpers.
type helperContext struct {
position tw.Position // Section being processed (Header, Row, Footer)
rowIdx int // Row index within section
lineIdx int // Line index within row
location tw.Location // Boundary location (First, Middle, End)
line []string // Current line content
}
// renderMergeResponse holds cell context data from rendering operations.
type renderMergeResponse struct {
cells map[int]tw.CellContext // Current line cells
prevCells map[int]tw.CellContext // Previous line cells
nextCells map[int]tw.CellContext // Next line cells
location tw.Location // Determined Location for this line
cellsContent []string
}
// NewTable creates a new table instance with specified writer and options.
// Parameters include writer for output and optional configuration options.
// Returns a pointer to the initialized Table instance.
func NewTable(w io.Writer, opts ...Option) *Table {
t := &Table{
writer: w,
headerWidths: tw.NewMapper[int, int](),
rowWidths: tw.NewMapper[int, int](),
footerWidths: tw.NewMapper[int, int](),
renderer: renderer.NewBlueprint(),
config: defaultConfig(),
newLine: tw.NewLine,
trace: &bytes.Buffer{},
// Streaming
streamWidths: tw.NewMapper[int, int](), // Initialize empty mapper for streaming widths
lastRenderedMergeState: tw.NewMapper[int, tw.MergeState](),
headerRendered: false,
firstRowRendered: false,
lastRenderedPosition: "",
streamNumCols: 0,
streamRowCounter: 0,
// Cache
stringerCache: make(map[reflect.Type]reflect.Value),
stringerCacheEnabled: false, // Disabled by default
}
// set Options
t.Options(opts...)
t.logger.Infof("Table initialized with %d options", len(opts))
return t
}
// NewWriter creates a new table with default settings for backward compatibility.
// It logs the creation if debugging is enabled.
func NewWriter(w io.Writer) *Table {
t := NewTable(w)
if t.logger != nil {
t.logger.Debug("NewWriter created buffered Table")
}
return t
}
// Caption sets the table caption (legacy method).
// Defaults to BottomCenter alignment, wrapping to table width.
// Use SetCaptionOptions for more control.
func (t *Table) Caption(caption tw.Caption) *Table { // This is the one we modified
originalSpot := caption.Spot
originalAlign := caption.Align
if caption.Spot == tw.SpotNone {
caption.Spot = tw.SpotBottomCenter
t.logger.Debugf("[Table.Caption] Input Spot was SpotNone, defaulting Spot to SpotBottomCenter (%d)", caption.Spot)
}
if caption.Align == "" || caption.Align == tw.AlignDefault || caption.Align == tw.AlignNone {
switch caption.Spot {
case tw.SpotTopLeft, tw.SpotBottomLeft:
caption.Align = tw.AlignLeft
case tw.SpotTopRight, tw.SpotBottomRight:
caption.Align = tw.AlignRight
default:
caption.Align = tw.AlignCenter
}
t.logger.Debugf("[Table.Caption] Input Align was empty/default, defaulting Align to %s for Spot %d", caption.Align, caption.Spot)
}
t.caption = caption // t.caption on the struct is now updated.
t.logger.Debugf("Caption method called: Input(Spot:%v, Align:%q), Final(Spot:%v, Align:%q), Text:'%.20s', MaxWidth:%d",
originalSpot, originalAlign, t.caption.Spot, t.caption.Align, t.caption.Text, t.caption.Width)
return t
}
// Append adds data to the current row being built for the table.
// This method always contributes to a single logical row in the table.
// To add multiple distinct rows, call Append multiple times (once for each row's data)
// or use the Bulk() method if providing a slice where each element is a row.
func (t *Table) Append(rows ...interface{}) error {
t.ensureInitialized()
if t.config.Stream.Enable && t.hasPrinted {
// Streaming logic remains unchanged, as AutoHeader is a batch-mode concept.
t.logger.Debugf("Append() called in streaming mode with %d items for a single row", len(rows))
var rowItemForStream interface{}
if len(rows) == 1 {
rowItemForStream = rows[0]
} else {
rowItemForStream = rows
}
if err := t.streamAppendRow(rowItemForStream); err != nil {
t.logger.Errorf("Error rendering streaming row: %v", err)
return errors.Newf("failed to stream append row").Wrap(err)
}
return nil
}
// Batch Mode Logic
t.logger.Debugf("Append (Batch) received %d arguments: %v", len(rows), rows)
var cellsSource interface{}
if len(rows) == 1 {
cellsSource = rows[0]
} else {
cellsSource = rows
}
// Check if we should attempt to auto-generate headers from this append operation.
// Conditions: AutoHeader is on, no headers are set yet, and this is the first data row.
isFirstRow := len(t.rows) == 0
if t.config.Behavior.Structs.AutoHeader.Enabled() && len(t.headers) == 0 && isFirstRow {
t.logger.Debug("Append: Triggering AutoHeader for the first row.")
headers := t.extractHeadersFromStruct(cellsSource)
if len(headers) > 0 {
// Set the extracted headers. The Header() method handles the rest.
t.Header(headers)
}
}
// The rest of the function proceeds as before, converting the data to string lines.
lines, err := t.toStringLines(cellsSource, t.config.Row)
if err != nil {
t.logger.Errorf("Append (Batch) failed for cellsSource %v: %v", cellsSource, err)
return err
}
t.rows = append(t.rows, lines)
t.logger.Debugf("Append (Batch) completed for one row, total rows in table: %d", len(t.rows))
return nil
}
// Bulk adds multiple rows from a slice to the table.
// If Behavior.AutoHeader is enabled, no headers set, and rows is a slice of structs,
// automatically extracts/sets headers from the first struct.
func (t *Table) Bulk(rows interface{}) error {
rv := reflect.ValueOf(rows)
if rv.Kind() != reflect.Slice {
return errors.Newf("Bulk expects a slice, got %T", rows)
}
if rv.Len() == 0 {
return nil
}
// AutoHeader logic remains here, as it's a "Bulk" operation concept.
if t.config.Behavior.Structs.AutoHeader.Enabled() && len(t.headers) == 0 {
first := rv.Index(0).Interface()
// We can now correctly get headers from pointers or embedded structs
headers := t.extractHeadersFromStruct(first)
if len(headers) > 0 {
t.Header(headers)
}
}
// The rest of the logic is now just a loop over Append.
for i := 0; i < rv.Len(); i++ {
row := rv.Index(i).Interface()
if err := t.Append(row); err != nil { // Use Append
return err
}
}
return nil
}
// Config returns the current table configuration.
// No parameters are required.
// Returns the Config struct with current settings.
func (t *Table) Config() Config {
return t.config
}
// Configure updates the table's configuration using a provided function.
// Parameter fn is a function that modifies the Config struct.
// Returns the Table instance for method chaining.
func (t *Table) Configure(fn func(cfg *Config)) *Table {
fn(&t.config) // Let the user modify the config directly
// Handle any immediate side-effects of config changes, e.g., logger state
if t.config.Debug {
t.logger.Enable()
t.logger.Resume() // in case it was suspended
} else {
t.logger.Disable()
t.logger.Suspend() // suspend totally, especially because of tight loops
}
t.logger.Debugf("Configure complete. New t.config: %+v", t.config)
return t
}
// Debug retrieves the accumulated debug trace logs.
// No parameters are required.
// Returns a slice of debug messages including renderer logs.
func (t *Table) Debug() *bytes.Buffer {
return t.trace
}
// Header sets the table's header content, padding to match column count.
// Parameter elements is a slice of strings for header content.
// No return value.
// In streaming mode, this processes and renders the header immediately.
func (t *Table) Header(elements ...any) {
t.ensureInitialized()
t.logger.Debugf("Header() method called with raw variadic elements: %v (len %d). Streaming: %v, Started: %v", elements, len(elements), t.config.Stream.Enable, t.hasPrinted)
// just forget
if t.config.Behavior.Header.Hide.Enabled() {
return
}
// add come common default
if t.config.Header.Formatting.AutoFormat == tw.Unknown {
t.config.Header.Formatting.AutoFormat = tw.On
}
if t.config.Stream.Enable && t.hasPrinted {
// Streaming Path
actualCellsToProcess := t.processVariadic(elements)
headersAsStrings, err := t.convertCellsToStrings(actualCellsToProcess, t.config.Header)
if err != nil {
t.logger.Errorf("Header(): Failed to convert header elements to strings for streaming: %v", err)
headersAsStrings = []string{} // Use empty on error
}
errStream := t.streamRenderHeader(headersAsStrings) // streamRenderHeader handles padding to streamNumCols internally
if errStream != nil {
t.logger.Errorf("Error rendering streaming header: %v", errStream)
}
return
}
// Batch Path
processedElements := t.processVariadic(elements)
t.logger.Debugf("Header() (Batch): Effective cells to process: %v", processedElements)
headersAsStrings, err := t.convertCellsToStrings(processedElements, t.config.Header)
if err != nil {
t.logger.Errorf("Header() (Batch): Failed to convert to strings: %v", err)
t.headers = [][]string{} // Set to empty on error
return
}
// prepareContent uses t.config.Header for AutoFormat and MaxWidth constraints.
// It processes based on the number of columns in headersAsStrings.
preparedHeaderLines := t.prepareContent(headersAsStrings, t.config.Header)
t.headers = preparedHeaderLines // Store directly. Padding to t.maxColumns() will happen in prepareContexts.
t.logger.Debugf("Header set (batch mode), lines stored: %d. First line if exists: %v", len(t.headers), func() []string {
if len(t.headers) > 0 {
return t.headers[0]
} else {
return nil
}
}())
}
// Footer sets the table's footer content, padding to match column count.
// Parameter footers is a slice of strings for footer content.
// No return value.
// Footer sets the table's footer content.
// Parameter footers is a slice of strings for footer content.
// In streaming mode, this processes and stores the footer for rendering by Close().
func (t *Table) Footer(elements ...any) {
t.ensureInitialized()
t.logger.Debugf("Footer() method called with raw variadic elements: %v (len %d). Streaming: %v, Started: %v", elements, len(elements), t.config.Stream.Enable, t.hasPrinted)
// just forget
if t.config.Behavior.Footer.Hide.Enabled() {
return
}
if t.config.Stream.Enable && t.hasPrinted {
// Streaming Path
actualCellsToProcess := t.processVariadic(elements)
footersAsStrings, err := t.convertCellsToStrings(actualCellsToProcess, t.config.Footer)
if err != nil {
t.logger.Errorf("Footer(): Failed to convert footer elements to strings for streaming: %v", err)
footersAsStrings = []string{} // Use empty on error
}
errStream := t.streamStoreFooter(footersAsStrings) // streamStoreFooter handles padding to streamNumCols internally
if errStream != nil {
t.logger.Errorf("Error processing streaming footer: %v", errStream)
}
return
}
// Batch Path
processedElements := t.processVariadic(elements)
t.logger.Debugf("Footer() (Batch): Effective cells to process: %v", processedElements)
footersAsStrings, err := t.convertCellsToStrings(processedElements, t.config.Footer)
if err != nil {
t.logger.Errorf("Footer() (Batch): Failed to convert to strings: %v", err)
t.footers = [][]string{} // Set to empty on error
return
}
preparedFooterLines := t.prepareContent(footersAsStrings, t.config.Footer)
t.footers = preparedFooterLines // Store directly. Padding to t.maxColumns() will happen in prepareContexts.
t.logger.Debugf("Footer set (batch mode), lines stored: %d. First line if exists: %v",
len(t.footers), func() []string {
if len(t.footers) > 0 {
return t.footers[0]
} else {
return nil
}
}())
}
// Options updates the table's Options using a provided function.
// Parameter opts is a function that modifies the Table struct.
// Returns the Table instance for method chaining.
func (t *Table) Options(opts ...Option) *Table {
// add logger
if t.logger == nil {
t.logger = ll.New("table").Handler(lh.NewTextHandler(t.trace))
}
// loop through options
for _, opt := range opts {
opt(t)
}
// force debugging mode if set
// This should be move away form WithDebug
if t.config.Debug {
t.logger.Enable()
t.logger.Resume()
} else {
t.logger.Disable()
t.logger.Suspend()
}
// Get additional system information for debugging
goVersion := runtime.Version()
goOS := runtime.GOOS
goArch := runtime.GOARCH
numCPU := runtime.NumCPU()
t.logger.Infof("Environment: LC_CTYPE=%s, LANG=%s, TERM=%s", os.Getenv("LC_CTYPE"), os.Getenv("LANG"), os.Getenv("TERM"))
t.logger.Infof("Go Runtime: Version=%s, OS=%s, Arch=%s, CPUs=%d", goVersion, goOS, goArch, numCPU)
// send logger to renderer
// this will overwrite the default logger
t.renderer.Logger(t.logger)
return t
}
// Reset clears all data (headers, rows, footers, caption) and rendering state
// from the table, allowing the Table instance to be reused for a new table
// with the same configuration and writer.
// It does NOT reset the configuration itself (set by NewTable options or Configure)
// or the underlying io.Writer.
func (t *Table) Reset() {
t.logger.Debug("Reset() called. Clearing table data and render state.")
// Clear data slices
t.rows = nil // Or t.rows = make([][][]string, 0)
t.headers = nil // Or t.headers = make([][]string, 0)
t.footers = nil // Or t.footers = make([][]string, 0)
// Reset width mappers (important for recalculating widths for the new table)
t.headerWidths = tw.NewMapper[int, int]()
t.rowWidths = tw.NewMapper[int, int]()
t.footerWidths = tw.NewMapper[int, int]()
// Reset caption
t.caption = tw.Caption{} // Reset to zero value
// Reset rendering state flags
t.hasPrinted = false // Critical for allowing Render() or stream Start() again
// Reset streaming-specific state
// (Important if the table was used in streaming mode and might be reused in batch or another stream)
t.streamWidths = tw.NewMapper[int, int]()
t.streamFooterLines = nil
t.headerRendered = false
t.firstRowRendered = false
t.lastRenderedLineContent = nil
t.lastRenderedMergeState = tw.NewMapper[int, tw.MergeState]() // Re-initialize
t.lastRenderedPosition = ""
t.streamNumCols = 0
t.streamRowCounter = 0
// The stringer and its cache are part of the table's configuration,
if t.stringerCacheEnabled {
t.stringerCacheMu.Lock()
t.stringerCache = make(map[reflect.Type]reflect.Value)
t.stringerCacheMu.Unlock()
t.logger.Debug("Reset(): Stringer cache cleared.")
}
// If the renderer has its own state that needs resetting after a table is done,
// this would be the place to call a renderer.Reset() method if it existed.
// Most current renderers are stateless per render call or reset in their Start/Close.
// For instance, HTML and SVG renderers have their own Reset method.
// It might be good practice to call it if available.
if r, ok := t.renderer.(interface{ Reset() }); ok {
t.logger.Debug("Reset(): Calling Reset() on the current renderer.")
r.Reset()
}
t.logger.Info("Table instance has been reset.")
}
// Render triggers the table rendering process to the configured writer.
// No parameters are required.
// Returns an error if rendering fails.
func (t *Table) Render() error {
return t.render()
}
// Lines returns the total number of lines rendered.
// This method is only effective if the WithLineCounter() option was used during
// table initialization and must be called *after* Render().
// It actively searches for the default tw.LineCounter among all active counters.
// It returns -1 if the line counter was not enabled.
func (t *Table) Lines() int {
for _, counter := range t.counters {
if lc, ok := counter.(*tw.LineCounter); ok {
return lc.Total()
}
}
// use -1 to indicate no line counter is attached
return -1
}
// Counters returns the slice of all active counter instances.
// This is useful when multiple counters are enabled.
// It must be called *after* Render().
func (t *Table) Counters() []tw.Counter {
return t.counters
}
// Trimmer trims whitespace from a string based on the Tables configuration.
// It conditionally applies strings.TrimSpace to the input string if the TrimSpace behavior
// is enabled in t.config.Behavior, otherwise returning the string unchanged. This method
// is used in the logging library to format strings for tabular output, ensuring consistent
// display in log messages. Thread-safe as it only reads configuration and operates on the
// input string.
func (t *Table) Trimmer(str string) string {
if t.config.Behavior.TrimSpace.Enabled() {
return strings.TrimSpace(str)
}
return str
}
// appendSingle adds a single row to the table's row data.
// Parameter row is the data to append, converted via stringer if needed.
// Returns an error if conversion or appending fails.
func (t *Table) appendSingle(row interface{}) error {
t.ensureInitialized() // Already here
if t.config.Stream.Enable && t.hasPrinted { // If streaming is active
t.logger.Debugf("appendSingle: Dispatching to streamAppendRow for row: %v", row)
return t.streamAppendRow(row) // Call the streaming render function
}
// Existing batch logic:
t.logger.Debugf("appendSingle: Processing for batch mode, row: %v", row)
// toStringLines now uses the new convertCellsToStrings internally, then prepareContent.
// This is fine for batch.
lines, err := t.toStringLines(row, t.config.Row)
if err != nil {
t.logger.Debugf("Error in toStringLines (batch mode): %v", err)
return err
}
t.rows = append(t.rows, lines) // Add to batch storage
t.logger.Debugf("Row appended to batch t.rows, total batch rows: %d", len(t.rows))
return nil
}
// buildAligns constructs a map of column alignments from configuration.
// Parameter config provides alignment settings for the section.
// Returns a map of column indices to alignment settings.
func (t *Table) buildAligns(config tw.CellConfig) map[int]tw.Align {
// Start with global alignment, preferring deprecated Formatting.Alignment
effectiveGlobalAlign := config.Formatting.Alignment
if effectiveGlobalAlign == tw.Empty || effectiveGlobalAlign == tw.Skip {
effectiveGlobalAlign = config.Alignment.Global
if config.Formatting.Alignment != tw.Empty && config.Formatting.Alignment != tw.Skip {
t.logger.Warnf("Using deprecated CellFormatting.Alignment (%s). Migrate to CellConfig.Alignment.Global.", config.Formatting.Alignment)
}
}
// Use per-column alignments, preferring deprecated ColumnAligns
effectivePerColumn := config.ColumnAligns
if len(effectivePerColumn) == 0 && len(config.Alignment.PerColumn) > 0 {
effectivePerColumn = make([]tw.Align, len(config.Alignment.PerColumn))
copy(effectivePerColumn, config.Alignment.PerColumn)
if len(config.ColumnAligns) > 0 {
t.logger.Warnf("Using deprecated CellConfig.ColumnAligns (%v). Migrate to CellConfig.Alignment.PerColumn.", config.ColumnAligns)
}
}
// Log input for debugging
t.logger.Debugf("buildAligns INPUT: deprecated Formatting.Alignment=%s, deprecated ColumnAligns=%v, config.Alignment.Global=%s, config.Alignment.PerColumn=%v",
config.Formatting.Alignment, config.ColumnAligns, config.Alignment.Global, config.Alignment.PerColumn)
numColsToUse := t.getNumColsToUse()
colAlignsResult := make(map[int]tw.Align)
for i := 0; i < numColsToUse; i++ {
currentAlign := effectiveGlobalAlign
if i < len(effectivePerColumn) && effectivePerColumn[i] != tw.Empty && effectivePerColumn[i] != tw.Skip {
currentAlign = effectivePerColumn[i]
}
// Skip validation here; rely on rendering to handle invalid alignments
colAlignsResult[i] = currentAlign
}
t.logger.Debugf("Aligns built: %v (length %d)", colAlignsResult, len(colAlignsResult))
return colAlignsResult
}
// buildPadding constructs a map of column padding settings from configuration.
// Parameter padding provides padding settings for the section.
// Returns a map of column indices to padding settings.
func (t *Table) buildPadding(padding tw.CellPadding) map[int]tw.Padding {
numColsToUse := t.getNumColsToUse()
colPadding := make(map[int]tw.Padding)
for i := 0; i < numColsToUse; i++ {
if i < len(padding.PerColumn) && padding.PerColumn[i].Paddable() {
colPadding[i] = padding.PerColumn[i]
} else {
colPadding[i] = padding.Global
}
}
t.logger.Debugf("Padding built: %v (length %d)", colPadding, len(colPadding))
return colPadding
}
// ensureInitialized initializes required fields before use.
// No parameters are required.
// No return value.
func (t *Table) ensureInitialized() {
if t.headerWidths == nil {
t.headerWidths = tw.NewMapper[int, int]()
}
if t.rowWidths == nil {
t.rowWidths = tw.NewMapper[int, int]()
}
if t.footerWidths == nil {
t.footerWidths = tw.NewMapper[int, int]()
}
if t.renderer == nil {
t.renderer = renderer.NewBlueprint()
}
t.logger.Debug("ensureInitialized called")
}
// finalizeHierarchicalMergeBlock sets Span and End for hierarchical merges.
// Parameters include ctx, mctx, col, startRow, and endRow.
// No return value.
func (t *Table) finalizeHierarchicalMergeBlock(ctx *renderContext, mctx *mergeContext, col, startRow, endRow int) {
if endRow < startRow {
ctx.logger.Debugf("Hierarchical merge FINALIZE WARNING: Invalid block col %d, start %d > end %d", col, startRow, endRow)
return
}
if startRow < 0 || endRow < 0 {
ctx.logger.Debugf("Hierarchical merge FINALIZE WARNING: Negative row indices col %d, start %d, end %d", col, startRow, endRow)
return
}
requiredLen := endRow + 1
if requiredLen > len(mctx.rowMerges) {
ctx.logger.Debugf("Hierarchical merge FINALIZE WARNING: rowMerges slice too short (len %d) for endRow %d", len(mctx.rowMerges), endRow)
return
}
if mctx.rowMerges[startRow] == nil {
mctx.rowMerges[startRow] = make(map[int]tw.MergeState)
}
if mctx.rowMerges[endRow] == nil {
mctx.rowMerges[endRow] = make(map[int]tw.MergeState)
}
finalSpan := (endRow - startRow) + 1
ctx.logger.Debugf("Finalizing H-merge block: col=%d, startRow=%d, endRow=%d, span=%d", col, startRow, endRow, finalSpan)
startState := mctx.rowMerges[startRow][col]
if startState.Hierarchical.Present && startState.Hierarchical.Start {
startState.Hierarchical.Span = finalSpan
startState.Hierarchical.End = finalSpan == 1
mctx.rowMerges[startRow][col] = startState
ctx.logger.Debugf(" -> Updated start state: %+v", startState.Hierarchical)
} else {
ctx.logger.Debugf("Hierarchical merge FINALIZE WARNING: col %d, startRow %d was not marked as Present/Start? Current state: %+v. Attempting recovery.", col, startRow, startState.Hierarchical)
startState.Hierarchical.Present = true
startState.Hierarchical.Start = true
startState.Hierarchical.Span = finalSpan
startState.Hierarchical.End = finalSpan == 1
mctx.rowMerges[startRow][col] = startState
}
if endRow > startRow {
endState := mctx.rowMerges[endRow][col]
if endState.Hierarchical.Present && !endState.Hierarchical.Start {
endState.Hierarchical.End = true
endState.Hierarchical.Span = finalSpan
mctx.rowMerges[endRow][col] = endState
ctx.logger.Debugf(" -> Updated end state: %+v", endState.Hierarchical)
} else {
ctx.logger.Debugf("Hierarchical merge FINALIZE WARNING: col %d, endRow %d was not marked as Present/Continuation? Current state: %+v. Attempting recovery.", col, endRow, endState.Hierarchical)
endState.Hierarchical.Present = true
endState.Hierarchical.Start = false
endState.Hierarchical.End = true
endState.Hierarchical.Span = finalSpan
mctx.rowMerges[endRow][col] = endState
}
} else {
ctx.logger.Debugf(" -> Span is 1, startRow is also endRow.")
}
}
// getLevel maps a position to its rendering level.
// Parameter position specifies the section (Header, Row, Footer).
// Returns the corresponding tw.Level (Header, Body, Footer).
func (t *Table) getLevel(position tw.Position) tw.Level {
switch position {
case tw.Header:
return tw.LevelHeader
case tw.Row:
return tw.LevelBody
case tw.Footer:
return tw.LevelFooter
default:
return tw.LevelBody
}
}
// hasFooterElements checks if the footer has renderable elements.
// No parameters are required.
// Returns true if footer has content or padding, false otherwise.
func (t *Table) hasFooterElements() bool {
hasContent := len(t.footers) > 0
hasTopPadding := t.config.Footer.Padding.Global.Top != tw.Empty
hasBottomPaddingConfig := t.config.Footer.Padding.Global.Bottom != tw.Empty || t.hasPerColumnBottomPadding()
return hasContent || hasTopPadding || hasBottomPaddingConfig
}
// hasPerColumnBottomPadding checks for per-column bottom padding in footer.
// No parameters are required.
// Returns true if any per-column bottom padding is defined.
func (t *Table) hasPerColumnBottomPadding() bool {
if t.config.Footer.Padding.PerColumn == nil {
return false
}
for _, pad := range t.config.Footer.Padding.PerColumn {
if pad.Bottom != tw.Empty {
return true
}
}
return false
}
// Logger retrieves the table's logger instance.
// No parameters are required.
// Returns the ll.Logger instance used for debug tracing.
func (t *Table) Logger() *ll.Logger {
return t.logger
}
// Renderer retrieves the current renderer instance used by the table.
// No parameters are required.
// Returns the tw.Renderer interface instance.
func (t *Table) Renderer() tw.Renderer {
t.logger.Debug("Renderer requested")
return t.renderer
}
// maxColumns calculates the maximum column count across sections.
// No parameters are required.
// Returns the highest number of columns found.
func (t *Table) maxColumns() int {
m := 0
if len(t.headers) > 0 && len(t.headers[0]) > m {
m = len(t.headers[0])
}
for _, row := range t.rows {
if len(row) > 0 && len(row[0]) > m {
m = len(row[0])
}
}
if len(t.footers) > 0 && len(t.footers[0]) > m {
m = len(t.footers[0])
}
t.logger.Debugf("Max columns: %d", m)
return m
}
// printTopBottomCaption prints the table's caption at the specified top or bottom position.
// It wraps the caption text to fit the table width or a user-defined width, aligns it according
// to the specified alignment, and writes it to the provided writer. If the caption text is empty
// or the spot is invalid, it logs the issue and returns without printing. The function handles
// wrapping errors by falling back to splitting on newlines or using the original text.
func (t *Table) printTopBottomCaption(w io.Writer, actualTableWidth int) {
t.logger.Debugf("[printCaption Entry] Text=%q, Spot=%v (type %T), Align=%q, UserWidth=%d, ActualTableWidth=%d",
t.caption.Text, t.caption.Spot, t.caption.Spot, t.caption.Align, t.caption.Width, actualTableWidth)
currentCaptionSpot := t.caption.Spot
isValidSpot := currentCaptionSpot >= tw.SpotTopLeft && currentCaptionSpot <= tw.SpotBottomRight
if t.caption.Text == "" || !isValidSpot {
t.logger.Debugf("[printCaption] Aborting: Text empty OR Spot invalid...")
return
}
var captionWrapWidth int
if t.caption.Width > 0 {
captionWrapWidth = t.caption.Width
t.logger.Debugf("[printCaption] Using user-defined caption.Width %d for wrapping.", captionWrapWidth)
} else if actualTableWidth <= 4 {
captionWrapWidth = twwidth.Width(t.caption.Text)
t.logger.Debugf("[printCaption] Empty table, no user caption.Width: Using natural caption width %d.", captionWrapWidth)
} else {
captionWrapWidth = actualTableWidth
t.logger.Debugf("[printCaption] Non-empty table, no user caption.Width: Using actualTableWidth %d for wrapping.", actualTableWidth)
}
if captionWrapWidth <= 0 {
captionWrapWidth = 10
t.logger.Warnf("[printCaption] captionWrapWidth was %d (<=0). Setting to minimum %d.", captionWrapWidth, 10)
}
t.logger.Debugf("[printCaption] Final captionWrapWidth to be used by twwarp: %d", captionWrapWidth)
wrappedCaptionLines, count := twwarp.WrapString(t.caption.Text, captionWrapWidth)
if count == 0 {
t.logger.Errorf("[printCaption] Error from twwarp.WrapString (width %d): %v. Text: %q", captionWrapWidth, count, t.caption.Text)
if strings.Contains(t.caption.Text, "\n") {
wrappedCaptionLines = strings.Split(t.caption.Text, "\n")
} else {
wrappedCaptionLines = []string{t.caption.Text}
}
t.logger.Debugf("[printCaption] Fallback: using %d lines from original text.", len(wrappedCaptionLines))
}
if len(wrappedCaptionLines) == 0 && t.caption.Text != "" {
t.logger.Warn("[printCaption] Wrapping resulted in zero lines for non-empty text. Using fallback.")
if strings.Contains(t.caption.Text, "\n") {
wrappedCaptionLines = strings.Split(t.caption.Text, "\n")
} else {
wrappedCaptionLines = []string{t.caption.Text}
}
} else if t.caption.Text != "" {
t.logger.Debugf("[printCaption] Wrapped caption into %d lines: %v", len(wrappedCaptionLines), wrappedCaptionLines)
}
paddingTargetWidth := actualTableWidth
if t.caption.Width > 0 {
paddingTargetWidth = t.caption.Width
} else if actualTableWidth <= 4 {
paddingTargetWidth = captionWrapWidth
}
t.logger.Debugf("[printCaption] Final paddingTargetWidth for tw.Pad: %d", paddingTargetWidth)
for i, line := range wrappedCaptionLines {
align := t.caption.Align
if align == "" || align == tw.AlignDefault || align == tw.AlignNone {
switch t.caption.Spot {
case tw.SpotTopLeft, tw.SpotBottomLeft:
align = tw.AlignLeft
case tw.SpotTopRight, tw.SpotBottomRight:
align = tw.AlignRight
default:
align = tw.AlignCenter
}
t.logger.Debugf("[printCaption] Line %d: Alignment defaulted to %s based on Spot %v", i, align, t.caption.Spot)
}
paddedLine := tw.Pad(line, " ", paddingTargetWidth, align)
t.logger.Debugf("[printCaption] Printing line %d: InputLine=%q, Align=%s, PaddingTargetWidth=%d, PaddedLine=%q",
i, line, align, paddingTargetWidth, paddedLine)
w.Write([]byte(paddedLine))
w.Write([]byte(tw.NewLine))
}
t.logger.Debugf("[printCaption] Finished printing all caption lines.")
}
// prepareContent processes cell content with formatting and wrapping.
// Parameters include cells to process and config for formatting rules.
// Returns a slice of string slices representing processed lines.
func (t *Table) prepareContent(cells []string, config tw.CellConfig) [][]string {
isStreaming := t.config.Stream.Enable && t.hasPrinted
t.logger.Debugf("prepareContent: Processing cells=%v (streaming: %v)", cells, isStreaming)
initialInputCellCount := len(cells)
result := make([][]string, 0)
effectiveNumCols := initialInputCellCount
if isStreaming {
if t.streamNumCols > 0 {
effectiveNumCols = t.streamNumCols
t.logger.Debugf("prepareContent: Streaming mode, using fixed streamNumCols: %d", effectiveNumCols)
if len(cells) != effectiveNumCols {
t.logger.Warnf("prepareContent: Streaming mode, input cell count (%d) does not match streamNumCols (%d). Input cells will be padded/truncated.", len(cells), effectiveNumCols)
if len(cells) < effectiveNumCols {
paddedCells := make([]string, effectiveNumCols)
copy(paddedCells, cells)
for i := len(cells); i < effectiveNumCols; i++ {
paddedCells[i] = tw.Empty
}
cells = paddedCells
} else if len(cells) > effectiveNumCols {
cells = cells[:effectiveNumCols]
}
}
} else {
t.logger.Warnf("prepareContent: Streaming mode enabled but streamNumCols is 0. Using input cell count %d. Stream widths may not be available.", effectiveNumCols)
}
}
if t.config.MaxWidth > 0 && !t.config.Widths.Constrained() {
if effectiveNumCols > 0 {
derivedSectionGlobalMaxWidth := int(math.Floor(float64(t.config.MaxWidth) / float64(effectiveNumCols)))
config.ColMaxWidths.Global = derivedSectionGlobalMaxWidth
t.logger.Debugf("prepareContent: Table MaxWidth %d active and t.config.Widths not constrained. "+
"Derived section ColMaxWidths.Global: %d for %d columns. This will be used by calculateContentMaxWidth if no higher priority constraints exist.",
t.config.MaxWidth, config.ColMaxWidths.Global, effectiveNumCols)
}
}
for i := 0; i < effectiveNumCols; i++ {
cellContent := ""
if i < len(cells) {
cellContent = cells[i]
} else {
cellContent = tw.Empty
}
cellContent = t.Trimmer(cellContent)
colPad := config.Padding.Global
if i < len(config.Padding.PerColumn) && config.Padding.PerColumn[i].Paddable() {
colPad = config.Padding.PerColumn[i]
}
padLeftWidth := twwidth.Width(colPad.Left)
padRightWidth := twwidth.Width(colPad.Right)
effectiveContentMaxWidth := t.calculateContentMaxWidth(i, config, padLeftWidth, padRightWidth, isStreaming)
if config.Formatting.AutoFormat.Enabled() {
cellContent = tw.Title(strings.Join(tw.SplitCamelCase(cellContent), tw.Space))
}
lines := strings.Split(cellContent, "\n")
finalLinesForCell := make([]string, 0)
for _, line := range lines {
if effectiveContentMaxWidth > 0 {
switch config.Formatting.AutoWrap {
case tw.WrapNormal:
var wrapped []string
if t.config.Behavior.TrimSpace.Enabled() {
wrapped, _ = twwarp.WrapString(line, effectiveContentMaxWidth)
} else {
wrapped, _ = twwarp.WrapStringWithSpaces(line, effectiveContentMaxWidth)
}
finalLinesForCell = append(finalLinesForCell, wrapped...)
case tw.WrapTruncate:
if twwidth.Width(line) > effectiveContentMaxWidth {
ellipsisWidth := twwidth.Width(tw.CharEllipsis)
if effectiveContentMaxWidth >= ellipsisWidth {
finalLinesForCell = append(finalLinesForCell, twwidth.Truncate(line, effectiveContentMaxWidth-ellipsisWidth, tw.CharEllipsis))
} else {
finalLinesForCell = append(finalLinesForCell, twwidth.Truncate(line, effectiveContentMaxWidth, ""))
}
} else {
finalLinesForCell = append(finalLinesForCell, line)
}
case tw.WrapBreak:
wrapped := make([]string, 0)
currentLine := line
breakCharWidth := twwidth.Width(tw.CharBreak)
for twwidth.Width(currentLine) > effectiveContentMaxWidth {
targetWidth := max(effectiveContentMaxWidth-breakCharWidth, 0)
breakPoint := tw.BreakPoint(currentLine, targetWidth)
runes := []rune(currentLine)
if breakPoint <= 0 || breakPoint > len(runes) {
t.logger.Warnf("prepareContent: WrapBreak - Invalid BreakPoint %d for line '%s' at width %d. Attempting manual break.", breakPoint, currentLine, targetWidth)
actualBreakRuneCount := 0
tempWidth := 0
for charIdx, r := range runes {
runeStr := string(r)
rw := twwidth.Width(runeStr)
if tempWidth+rw > targetWidth && charIdx > 0 {
break
}
tempWidth += rw
actualBreakRuneCount = charIdx + 1
if tempWidth >= targetWidth && charIdx == 0 {
break
}
}
if actualBreakRuneCount == 0 && len(runes) > 0 {
actualBreakRuneCount = 1
}
if actualBreakRuneCount > 0 && actualBreakRuneCount <= len(runes) {
wrapped = append(wrapped, string(runes[:actualBreakRuneCount])+tw.CharBreak)
currentLine = string(runes[actualBreakRuneCount:])
} else {
t.logger.Warnf("prepareContent: WrapBreak - Cannot break line '%s'. Adding as is.", currentLine)
wrapped = append(wrapped, currentLine)
currentLine = ""
break
}
} else {
wrapped = append(wrapped, string(runes[:breakPoint])+tw.CharBreak)
currentLine = string(runes[breakPoint:])
}
}
if twwidth.Width(currentLine) > 0 {
wrapped = append(wrapped, currentLine)
}
if len(wrapped) == 0 && twwidth.Width(line) > 0 && len(finalLinesForCell) == 0 {
finalLinesForCell = append(finalLinesForCell, line)
} else {
finalLinesForCell = append(finalLinesForCell, wrapped...)
}
default:
finalLinesForCell = append(finalLinesForCell, line)
}
} else {
finalLinesForCell = append(finalLinesForCell, line)
}
}
for len(result) < len(finalLinesForCell) {
newRow := make([]string, effectiveNumCols)
for j := range newRow {
newRow[j] = tw.Empty
}
result = append(result, newRow)
}
for j := 0; j < len(result); j++ {
cellLineContent := tw.Empty
if j < len(finalLinesForCell) {
cellLineContent = finalLinesForCell[j]
}
if i < len(result[j]) {
result[j][i] = cellLineContent
} else {
t.logger.Warnf("prepareContent: Column index %d out of bounds (%d) during result matrix population. EffectiveNumCols: %d. This indicates a logic error.",
i, len(result[j]), effectiveNumCols)
}
}
}
t.logger.Debugf("prepareContent: Content prepared, result %d lines.", len(result))
return result
}
// prepareContexts initializes rendering and merge contexts.
// No parameters are required.
// Returns renderContext, mergeContext, and an error if initialization fails.
func (t *Table) prepareContexts() (*renderContext, *mergeContext, error) {
numOriginalCols := t.maxColumns()
t.logger.Debugf("prepareContexts: Original number of columns: %d", numOriginalCols)
ctx := &renderContext{
table: t,
renderer: t.renderer,
cfg: t.renderer.Config(),
numCols: numOriginalCols,
widths: map[tw.Position]tw.Mapper[int, int]{
tw.Header: tw.NewMapper[int, int](),
tw.Row: tw.NewMapper[int, int](),
tw.Footer: tw.NewMapper[int, int](),
},
logger: t.logger,
}
isEmpty, visibleCount := t.getEmptyColumnInfo(numOriginalCols)
ctx.emptyColumns = isEmpty
ctx.visibleColCount = visibleCount
mctx := &mergeContext{
headerMerges: make(map[int]tw.MergeState),
rowMerges: make([]map[int]tw.MergeState, len(t.rows)),
footerMerges: make(map[int]tw.MergeState),
horzMerges: make(map[tw.Position]map[int]bool),
}
for i := range mctx.rowMerges {
mctx.rowMerges[i] = make(map[int]tw.MergeState)
}
ctx.headerLines = t.headers
ctx.rowLines = t.rows
ctx.footerLines = t.footers
if err := t.calculateAndNormalizeWidths(ctx); err != nil {
t.logger.Debugf("Error during initial width calculation: %v", err)
return nil, nil, err
}
t.logger.Debugf("Initial normalized widths (before hiding): H=%v, R=%v, F=%v",
ctx.widths[tw.Header], ctx.widths[tw.Row], ctx.widths[tw.Footer])
preparedHeaderLines, headerMerges, _ := t.prepareWithMerges(ctx.headerLines, t.config.Header, tw.Header)
ctx.headerLines = preparedHeaderLines
mctx.headerMerges = headerMerges
processedRowLines := make([][][]string, len(ctx.rowLines))
for i, row := range ctx.rowLines {
if mctx.rowMerges[i] == nil {
mctx.rowMerges[i] = make(map[int]tw.MergeState)
}
processedRowLines[i], mctx.rowMerges[i], _ = t.prepareWithMerges(row, t.config.Row, tw.Row)
}
ctx.rowLines = processedRowLines
t.applyHorizontalMergeWidths(tw.Header, ctx, mctx.headerMerges)
if t.config.Row.Formatting.MergeMode&tw.MergeVertical != 0 {
t.applyVerticalMerges(ctx, mctx)
}
if t.config.Row.Formatting.MergeMode&tw.MergeHierarchical != 0 {
t.applyHierarchicalMerges(ctx, mctx)
}
t.prepareFooter(ctx, mctx)
t.logger.Debugf("Footer prepared. Widths before hiding: H=%v, R=%v, F=%v",
ctx.widths[tw.Header], ctx.widths[tw.Row], ctx.widths[tw.Footer])
if t.config.Behavior.AutoHide.Enabled() {
t.logger.Debugf("Applying AutoHide: Adjusting widths for empty columns.")
if ctx.emptyColumns == nil {
t.logger.Debugf("Warning: ctx.emptyColumns is nil during width adjustment.")
} else if len(ctx.emptyColumns) != ctx.numCols {
t.logger.Debugf("Warning: Length mismatch between emptyColumns (%d) and numCols (%d). Skipping adjustment.", len(ctx.emptyColumns), ctx.numCols)
} else {
for colIdx := 0; colIdx < ctx.numCols; colIdx++ {
if ctx.emptyColumns[colIdx] {
t.logger.Debugf("AutoHide: Hiding column %d by setting width to 0.", colIdx)
ctx.widths[tw.Header].Set(colIdx, 0)
ctx.widths[tw.Row].Set(colIdx, 0)
ctx.widths[tw.Footer].Set(colIdx, 0)
}
}
t.logger.Debugf("Widths after AutoHide adjustment: H=%v, R=%v, F=%v",
ctx.widths[tw.Header], ctx.widths[tw.Row], ctx.widths[tw.Footer])
}
} else {
t.logger.Debugf("AutoHide is disabled, skipping width adjustment.")
}
t.logger.Debugf("prepareContexts completed all stages.")
return ctx, mctx, nil
}
// prepareFooter processes footer content and applies merges.
// Parameters ctx and mctx hold rendering and merge state.
// No return value.
func (t *Table) prepareFooter(ctx *renderContext, mctx *mergeContext) {
if len(t.footers) == 0 {
ctx.logger.Debugf("Skipping footer preparation - no footer data")
if ctx.widths[tw.Footer] == nil {
ctx.widths[tw.Footer] = tw.NewMapper[int, int]()
}
numCols := ctx.numCols
for i := 0; i < numCols; i++ {
ctx.widths[tw.Footer].Set(i, ctx.widths[tw.Row].Get(i))
}
t.logger.Debug("Initialized empty footer widths based on row widths: %v", ctx.widths[tw.Footer])
ctx.footerPrepared = true
return
}
t.logger.Debugf("Preparing footer with merge mode: %d", t.config.Footer.Formatting.MergeMode)
preparedLines, mergeStates, _ := t.prepareWithMerges(t.footers, t.config.Footer, tw.Footer)
t.footers = preparedLines
mctx.footerMerges = mergeStates
ctx.footerLines = t.footers
t.logger.Debugf("Base footer widths (normalized from rows/header): %v", ctx.widths[tw.Footer])
t.applyHorizontalMergeWidths(tw.Footer, ctx, mctx.footerMerges)
ctx.footerPrepared = true
t.logger.Debugf("Footer preparation completed. Final footer widths: %v", ctx.widths[tw.Footer])
}
// prepareWithMerges processes content and detects horizontal merges.
// Parameters include content, config, and position (Header, Row, Footer).
// Returns processed lines, merge states, and horizontal merge map.
func (t *Table) prepareWithMerges(content [][]string, config tw.CellConfig, position tw.Position) ([][]string, map[int]tw.MergeState, map[int]bool) {
t.logger.Debugf("PrepareWithMerges START: position=%s, mergeMode=%d", position, config.Formatting.MergeMode)
if len(content) == 0 {
t.logger.Debugf("PrepareWithMerges END: No content.")
return content, nil, nil
}
numCols := 0
if len(content) > 0 && len(content[0]) > 0 { // Assumes content[0] exists and has items
numCols = len(content[0])
} else { // Fallback if first line is empty or content is empty
for _, line := range content { // Find max columns from any line
if len(line) > numCols {
numCols = len(line)
}
}
if numCols == 0 { // If still 0, try table-wide max (batch mode context)
numCols = t.maxColumns()
}
}
if numCols == 0 {
t.logger.Debugf("PrepareWithMerges END: numCols is zero.")
return content, nil, nil
}
horzMergeMap := make(map[int]bool) // Tracks if a column is part of any horizontal merge for this logical row
mergeMap := make(map[int]tw.MergeState) // Final merge states for this logical row
// Ensure all lines in 'content' are padded to numCols for consistent processing
// This result is what will be modified and returned.
result := make([][]string, len(content))
for i := range content {
result[i] = padLine(content[i], numCols)
}
if config.Formatting.MergeMode&tw.MergeHorizontal != 0 {
t.logger.Debugf("Checking for horizontal merges (logical cell comparison) for %d visual lines, %d columns", len(content), numCols)
// Special handling for footer lead merge (often for "TOTAL" spanning empty cells)
// This logic only applies if it's a footer and typically to the first (often only) visual line.
if position == tw.Footer && len(content) > 0 {
lineIdx := 0 // Assume footer lead merge applies to the first visual line primarily
originalLine := padLine(content[lineIdx], numCols) // Use original content for decision
currentLineResult := result[lineIdx] // Modify the result line
firstContentIdx := -1
var firstContent string
for c := 0; c < numCols; c++ {
if c >= len(originalLine) {
break
}
trimmedVal := t.Trimmer(originalLine[c])
if trimmedVal != "" && trimmedVal != "-" { // "-" is often a placeholder not to merge over
firstContentIdx = c
firstContent = originalLine[c] // Store the raw content for placement
break
} else if trimmedVal == "-" { // Stop if we hit a hard non-mergeable placeholder
break
}
}
if firstContentIdx > 0 { // If content starts after the first column
span := firstContentIdx + 1 // Merge from col 0 up to and including firstContentIdx
startCol := 0
allEmptyBefore := true
for c := 0; c < firstContentIdx; c++ {
originalLine[c] = t.Trimmer(originalLine[c])
if c >= len(originalLine) || originalLine[c] != "" {
allEmptyBefore = false
break
}
}
if allEmptyBefore {
t.logger.Debugf("Footer lead-merge applied line %d: content '%s' from col %d moved to col %d, span %d",
lineIdx, firstContent, firstContentIdx, startCol, span)
if startCol < len(currentLineResult) {
currentLineResult[startCol] = firstContent // Place the original content
}
for k := startCol + 1; k < startCol+span; k++ { // Clear out other cells in the span
if k < len(currentLineResult) {
currentLineResult[k] = tw.Empty
}
}
// Update mergeMap for all visual lines of this logical row
for visualLine := 0; visualLine < len(result); visualLine++ {
// Only apply the data move to the line where it was detected,
// but the merge state should apply to the logical cell (all its visual lines).
if visualLine != lineIdx { // For other visual lines, just clear the cells in the span
if startCol < len(result[visualLine]) {
result[visualLine][startCol] = tw.Empty // Typically empty for other lines in a lead merge
}
for k := startCol + 1; k < startCol+span; k++ {
if k < len(result[visualLine]) {
result[visualLine][k] = tw.Empty
}
}
}
}
// Set merge state for the starting column
startState := mergeMap[startCol]
startState.Horizontal = tw.MergeStateOption{Present: true, Span: span, Start: true, End: (span == 1)}
mergeMap[startCol] = startState
horzMergeMap[startCol] = true // Mark this column as processed by a merge
// Set merge state for subsequent columns in the span
for k := startCol + 1; k < startCol+span; k++ {
colState := mergeMap[k]
colState.Horizontal = tw.MergeStateOption{Present: true, Span: span, Start: false, End: k == startCol+span-1}
mergeMap[k] = colState
horzMergeMap[k] = true // Mark as processed
}
}
}
}
// Standard horizontal merge logic based on full logical cell content
col := 0
for col < numCols {
if horzMergeMap[col] { // If already part of a footer lead-merge, skip
col++
continue
}
// Get full content of logical cell 'col'
var currentLogicalCellContentBuilder strings.Builder
for lineIdx := 0; lineIdx < len(content); lineIdx++ {
if col < len(content[lineIdx]) {
currentLogicalCellContentBuilder.WriteString(content[lineIdx][col])
}
}
currentLogicalCellTrimmed := t.Trimmer(currentLogicalCellContentBuilder.String())
if currentLogicalCellTrimmed == "" || currentLogicalCellTrimmed == "-" {
col++
continue
}
span := 1
for nextCol := col + 1; nextCol < numCols; nextCol++ {
if horzMergeMap[nextCol] { // Don't merge into an already merged (e.g. footer lead) column
break
}
var nextLogicalCellContentBuilder strings.Builder
for lineIdx := 0; lineIdx < len(content); lineIdx++ {
if nextCol < len(content[lineIdx]) {
nextLogicalCellContentBuilder.WriteString(content[lineIdx][nextCol])
}
}
nextLogicalCellTrimmed := t.Trimmer(nextLogicalCellContentBuilder.String())
if currentLogicalCellTrimmed == nextLogicalCellTrimmed && nextLogicalCellTrimmed != "-" {
span++
} else {
break
}
}
if span > 1 {
t.logger.Debugf("Standard horizontal merge (logical cell): startCol %d, span %d for content '%s'", col, span, currentLogicalCellTrimmed)
startState := mergeMap[col]
startState.Horizontal = tw.MergeStateOption{Present: true, Span: span, Start: true, End: (span == 1)}
mergeMap[col] = startState
horzMergeMap[col] = true
// For all visual lines, clear out the content of the merged-over cells
for lineIdx := 0; lineIdx < len(result); lineIdx++ {
for k := col + 1; k < col+span; k++ {
if k < len(result[lineIdx]) {
result[lineIdx][k] = tw.Empty
}
}
}
// Set merge state for subsequent columns in the span
for k := col + 1; k < col+span; k++ {
colState := mergeMap[k]
colState.Horizontal = tw.MergeStateOption{Present: true, Span: span, Start: false, End: k == col+span-1}
mergeMap[k] = colState
horzMergeMap[k] = true
}
col += span
} else {
col++
}
}
}
t.logger.Debugf("PrepareWithMerges END: position=%s, lines=%d, mergeMapH: %v", position, len(result), func() map[int]tw.MergeStateOption {
m := make(map[int]tw.MergeStateOption)
for k, v := range mergeMap {
m[k] = v.Horizontal
}
return m
}())
return result, mergeMap, horzMergeMap
}
// render generates the table output using the configured renderer.
// No parameters are required.
// Returns an error if rendering fails in any section.
func (t *Table) render() error {
t.ensureInitialized()
// Save the original writer and schedule its restoration upon function exit.
// This guarantees the table's writer is restored even if errors occur.
originalWriter := t.writer
defer func() {
t.writer = originalWriter
}()
// If a counter is active, wrap the writer in a MultiWriter.
if len(t.counters) > 0 {
// The slice must be of type io.Writer.
// Start it with the original destination writer.
allWriters := []io.Writer{originalWriter}
// Append each counter to the slice of writers.
for _, c := range t.counters {
allWriters = append(allWriters, c)
}
// Create a MultiWriter that broadcasts to the original writer AND all counters.
t.writer = io.MultiWriter(allWriters...)
}
if t.config.Stream.Enable {
t.logger.Warn("Render() called in streaming mode. Use Start/Append/Close methods instead.")
return errors.New("render called in streaming mode; use Start/Append/Close")
}
// Calculate and cache the column count for this specific batch render pass.
t.batchRenderNumCols = t.maxColumns()
t.isBatchRenderNumColsSet = true
defer func() {
t.isBatchRenderNumColsSet = false
t.logger.Debugf("Render(): Cleared isBatchRenderNumColsSet to false (batchRenderNumCols was %d).", t.batchRenderNumCols)
}()
hasCaption := t.caption.Text != "" && t.caption.Spot != tw.SpotNone
isTopOrBottomCaption := hasCaption &&
(t.caption.Spot >= tw.SpotTopLeft && t.caption.Spot <= tw.SpotBottomRight)
var tableStringBuffer *strings.Builder
targetWriter := t.writer // Can be the original writer or the MultiWriter.
// If a caption is present, the main table content must be rendered to an
// in-memory buffer first to calculate its final width.
if isTopOrBottomCaption {
tableStringBuffer = &strings.Builder{}
targetWriter = tableStringBuffer
t.logger.Debugf("Top/Bottom caption detected. Rendering table core to buffer first.")
} else {
t.logger.Debugf("No caption detected. Rendering table core directly to writer.")
}
// Point the table's writer to the target (either the final destination or the buffer).
t.writer = targetWriter
ctx, mctx, err := t.prepareContexts()
if err != nil {
t.logger.Errorf("prepareContexts failed: %v", err)
return errors.Newf("failed to prepare table contexts").Wrap(err)
}
if err := ctx.renderer.Start(t.writer); err != nil {
t.logger.Errorf("Renderer Start() error: %v", err)
return errors.Newf("renderer start failed").Wrap(err)
}
renderError := false
var firstRenderErr error
renderFuncs := []func(*renderContext, *mergeContext) error{
t.renderHeader,
t.renderRow,
t.renderFooter,
}
for i, renderFn := range renderFuncs {
sectionName := []string{"Header", "Row", "Footer"}[i]
if renderErr := renderFn(ctx, mctx); renderErr != nil {
t.logger.Errorf("Renderer section error (%s): %v", sectionName, renderErr)
if !renderError {
firstRenderErr = errors.Newf("failed to render %s section", sectionName).Wrap(renderErr)
}
renderError = true
break
}
}
if closeErr := ctx.renderer.Close(); closeErr != nil {
t.logger.Errorf("Renderer Close() error: %v", closeErr)
if !renderError {
firstRenderErr = errors.Newf("renderer close failed").Wrap(closeErr)
}
renderError = true
}
// Restore the writer to the original for the caption-handling logic.
// This is necessary because the caption must be written to the final
// destination, not the temporary buffer used for the table body.
t.writer = originalWriter
if renderError {
return firstRenderErr
}
// Caption Handling & Final Output
if isTopOrBottomCaption {
renderedTableContent := tableStringBuffer.String()
t.logger.Debugf("[Render] Table core buffer length: %d", len(renderedTableContent))
// Handle edge case where table is empty but should have borders.
shouldHaveBorders := t.renderer != nil && (t.renderer.Config().Borders.Top.Enabled() || t.renderer.Config().Borders.Bottom.Enabled())
if len(renderedTableContent) == 0 && shouldHaveBorders {
var sb strings.Builder
if t.renderer.Config().Borders.Top.Enabled() {
sb.WriteString("+--+")
sb.WriteString(t.newLine)
}
if t.renderer.Config().Borders.Bottom.Enabled() {
sb.WriteString("+--+")
}
renderedTableContent = sb.String()
t.logger.Warnf("[Render] Table buffer was empty despite enabled borders. Manually generated minimal output: %q", renderedTableContent)
}
actualTableWidth := 0
trimmedBuffer := strings.TrimRight(renderedTableContent, "\r\n \t")
for _, line := range strings.Split(trimmedBuffer, "\n") {
w := twwidth.Width(line)
if w > actualTableWidth {
actualTableWidth = w
}
}
t.logger.Debugf("[Render] Calculated actual table width: %d (from content: %q)", actualTableWidth, renderedTableContent)
isTopCaption := t.caption.Spot >= tw.SpotTopLeft && t.caption.Spot <= tw.SpotTopRight
if isTopCaption {
t.logger.Debugf("[Render] Printing Top Caption.")
t.printTopBottomCaption(t.writer, actualTableWidth)
}
if len(renderedTableContent) > 0 {
t.logger.Debugf("[Render] Printing table content (length %d) to final writer.", len(renderedTableContent))
t.writer.Write([]byte(renderedTableContent))
if !isTopCaption && t.caption.Text != "" && !strings.HasSuffix(renderedTableContent, t.newLine) {
t.writer.Write([]byte(tw.NewLine))
t.logger.Debugf("[Render] Added trailing newline after table content before bottom caption.")
}
} else {
t.logger.Debugf("[Render] No table content (original buffer or generated) to print.")
}
if !isTopCaption {
t.logger.Debugf("[Render] Calling printTopBottomCaption for Bottom Caption. Width: %d", actualTableWidth)
t.printTopBottomCaption(t.writer, actualTableWidth)
t.logger.Debugf("[Render] Returned from printTopBottomCaption for Bottom Caption.")
}
}
t.hasPrinted = true
t.logger.Info("Render() completed.")
return nil
}
// renderFooter renders the table's footer section with borders and padding.
// Parameters ctx and mctx hold rendering and merge state.
// Returns an error if rendering fails.
func (t *Table) renderFooter(ctx *renderContext, mctx *mergeContext) error {
if !ctx.footerPrepared {
t.prepareFooter(ctx, mctx)
}
f := ctx.renderer
cfg := ctx.cfg
hasContent := len(ctx.footerLines) > 0
hasTopPadding := t.config.Footer.Padding.Global.Top != tw.Empty
hasBottomPaddingConfig := t.config.Footer.Padding.Global.Bottom != tw.Empty || t.hasPerColumnBottomPadding()
hasAnyFooterElement := hasContent || hasTopPadding || hasBottomPaddingConfig
if !hasAnyFooterElement {
hasContentAbove := len(ctx.rowLines) > 0 || len(ctx.headerLines) > 0
if hasContentAbove && cfg.Borders.Bottom.Enabled() && cfg.Settings.Lines.ShowBottom.Enabled() {
ctx.logger.Debugf("Footer is empty, rendering table bottom border based on last row/header")
var lastLineAboveCtx *helperContext
var lastLineAligns map[int]tw.Align
var lastLinePadding map[int]tw.Padding
if len(ctx.rowLines) > 0 {
lastRowIdx := len(ctx.rowLines) - 1
lastRowLineIdx := -1
var lastRowLine []string
if lastRowIdx >= 0 && len(ctx.rowLines[lastRowIdx]) > 0 {
lastRowLineIdx = len(ctx.rowLines[lastRowIdx]) - 1
lastRowLine = padLine(ctx.rowLines[lastRowIdx][lastRowLineIdx], ctx.numCols)
} else {
lastRowLine = make([]string, ctx.numCols)
}
lastLineAboveCtx = &helperContext{
position: tw.Row,
rowIdx: lastRowIdx,
lineIdx: lastRowLineIdx,
line: lastRowLine,
location: tw.LocationEnd,
}
lastLineAligns = t.buildAligns(t.config.Row)
lastLinePadding = t.buildPadding(t.config.Row.Padding)
} else {
lastHeaderLineIdx := -1
var lastHeaderLine []string
if len(ctx.headerLines) > 0 {
lastHeaderLineIdx = len(ctx.headerLines) - 1
lastHeaderLine = padLine(ctx.headerLines[lastHeaderLineIdx], ctx.numCols)
} else {
lastHeaderLine = make([]string, ctx.numCols)
}
lastLineAboveCtx = &helperContext{
position: tw.Header,
rowIdx: 0,
lineIdx: lastHeaderLineIdx,
line: lastHeaderLine,
location: tw.LocationEnd,
}
lastLineAligns = t.buildAligns(t.config.Header)
lastLinePadding = t.buildPadding(t.config.Header.Padding)
}
resp := t.buildCellContexts(ctx, mctx, lastLineAboveCtx, lastLineAligns, lastLinePadding)
ctx.logger.Debugf("Bottom border: Using Widths=%v", ctx.widths[tw.Row])
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: ctx.widths[tw.Row],
Current: resp.cells,
Previous: resp.prevCells,
Position: lastLineAboveCtx.position,
Location: tw.LocationEnd,
ColMaxWidths: t.getColMaxWidths(tw.Footer),
},
Level: tw.LevelFooter,
IsSubRow: false,
})
} else {
ctx.logger.Debugf("Footer is empty and no content above or borders disabled, skipping footer render")
}
return nil
}
ctx.logger.Debugf("Rendering footer section (has elements)")
hasContentAbove := len(ctx.rowLines) > 0 || len(ctx.headerLines) > 0
colAligns := t.buildAligns(t.config.Footer)
colPadding := t.buildPadding(t.config.Footer.Padding)
hctx := &helperContext{position: tw.Footer}
// Declare paddingLineContentForContext with a default value
paddingLineContentForContext := make([]string, ctx.numCols)
if hasContentAbove && cfg.Settings.Lines.ShowFooterLine.Enabled() && !hasTopPadding && len(ctx.footerLines) > 0 {
ctx.logger.Debugf("Rendering footer separator line")
var lastLineAboveCtx *helperContext
var lastLineAligns map[int]tw.Align
var lastLinePadding map[int]tw.Padding
var lastLinePosition tw.Position
if len(ctx.rowLines) > 0 {
lastRowIdx := len(ctx.rowLines) - 1
lastRowLineIdx := -1
var lastRowLine []string
if lastRowIdx >= 0 && len(ctx.rowLines[lastRowIdx]) > 0 {
lastRowLineIdx = len(ctx.rowLines[lastRowIdx]) - 1
lastRowLine = padLine(ctx.rowLines[lastRowIdx][lastRowLineIdx], ctx.numCols)
} else {
lastRowLine = make([]string, ctx.numCols)
}
lastLineAboveCtx = &helperContext{
position: tw.Row,
rowIdx: lastRowIdx,
lineIdx: lastRowLineIdx,
line: lastRowLine,
location: tw.LocationMiddle,
}
lastLineAligns = t.buildAligns(t.config.Row)
lastLinePadding = t.buildPadding(t.config.Row.Padding)
lastLinePosition = tw.Row
} else {
lastHeaderLineIdx := -1
var lastHeaderLine []string
if len(ctx.headerLines) > 0 {
lastHeaderLineIdx = len(ctx.headerLines) - 1
lastHeaderLine = padLine(ctx.headerLines[lastHeaderLineIdx], ctx.numCols)
} else {
lastHeaderLine = make([]string, ctx.numCols)
}
lastLineAboveCtx = &helperContext{
position: tw.Header,
rowIdx: 0,
lineIdx: lastHeaderLineIdx,
line: lastHeaderLine,
location: tw.LocationMiddle,
}
lastLineAligns = t.buildAligns(t.config.Header)
lastLinePadding = t.buildPadding(t.config.Header.Padding)
lastLinePosition = tw.Header
}
resp := t.buildCellContexts(ctx, mctx, lastLineAboveCtx, lastLineAligns, lastLinePadding)
var nextCells map[int]tw.CellContext
if hasContent {
nextCells = make(map[int]tw.CellContext)
for j, cellData := range padLine(ctx.footerLines[0], ctx.numCols) {
mergeState := tw.MergeState{}
if mctx.footerMerges != nil {
mergeState = mctx.footerMerges[j]
}
nextCells[j] = tw.CellContext{Data: cellData, Merge: mergeState, Width: ctx.widths[tw.Footer].Get(j)}
}
}
ctx.logger.Debugf("Footer separator: Using Widths=%v", ctx.widths[tw.Row])
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: ctx.widths[tw.Row],
Current: resp.cells,
Previous: resp.prevCells,
Next: nextCells,
Position: lastLinePosition,
Location: tw.LocationMiddle,
ColMaxWidths: t.getColMaxWidths(tw.Footer),
},
Level: tw.LevelFooter,
IsSubRow: false,
HasFooter: true,
})
}
if hasTopPadding {
hctx.rowIdx = 0
hctx.lineIdx = -1
if !hasContentAbove || !cfg.Settings.Lines.ShowFooterLine.Enabled() {
hctx.location = tw.LocationFirst
} else {
hctx.location = tw.LocationMiddle
}
hctx.line = t.buildPaddingLineContents(t.config.Footer.Padding.Global.Top, ctx.widths[tw.Footer], ctx.numCols, mctx.footerMerges)
ctx.logger.Debugf("Calling renderPadding for Footer Top Padding line: %v (loc: %v)", hctx.line, hctx.location)
if err := t.renderPadding(ctx, mctx, hctx, t.config.Footer.Padding.Global.Top); err != nil {
return err
}
}
lastRenderedLineIdx := -2
if hasTopPadding {
lastRenderedLineIdx = -1
}
for i, line := range ctx.footerLines {
hctx.rowIdx = 0
hctx.lineIdx = i
hctx.line = padLine(line, ctx.numCols)
isFirstContentLine := i == 0
isLastContentLine := i == len(ctx.footerLines)-1
if isFirstContentLine && !hasTopPadding && (!hasContentAbove || !cfg.Settings.Lines.ShowFooterLine.Enabled()) {
hctx.location = tw.LocationFirst
} else if isLastContentLine && !hasBottomPaddingConfig {
hctx.location = tw.LocationEnd
} else {
hctx.location = tw.LocationMiddle
}
ctx.logger.Debugf("Rendering footer content line %d with location %v", i, hctx.location)
if err := t.renderLine(ctx, mctx, hctx, colAligns, colPadding); err != nil {
return err
}
lastRenderedLineIdx = i
}
if hasBottomPaddingConfig {
paddingLineContentForContext = make([]string, ctx.numCols)
formattedPaddingCells := make([]string, ctx.numCols)
representativePadChar := " "
ctx.logger.Debugf("Constructing Footer Bottom Padding line content strings")
for j := 0; j < ctx.numCols; j++ {
colWd := ctx.widths[tw.Footer].Get(j)
mergeState := tw.MergeState{}
if mctx.footerMerges != nil {
if state, ok := mctx.footerMerges[j]; ok {
mergeState = state
}
}
if mergeState.Horizontal.Present && !mergeState.Horizontal.Start {
paddingLineContentForContext[j] = ""
formattedPaddingCells[j] = ""
continue
}
padChar := " "
if j < len(t.config.Footer.Padding.PerColumn) && t.config.Footer.Padding.PerColumn[j].Bottom != tw.Empty {
padChar = t.config.Footer.Padding.PerColumn[j].Bottom
} else if t.config.Footer.Padding.Global.Bottom != tw.Empty {
padChar = t.config.Footer.Padding.Global.Bottom
}
paddingLineContentForContext[j] = padChar
if j == 0 || representativePadChar == " " {
representativePadChar = padChar
}
padWidth := max(twwidth.Width(padChar), 1)
repeatCount := 0
if colWd > 0 && padWidth > 0 {
repeatCount = colWd / padWidth
}
if colWd > 0 && repeatCount < 1 && padChar != " " {
repeatCount = 1
}
if colWd == 0 {
repeatCount = 0
}
rawPaddingContent := strings.Repeat(padChar, repeatCount)
currentWd := twwidth.Width(rawPaddingContent)
if currentWd < colWd {
rawPaddingContent += strings.Repeat(" ", colWd-currentWd)
}
if currentWd > colWd && colWd > 0 {
rawPaddingContent = twwidth.Truncate(rawPaddingContent, colWd)
}
if colWd == 0 {
rawPaddingContent = ""
}
formattedPaddingCells[j] = rawPaddingContent
}
ctx.logger.Debugf("Manually rendering Footer Bottom Padding line (char like '%s')", representativePadChar)
var paddingLineOutput strings.Builder
if cfg.Borders.Left.Enabled() {
paddingLineOutput.WriteString(cfg.Symbols.Column())
}
for colIdx := 0; colIdx < ctx.numCols; {
if colIdx > 0 && cfg.Settings.Separators.BetweenColumns.Enabled() {
shouldAddSeparator := true
if prevMergeState, ok := mctx.footerMerges[colIdx-1]; ok {
if prevMergeState.Horizontal.Present && !prevMergeState.Horizontal.End {
shouldAddSeparator = false
}
}
if shouldAddSeparator {
paddingLineOutput.WriteString(cfg.Symbols.Column())
}
}
if colIdx < len(formattedPaddingCells) {
paddingLineOutput.WriteString(formattedPaddingCells[colIdx])
}
currentMergeState := tw.MergeState{}
if mctx.footerMerges != nil {
if state, ok := mctx.footerMerges[colIdx]; ok {
currentMergeState = state
}
}
if currentMergeState.Horizontal.Present && currentMergeState.Horizontal.Start {
colIdx += currentMergeState.Horizontal.Span
} else {
colIdx++
}
}
if cfg.Borders.Right.Enabled() {
paddingLineOutput.WriteString(cfg.Symbols.Column())
}
paddingLineOutput.WriteString(t.newLine)
t.writer.Write([]byte(paddingLineOutput.String()))
ctx.logger.Debugf("Manually rendered Footer Bottom Padding line: %s", strings.TrimSuffix(paddingLineOutput.String(), t.newLine))
hctx.rowIdx = 0
hctx.lineIdx = len(ctx.footerLines)
hctx.line = paddingLineContentForContext
hctx.location = tw.LocationEnd
lastRenderedLineIdx = hctx.lineIdx
}
if cfg.Borders.Bottom.Enabled() && cfg.Settings.Lines.ShowBottom.Enabled() {
ctx.logger.Debugf("Rendering final table bottom border")
if lastRenderedLineIdx == len(ctx.footerLines) {
hctx.rowIdx = 0
hctx.lineIdx = lastRenderedLineIdx
hctx.line = paddingLineContentForContext
hctx.location = tw.LocationEnd
ctx.logger.Debugf("Setting border context based on bottom padding line")
} else if lastRenderedLineIdx >= 0 {
hctx.rowIdx = 0
hctx.lineIdx = lastRenderedLineIdx
hctx.line = padLine(ctx.footerLines[hctx.lineIdx], ctx.numCols)
hctx.location = tw.LocationEnd
ctx.logger.Debugf("Setting border context based on last content line idx %d", hctx.lineIdx)
} else if lastRenderedLineIdx == -1 {
hctx.rowIdx = 0
hctx.lineIdx = -1
hctx.line = paddingLineContentForContext
hctx.location = tw.LocationEnd
ctx.logger.Debugf("Setting border context based on top padding line")
} else {
hctx.rowIdx = 0
hctx.lineIdx = -2
hctx.line = make([]string, ctx.numCols)
hctx.location = tw.LocationEnd
ctx.logger.Debugf("Warning: Cannot determine context for bottom border")
}
resp := t.buildCellContexts(ctx, mctx, hctx, colAligns, colPadding)
ctx.logger.Debugf("Bottom border: Using Widths=%v", ctx.widths[tw.Row])
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: ctx.widths[tw.Row],
Current: resp.cells,
Previous: resp.prevCells,
Position: tw.Footer,
Location: tw.LocationEnd,
ColMaxWidths: t.getColMaxWidths(tw.Footer),
},
Level: tw.LevelFooter,
IsSubRow: false,
})
}
return nil
}
// renderHeader renders the table's header section with borders and padding.
// Parameters ctx and mctx hold rendering and merge state.
// Returns an error if rendering fails.
func (t *Table) renderHeader(ctx *renderContext, mctx *mergeContext) error {
if len(ctx.headerLines) == 0 {
return nil
}
ctx.logger.Debug("Rendering header section")
f := ctx.renderer
cfg := ctx.cfg
colAligns := t.buildAligns(t.config.Header)
colPadding := t.buildPadding(t.config.Header.Padding)
hctx := &helperContext{position: tw.Header}
if cfg.Borders.Top.Enabled() && cfg.Settings.Lines.ShowTop.Enabled() {
ctx.logger.Debug("Rendering table top border")
nextCells := make(map[int]tw.CellContext)
if len(ctx.headerLines) > 0 {
for j, cell := range ctx.headerLines[0] {
nextCells[j] = tw.CellContext{Data: cell, Merge: mctx.headerMerges[j]}
}
}
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: ctx.widths[tw.Header],
Next: nextCells,
Position: tw.Header,
Location: tw.LocationFirst,
},
Level: tw.LevelHeader,
IsSubRow: false,
})
}
if t.config.Header.Padding.Global.Top != tw.Empty {
hctx.location = tw.LocationFirst
hctx.line = t.buildPaddingLineContents(t.config.Header.Padding.Global.Top, ctx.widths[tw.Header], ctx.numCols, mctx.headerMerges)
if err := t.renderPadding(ctx, mctx, hctx, t.config.Header.Padding.Global.Top); err != nil {
return err
}
}
for i, line := range ctx.headerLines {
hctx.rowIdx = 0
hctx.lineIdx = i
hctx.line = padLine(line, ctx.numCols)
hctx.location = t.determineLocation(i, len(ctx.headerLines), t.config.Header.Padding.Global.Top, t.config.Header.Padding.Global.Bottom)
if t.config.Header.Callbacks.Global != nil {
ctx.logger.Debug("Executing global header callback for line %d", i)
t.config.Header.Callbacks.Global()
}
for colIdx, cb := range t.config.Header.Callbacks.PerColumn {
if colIdx < ctx.numCols && cb != nil {
ctx.logger.Debug("Executing per-column header callback for line %d, col %d", i, colIdx)
cb()
}
}
if err := t.renderLine(ctx, mctx, hctx, colAligns, colPadding); err != nil {
return err
}
}
if t.config.Header.Padding.Global.Bottom != tw.Empty {
hctx.location = tw.LocationEnd
hctx.line = t.buildPaddingLineContents(t.config.Header.Padding.Global.Bottom, ctx.widths[tw.Header], ctx.numCols, mctx.headerMerges)
if err := t.renderPadding(ctx, mctx, hctx, t.config.Header.Padding.Global.Bottom); err != nil {
return err
}
}
if cfg.Settings.Lines.ShowHeaderLine.Enabled() && (len(ctx.rowLines) > 0 || len(ctx.footerLines) > 0) {
ctx.logger.Debug("Rendering header separator line")
resp := t.buildCellContexts(ctx, mctx, hctx, colAligns, colPadding)
var nextSectionCells map[int]tw.CellContext
var nextSectionWidths tw.Mapper[int, int]
if len(ctx.rowLines) > 0 {
nextSectionWidths = ctx.widths[tw.Row]
rowColAligns := t.buildAligns(t.config.Row)
rowColPadding := t.buildPadding(t.config.Row.Padding)
firstRowHctx := &helperContext{
position: tw.Row,
rowIdx: 0,
lineIdx: 0,
}
if len(ctx.rowLines[0]) > 0 {
firstRowHctx.line = padLine(ctx.rowLines[0][0], ctx.numCols)
} else {
firstRowHctx.line = make([]string, ctx.numCols)
}
firstRowResp := t.buildCellContexts(ctx, mctx, firstRowHctx, rowColAligns, rowColPadding)
nextSectionCells = firstRowResp.cells
} else if len(ctx.footerLines) > 0 {
nextSectionWidths = ctx.widths[tw.Row]
footerColAligns := t.buildAligns(t.config.Footer)
footerColPadding := t.buildPadding(t.config.Footer.Padding)
firstFooterHctx := &helperContext{
position: tw.Footer,
rowIdx: 0,
lineIdx: 0,
}
if len(ctx.footerLines) > 0 {
firstFooterHctx.line = padLine(ctx.footerLines[0], ctx.numCols)
} else {
firstFooterHctx.line = make([]string, ctx.numCols)
}
firstFooterResp := t.buildCellContexts(ctx, mctx, firstFooterHctx, footerColAligns, footerColPadding)
nextSectionCells = firstFooterResp.cells
} else {
nextSectionWidths = ctx.widths[tw.Header]
nextSectionCells = nil
}
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: nextSectionWidths,
Current: resp.cells,
Previous: resp.prevCells,
Next: nextSectionCells,
Position: tw.Header,
Location: tw.LocationMiddle,
},
Level: tw.LevelBody,
IsSubRow: false,
})
}
return nil
}
// renderLine renders a single line with callbacks and normalized widths.
// Parameters include ctx, mctx, hctx, aligns, and padding for rendering.
// Returns an error if rendering fails.
func (t *Table) renderLine(ctx *renderContext, mctx *mergeContext, hctx *helperContext, aligns map[int]tw.Align, padding map[int]tw.Padding) error {
resp := t.buildCellContexts(ctx, mctx, hctx, aligns, padding)
f := ctx.renderer
isPaddingLine := false
sectionConfig := t.config.Row
switch hctx.position {
case tw.Header:
sectionConfig = t.config.Header
isPaddingLine = (hctx.lineIdx == -1 && sectionConfig.Padding.Global.Top != tw.Empty) ||
(hctx.lineIdx == len(ctx.headerLines) && sectionConfig.Padding.Global.Bottom != tw.Empty)
case tw.Footer:
sectionConfig = t.config.Footer
isPaddingLine = (hctx.lineIdx == -1 && sectionConfig.Padding.Global.Top != tw.Empty) ||
(hctx.lineIdx == len(ctx.footerLines) && (sectionConfig.Padding.Global.Bottom != tw.Empty || t.hasPerColumnBottomPadding()))
case tw.Row:
if hctx.rowIdx >= 0 && hctx.rowIdx < len(ctx.rowLines) {
isPaddingLine = (hctx.lineIdx == -1 && sectionConfig.Padding.Global.Top != tw.Empty) ||
(hctx.lineIdx == len(ctx.rowLines[hctx.rowIdx]) && sectionConfig.Padding.Global.Bottom != tw.Empty)
}
}
sectionWidths := ctx.widths[hctx.position]
normalizedWidths := ctx.widths[tw.Row]
formatting := tw.Formatting{
Row: tw.RowContext{
Widths: sectionWidths,
ColMaxWidths: t.getColMaxWidths(hctx.position),
Current: resp.cells,
Previous: resp.prevCells,
Next: resp.nextCells,
Position: hctx.position,
Location: hctx.location,
},
Level: t.getLevel(hctx.position),
IsSubRow: hctx.lineIdx > 0 || isPaddingLine,
NormalizedWidths: normalizedWidths,
}
if hctx.position == tw.Row {
formatting.HasFooter = len(ctx.footerLines) > 0
}
switch hctx.position {
case tw.Header:
f.Header([][]string{hctx.line}, formatting)
case tw.Row:
f.Row(hctx.line, formatting)
case tw.Footer:
f.Footer([][]string{hctx.line}, formatting)
}
return nil
}
// renderPadding renders padding lines for a section.
// Parameters include ctx, mctx, hctx, and padChar for padding content.
// Returns an error if rendering fails.
func (t *Table) renderPadding(ctx *renderContext, mctx *mergeContext, hctx *helperContext, padChar string) error {
ctx.logger.Debug("Rendering padding line for %s (using char like '%s')", hctx.position, padChar)
colAligns := t.buildAligns(t.config.Row)
colPadding := t.buildPadding(t.config.Row.Padding)
switch hctx.position {
case tw.Header:
colAligns = t.buildAligns(t.config.Header)
colPadding = t.buildPadding(t.config.Header.Padding)
case tw.Footer:
colAligns = t.buildAligns(t.config.Footer)
colPadding = t.buildPadding(t.config.Footer.Padding)
}
return t.renderLine(ctx, mctx, hctx, colAligns, colPadding)
}
// renderRow renders the table's row section with borders and padding.
// Parameters ctx and mctx hold rendering and merge state.
// Returns an error if rendering fails.
func (t *Table) renderRow(ctx *renderContext, mctx *mergeContext) error {
if len(ctx.rowLines) == 0 {
return nil
}
ctx.logger.Debugf("Rendering row section (total rows: %d)", len(ctx.rowLines))
f := ctx.renderer
cfg := ctx.cfg
colAligns := t.buildAligns(t.config.Row)
colPadding := t.buildPadding(t.config.Row.Padding)
hctx := &helperContext{position: tw.Row}
footerIsEmptyOrNonExistent := !t.hasFooterElements()
if len(ctx.headerLines) == 0 && footerIsEmptyOrNonExistent && cfg.Borders.Top.Enabled() && cfg.Settings.Lines.ShowTop.Enabled() {
ctx.logger.Debug("Rendering table top border (rows only table)")
nextCells := make(map[int]tw.CellContext)
if len(ctx.rowLines) > 0 && len(ctx.rowLines[0]) > 0 && len(mctx.rowMerges) > 0 {
firstLine := ctx.rowLines[0][0]
firstMerges := mctx.rowMerges[0]
for j, cell := range padLine(firstLine, ctx.numCols) {
mergeState := tw.MergeState{}
if firstMerges != nil {
mergeState = firstMerges[j]
}
nextCells[j] = tw.CellContext{Data: cell, Merge: mergeState, Width: ctx.widths[tw.Row].Get(j)}
}
}
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: ctx.widths[tw.Row],
Next: nextCells,
Position: tw.Row,
Location: tw.LocationFirst,
},
Level: tw.LevelHeader,
IsSubRow: false,
})
}
for i, lines := range ctx.rowLines {
rowHasTopPadding := t.config.Row.Padding.Global.Top != tw.Empty
if rowHasTopPadding {
hctx.rowIdx = i
hctx.lineIdx = -1
if i == 0 && len(ctx.headerLines) == 0 {
hctx.location = tw.LocationFirst
} else {
hctx.location = tw.LocationMiddle
}
hctx.line = t.buildPaddingLineContents(t.config.Row.Padding.Global.Top, ctx.widths[tw.Row], ctx.numCols, mctx.rowMerges[i])
ctx.logger.Debug("Calling renderPadding for Row Top Padding (row %d): %v (loc: %v)", i, hctx.line, hctx.location)
if err := t.renderPadding(ctx, mctx, hctx, t.config.Row.Padding.Global.Top); err != nil {
return err
}
}
footerExists := t.hasFooterElements()
rowHasBottomPadding := t.config.Row.Padding.Global.Bottom != tw.Empty
isLastRow := i == len(ctx.rowLines)-1
for j, visualLineData := range lines {
hctx.rowIdx = i
hctx.lineIdx = j
hctx.line = padLine(visualLineData, ctx.numCols)
if t.config.Behavior.TrimLine.Enabled() {
if j > 0 {
visualLineHasActualContent := false
for kCellIdx, cellContentInVisualLine := range hctx.line {
if t.Trimmer(cellContentInVisualLine) != "" {
visualLineHasActualContent = true
ctx.logger.Debug("Visual line [%d][%d] has content in cell %d: '%s'. Not skipping.", i, j, kCellIdx, cellContentInVisualLine)
break
}
}
if !visualLineHasActualContent {
ctx.logger.Debug("Skipping visual line [%d][%d] as it's entirely blank after trimming. Line: %q", i, j, hctx.line)
continue
}
}
}
isFirstRow := i == 0
isLastLineOfRow := j == len(lines)-1
if isFirstRow && j == 0 && !rowHasTopPadding && len(ctx.headerLines) == 0 {
hctx.location = tw.LocationFirst
} else if isLastRow && isLastLineOfRow && !rowHasBottomPadding && !footerExists {
hctx.location = tw.LocationEnd
} else {
hctx.location = tw.LocationMiddle
}
ctx.logger.Debugf("Rendering row %d line %d with location %v. Content: %q", i, j, hctx.location, hctx.line)
if err := t.renderLine(ctx, mctx, hctx, colAligns, colPadding); err != nil {
return err
}
}
if rowHasBottomPadding {
hctx.rowIdx = i
hctx.lineIdx = len(lines)
if isLastRow && !footerExists {
hctx.location = tw.LocationEnd
} else {
hctx.location = tw.LocationMiddle
}
hctx.line = t.buildPaddingLineContents(t.config.Row.Padding.Global.Bottom, ctx.widths[tw.Row], ctx.numCols, mctx.rowMerges[i])
ctx.logger.Debug("Calling renderPadding for Row Bottom Padding (row %d): %v (loc: %v)", i, hctx.line, hctx.location)
if err := t.renderPadding(ctx, mctx, hctx, t.config.Row.Padding.Global.Bottom); err != nil {
return err
}
}
if cfg.Settings.Separators.BetweenRows.Enabled() && !isLastRow {
ctx.logger.Debug("Rendering between-rows separator after logical row %d", i)
respCurrent := t.buildCellContexts(ctx, mctx, hctx, colAligns, colPadding)
var nextCellsForSeparator map[int]tw.CellContext = nil
nextRowIdx := i + 1
if nextRowIdx < len(ctx.rowLines) && nextRowIdx < len(mctx.rowMerges) {
hctxNext := &helperContext{position: tw.Row, rowIdx: nextRowIdx, location: tw.LocationMiddle}
nextRowActualLines := ctx.rowLines[nextRowIdx]
nextRowMerges := mctx.rowMerges[nextRowIdx]
if t.config.Row.Padding.Global.Top != tw.Empty {
hctxNext.lineIdx = -1
hctxNext.line = t.buildPaddingLineContents(t.config.Row.Padding.Global.Top, ctx.widths[tw.Row], ctx.numCols, nextRowMerges)
} else if len(nextRowActualLines) > 0 {
hctxNext.lineIdx = 0
hctxNext.line = padLine(nextRowActualLines[0], ctx.numCols)
} else {
hctxNext.lineIdx = 0
hctxNext.line = make([]string, ctx.numCols)
}
respNext := t.buildCellContexts(ctx, mctx, hctxNext, colAligns, colPadding)
nextCellsForSeparator = respNext.cells
} else {
ctx.logger.Debug("Separator context: No next logical row for separator after row %d.", i)
}
f.Line(tw.Formatting{
Row: tw.RowContext{
Widths: ctx.widths[tw.Row],
Current: respCurrent.cells,
Previous: respCurrent.prevCells,
Next: nextCellsForSeparator,
Position: tw.Row,
Location: tw.LocationMiddle,
ColMaxWidths: t.getColMaxWidths(tw.Row),
},
Level: tw.LevelBody,
IsSubRow: false,
HasFooter: footerExists,
})
}
}
return nil
}