Repository: jedib0t/go-pretty (go)

Issue:
vertical column autoMerge needed for HTML mode
**Is your feature request related to a problem? Please describe.**
Currently vertical autoMerge works well with console table mode, but doesn't work with html mode   
html mode:   
![WeChatWorkScreenshot_a4c6357f-3772-4b3d-8fe3-0ee2559c276b](https://github.com/user-attachments/assets/cece0ede-0b13-49ad-bb36-1cb3c9acb6d6)
normal mode:    
![WeChatWorkScreenshot_f1d2f2a2-f9d8-46c5-9957-bb0e6f2eac0f](https://github.com/user-attachments/assets/bab3f0ba-cc83-4c57-98d2-bd279a16c38b)


**Describe the solution you'd like**
Need this for html mode, any workaround?

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


Pull request:
table: support vertical merge in HTML rendering; fixes #348

Patch:
diff --git a/table/config.go b/table/config.go
index fb96d70..c36124a 100644
--- a/table/config.go
+++ b/table/config.go
@@ -25,8 +25,8 @@ type ColumnConfig struct {
 	// AutoMerge merges cells with similar values and prevents separators from
 	// being drawn. Caveats:
 	// * VAlign is applied on the individual cell and not on the merged cell
-	// * Does not work in CSV/HTML/Markdown render modes
 	// * Does not work well with horizontal auto-merge (RowConfig.AutoMerge)
+	// * Does not work in CSV/Markdown render modes
 	//
 	// Works best when:
 	// * Style().Options.SeparateRows == true
@@ -87,8 +87,8 @@ type RowConfig struct {
 	// being drawn. Caveats:
 	// * Align is overridden to text.AlignCenter on the merged cell (unless set
 	//   by AutoMergeAlign value below)
-	// * Does not work in CSV/HTML/Markdown render modes
 	// * Does not work well with vertical auto-merge (ColumnConfig.AutoMerge)
+	// * Does not work in CSV/Markdown render modes
 	AutoMerge bool
 
 	// Alignment to use on a merge (defaults to text.AlignCenter)
diff --git a/table/render.go b/table/render.go
index 2fd759e..4ef68f9 100644
--- a/table/render.go
+++ b/table/render.go
@@ -67,7 +67,7 @@ func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxCo
 	}
 
 	// extract the text, convert-case if not-empty and align horizontally
-	mergeVertically := t.shouldMergeCellsVertically(colIdx, hint)
+	mergeVertically := t.shouldMergeCellsVerticallyAbove(colIdx, hint)
 	var colStr string
 	if mergeVertically {
 		// leave colStr empty; align will expand the column as necessary
diff --git a/table/render_html.go b/table/render_html.go
index decf4fa..729f95e 100644
--- a/table/render_html.go
+++ b/table/render_html.go
@@ -159,6 +159,10 @@ func (t *Table) htmlRenderRow(out *strings.Builder, row rowStr, hint renderHint)
 		if colIdx == 0 && t.autoIndex {
 			t.htmlRenderColumnAutoIndex(out, hint)
 		}
+		// auto-merged columns should be skipped
+		if t.shouldMergeCellsVerticallyAbove(colIdx, hint) {
+			continue
+		}
 
 		align := t.getAlign(colIdx, hint)
 		rowConfig := t.getRowConfig(hint)
@@ -184,6 +188,9 @@ func (t *Table) htmlRenderRow(out *strings.Builder, row rowStr, hint renderHint)
 		if extraColumnsRendered > 0 {
 			out.WriteString(" colspan=")
 			out.WriteString(fmt.Sprint(extraColumnsRendered + 1))
+		} else if rowSpan := t.shouldMergeCellsVerticallyBelow(colIdx, hint); rowSpan > 1 {
+			out.WriteString(" rowspan=")
+			out.WriteString(fmt.Sprint(rowSpan))
 		}
 		out.WriteString(">")
 		if len(colStr) == 0 {
@@ -222,6 +229,7 @@ func (t *Table) htmlRenderRows(out *strings.Builder, rows []rowStr, hint renderH
 				t.htmlRenderRow(out, row, hint)
 				shouldRenderTagClose = true
 			}
+			t.firstRowOfPage = false
 		}
 		if shouldRenderTagClose {
 			out.WriteString("  </")
diff --git a/table/render_tsv.go b/table/render_tsv.go
index bb73975..67f1e7f 100644
--- a/table/render_tsv.go
+++ b/table/render_tsv.go
@@ -50,7 +50,8 @@ func (t *Table) tsvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
 		}
 
 		if strings.ContainsAny(col, "\t\n\"") || strings.Contains(col, "    ") {
-			out.WriteString(fmt.Sprintf("\"%s\"", t.tsvFixDoubleQuotes(col)))
+			col = strings.ReplaceAll(col, "\"", "\"\"") // fix double-quotes
+			out.WriteString(fmt.Sprintf("\"%s\"", col))
 		} else {
 			out.WriteString(col)
 		}
@@ -61,10 +62,6 @@ func (t *Table) tsvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
 	}
 }
 
-func (t *Table) tsvFixDoubleQuotes(str string) string {
-	return strings.Replace(str, "\"", "\"\"", -1)
-}
-
 func (t *Table) tsvRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
 	for idx, row := range rows {
 		hint.rowNumber = idx + 1
diff --git a/table/table.go b/table/table.go
index c15ccd7..191dd77 100644
--- a/table/table.go
+++ b/table/table.go
@@ -434,7 +434,7 @@ func (t *Table) getBorderLeft(hint renderHint) string {
 	} else if hint.isSeparatorRow {
 		if t.autoIndex && hint.isHeaderOrFooterSeparator() {
 			border = t.style.Box.Left
-		} else if !t.autoIndex && t.shouldMergeCellsVertically(0, hint) {
+		} else if !t.autoIndex && t.shouldMergeCellsVerticallyAbove(0, hint) {
 			border = t.style.Box.Left
 		} else {
 			border = t.style.Box.LeftSeparator
@@ -454,7 +454,7 @@ func (t *Table) getBorderRight(hint renderHint) string {
 	} else if hint.isBorderBottom {
 		border = t.style.Box.BottomRight
 	} else if hint.isSeparatorRow {
-		if t.shouldMergeCellsVertically(t.numColumns-1, hint) {
+		if t.shouldMergeCellsVerticallyAbove(t.numColumns-1, hint) {
 			border = t.style.Box.Right
 		} else {
 			border = t.style.Box.RightSeparator
@@ -525,12 +525,12 @@ func (t *Table) getColumnSeparator(row rowStr, colIdx int, hint renderHint) stri
 }
 
 func (t *Table) getColumnSeparatorNonBorder(mergeCellsAbove bool, mergeCellsBelow bool, colIdx int, hint renderHint) string {
-	mergeNextCol := t.shouldMergeCellsVertically(colIdx, hint)
+	mergeNextCol := t.shouldMergeCellsVerticallyAbove(colIdx, hint)
 	if hint.isAutoIndexColumn {
 		return t.getColumnSeparatorNonBorderAutoIndex(mergeNextCol, hint)
 	}
 
-	mergeCurrCol := t.shouldMergeCellsVertically(colIdx-1, hint)
+	mergeCurrCol := t.shouldMergeCellsVerticallyAbove(colIdx-1, hint)
 	return t.getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove, mergeCellsBelow, mergeCurrCol, mergeNextCol)
 }
 
@@ -839,7 +839,7 @@ func (t *Table) shouldMergeCellsHorizontallyBelow(row rowStr, colIdx int, hint r
 	return false
 }
 
-func (t *Table) shouldMergeCellsVertically(colIdx int, hint renderHint) bool {
+func (t *Table) shouldMergeCellsVerticallyAbove(colIdx int, hint renderHint) bool {
 	if !t.firstRowOfPage && t.columnConfigMap[colIdx].AutoMerge && colIdx < t.numColumns {
 		if hint.isSeparatorRow {
 			rowPrev := t.getRow(hint.rowNumber-1, hint)
@@ -858,6 +858,23 @@ func (t *Table) shouldMergeCellsVertically(colIdx int, hint renderHint) bool {
 	return false
 }
 
+func (t *Table) shouldMergeCellsVerticallyBelow(colIdx int, hint renderHint) int {
+	numRowsToMerge := 0
+	if t.columnConfigMap[colIdx].AutoMerge && colIdx < t.numColumns {
+		numRowsToMerge = 1
+		rowCurr := t.getRow(hint.rowNumber-1, hint)
+		for rowIdx := hint.rowNumber; rowIdx < len(t.rows); rowIdx++ {
+			rowNext := t.getRow(rowIdx, hint)
+			if colIdx < len(rowCurr) && colIdx < len(rowNext) && rowNext[colIdx] == rowCurr[colIdx] {
+				numRowsToMerge++
+			} else {
+				break
+			}
+		}
+	}
+	return numRowsToMerge
+}
+
 func (t *Table) shouldSeparateRows(rowIdx int, numRows int) bool {
 	// not asked to separate rows and no manually added separator
 	if !t.style.Options.SeparateRows && !t.separators[rowIdx] {


Test patch:
diff --git a/table/render_html_test.go b/table/render_html_test.go
index ad2eae3..d0f8509 100644
--- a/table/render_html_test.go
+++ b/table/render_html_test.go
@@ -518,6 +518,44 @@ func TestTable_RenderHTML_Sorted(t *testing.T) {
 </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}
@@ -544,6 +582,7 @@ func TestTable_RenderHTML_RowAutoMerge(t *testing.T) {
   </tbody>
 </table>`)
 	})
+
 	t.Run("merged and unmerged entries", func(t *testing.T) {
 		rcAutoMerge := RowConfig{AutoMerge: true}
 		tw := NewWriter()