summary history files

vendor/github.com/olekukonko/tablewriter/renderer/html.go
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"),
	}
}

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
}