Skip to content

Commit 3b69e0f

Browse files
authored
table: HTML: flexible color handling for column text (#385)
1 parent 2fe6597 commit 3b69e0f

File tree

13 files changed

+549
-185
lines changed

13 files changed

+549
-185
lines changed

table/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ If you want very specific examples, look at the [Examples](#examples) section.
114114
- Render table with or without borders
115115
- Customize box-drawing characters
116116
- Title and caption styling options
117-
- HTML rendering options (CSS class, escaping, newlines)
117+
- HTML rendering options (CSS class, escaping, newlines, color conversion)
118118
- Bidirectional text support (`Style().Format.Direction`)
119119

120120
### Output Formats
@@ -574,10 +574,11 @@ to get:
574574

575575
```golang
576576
t.Style().HTML = table.HTMLOptions{
577-
CSSClass: "game-of-thrones",
578-
EmptyColumn: " ",
579-
EscapeText: true,
580-
Newline: "<br/>",
577+
CSSClass: "game-of-thrones",
578+
EmptyColumn: "&nbsp;",
579+
EscapeText: true,
580+
Newline: "<br/>",
581+
ConvertColorsToSpans: true, // Convert ANSI escape sequences to HTML <span> tags with CSS classes
581582
}
582583
t.RenderHTML()
583584
```

table/render_html.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,15 @@ func (t *Table) htmlRenderCaption(out *strings.Builder) {
106106
}
107107

108108
func (t *Table) htmlRenderColumn(out *strings.Builder, colStr string) {
109-
if t.style.HTML.EscapeText {
109+
// convertEscSequencesToSpans already escapes text content, so skip
110+
// EscapeText if ConvertColorsToSpans is true
111+
if t.style.HTML.ConvertColorsToSpans {
112+
colStr = convertEscSequencesToSpans(colStr)
113+
} else if t.style.HTML.EscapeText {
110114
colStr = html.EscapeString(colStr)
111115
}
112116
if t.style.HTML.Newline != "\n" {
113-
colStr = strings.Replace(colStr, "\n", t.style.HTML.Newline, -1)
117+
colStr = strings.ReplaceAll(colStr, "\n", t.style.HTML.Newline)
114118
}
115119
out.WriteString(colStr)
116120
}

table/render_html_test.go

Lines changed: 165 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,130 @@ func TestTable_RenderHTML_AutoIndex(t *testing.T) {
142142
</table>`)
143143
}
144144

145+
func TestTable_RenderHTML_AutoMergeColumn(t *testing.T) {
146+
t.Run("simple", func(t *testing.T) {
147+
tw := NewWriter()
148+
tw.AppendHeader(Row{"A", "B", "C"})
149+
tw.AppendRow(Row{"Y", "Y", 1})
150+
tw.AppendRow(Row{"Y", "N", 2})
151+
tw.AppendRow(Row{"Y", "N", 3})
152+
tw.SetColumnConfigs([]ColumnConfig{
153+
{Name: "A", AutoMerge: true},
154+
{Name: "B", AutoMerge: true},
155+
})
156+
compareOutput(t, tw.RenderHTML(), `
157+
<table class="go-pretty-table">
158+
<thead>
159+
<tr>
160+
<th>A</th>
161+
<th>B</th>
162+
<th align="right">C</th>
163+
</tr>
164+
</thead>
165+
<tbody>
166+
<tr>
167+
<td rowspan=3>Y</td>
168+
<td>Y</td>
169+
<td align="right">1</td>
170+
</tr>
171+
<tr>
172+
<td rowspan=2>N</td>
173+
<td align="right">2</td>
174+
</tr>
175+
<tr>
176+
<td align="right">3</td>
177+
</tr>
178+
</tbody>
179+
</table>`)
180+
})
181+
}
182+
183+
func TestTable_RenderHTML_AutoMergeRow(t *testing.T) {
184+
t.Run("simple", func(t *testing.T) {
185+
rcAutoMerge := RowConfig{AutoMerge: true}
186+
tw := NewWriter()
187+
tw.AppendHeader(Row{"A", "B"})
188+
tw.AppendRow(Row{"Y", "Y"}, rcAutoMerge)
189+
tw.AppendRow(Row{"Y", "N"}, rcAutoMerge)
190+
compareOutput(t, tw.RenderHTML(), `
191+
<table class="go-pretty-table">
192+
<thead>
193+
<tr>
194+
<th>A</th>
195+
<th>B</th>
196+
</tr>
197+
</thead>
198+
<tbody>
199+
<tr>
200+
<td align="center" colspan=2>Y</td>
201+
</tr>
202+
<tr>
203+
<td>Y</td>
204+
<td>N</td>
205+
</tr>
206+
</tbody>
207+
</table>`)
208+
})
209+
210+
t.Run("merged and unmerged entries", func(t *testing.T) {
211+
rcAutoMerge := RowConfig{AutoMerge: true}
212+
tw := NewWriter()
213+
tw.AppendHeader(Row{"A", "B", "C", "D"})
214+
tw.AppendRow(Row{"Y", "Y", "0", "1"}, rcAutoMerge)
215+
tw.AppendRow(Row{"0", "Y", "Y", "1"}, rcAutoMerge)
216+
tw.AppendRow(Row{"0", "1", "Y", "Y"}, rcAutoMerge)
217+
tw.AppendRow(Row{"Y", "Y", "Y", "0"}, rcAutoMerge)
218+
tw.AppendRow(Row{"0", "Y", "Y", "Y"}, rcAutoMerge)
219+
tw.AppendRow(Row{"Y", "Y", "Y", "Y"}, rcAutoMerge)
220+
tw.AppendRow(Row{"0", "1", "2", "3"}, rcAutoMerge)
221+
compareOutput(t, tw.RenderHTML(), `
222+
<table class="go-pretty-table">
223+
<thead>
224+
<tr>
225+
<th>A</th>
226+
<th>B</th>
227+
<th>C</th>
228+
<th>D</th>
229+
</tr>
230+
</thead>
231+
<tbody>
232+
<tr>
233+
<td align="center" colspan=2>Y</td>
234+
<td>0</td>
235+
<td>1</td>
236+
</tr>
237+
<tr>
238+
<td>0</td>
239+
<td align="center" colspan=2>Y</td>
240+
<td>1</td>
241+
</tr>
242+
<tr>
243+
<td>0</td>
244+
<td>1</td>
245+
<td align="center" colspan=2>Y</td>
246+
</tr>
247+
<tr>
248+
<td align="center" colspan=3>Y</td>
249+
<td>0</td>
250+
</tr>
251+
<tr>
252+
<td>0</td>
253+
<td align="center" colspan=3>Y</td>
254+
</tr>
255+
<tr>
256+
<td align="center" colspan=4>Y</td>
257+
</tr>
258+
<tr>
259+
<td>0</td>
260+
<td>1</td>
261+
<td>2</td>
262+
<td>3</td>
263+
</tr>
264+
</tbody>
265+
</table>`)
266+
})
267+
}
268+
145269
func TestTable_RenderHTML_Colored(t *testing.T) {
146270
tw := NewWriter()
147271
tw.AppendHeader(testHeader)
@@ -307,6 +431,47 @@ func TestTable_RenderHTML_Empty(t *testing.T) {
307431
assert.Empty(t, tw.RenderHTML())
308432
}
309433

434+
func TestTable_RenderHTML_ConvertColorsToSpans(t *testing.T) {
435+
t.Run("enabled (default)", func(t *testing.T) {
436+
tw := NewWriter()
437+
tw.Style().HTML.EscapeText = false // Disable text escaping to see the HTML tags
438+
tw.AppendRow(Row{text.FgRed.Sprint("Red Text")})
439+
result := tw.RenderHTML()
440+
// Should convert escape sequences to spans
441+
assert.Contains(t, result, "<span class=\"fg-red\">Red Text</span>")
442+
assert.NotContains(t, result, "\x1b[31m")
443+
})
444+
445+
t.Run("disabled", func(t *testing.T) {
446+
tw := NewWriter()
447+
tw.Style().HTML.ConvertColorsToSpans = false
448+
tw.Style().HTML.EscapeText = true // Enable text escaping to test escape functionality
449+
tw.AppendRow(Row{text.FgRed.Sprint("Red Text")})
450+
result := tw.RenderHTML()
451+
// Should NOT convert escape sequences to spans
452+
assert.Contains(t, result, "\x1b[31m")
453+
assert.Contains(t, result, "Red Text")
454+
assert.NotContains(t, result, "<span class=\"fg-red\">")
455+
// Verify escape sequences are not converted to spans
456+
assert.NotContains(t, result, "<span")
457+
})
458+
459+
t.Run("disabled with HTML special chars", func(t *testing.T) {
460+
tw := NewWriter()
461+
tw.Style().HTML.ConvertColorsToSpans = false
462+
tw.Style().HTML.EscapeText = true // Enable text escaping
463+
tw.AppendRow(Row{"Text <bold> & \"quoted\""})
464+
result := tw.RenderHTML()
465+
// HTML special characters should be escaped
466+
assert.Contains(t, result, "&lt;bold&gt;")
467+
assert.Contains(t, result, "&amp;")
468+
assert.Contains(t, result, "&#34;quoted&#34;")
469+
// Should NOT contain unescaped HTML
470+
assert.NotContains(t, result, "<bold>")
471+
assert.NotContains(t, result, "\"quoted\"")
472+
})
473+
}
474+
310475
func TestTable_RenderHTML_HiddenColumns(t *testing.T) {
311476
tw := NewWriter()
312477
tw.AppendHeader(testHeader)
@@ -517,127 +682,3 @@ func TestTable_RenderHTML_Sorted(t *testing.T) {
517682
</tfoot>
518683
</table>`)
519684
}
520-
521-
func TestTable_RenderHTML_ColAutoMerge(t *testing.T) {
522-
t.Run("simple", func(t *testing.T) {
523-
tw := NewWriter()
524-
tw.AppendHeader(Row{"A", "B", "C"})
525-
tw.AppendRow(Row{"Y", "Y", 1})
526-
tw.AppendRow(Row{"Y", "N", 2})
527-
tw.AppendRow(Row{"Y", "N", 3})
528-
tw.SetColumnConfigs([]ColumnConfig{
529-
{Name: "A", AutoMerge: true},
530-
{Name: "B", AutoMerge: true},
531-
})
532-
compareOutput(t, tw.RenderHTML(), `
533-
<table class="go-pretty-table">
534-
<thead>
535-
<tr>
536-
<th>A</th>
537-
<th>B</th>
538-
<th align="right">C</th>
539-
</tr>
540-
</thead>
541-
<tbody>
542-
<tr>
543-
<td rowspan=3>Y</td>
544-
<td>Y</td>
545-
<td align="right">1</td>
546-
</tr>
547-
<tr>
548-
<td rowspan=2>N</td>
549-
<td align="right">2</td>
550-
</tr>
551-
<tr>
552-
<td align="right">3</td>
553-
</tr>
554-
</tbody>
555-
</table>`)
556-
})
557-
}
558-
559-
func TestTable_RenderHTML_RowAutoMerge(t *testing.T) {
560-
t.Run("simple", func(t *testing.T) {
561-
rcAutoMerge := RowConfig{AutoMerge: true}
562-
tw := NewWriter()
563-
tw.AppendHeader(Row{"A", "B"})
564-
tw.AppendRow(Row{"Y", "Y"}, rcAutoMerge)
565-
tw.AppendRow(Row{"Y", "N"}, rcAutoMerge)
566-
compareOutput(t, tw.RenderHTML(), `
567-
<table class="go-pretty-table">
568-
<thead>
569-
<tr>
570-
<th>A</th>
571-
<th>B</th>
572-
</tr>
573-
</thead>
574-
<tbody>
575-
<tr>
576-
<td align="center" colspan=2>Y</td>
577-
</tr>
578-
<tr>
579-
<td>Y</td>
580-
<td>N</td>
581-
</tr>
582-
</tbody>
583-
</table>`)
584-
})
585-
586-
t.Run("merged and unmerged entries", func(t *testing.T) {
587-
rcAutoMerge := RowConfig{AutoMerge: true}
588-
tw := NewWriter()
589-
tw.AppendHeader(Row{"A", "B", "C", "D"})
590-
tw.AppendRow(Row{"Y", "Y", "0", "1"}, rcAutoMerge)
591-
tw.AppendRow(Row{"0", "Y", "Y", "1"}, rcAutoMerge)
592-
tw.AppendRow(Row{"0", "1", "Y", "Y"}, rcAutoMerge)
593-
tw.AppendRow(Row{"Y", "Y", "Y", "0"}, rcAutoMerge)
594-
tw.AppendRow(Row{"0", "Y", "Y", "Y"}, rcAutoMerge)
595-
tw.AppendRow(Row{"Y", "Y", "Y", "Y"}, rcAutoMerge)
596-
tw.AppendRow(Row{"0", "1", "2", "3"}, rcAutoMerge)
597-
compareOutput(t, tw.RenderHTML(), `
598-
<table class="go-pretty-table">
599-
<thead>
600-
<tr>
601-
<th>A</th>
602-
<th>B</th>
603-
<th>C</th>
604-
<th>D</th>
605-
</tr>
606-
</thead>
607-
<tbody>
608-
<tr>
609-
<td align="center" colspan=2>Y</td>
610-
<td>0</td>
611-
<td>1</td>
612-
</tr>
613-
<tr>
614-
<td>0</td>
615-
<td align="center" colspan=2>Y</td>
616-
<td>1</td>
617-
</tr>
618-
<tr>
619-
<td>0</td>
620-
<td>1</td>
621-
<td align="center" colspan=2>Y</td>
622-
</tr>
623-
<tr>
624-
<td align="center" colspan=3>Y</td>
625-
<td>0</td>
626-
</tr>
627-
<tr>
628-
<td>0</td>
629-
<td align="center" colspan=3>Y</td>
630-
</tr>
631-
<tr>
632-
<td align="center" colspan=4>Y</td>
633-
</tr>
634-
<tr>
635-
<td>0</td>
636-
<td>1</td>
637-
<td>2</td>
638-
<td>3</td>
639-
</tr>
640-
</tbody>
641-
</table>`)
642-
})
643-
}

table/style.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -717,18 +717,20 @@ var FormatOptionsDefault = FormatOptions{
717717

718718
// HTMLOptions defines the global options to control HTML rendering.
719719
type HTMLOptions struct {
720-
CSSClass string // CSS class to set on the overall <table> tag
721-
EmptyColumn string // string to replace "" columns with (entire content being "")
722-
EscapeText bool // escape text into HTML-safe content?
723-
Newline string // string to replace "\n" characters with
720+
ConvertColorsToSpans bool // convert ANSI escape sequences to HTML <span> tags with CSS classes? EscapeText will be true if this is true.
721+
CSSClass string // CSS class to set on the overall <table> tag
722+
EmptyColumn string // string to replace "" columns with (entire content being "")
723+
EscapeText bool // escape text into HTML-safe content?
724+
Newline string // string to replace "\n" characters with
724725
}
725726

726727
// DefaultHTMLOptions defines sensible HTML rendering defaults.
727728
var DefaultHTMLOptions = HTMLOptions{
728-
CSSClass: DefaultHTMLCSSClass,
729-
EmptyColumn: "&nbsp;",
730-
EscapeText: true,
731-
Newline: "<br/>",
729+
ConvertColorsToSpans: true,
730+
CSSClass: DefaultHTMLCSSClass,
731+
EmptyColumn: "&nbsp;",
732+
EscapeText: true,
733+
Newline: "<br/>",
732734
}
733735

734736
// Options defines the global options that determine how the Table is

0 commit comments

Comments
 (0)