From a2736b6b5d0216dfb5d5a4a221debc53a05ab81c Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Fri, 27 Feb 2026 09:04:32 -0800 Subject: [PATCH] table: markdown padding for human-friendly output; fixes #402 --- table/render_markdown.go | 46 +++++++++++++++++++++++---- table/render_markdown_test.go | 60 +++++++++++++++++++++++++++++++++++ table/style.go | 17 +++++----- table/style_markdown.go | 25 +++++++++++++++ text/align.go | 18 ++++++++--- text/align_test.go | 13 ++++++++ 6 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 table/style_markdown.go diff --git a/table/render_markdown.go b/table/render_markdown.go index ff298b1f..d7227d36 100644 --- a/table/render_markdown.go +++ b/table/render_markdown.go @@ -51,22 +51,49 @@ func (t *Table) markdownRenderRow(out *strings.Builder, row rowStr, hint renderH if colIdx < len(row) { colStr = row[colIdx] } - out.WriteRune(' ') colStr = strings.ReplaceAll(colStr, "|", "\\|") colStr = strings.ReplaceAll(colStr, "\n", "
") - out.WriteString(colStr) - out.WriteRune(' ') + if t.style.Markdown.PadContent { + out.WriteRune(' ') + align := t.getAlign(colIdx, hint) + out.WriteString(align.Apply(colStr, t.maxColumnLengths[colIdx])) + out.WriteRune(' ') + } else { + out.WriteRune(' ') + out.WriteString(colStr) + out.WriteRune(' ') + } out.WriteRune('|') } } func (t *Table) markdownRenderRowAutoIndex(out *strings.Builder, colIdx int, hint renderHint) { if colIdx == 0 && t.autoIndex { - out.WriteRune(' ') if hint.isSeparatorRow { - out.WriteString("---:") + if t.style.Markdown.PadContent { + out.WriteString(" " + strings.Repeat("-", t.autoIndexVIndexMaxLength) + ":") + } else { + out.WriteRune(' ') + out.WriteString("---:") + } } else if hint.isRegularRow() { - fmt.Fprintf(out, "%d ", hint.rowNumber) + if t.style.Markdown.PadContent { + rowNumStr := fmt.Sprint(hint.rowNumber) + out.WriteRune(' ') + fmt.Fprintf(out, "%*s", t.autoIndexVIndexMaxLength, rowNumStr) + out.WriteRune(' ') + } else { + out.WriteRune(' ') + fmt.Fprintf(out, "%d ", hint.rowNumber) + } + } else { + if t.style.Markdown.PadContent { + out.WriteRune(' ') + out.WriteString(strings.Repeat(" ", t.autoIndexVIndexMaxLength)) + out.WriteRune(' ') + } else { + out.WriteRune(' ') + } } out.WriteRune('|') } @@ -107,7 +134,12 @@ func (t *Table) markdownRenderSeparator(out *strings.Builder, hint renderHint) { for colIdx := 0; colIdx < t.numColumns; colIdx++ { t.markdownRenderRowAutoIndex(out, colIdx, hint) - out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty()) + align := t.getAlign(colIdx, hint) + if t.style.Markdown.PadContent { + out.WriteString(align.MarkdownProperty(t.maxColumnLengths[colIdx])) + } else { + out.WriteString(align.MarkdownProperty()) + } out.WriteRune('|') } } diff --git a/table/render_markdown_test.go b/table/render_markdown_test.go index cb8e8de6..8c9c7e48 100644 --- a/table/render_markdown_test.go +++ b/table/render_markdown_test.go @@ -65,6 +65,66 @@ func TestTable_RenderMarkdown_AutoIndex(t *testing.T) { | | AF | BF | CF | DF | EF | FF | GF | HF | IF | JF |`) } +func TestTable_RenderMarkdown_Padded(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowNewLines) + tw.AppendRow(testRowPipes) + tw.AppendFooter(testFooter) + tw.SetCaption(testCaption) + tw.SetTitle(testTitle1) + tw.Style().Markdown.PadContent = true + + compareOutput(t, tw.RenderMarkdown(), ` +# Game of Thrones +| # | First Name | Last Name | Salary | | +| ---:| ---------- | --------- | ------:| --------------------------- | +| 1 | Arya | Stark | 3000 | | +| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! | +| 300 | Tyrion | Lannister | 5000 | | +| 0 | Valar | Morghulis | 0 | Faceless
Men | +| 0 | Valar | Morghulis | 0 | Faceless\|Men | +| | | Total | 10000 | | +_A Song of Ice and Fire_`) +} + +func TestTable_RenderMarkdown_Padded_AutoIndex(t *testing.T) { + tw := NewWriter() + for rowIdx := 0; rowIdx < 10; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1) + } + tw.AppendRow(row) + } + for rowIdx := 0; rowIdx < 1; rowIdx++ { + row := make(Row, 10) + for colIdx := 0; colIdx < 10; colIdx++ { + row[colIdx] = AutoIndexColumnID(colIdx) + "F" + } + tw.AppendFooter(row) + } + tw.SetAutoIndex(true) + tw.SetStyle(StyleLight) + tw.Style().Markdown.PadContent = true + + compareOutput(t, tw.RenderMarkdown(), ` +| | A | B | C | D | E | F | G | H | I | J | +| --:| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | A1 | B1 | C1 | D1 | E1 | F1 | G1 | H1 | I1 | J1 | +| 2 | A2 | B2 | C2 | D2 | E2 | F2 | G2 | H2 | I2 | J2 | +| 3 | A3 | B3 | C3 | D3 | E3 | F3 | G3 | H3 | I3 | J3 | +| 4 | A4 | B4 | C4 | D4 | E4 | F4 | G4 | H4 | I4 | J4 | +| 5 | A5 | B5 | C5 | D5 | E5 | F5 | G5 | H5 | I5 | J5 | +| 6 | A6 | B6 | C6 | D6 | E6 | F6 | G6 | H6 | I6 | J6 | +| 7 | A7 | B7 | C7 | D7 | E7 | F7 | G7 | H7 | I7 | J7 | +| 8 | A8 | B8 | C8 | D8 | E8 | F8 | G8 | H8 | I8 | J8 | +| 9 | A9 | B9 | C9 | D9 | E9 | F9 | G9 | H9 | I9 | J9 | +| 10 | A10 | B10 | C10 | D10 | E10 | F10 | G10 | H10 | I10 | J10 | +| | AF | BF | CF | DF | EF | FF | GF | HF | IF | JF |`) +} + func TestTable_RenderMarkdown_Empty(t *testing.T) { tw := NewWriter() assert.Empty(t, tw.RenderMarkdown()) diff --git a/table/style.go b/table/style.go index b5f58493..0b2473de 100644 --- a/table/style.go +++ b/table/style.go @@ -3,14 +3,15 @@ package table // Style declares how to render the Table and provides very fine-grained control // on how the Table gets rendered on the Console. type Style struct { - Name string // name of the Style - Box BoxStyle // characters to use for the boxes - Color ColorOptions // colors to use for the rows and columns - Format FormatOptions // formatting options for the rows and columns - HTML HTMLOptions // rendering options for HTML mode - Options Options // misc. options for the table - Size SizeOptions // size (width) options for the table - Title TitleOptions // formation options for the title text + Name string // name of the Style + Box BoxStyle // characters to use for the boxes + Color ColorOptions // colors to use for the rows and columns + Format FormatOptions // formatting options for the rows and columns + HTML HTMLOptions // rendering options for HTML mode + Markdown MarkdownOptions // rendering options for Markdown mode + Options Options // misc. options for the table + Size SizeOptions // size (width) options for the table + Title TitleOptions // formation options for the title text } var ( diff --git a/table/style_markdown.go b/table/style_markdown.go new file mode 100644 index 00000000..a4c6632c --- /dev/null +++ b/table/style_markdown.go @@ -0,0 +1,25 @@ +package table + +// MarkdownOptions defines options to control Markdown rendering. +type MarkdownOptions struct { + // PadContent pads each column content to match the longest content in + // the column, and extends the separator dashes to match. This makes the + // raw Markdown source more readable without affecting the rendered + // output. + // + // When disabled (default): + // | # | First Name | Last Name | Salary | | + // | ---:| --- | --- | ---:| --- | + // | 1 | Arya | Stark | 3000 | | + // + // When enabled: + // | # | First Name | Last Name | Salary | | + // | ---:| ---------- | --------- | ------:| --------------------------- | + // | 1 | Arya | Stark | 3000 | | + PadContent bool +} + +var ( + // DefaultMarkdownOptions defines sensible Markdown rendering defaults. + DefaultMarkdownOptions = MarkdownOptions{} +) diff --git a/text/align.go b/text/align.go index 189531dc..33512ee8 100644 --- a/text/align.go +++ b/text/align.go @@ -76,16 +76,24 @@ func (a Align) HTMLProperty() string { } // MarkdownProperty returns the equivalent Markdown horizontal-align separator. -func (a Align) MarkdownProperty() string { +// An optional minLength can be provided to extend the dashes to match the +// column content width; the result will be max(minLength, 3)+2 wide (including +// leading/trailing space or colon). Without minLength (or 0), it defaults to 3. +func (a Align) MarkdownProperty(minLength ...int) string { + length := 3 + if len(minLength) > 0 && minLength[0] > length { + length = minLength[0] + } + dashes := strings.Repeat("-", length) switch a { case AlignLeft: - return ":--- " + return ":" + dashes + " " case AlignCenter: - return ":---:" + return ":" + dashes + ":" case AlignRight: - return " ---:" + return " " + dashes + ":" default: - return " --- " + return " " + dashes + " " } } diff --git a/text/align_test.go b/text/align_test.go index a1488453..75b7425a 100644 --- a/text/align_test.go +++ b/text/align_test.go @@ -151,3 +151,16 @@ func TestAlign_MarkdownProperty(t *testing.T) { assert.Contains(t, align.MarkdownProperty(), markdownSeparator) } } + +func TestAlign_MarkdownProperty_WithMinLength(t *testing.T) { + assert.Equal(t, " ---------- ", AlignDefault.MarkdownProperty(10)) + assert.Equal(t, ":---------- ", AlignLeft.MarkdownProperty(10)) + assert.Equal(t, ":----------:", AlignCenter.MarkdownProperty(10)) + assert.Equal(t, " ---------- ", AlignJustify.MarkdownProperty(10)) + assert.Equal(t, " ----------:", AlignRight.MarkdownProperty(10)) + + // minimum width of 3 + assert.Equal(t, " --- ", AlignDefault.MarkdownProperty(1)) + assert.Equal(t, " --- ", AlignDefault.MarkdownProperty(3)) + assert.Equal(t, " ---- ", AlignDefault.MarkdownProperty(4)) +}