Repository: jedib0t/go-pretty

Issue:
Want to handle color more flexibly for individual cells in HTML
I have tables where the color of a single cell is based on a status (e.g. "UP" is green, and "DOWN" is red).  (I'm using color.Sprint() generally for this, or sometimes color.Sprintf.)

Unfortunately the HTML colorizer doesn't understand this, and just puts the ANSI escapes right into the HTML.

Further, I don't see a mechanism like color.Sprint() ... it would be workable if I could do color.SprintHTML() or something that would emit a <span>. ... but setting the class on the TD or TH element works well too.  Provided that I can have per-cell tunability of this.

Discussion:
@gdamore: I have solved this on my end by using a substitution that looks for the escape sequences in the HTML and rewrites them into spans.  Its a little hacky because it won't deal well with multiple color settings in the same span (e.g. bold and red together), but it works ok for my use case. @gdamore: Here's my implementation, feel free to use as you like.  I have a special stand in "brand color" that is tunable for branding.  You probably don't want that. :-) 

```
// HtmlColorize takes a string that contains ANSI escape sequences and converts them to HTML span elements suitably styled.
// At present it does not handle mixing of colors or styles.
func HtmlColorize(s string) string {
	var colors = []Color{
		Red,
		Green,
		Yellow,
		Magenta,
		Black,
		Grey,
		White,
		Blue,
		Cyan,
		BrightRed,

		BgBlack,
		BgGrey,
		BgWhite,
		BgMagenta,
		BgRed,
		BgGreen,

		Underline,
		Dim,
		Bold,
	}

	for _, c := range colors {
		s = strings.ReplaceAll(s, c.EscapeSeq(), fmt.Sprintf("<span %s>", c.HTMLProperty()))
	}
	s = strings.ReplaceAll(s, Brand.EscapeSeq(), `<span class="brand">`)
	s = strings.ReplaceAll(s, NoColor.EscapeSeq(), "</span>")
	return s
}
```@jedib0t: This is a hard problem to solve with reasonable performance. Color codes need not appear as individual units and can be combined. To correctly support this, you'll have to parse each escape code and extract individual instructions.

* Green: `\x1b[32m` (simple case)
* Green Bold: `\x1b[32;1m` or `\x1b32m\x1b1m` (combined)@jedib0t: @gdamore The code in [v6.7.3](https://github.com/jedib0t/go-pretty/releases/tag/v6.7.3) will auto-convert all column text with colors covered by `text.Color` to be within HTML `<span>` tags with the appropriate CSS Classes on them.

You can disable this functionality if needed using `Style().HTML.ConvertColorsToSpans = false`.

Solution patch:
diff --git a/table/README.md b/table/README.md
index bf9df7f..386def1 100644
--- a/table/README.md
+++ b/table/README.md
@@ -114,7 +114,7 @@ If you want very specific examples, look at the [Examples](#examples) section.
     - Render table with or without borders
     - Customize box-drawing characters
     - Title and caption styling options
-    - HTML rendering options (CSS class, escaping, newlines)
+    - HTML rendering options (CSS class, escaping, newlines, color conversion)
     - Bidirectional text support (`Style().Format.Direction`)
 
 ### Output Formats
@@ -574,10 +574,11 @@ to get:
 
 ```golang
     t.Style().HTML = table.HTMLOptions{
-        CSSClass:    "game-of-thrones",
-        EmptyColumn: "&nbsp;",
-        EscapeText:  true,
-        Newline:     "<br/>",
+        CSSClass:             "game-of-thrones",
+        EmptyColumn:          "&nbsp;",
+        EscapeText:           true,
+        Newline:              "<br/>",
+        ConvertColorsToSpans: true, // Convert ANSI escape sequences to HTML <span> tags with CSS classes
     }
     t.RenderHTML()
 ```
diff --git a/table/render_html.go b/table/render_html.go
index 729f95e..1057207 100644
--- a/table/render_html.go
+++ b/table/render_html.go
@@ -106,11 +106,15 @@ func (t *Table) htmlRenderCaption(out *strings.Builder) {
 }
 
 func (t *Table) htmlRenderColumn(out *strings.Builder, colStr string) {
-	if t.style.HTML.EscapeText {
+	// convertEscSequencesToSpans already escapes text content, so skip
+	// EscapeText if ConvertColorsToSpans is true
+	if t.style.HTML.ConvertColorsToSpans {
+		colStr = convertEscSequencesToSpans(colStr)
+	} else if t.style.HTML.EscapeText {
 		colStr = html.EscapeString(colStr)
 	}
 	if t.style.HTML.Newline != "\n" {
-		colStr = strings.Replace(colStr, "\n", t.style.HTML.Newline, -1)
+		colStr = strings.ReplaceAll(colStr, "\n", t.style.HTML.Newline)
 	}
 	out.WriteString(colStr)
 }
diff --git a/table/style.go b/table/style.go
index aaed2c2..2534fac 100644
--- a/table/style.go
+++ b/table/style.go
@@ -717,18 +717,20 @@ var FormatOptionsDefault = FormatOptions{
 
 // HTMLOptions defines the global options to control HTML rendering.
 type HTMLOptions struct {
-	CSSClass    string // CSS class to set on the overall <table> tag
-	EmptyColumn string // string to replace "" columns with (entire content being "")
-	EscapeText  bool   // escape text into HTML-safe content?
-	Newline     string // string to replace "\n" characters with
+	ConvertColorsToSpans bool   // convert ANSI escape sequences to HTML <span> tags with CSS classes? EscapeText will be true if this is true.
+	CSSClass             string // CSS class to set on the overall <table> tag
+	EmptyColumn          string // string to replace "" columns with (entire content being "")
+	EscapeText           bool   // escape text into HTML-safe content?
+	Newline              string // string to replace "\n" characters with
 }
 
 // DefaultHTMLOptions defines sensible HTML rendering defaults.
 var DefaultHTMLOptions = HTMLOptions{
-	CSSClass:    DefaultHTMLCSSClass,
-	EmptyColumn: "&nbsp;",
-	EscapeText:  true,
-	Newline:     "<br/>",
+	ConvertColorsToSpans: true,
+	CSSClass:             DefaultHTMLCSSClass,
+	EmptyColumn:          "&nbsp;",
+	EscapeText:           true,
+	Newline:              "<br/>",
 }
 
 // Options defines the global options that determine how the Table is
diff --git a/table/util_html.go b/table/util_html.go
new file mode 100644
index 0000000..97e2c52
--- /dev/null
+++ b/table/util_html.go
@@ -0,0 +1,151 @@
+package table
+
+import (
+	"strings"
+
+	"github.com/jedib0t/go-pretty/v6/text"
+)
+
+// convertEscSequencesToSpans converts ANSI escape sequences to HTML <span> tags with CSS classes.
+func convertEscSequencesToSpans(str string) string {
+	converter := newEscSeqToSpanConverter()
+	return converter.Convert(str)
+}
+
+// escSeqToSpanConverter converts ANSI escape sequences to HTML <span> tags with CSS classes.
+type escSeqToSpanConverter struct {
+	result        strings.Builder
+	esp           text.EscSeqParser
+	currentColors map[int]bool
+}
+
+// newEscSeqToSpanConverter creates a new escape sequence to span converter.
+func newEscSeqToSpanConverter() *escSeqToSpanConverter {
+	return &escSeqToSpanConverter{
+		currentColors: make(map[int]bool),
+	}
+}
+
+// Convert converts ANSI escape sequences in the string to HTML <span> tags with CSS classes.
+func (c *escSeqToSpanConverter) Convert(str string) string {
+	c.reset()
+
+	// Process the string character by character
+	for _, char := range str {
+		wasInSequence := c.esp.InSequence()
+		c.esp.Consume(char)
+
+		if c.esp.InSequence() {
+			// We're inside an escape sequence, skip it (don't write to result)
+			continue
+		}
+
+		if wasInSequence {
+			// We just finished an escape sequence, update colors
+			newColors := make(map[int]bool)
+			for _, code := range c.esp.Codes() {
+				newColors[code] = true
+			}
+			c.updateSpan(newColors)
+		} else {
+			// Regular character, escape it for HTML safety and write it
+			// (will be inside current span if colors are active)
+			c.writeEscapedRune(char)
+		}
+	}
+
+	// Close any open span
+	if len(c.currentColors) > 0 {
+		c.result.WriteString("</span>")
+	}
+
+	return c.result.String()
+}
+
+// clearColors clears the current color tracking.
+func (c *escSeqToSpanConverter) clearColors() {
+	c.currentColors = make(map[int]bool)
+}
+
+// closeSpan closes the current span if one is open.
+func (c *escSeqToSpanConverter) closeSpan() {
+	if len(c.currentColors) > 0 {
+		c.result.WriteString("</span>")
+	}
+}
+
+// colorsChanged checks if the color set has changed.
+func (c *escSeqToSpanConverter) colorsChanged(newColors map[int]bool) bool {
+	// we never set the map values to false, so a simple size compare is enough
+	return len(c.currentColors) != len(newColors)
+}
+
+// cssClasses converts color codes to CSS class names.
+func (c *escSeqToSpanConverter) cssClasses(codes map[int]bool) string {
+	var colors text.Colors
+	for code := range codes {
+		colors = append(colors, text.Color(code))
+	}
+	return colors.CSSClasses()
+}
+
+// openSpan opens a new span with the given CSS class and tracks the colors.
+func (c *escSeqToSpanConverter) openSpan(class string, newColors map[int]bool) {
+	c.result.WriteString("<span class=\"")
+	c.result.WriteString(class)
+	c.result.WriteString("\">")
+	// Track colors since we opened a span
+	c.currentColors = make(map[int]bool)
+	for code := range newColors {
+		c.currentColors[code] = true
+	}
+}
+
+// reset initializes the converter state for a new conversion.
+func (c *escSeqToSpanConverter) reset() {
+	c.result.Reset()
+	c.esp = text.EscSeqParser{}
+	c.currentColors = make(map[int]bool)
+}
+
+// updateSpan updates span tags when colors change.
+func (c *escSeqToSpanConverter) updateSpan(newColors map[int]bool) {
+	if !c.colorsChanged(newColors) {
+		return
+	}
+
+	c.closeSpan()
+
+	// Open new span if there are colors with valid CSS classes
+	if len(newColors) > 0 {
+		class := c.cssClasses(newColors)
+		if class != "" {
+			c.openSpan(class, newColors)
+		} else {
+			// No CSS classes, so don't track these colors
+			c.clearColors()
+		}
+	} else {
+		// No colors, clear tracking
+		c.clearColors()
+	}
+}
+
+// writeEscapedRune writes a rune to the result, escaping it if necessary for HTML safety.
+func (c *escSeqToSpanConverter) writeEscapedRune(char rune) {
+	switch char {
+	case '<':
+		c.result.WriteString("&lt;")
+	case '>':
+		c.result.WriteString("&gt;")
+	case '&':
+		c.result.WriteString("&amp;")
+	case '"':
+		c.result.WriteString("&#34;")
+	case '\'':
+		c.result.WriteString("&#39;")
+	default:
+		// Most characters don't need escaping, write directly
+		c.result.WriteRune(char)
+	}
+}
diff --git a/text/README.md b/text/README.md
index 2b240de..8a5b7ad 100644
--- a/text/README.md
+++ b/text/README.md
@@ -73,6 +73,9 @@ Used heavily in the other packages in this repo ([list](../list),
     - All functions properly handle ANSI escape sequences
     - Escape sequences are preserved during transformations
     - Width calculations ignore escape sequences
+    - `EscSeqParser` - Parser for advanced escape sequence parsing and tracking
+      - Supports both CSI (Control Sequence Introducer) and OSI (Operating System Command) formats
+      - Tracks active formatting codes and can generate consolidated escape sequences
 
 ### Cursor Control
 
diff --git a/text/color.go b/text/color.go
index 264e43a..01b6111 100644
--- a/text/color.go
+++ b/text/color.go
@@ -107,6 +107,14 @@ const (
 	BgHiWhite
 )
 
+// CSSClasses returns the CSS class names for the color.
+func (c Color) CSSClasses() string {
+	if class, ok := colorCSSClassMap[c]; ok {
+		return class
+	}
+	return ""
+}
+
 // EscapeSeq returns the ANSI escape sequence for the color.
 func (c Color) EscapeSeq() string {
 	return EscapeStart + strconv.Itoa(int(c)) + EscapeStop
@@ -114,11 +122,11 @@ func (c Color) EscapeSeq() string {
 
 // HTMLProperty returns the "class" attribute for the color.
 func (c Color) HTMLProperty() string {
-	out := ""
-	if class, ok := colorCSSClassMap[c]; ok {
-		out = fmt.Sprintf("class=\"%s\"", class)
+	classes := c.CSSClasses()
+	if classes == "" {
+		return ""
 	}
-	return out
+	return fmt.Sprintf("class=\"%s\"", classes)
 }
 
 // Sprint colorizes and prints the given string(s).
@@ -138,6 +146,24 @@ type Colors []Color
 // colorsSeqMap caches the escape sequence for a set of colors
 var colorsSeqMap = sync.Map{}
 
+// CSSClasses returns the CSS class names for the colors.
+func (c Colors) CSSClasses() string {
+	if len(c) == 0 {
+		return ""
+	}
+
+	var classes []string
+	for _, color := range c {
+		if class, ok := colorCSSClassMap[color]; ok {
+			classes = append(classes, class)
+		}
+	}
+	if len(classes) > 1 {
+		sort.Strings(classes)
+	}
+	return strings.Join(classes, " ")
+}
+
 // EscapeSeq returns the ANSI escape sequence for the colors set.
 func (c Colors) EscapeSeq() string {
 	if len(c) == 0 {
@@ -159,20 +185,11 @@ func (c Colors) EscapeSeq() string {
 
 // HTMLProperty returns the "class" attribute for the colors.
 func (c Colors) HTMLProperty() string {
-	if len(c) == 0 {
+	classes := c.CSSClasses()
+	if classes == "" {
 		return ""
 	}
-
-	var classes []string
-	for _, color := range c {
-		if class, ok := colorCSSClassMap[color]; ok {
-			classes = append(classes, class)
-		}
-	}
-	if len(classes) > 1 {
-		sort.Strings(classes)
-	}
-	return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " "))
+	return fmt.Sprintf("class=\"%s\"", classes)
 }
 
 // Sprint colorizes and prints the given string(s).
diff --git a/text/escape_seq_parser.go b/text/escape_seq_parser.go
index c6ffa43..dd81a27 100644
--- a/text/escape_seq_parser.go
+++ b/text/escape_seq_parser.go
@@ -42,16 +42,22 @@ const (
 	escSeqKindOSI
 )
 
-type escSeqParser struct {
+// EscSeqParser parses ANSI escape sequences from text and tracks active formatting codes.
+// It supports both CSI (Control Sequence Introducer) and OSI (Operating System Command)
+// escape sequence formats.
+type EscSeqParser struct {
+	// codes tracks active escape sequence codes (e.g., 1 for bold, 3 for italic).
 	codes map[int]bool
 
-	// consume specific
-	inEscSeq   bool
+	// inEscSeq indicates whether the parser is currently inside an escape sequence.
+	inEscSeq bool
+	// escSeqKind identifies the type of escape sequence being parsed (CSI or OSI).
 	escSeqKind escSeqKind
-	escapeSeq  string
+	// escapeSeq accumulates the current escape sequence being parsed.
+	escapeSeq string
 }
 
-func (s *escSeqParser) Codes() []int {
+func (s *EscSeqParser) Codes() []int {
 	codes := make([]int, 0)
 	for code, val := range s.codes {
 		if val {
@@ -62,7 +68,7 @@ func (s *escSeqParser) Codes() []int {
 	return codes
 }
 
-func (s *escSeqParser) Consume(char rune) {
+func (s *EscSeqParser) Consume(char rune) {
 	if !s.inEscSeq && char == EscapeStartRune {
 		s.inEscSeq = true
 		s.escSeqKind = escSeqKindUnknown
@@ -95,15 +101,15 @@ func (s *escSeqParser) Consume(char rune) {
 	}
 }
 
-func (s *escSeqParser) InSequence() bool {
+func (s *EscSeqParser) InSequence() bool {
 	return s.inEscSeq
 }
 
-func (s *escSeqParser) IsOpen() bool {
+func (s *EscSeqParser) IsOpen() bool {
 	return len(s.codes) > 0
 }
 
-func (s *escSeqParser) Reset() {
+func (s *EscSeqParser) Reset() {
 	s.inEscSeq = false
 	s.escSeqKind = escSeqKindUnknown
 	s.escapeSeq = ""
@@ -128,7 +134,7 @@ const (
 	escCodeCrossedOut      = 9
 )
 
-func (s *escSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
+func (s *EscSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
 	if s.codes == nil {
 		s.codes = make(map[int]bool)
 	}
@@ -169,7 +175,7 @@ func (s *escSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
 	}
 }
 
-func (s *escSeqParser) ParseString(str string) string {
+func (s *EscSeqParser) ParseString(str string) string {
 	s.escapeSeq, s.inEscSeq, s.escSeqKind = "", false, escSeqKindUnknown
 	for _, char := range str {
 		s.Consume(char)
@@ -177,7 +183,7 @@ func (s *escSeqParser) ParseString(str string) string {
 	return s.Sequence()
 }
 
-func (s *escSeqParser) Sequence() string {
+func (s *EscSeqParser) Sequence() string {
 	out := strings.Builder{}
 	if s.IsOpen() {
 		out.WriteString(EscapeStart)
@@ -198,7 +204,7 @@ const (
 	escapeStopConcealOSI  = "\x1b\\"
 )
 
-func (s *escSeqParser) isEscapeStopRune(char rune) bool {
+func (s *EscSeqParser) isEscapeStopRune(char rune) bool {
 	if strings.HasPrefix(s.escapeSeq, escapeStartConcealOSI) {
 		if strings.HasSuffix(s.escapeSeq, escapeStopConcealOSI) {
 			return true
diff --git a/text/string.go b/text/string.go
index fa28a4c..ab5e293 100644
--- a/text/string.go
+++ b/text/string.go
@@ -28,7 +28,7 @@ func InsertEveryN(str string, runeToInsert rune, n int) string {
 	sLen := StringWidthWithoutEscSequences(str)
 	var out strings.Builder
 	out.Grow(sLen + (sLen / n))
-	outLen, esp := 0, escSeqParser{}
+	outLen, esp := 0, EscSeqParser{}
 	for idx, c := range str {
 		if esp.InSequence() {
 			esp.Consume(c)
@@ -52,7 +52,7 @@ func InsertEveryN(str string, runeToInsert rune, n int) string {
 //
 //	LongestLineLen("Ghost!\nCome back here!\nRight now!") == 15
 func LongestLineLen(str string) int {
-	maxLength, currLength, esp := 0, 0, escSeqParser{}
+	maxLength, currLength, esp := 0, 0, EscSeqParser{}
 	//fmt.Println(str)
 	for _, c := range str {
 		//fmt.Printf("%03d | %03d | %c | %5v | %v | %#v\n", idx, c, c, esp.inEscSeq, esp.Codes(), esp.escapeSeq)
@@ -248,7 +248,7 @@ func StringWidth(str string) int {
 //	StringWidthWithoutEscSequences("Ghost 生命"): 10
 //	StringWidthWithoutEscSequences("\x1b[33mGhost 生命\x1b[0m"): 10
 func StringWidthWithoutEscSequences(str string) int {
-	count, esp := 0, escSeqParser{}
+	count, esp := 0, EscSeqParser{}
 	for _, c := range str {
 		if esp.InSequence() {
 			esp.Consume(c)
@@ -277,7 +277,7 @@ func Trim(str string, maxLen int) string {
 	var out strings.Builder
 	out.Grow(maxLen)
 
-	outLen, esp := 0, escSeqParser{}
+	outLen, esp := 0, EscSeqParser{}
 	for _, sChr := range str {
 		if esp.InSequence() {
 			esp.Consume(sChr)
@@ -306,7 +306,7 @@ func Widen(str string) string {
 	sb := strings.Builder{}
 	sb.Grow(len(str))
 
-	esp := escSeqParser{}
+	esp := EscSeqParser{}
 	for _, c := range str {
 		if esp.InSequence() {
 			sb.WriteRune(c)
diff --git a/text/wrap.go b/text/wrap.go
index 8ad8485..8a9e803 100644
--- a/text/wrap.go
+++ b/text/wrap.go
@@ -157,7 +157,7 @@ func terminateOutput(lastSeenEscSeq string, out *strings.Builder) {
 }
 
 func wrapHard(paragraph string, wrapLen int, out *strings.Builder) {
-	esp := escSeqParser{}
+	esp := EscSeqParser{}
 	lineLen, lastSeenEscSeq := 0, ""
 	words := strings.Fields(paragraph)
 	for wordIdx, word := range words {
@@ -186,7 +186,7 @@ func wrapHard(paragraph string, wrapLen int, out *strings.Builder) {
 }
 
 func wrapSoft(paragraph string, wrapLen int, out *strings.Builder) {
-	esp := escSeqParser{}
+	esp := EscSeqParser{}
 	lineLen, lastSeenEscSeq := 0, ""
 	words := strings.Fields(paragraph)
 	for wordIdx, word := range words {



Test patch:
diff --git a/table/render_html_test.go b/table/render_html_test.go
index d0f8509..d48eef3 100644
--- a/table/render_html_test.go
+++ b/table/render_html_test.go
@@ -142,6 +142,130 @@ func TestTable_RenderHTML_AutoIndex(t *testing.T) {
 </table>`)
 }
 
+func TestTable_RenderHTML_AutoMergeColumn(t *testing.T) {
+	t.Run("simple", func(t *testing.T) {
+		tw := NewWriter()
+		tw.AppendHeader(Row{"A", "B", "C"})
+		tw.AppendRow(Row{"Y", "Y", 1})
+		tw.AppendRow(Row{"Y", "N", 2})
+		tw.AppendRow(Row{"Y", "N", 3})
+		tw.SetColumnConfigs([]ColumnConfig{
+			{Name: "A", AutoMerge: true},
+			{Name: "B", AutoMerge: true},
+		})
+		compareOutput(t, tw.RenderHTML(), `
+<table class="go-pretty-table">
+  <thead>
+  <tr>
+    <th>A</th>
+    <th>B</th>
+    <th align="right">C</th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr>
+    <td rowspan=3>Y</td>
+    <td>Y</td>
+    <td align="right">1</td>
+  </tr>
+  <tr>
+    <td rowspan=2>N</td>
+    <td align="right">2</td>
+  </tr>
+  <tr>
+    <td align="right">3</td>
+  </tr>
+  </tbody>
+</table>`)
+	})
+}
+
+func TestTable_RenderHTML_AutoMergeRow(t *testing.T) {
+	t.Run("simple", func(t *testing.T) {
+		rcAutoMerge := RowConfig{AutoMerge: true}
+		tw := NewWriter()
+		tw.AppendHeader(Row{"A", "B"})
+		tw.AppendRow(Row{"Y", "Y"}, rcAutoMerge)
+		tw.AppendRow(Row{"Y", "N"}, rcAutoMerge)
+		compareOutput(t, tw.RenderHTML(), `
+<table class="go-pretty-table">
+  <thead>
+  <tr>
+    <th>A</th>
+    <th>B</th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr>
+    <td align="center" colspan=2>Y</td>
+  </tr>
+  <tr>
+    <td>Y</td>
+    <td>N</td>
+  </tr>
+  </tbody>
+</table>`)
+	})
+
+	t.Run("merged and unmerged entries", func(t *testing.T) {
+		rcAutoMerge := RowConfig{AutoMerge: true}
+		tw := NewWriter()
+		tw.AppendHeader(Row{"A", "B", "C", "D"})
+		tw.AppendRow(Row{"Y", "Y", "0", "1"}, rcAutoMerge)
+		tw.AppendRow(Row{"0", "Y", "Y", "1"}, rcAutoMerge)
+		tw.AppendRow(Row{"0", "1", "Y", "Y"}, rcAutoMerge)
+		tw.AppendRow(Row{"Y", "Y", "Y", "0"}, rcAutoMerge)
+		tw.AppendRow(Row{"0", "Y", "Y", "Y"}, rcAutoMerge)
+		tw.AppendRow(Row{"Y", "Y", "Y", "Y"}, rcAutoMerge)
+		tw.AppendRow(Row{"0", "1", "2", "3"}, rcAutoMerge)
+		compareOutput(t, tw.RenderHTML(), `
+<table class="go-pretty-table">
+  <thead>
+  <tr>
+    <th>A</th>
+    <th>B</th>
+    <th>C</th>
+    <th>D</th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr>
+    <td align="center" colspan=2>Y</td>
+    <td>0</td>
+    <td>1</td>
+  </tr>
+  <tr>
+    <td>0</td>
+    <td align="center" colspan=2>Y</td>
+    <td>1</td>
+  </tr>
+  <tr>
+    <td>0</td>
+    <td>1</td>
+    <td align="center" colspan=2>Y</td>
+  </tr>
+  <tr>
+    <td align="center" colspan=3>Y</td>
+    <td>0</td>
+  </tr>
+  <tr>
+    <td>0</td>
+    <td align="center" colspan=3>Y</td>
+  </tr>
+  <tr>
+    <td align="center" colspan=4>Y</td>
+  </tr>
+  <tr>
+    <td>0</td>
+    <td>1</td>
+    <td>2</td>
+    <td>3</td>
+  </tr>
+  </tbody>
+</table>`)
+	})
+}
+
 func TestTable_RenderHTML_Colored(t *testing.T) {
 	tw := NewWriter()
 	tw.AppendHeader(testHeader)
@@ -307,6 +431,47 @@ func TestTable_RenderHTML_Empty(t *testing.T) {
 	assert.Empty(t, tw.RenderHTML())
 }
 
+func TestTable_RenderHTML_ConvertColorsToSpans(t *testing.T) {
+	t.Run("enabled (default)", func(t *testing.T) {
+		tw := NewWriter()
+		tw.Style().HTML.EscapeText = false // Disable text escaping to see the HTML tags
+		tw.AppendRow(Row{text.FgRed.Sprint("Red Text")})
+		result := tw.RenderHTML()
+		// Should convert escape sequences to spans
+		assert.Contains(t, result, "<span class=\"fg-red\">Red Text</span>")
+		assert.NotContains(t, result, "\x1b[31m")
+	})
+
+	t.Run("disabled", func(t *testing.T) {
+		tw := NewWriter()
+		tw.Style().HTML.ConvertColorsToSpans = false
+		tw.Style().HTML.EscapeText = true // Enable text escaping to test escape functionality
+		tw.AppendRow(Row{text.FgRed.Sprint("Red Text")})
+		result := tw.RenderHTML()
+		// Should NOT convert escape sequences to spans
+		assert.Contains(t, result, "\x1b[31m")
+		assert.Contains(t, result, "Red Text")
+		assert.NotContains(t, result, "<span class=\"fg-red\">")
+		// Verify escape sequences are not converted to spans
+		assert.NotContains(t, result, "<span")
+	})
+
+	t.Run("disabled with HTML special chars", func(t *testing.T) {
+		tw := NewWriter()
+		tw.Style().HTML.ConvertColorsToSpans = false
+		tw.Style().HTML.EscapeText = true // Enable text escaping
+		tw.AppendRow(Row{"Text <bold> & \"quoted\""})
+		result := tw.RenderHTML()
+		// HTML special characters should be escaped
+		assert.Contains(t, result, "&lt;bold&gt;")
+		assert.Contains(t, result, "&amp;")
+		assert.Contains(t, result, "&#34;quoted&#34;")
+		// Should NOT contain unescaped HTML
+		assert.NotContains(t, result, "<bold>")
+		assert.NotContains(t, result, "\"quoted\"")
+	})
+}
+
 func TestTable_RenderHTML_HiddenColumns(t *testing.T) {
 	tw := NewWriter()
 	tw.AppendHeader(testHeader)
@@ -517,127 +682,3 @@ func TestTable_RenderHTML_Sorted(t *testing.T) {
   </tfoot>
 </table>`)
 }
-
-func TestTable_RenderHTML_ColAutoMerge(t *testing.T) {
-	t.Run("simple", func(t *testing.T) {
-		tw := NewWriter()
-		tw.AppendHeader(Row{"A", "B", "C"})
-		tw.AppendRow(Row{"Y", "Y", 1})
-		tw.AppendRow(Row{"Y", "N", 2})
-		tw.AppendRow(Row{"Y", "N", 3})
-		tw.SetColumnConfigs([]ColumnConfig{
-			{Name: "A", AutoMerge: true},
-			{Name: "B", AutoMerge: true},
-		})
-		compareOutput(t, tw.RenderHTML(), `
-<table class="go-pretty-table">
-  <thead>
-  <tr>
-    <th>A</th>
-    <th>B</th>
-    <th align="right">C</th>
-  </tr>
-  </thead>
-  <tbody>
-  <tr>
-    <td rowspan=3>Y</td>
-    <td>Y</td>
-    <td align="right">1</td>
-  </tr>
-  <tr>
-    <td rowspan=2>N</td>
-    <td align="right">2</td>
-  </tr>
-  <tr>
-    <td align="right">3</td>
-  </tr>
-  </tbody>
-</table>`)
-	})
-}
-
-func TestTable_RenderHTML_RowAutoMerge(t *testing.T) {
-	t.Run("simple", func(t *testing.T) {
-		rcAutoMerge := RowConfig{AutoMerge: true}
-		tw := NewWriter()
-		tw.AppendHeader(Row{"A", "B"})
-		tw.AppendRow(Row{"Y", "Y"}, rcAutoMerge)
-		tw.AppendRow(Row{"Y", "N"}, rcAutoMerge)
-		compareOutput(t, tw.RenderHTML(), `
-<table class="go-pretty-table">
-  <thead>
-  <tr>
-    <th>A</th>
-    <th>B</th>
-  </tr>
-  </thead>
-  <tbody>
-  <tr>
-    <td align="center" colspan=2>Y</td>
-  </tr>
-  <tr>
-    <td>Y</td>
-    <td>N</td>
-  </tr>
-  </tbody>
-</table>`)
-	})
-
-	t.Run("merged and unmerged entries", func(t *testing.T) {
-		rcAutoMerge := RowConfig{AutoMerge: true}
-		tw := NewWriter()
-		tw.AppendHeader(Row{"A", "B", "C", "D"})
-		tw.AppendRow(Row{"Y", "Y", "0", "1"}, rcAutoMerge)
-		tw.AppendRow(Row{"0", "Y", "Y", "1"}, rcAutoMerge)
-		tw.AppendRow(Row{"0", "1", "Y", "Y"}, rcAutoMerge)
-		tw.AppendRow(Row{"Y", "Y", "Y", "0"}, rcAutoMerge)
-		tw.AppendRow(Row{"0", "Y", "Y", "Y"}, rcAutoMerge)
-		tw.AppendRow(Row{"Y", "Y", "Y", "Y"}, rcAutoMerge)
-		tw.AppendRow(Row{"0", "1", "2", "3"}, rcAutoMerge)
-		compareOutput(t, tw.RenderHTML(), `
-<table class="go-pretty-table">
-  <thead>
-  <tr>
-    <th>A</th>
-    <th>B</th>
-    <th>C</th>
-    <th>D</th>
-  </tr>
-  </thead>
-  <tbody>
-  <tr>
-    <td align="center" colspan=2>Y</td>
-    <td>0</td>
-    <td>1</td>
-  </tr>
-  <tr>
-    <td>0</td>
-    <td align="center" colspan=2>Y</td>
-    <td>1</td>
-  </tr>
-  <tr>
-    <td>0</td>
-    <td>1</td>
-    <td align="center" colspan=2>Y</td>
-  </tr>
-  <tr>
-    <td align="center" colspan=3>Y</td>
-    <td>0</td>
-  </tr>
-  <tr>
-    <td>0</td>
-    <td align="center" colspan=3>Y</td>
-  </tr>
-  <tr>
-    <td align="center" colspan=4>Y</td>
-  </tr>
-  <tr>
-    <td>0</td>
-    <td>1</td>
-    <td>2</td>
-    <td>3</td>
-  </tr>
-  </tbody>
-</table>`)
-	})
-}
diff --git a/table/util_html_test.go b/table/util_html_test.go
new file mode 100644
index 0000000..932b96b
--- /dev/null
+++ b/table/util_html_test.go
@@ -0,0 +1,101 @@
+package table
+
+import (
+	"testing"
+
+	"github.com/jedib0t/go-pretty/v6/text"
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_convertEscSequencesToSpans(t *testing.T) {
+	t.Run("basic cases", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected string
+		}{
+			{"empty string", "", ""},
+			{"no escape sequences", "Hello World", "Hello World"},
+			{"single foreground color", text.FgRed.Sprint("Red Text"), "<span class=\"fg-red\">Red Text</span>"},
+			{"single background color", text.BgBlue.Sprint("Blue Background"), "<span class=\"bg-blue\">Blue Background</span>"},
+			{"only escape sequence with reset", text.FgYellow.EscapeSeq() + text.Reset.EscapeSeq(), "<span class=\"fg-yellow\"></span>"},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := convertEscSequencesToSpans(tt.input)
+				assert.Equal(t, tt.expected, result)
+			})
+		}
+	})
+
+	t.Run("multiple colors and attributes", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected string
+		}{
+			{"two colors", text.Colors{text.FgRed, text.Bold}.Sprint("Bold Red"), "<span class=\"bold fg-red\">Bold Red</span>"},
+			{"three attributes", text.Colors{text.Bold, text.Italic, text.Underline}.Sprint("Bold Italic Underline"), "<span class=\"bold italic underline\">Bold Italic Underline</span>"},
+			{"complex combination", text.Colors{text.FgCyan, text.Bold, text.Underline}.Sprint("Styled"), "<span class=\"bold fg-cyan underline\">Styled</span>"},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := convertEscSequencesToSpans(tt.input)
+				assert.Equal(t, tt.expected, result)
+			})
+		}
+	})
+
+	t.Run("color changes and resets", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected string
+		}{
+			{"color reset", text.FgRed.Sprint("Red") + text.Reset.Sprint("Normal"), "<span class=\"fg-red\">Red</span>Normal"},
+			{"multiple color changes", "Start" + text.FgRed.Sprint("Red") + text.FgBlue.Sprint("Blue") + "End", "Start<span class=\"fg-red\">Red</span><span class=\"fg-blue\">Blue</span>End"},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := convertEscSequencesToSpans(tt.input)
+				assert.Equal(t, tt.expected, result)
+			})
+		}
+	})
+
+	t.Run("HTML escaping", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected string
+		}{
+			{"all HTML special chars with color", text.FgBlue.Sprint("Test < > & \" '"), "<span class=\"fg-blue\">Test &lt; &gt; &amp; &#34; &#39;</span>"},
+			{"HTML special chars without color", "Text <bold> & \"quoted\"", "Text &lt;bold&gt; &amp; &#34;quoted&#34;"},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := convertEscSequencesToSpans(tt.input)
+				assert.Equal(t, tt.expected, result)
+			})
+		}
+	})
+
+	t.Run("edge cases", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected string
+		}{
+			{"color with newlines", text.FgMagenta.Sprint("Line1\nLine2"), "<span class=\"fg-magenta\">Line1\nLine2</span>"},
+			{"unmapped color code", "\x1b[99mText\x1b[0m", "Text"},
+			{"same colors set twice", text.FgRed.EscapeSeq() + "Red1" + text.FgRed.EscapeSeq() + "Red2" + text.Reset.EscapeSeq(), "<span class=\"fg-red\">Red1Red2</span>"},
+			{"open span at end", text.FgRed.EscapeSeq() + "Red Text", "<span class=\"fg-red\">Red Text</span>"},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := convertEscSequencesToSpans(tt.input)
+				assert.Equal(t, tt.expected, result)
+			})
+		}
+	})
+}
diff --git a/text/color_test.go b/text/color_test.go
index 3fabb83..0a91542 100644
--- a/text/color_test.go
+++ b/text/color_test.go
@@ -70,6 +70,23 @@ func TestColor_areColorsOnInTheEnv(t *testing.T) {
 	assert.True(t, areColorsOnInTheEnv())
 }
 
+func ExampleColor_CSSClasses() {
+	fmt.Printf("Bold: %#v\n", Bold.CSSClasses())
+	fmt.Printf("Black Background: %#v\n", BgBlack.CSSClasses())
+	fmt.Printf("Black Foreground: %#v\n", FgBlack.CSSClasses())
+
+	// Output: Bold: "bold"
+	// Black Background: "bg-black"
+	// Black Foreground: "fg-black"
+}
+
+func TestColor_CSSClasses(t *testing.T) {
+	assert.Equal(t, "bold", Bold.CSSClasses())
+	assert.Equal(t, "bg-black", BgBlack.CSSClasses())
+	assert.Equal(t, "fg-black", FgBlack.CSSClasses())
+	assert.Equal(t, "", Reset.CSSClasses()) // Reset has no CSS class
+}
+
 func ExampleColor_EscapeSeq() {
 	fmt.Printf("Black Background: %#v\n", BgBlack.EscapeSeq())
 	fmt.Printf("Black Foreground: %#v\n", FgBlack.EscapeSeq())
@@ -96,6 +113,7 @@ func TestColor_HTMLProperty(t *testing.T) {
 	assert.Equal(t, "class=\"bold\"", Bold.HTMLProperty())
 	assert.Equal(t, "class=\"bg-black\"", BgBlack.HTMLProperty())
 	assert.Equal(t, "class=\"fg-black\"", FgBlack.HTMLProperty())
+	assert.Equal(t, "", Reset.HTMLProperty()) // Reset has no CSS class mapping
 }
 
 func ExampleColor_Sprint() {
@@ -127,6 +145,26 @@ func TestColor_Sprintf(t *testing.T) {
 	assert.Equal(t, "\x1b[31mtest true\x1b[0m", FgRed.Sprintf("test %s", "true"))
 }
 
+func ExampleColors_CSSClasses() {
+	fmt.Printf("Black Background: %#v\n", Colors{BgBlack}.CSSClasses())
+	fmt.Printf("Black Foreground: %#v\n", Colors{FgBlack}.CSSClasses())
+	fmt.Printf("Black Background, White Foreground: %#v\n", Colors{BgBlack, FgWhite}.CSSClasses())
+	fmt.Printf("Black Foreground, White Background: %#v\n", Colors{FgBlack, BgWhite}.CSSClasses())
+	fmt.Printf("Bold Italic Underline Red Text: %#v\n", Colors{Bold, Italic, Underline, FgRed}.CSSClasses())
+
+	// Output: Black Background: "bg-black"
+	// Black Foreground: "fg-black"
+	// Black Background, White Foreground: "bg-black fg-white"
+	// Black Foreground, White Background: "bg-white fg-black"
+	// Bold Italic Underline Red Text: "bold fg-red italic underline"
+}
+
+func TestColors_CSSClasses(t *testing.T) {
+	assert.Equal(t, "", Colors{}.CSSClasses())
+	assert.Equal(t, "bg-black fg-white", Colors{BgBlack, FgWhite}.CSSClasses())
+	assert.Equal(t, "bold fg-red", Colors{Bold, FgRed}.CSSClasses())
+}
+
 func ExampleColors_EscapeSeq() {
 	fmt.Printf("Black Background: %#v\n", Colors{BgBlack}.EscapeSeq())
 	fmt.Printf("Black Foreground: %#v\n", Colors{FgBlack}.EscapeSeq())
diff --git a/text/escape_seq_parser_test.go b/text/escape_seq_parser_test.go
index fcfd54f..095cddf 100644
--- a/text/escape_seq_parser_test.go
+++ b/text/escape_seq_parser_test.go
@@ -8,7 +8,7 @@ import (
 
 func Test_escSeqParser(t *testing.T) {
 	t.Run("extract csi", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		assert.Equal(t, "\x1b[1;3;4;5;7;9;91m", es.ParseString("\x1b[91m\x1b[1m\x1b[3m\x1b[4m\x1b[5m\x1b[7m\x1b[9m Spicy"))
 		assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.ParseString("\x1b[22m No Bold"))
@@ -21,7 +21,7 @@ func Test_escSeqParser(t *testing.T) {
 	})
 
 	t.Run("extract osi", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		assert.Equal(t, "\x1b[1;3;4;5;7;9;91m", es.ParseString("\x1b]91\\\x1b]1\\\x1b]3\\\x1b]4\\\x1b]5\\\x1b]7\\\x1b]9\\ Spicy"))
 		assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.ParseString("\x1b]22\\ No Bold"))
@@ -34,7 +34,7 @@ func Test_escSeqParser(t *testing.T) {
 	})
 
 	t.Run("parse csi", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		es.ParseSeq("\x1b[91m", escSeqKindCSI) // color
 		es.ParseSeq("\x1b[1m", escSeqKindCSI)  // bold
@@ -54,7 +54,7 @@ func Test_escSeqParser(t *testing.T) {
 	})
 
 	t.Run("parse osi", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		es.ParseSeq("\x1b]91\\", escSeqKindOSI) // color
 		es.ParseSeq("\x1b]1\\", escSeqKindOSI)  // bold
@@ -76,7 +76,7 @@ func Test_escSeqParser(t *testing.T) {
 	t.Run("osi hyperlink with bel", func(t *testing.T) {
 		// Test OSC 8 hyperlink termination with BEL character (\a)
 		// Format: \x1b]8;;url\x07label\x1b]8;;\x07
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		// Simulate parsing an OSC 8 hyperlink that ends with BEL
 		for _, char := range "\x1b]8;;url\x07" {
@@ -89,14 +89,14 @@ func Test_escSeqParser(t *testing.T) {
 		assert.Contains(t, es.Codes(), 8)
 
 		// Test with a full hyperlink sequence
-		es = escSeqParser{} // fresh parser
+		es = EscSeqParser{} // fresh parser
 		result := es.ParseString("\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07")
 		// Code 8 (conceal) will be parsed
 		assert.Equal(t, "\x1b[8m", result)
 	})
 
 	t.Run("consume directly", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		// Test entering escape sequence
 		es.Consume('\x1b')
@@ -131,7 +131,7 @@ func Test_escSeqParser(t *testing.T) {
 	})
 
 	t.Run("consume invalid escape sequence", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		// Test escape sequence that doesn't start with CSI or OSI
 		es.Consume('\x1b')
@@ -147,7 +147,7 @@ func Test_escSeqParser(t *testing.T) {
 	})
 
 	t.Run("parse seq with spaces and multiple codes", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		// Test parsing sequence with spaces
 		es.ParseSeq("\x1b[ 1 ; 3 ; 4 m", escSeqKindCSI)
@@ -166,7 +166,7 @@ func Test_escSeqParser(t *testing.T) {
 	})
 
 	t.Run("parse seq edge cases", func(t *testing.T) {
-		es := escSeqParser{}
+		es := EscSeqParser{}
 
 		// Test empty sequence after stripping
 		es.ParseSeq("\x1b[m", escSeqKindCSI)