Files
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

443 lines
12 KiB
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
}