package renderer import ( "errors" "fmt" "github.com/olekukonko/ll" "html" "io" "strings" "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 tags TableClass string // CSS class for HeaderClass string // CSS class for BodyClass string // CSS class for FooterClass string // CSS class for RowClass string // CSS class for in body HeaderRowClass string // CSS class for in header FooterRowClass string // CSS class for 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
tag is open tbodyStarted bool // Tracks if tag is open tfootStarted bool // Tracks if 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
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 tag", classAttr) _, err := fmt.Fprintf(h.w, "\n", classAttr) if err != nil { return err } h.tableStarted = true return nil } // closePreviousSection closes any open or sections. func (h *HTML) closePreviousSection() { if h.tbodyStarted { h.logger.Debug("Closing tag") fmt.Fprintln(h.w, "") h.tbodyStarted = false } if h.tfootStarted { h.logger.Debug("Closing tag") fmt.Fprintln(h.w, "") h.tfootStarted = false } } // Header renders the 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, "\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", 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", 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, "\n") fmt.Fprintln(h.w, "") } // Row renders a element within , 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 tag", classAttr) fmt.Fprintf(h.w, "\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", 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", 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, "\n") } // Footer renders the 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, "\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", 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", 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, "\n") fmt.Fprintln(h.w, "") 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, "
") } else { processedContent = html.EscapeString(originalContent) } } else if containsNewline { processedContent = strings.ReplaceAll(originalContent, "\n", "
") } if containsNewline && h.config.AddLinesTag { processedContent = "" + processedContent + "" } // 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 (
, , ) 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
tag.") _, err := fmt.Fprintln(h.w, "
") 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 }