Initial QSfera import
This commit is contained in:
+442
@@ -0,0 +1,442 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// HTMLConfig defines settings for the HTML table renderer.
|
||||
type HTMLConfig struct {
|
||||
EscapeContent bool // Whether to escape cell content
|
||||
AddLinesTag bool // Whether to wrap multiline content in <lines> tags
|
||||
TableClass string // CSS class for <table>
|
||||
HeaderClass string // CSS class for <thead>
|
||||
BodyClass string // CSS class for <tbody>
|
||||
FooterClass string // CSS class for <tfoot>
|
||||
RowClass string // CSS class for <tr> in body
|
||||
HeaderRowClass string // CSS class for <tr> in header
|
||||
FooterRowClass string // CSS class for <tr> in footer
|
||||
}
|
||||
|
||||
// HTML renders tables in HTML format with customizable classes and content handling.
|
||||
type HTML struct {
|
||||
config HTMLConfig // Renderer configuration
|
||||
w io.Writer // Output w
|
||||
trace []string // Debug trace messages
|
||||
debug bool // Enables debug logging
|
||||
tableStarted bool // Tracks if <table> tag is open
|
||||
tbodyStarted bool // Tracks if <tbody> tag is open
|
||||
tfootStarted bool // Tracks if <tfoot> tag is open
|
||||
vMergeTrack map[int]int // Tracks vertical merge spans by column index
|
||||
logger *ll.Logger
|
||||
}
|
||||
|
||||
// NewHTML initializes an HTML renderer with the given w, debug setting, and optional configuration.
|
||||
// It panics if the w is nil and applies defaults for unset config fields.
|
||||
// Update: see https://github.com/olekukonko/tablewriter/issues/258
|
||||
func NewHTML(configs ...HTMLConfig) *HTML {
|
||||
cfg := HTMLConfig{
|
||||
EscapeContent: true,
|
||||
AddLinesTag: false,
|
||||
}
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
cfg.EscapeContent = userCfg.EscapeContent
|
||||
cfg.AddLinesTag = userCfg.AddLinesTag
|
||||
cfg.TableClass = userCfg.TableClass
|
||||
cfg.HeaderClass = userCfg.HeaderClass
|
||||
cfg.BodyClass = userCfg.BodyClass
|
||||
cfg.FooterClass = userCfg.FooterClass
|
||||
cfg.RowClass = userCfg.RowClass
|
||||
cfg.HeaderRowClass = userCfg.HeaderRowClass
|
||||
cfg.FooterRowClass = userCfg.FooterRowClass
|
||||
}
|
||||
return &HTML{
|
||||
config: cfg,
|
||||
vMergeTrack: make(map[int]int),
|
||||
tableStarted: false,
|
||||
tbodyStarted: false,
|
||||
tfootStarted: false,
|
||||
logger: ll.New("html").Disable(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTML) Logger(logger *ll.Logger) {
|
||||
h.logger = logger
|
||||
}
|
||||
|
||||
// Config returns a Rendition representation of the current configuration.
|
||||
func (h *HTML) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.BorderNone,
|
||||
Symbols: tw.NewSymbols(tw.StyleNone),
|
||||
Settings: tw.Settings{},
|
||||
Streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
// debugLog appends a formatted message to the debug trace if debugging is enabled.
|
||||
// func (h *HTML) debugLog(format string, a ...interface{}) {
|
||||
// if h.debug {
|
||||
// msg := fmt.Sprintf(format, a...)
|
||||
// h.trace = append(h.trace, fmt.Sprintf("[HTML] %s", msg))
|
||||
// }
|
||||
//}
|
||||
|
||||
// Debug returns the accumulated debug trace messages.
|
||||
func (h *HTML) Debug() []string {
|
||||
return h.trace
|
||||
}
|
||||
|
||||
// Start begins the HTML table rendering by opening the <table> tag.
|
||||
func (h *HTML) Start(w io.Writer) error {
|
||||
h.w = w
|
||||
h.Reset()
|
||||
h.logger.Debug("HTML.Start() called.")
|
||||
|
||||
classAttr := tw.Empty
|
||||
if h.config.TableClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.TableClass)
|
||||
}
|
||||
h.logger.Debugf("Writing opening <table%s> tag", classAttr)
|
||||
_, err := fmt.Fprintf(h.w, "<table%s>\n", classAttr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.tableStarted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// closePreviousSection closes any open <tbody> or <tfoot> sections.
|
||||
func (h *HTML) closePreviousSection() {
|
||||
if h.tbodyStarted {
|
||||
h.logger.Debug("Closing <tbody> tag")
|
||||
fmt.Fprintln(h.w, "</tbody>")
|
||||
h.tbodyStarted = false
|
||||
}
|
||||
if h.tfootStarted {
|
||||
h.logger.Debug("Closing <tfoot> tag")
|
||||
fmt.Fprintln(h.w, "</tfoot>")
|
||||
h.tfootStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
// Header renders the <thead> section with header rows, supporting horizontal merges.
|
||||
func (h *HTML) Header(headers [][]string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Header called before Start")
|
||||
return
|
||||
}
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
h.logger.Debug("Header: No headers")
|
||||
return
|
||||
}
|
||||
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.HeaderClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "<thead%s>\n", classAttr)
|
||||
|
||||
headerRow := headers[0]
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(headerRow) > 0 {
|
||||
numCols = len(headerRow)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.HeaderRowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderRowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
// Skip columns consumed by vertical merges
|
||||
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
|
||||
h.logger.Debugf("Header: Skipping col %d due to vmerge", colIdx)
|
||||
h.vMergeTrack[colIdx]--
|
||||
if h.vMergeTrack[colIdx] <= 1 {
|
||||
delete(h.vMergeTrack, colIdx)
|
||||
}
|
||||
colIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Render cell
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignCenter}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(headerRow) {
|
||||
originalContent = headerRow[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, true, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
// Handle horizontal merges
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
fmt.Fprintln(h.w, "</thead>")
|
||||
}
|
||||
|
||||
// Row renders a <tr> element within <tbody>, supporting horizontal and vertical merges.
|
||||
func (h *HTML) Row(row []string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Row called before Start")
|
||||
return
|
||||
}
|
||||
|
||||
if !h.tbodyStarted {
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.BodyClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.BodyClass)
|
||||
}
|
||||
h.logger.Debugf("Writing opening <tbody%s> tag", classAttr)
|
||||
fmt.Fprintf(h.w, "<tbody%s>\n", classAttr)
|
||||
h.tbodyStarted = true
|
||||
}
|
||||
|
||||
h.logger.Debugf("Rendering row data: %v", row)
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(row) > 0 {
|
||||
numCols = len(row)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.RowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.RowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
// Skip columns consumed by vertical merges
|
||||
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
|
||||
h.logger.Debugf("Row: Skipping render for col %d due to vertical merge (remaining %d)", colIdx, remainingSpan-1)
|
||||
h.vMergeTrack[colIdx]--
|
||||
if h.vMergeTrack[colIdx] <= 1 {
|
||||
delete(h.vMergeTrack, colIdx)
|
||||
}
|
||||
colIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Render cell
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignLeft}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(row) {
|
||||
originalContent = row[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
// Handle horizontal merges
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
}
|
||||
|
||||
// Footer renders the <tfoot> section with footer rows, supporting horizontal merges.
|
||||
func (h *HTML) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Footer called before Start")
|
||||
return
|
||||
}
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
h.logger.Debug("Footer: No footers")
|
||||
return
|
||||
}
|
||||
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.FooterClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.FooterClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "<tfoot%s>\n", classAttr)
|
||||
h.tfootStarted = true
|
||||
|
||||
footerRow := footers[0]
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(footerRow) > 0 {
|
||||
numCols = len(footerRow)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.FooterRowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.FooterRowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignRight}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(footerRow) {
|
||||
originalContent = footerRow[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
fmt.Fprintln(h.w, "</tfoot>")
|
||||
h.tfootStarted = false
|
||||
}
|
||||
|
||||
// renderRowCell generates HTML for a single cell, handling content escaping, merges, and alignment.
|
||||
func (h *HTML) renderRowCell(originalContent string, cellCtx tw.CellContext, isHeader bool, colIdx int) (tag, attributes, processedContent string) {
|
||||
tag = "td"
|
||||
if isHeader {
|
||||
tag = "th"
|
||||
}
|
||||
|
||||
// Process content
|
||||
processedContent = originalContent
|
||||
containsNewline := strings.Contains(originalContent, "\n")
|
||||
|
||||
if h.config.EscapeContent {
|
||||
if containsNewline {
|
||||
const newlinePlaceholder = "[[--HTML_RENDERER_BR_PLACEHOLDER--]]"
|
||||
tempContent := strings.ReplaceAll(originalContent, "\n", newlinePlaceholder)
|
||||
escapedContent := html.EscapeString(tempContent)
|
||||
processedContent = strings.ReplaceAll(escapedContent, newlinePlaceholder, "<br>")
|
||||
} else {
|
||||
processedContent = html.EscapeString(originalContent)
|
||||
}
|
||||
} else if containsNewline {
|
||||
processedContent = strings.ReplaceAll(originalContent, "\n", "<br>")
|
||||
}
|
||||
|
||||
if containsNewline && h.config.AddLinesTag {
|
||||
processedContent = "<lines>" + processedContent + "</lines>"
|
||||
}
|
||||
|
||||
// Build attributes
|
||||
var attrBuilder strings.Builder
|
||||
merge := cellCtx.Merge
|
||||
|
||||
if merge.Horizontal.Present && merge.Horizontal.Start && merge.Horizontal.Span > 1 {
|
||||
fmt.Fprintf(&attrBuilder, ` colspan="%d"`, merge.Horizontal.Span)
|
||||
}
|
||||
|
||||
vSpan := 0
|
||||
if !isHeader {
|
||||
if merge.Vertical.Present && merge.Vertical.Start {
|
||||
vSpan = merge.Vertical.Span
|
||||
} else if merge.Hierarchical.Present && merge.Hierarchical.Start {
|
||||
vSpan = merge.Hierarchical.Span
|
||||
}
|
||||
if vSpan > 1 {
|
||||
fmt.Fprintf(&attrBuilder, ` rowspan="%d"`, vSpan)
|
||||
h.vMergeTrack[colIdx] = vSpan
|
||||
h.logger.Debugf("renderRowCell: Tracking rowspan=%d for col %d", vSpan, colIdx)
|
||||
}
|
||||
}
|
||||
|
||||
if style := getHTMLStyle(cellCtx.Align); style != tw.Empty {
|
||||
attrBuilder.WriteString(style)
|
||||
}
|
||||
attributes = attrBuilder.String()
|
||||
return
|
||||
}
|
||||
|
||||
// Line is a no-op for HTML rendering, as structural lines are handled by tags.
|
||||
func (h *HTML) Line(ctx tw.Formatting) {}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces and merge tracking.
|
||||
func (h *HTML) Reset() {
|
||||
h.logger.Debug("HTML.Reset() called.")
|
||||
h.tableStarted = false
|
||||
h.tbodyStarted = false
|
||||
h.tfootStarted = false
|
||||
h.vMergeTrack = make(map[int]int)
|
||||
h.trace = nil
|
||||
}
|
||||
|
||||
// Close ensures all open HTML tags (<table>, <tbody>, <tfoot>) are properly closed.
|
||||
func (h *HTML) Close() error {
|
||||
if h.w == nil {
|
||||
return errors.New("HTML Renderer Close called on nil internal w")
|
||||
}
|
||||
|
||||
if h.tableStarted {
|
||||
h.logger.Debug("HTML.Close() called.")
|
||||
h.closePreviousSection()
|
||||
h.logger.Debug("Closing <table> tag.")
|
||||
_, err := fmt.Fprintln(h.w, "</table>")
|
||||
h.tableStarted = false
|
||||
h.tbodyStarted = false
|
||||
h.tfootStarted = false
|
||||
h.vMergeTrack = make(map[int]int)
|
||||
return err
|
||||
}
|
||||
h.logger.Debug("HTML.Close() called, but table was not started (no-op).")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user