Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions table/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion table/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions table/render_html.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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(" </")
Expand Down
39 changes: 39 additions & 0 deletions table/render_html_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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()
Expand Down
7 changes: 2 additions & 5 deletions table/render_tsv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
27 changes: 22 additions & 5 deletions table/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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] {
Expand Down