├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE.md ├── MIGRATION.md ├── README.md ├── README_LEGACY.md ├── _example ├── filetable │ ├── main.go │ └── out.txt └── symbols │ ├── main.go │ └── out.txt ├── _readme └── color_1.png ├── cmd └── csv2table │ ├── README.md │ ├── _data │ ├── test.csv │ └── test_info.csv │ └── csv2table.go ├── config.go ├── csv.go ├── deprecated.go ├── go.mod ├── go.sum ├── option.go ├── pkg └── twwarp │ ├── _data │ ├── long-text-wrapped.txt │ └── long-text.txt │ ├── wrap.go │ └── wrap_test.go ├── renderer ├── blueprint.go ├── colorized.go ├── fn.go ├── html.go ├── junction.go ├── markdown.go ├── ocean.go └── svg.go ├── stream.go ├── tablewriter.go ├── tablewriter_test.go ├── tests ├── basic_test.go ├── blueprint_test.go ├── bug_test.go ├── caption_test.go ├── colorized_test.go ├── csv_test.go ├── extra_test.go ├── feature_test.go ├── fn.go ├── html_test.go ├── markdown_test.go ├── merge_test.go ├── ocean_test.go ├── streamer_test.go ├── struct_test.go ├── svg_test.go └── table_bench_test.go ├── tw ├── cell.go ├── deprecated.go ├── fn.go ├── fn_test.go ├── mapper.go ├── preset.go ├── renderer.go ├── slicer.go ├── state.go ├── symbols.go ├── tw.go └── types.go └── zoo.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.21' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | # folders 4 | .idea 5 | .vscode 6 | /tmp 7 | /lab 8 | dev.sh 9 | *csv2table 10 | _test/ 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 by Oleku Konko 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /_example/filetable/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/olekukonko/tablewriter" 6 | "github.com/olekukonko/tablewriter/tw" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const ( 15 | folder = "📁" 16 | file = "📄" 17 | baseDir = "../" 18 | indentStr = " " 19 | ) 20 | 21 | func main() { 22 | table := tablewriter.NewTable(os.Stdout, tablewriter.WithTrimSpace(tw.Off)) 23 | table.Header([]string{"Tree", "Size", "Permissions", "Modified"}) 24 | err := filepath.WalkDir(baseDir, func(path string, d fs.DirEntry, err error) error { 25 | if err != nil { 26 | return err 27 | } 28 | 29 | if d.Name() == "." || d.Name() == ".." { 30 | return nil 31 | } 32 | 33 | // Calculate relative path depth 34 | relPath, err := filepath.Rel(baseDir, path) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | depth := 0 40 | if relPath != "." { 41 | depth = len(strings.Split(relPath, string(filepath.Separator))) - 1 42 | } 43 | 44 | indent := strings.Repeat(indentStr, depth) 45 | 46 | var name string 47 | if d.IsDir() { 48 | name = fmt.Sprintf("%s%s %s", indent, folder, d.Name()) 49 | } else { 50 | name = fmt.Sprintf("%s%s %s", indent, file, d.Name()) 51 | } 52 | 53 | info, err := d.Info() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | table.Append([]string{ 59 | name, 60 | Size(info.Size()).String(), 61 | info.Mode().String(), 62 | Time(info.ModTime()).Format(), 63 | }) 64 | 65 | return nil 66 | }) 67 | 68 | if err != nil { 69 | fmt.Fprintf(os.Stdout, "Error: %v\n", err) 70 | return 71 | } 72 | 73 | table.Render() 74 | } 75 | 76 | const ( 77 | KB = 1024 78 | MB = KB * 1024 79 | GB = MB * 1024 80 | TB = GB * 1024 81 | ) 82 | 83 | type Size int64 84 | 85 | func (s Size) String() string { 86 | switch { 87 | case s < KB: 88 | return fmt.Sprintf("%d B", s) 89 | case s < MB: 90 | return fmt.Sprintf("%.2f KB", float64(s)/KB) 91 | case s < GB: 92 | return fmt.Sprintf("%.2f MB", float64(s)/MB) 93 | case s < TB: 94 | return fmt.Sprintf("%.2f GB", float64(s)/GB) 95 | default: 96 | return fmt.Sprintf("%.2f TB", float64(s)/TB) 97 | } 98 | } 99 | 100 | type Time time.Time 101 | 102 | func (t Time) Format() string { 103 | now := time.Now() 104 | diff := now.Sub(time.Time(t)) 105 | 106 | if diff.Seconds() < 60 { 107 | return "just now" 108 | } else if diff.Minutes() < 60 { 109 | return fmt.Sprintf("%d minutes ago", int(diff.Minutes())) 110 | } else if diff.Hours() < 24 { 111 | return fmt.Sprintf("%d hours ago", int(diff.Hours())) 112 | } else if diff.Hours() < 24*7 { 113 | return fmt.Sprintf("%d days ago", int(diff.Hours()/24)) 114 | } else { 115 | return time.Time(t).Format("Jan 2, 2006") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /_example/filetable/out.txt: -------------------------------------------------------------------------------- 1 | ┌──────────────────┬─────────┬─────────────┬──────────────┐ 2 | │ TREE │ SIZE │ PERMISSIONS │ MODIFIED │ 3 | ├──────────────────┼─────────┼─────────────┼──────────────┤ 4 | │ 📁 filetable │ 160 B │ drwxr-xr-x │ just now │ 5 | │ 📄 main.go │ 2.19 KB │ -rw-r--r-- │ 22 hours ago │ 6 | │ 📄 out.txt │ 0 B │ -rw-r--r-- │ just now │ 7 | │ 📁 testdata │ 128 B │ drwxr-xr-x │ 1 days ago │ 8 | │ 📄 a.txt │ 11 B │ -rw-r--r-- │ 1 days ago │ 9 | │ 📄 b.txt │ 17 B │ -rw-r--r-- │ 1 days ago │ 10 | │ 📁 symbols │ 128 B │ drwxr-xr-x │ just now │ 11 | │ 📄 main.go │ 4.58 KB │ -rw-r--r-- │ 1 hours ago │ 12 | │ 📄 out.txt │ 8.72 KB │ -rw-r--r-- │ just now │ 13 | └──────────────────┴─────────┴─────────────┴──────────────┘ 14 | -------------------------------------------------------------------------------- /_example/symbols/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/olekukonko/ll" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/olekukonko/tablewriter/renderer" 8 | "github.com/olekukonko/tablewriter/tw" 9 | "os" 10 | ) 11 | 12 | func main() { 13 | data := [][]string{ 14 | {"Engineering", "Backend", "API Team", "Alice"}, 15 | {"Engineering", "Backend", "Database Team", "Bob"}, 16 | {"Engineering", "Frontend", "UI Team", "Charlie"}, 17 | {"Marketing", "Digital", "SEO Team", "Dave"}, 18 | {"Marketing", "Digital", "Content Team", "Eve"}, 19 | } 20 | 21 | cnf := tablewriter.Config{ 22 | Header: tw.CellConfig{ 23 | Formatting: tw.CellFormatting{Alignment: tw.AlignCenter}, 24 | }, 25 | Row: tw.CellConfig{ 26 | Formatting: tw.CellFormatting{ 27 | MergeMode: tw.MergeHierarchical, 28 | Alignment: tw.AlignLeft, 29 | }, 30 | }, 31 | Debug: false, 32 | } 33 | 34 | // Create a custom border style 35 | DottedStyle := []tw.Symbols{ 36 | tw.NewSymbolCustom("Dotted"). 37 | WithRow("·"). 38 | WithColumn(":"). 39 | WithTopLeft("."). 40 | WithTopMid("·"). 41 | WithTopRight("."). 42 | WithMidLeft(":"). 43 | WithCenter("+"). 44 | WithMidRight(":"). 45 | WithBottomLeft("'"). 46 | WithBottomMid("·"). 47 | WithBottomRight("'"), 48 | 49 | // arrow style 50 | tw.NewSymbolCustom("Arrow"). 51 | WithRow("→"). 52 | WithColumn("↓"). 53 | WithTopLeft("↗"). 54 | WithTopMid("↑"). 55 | WithTopRight("↖"). 56 | WithMidLeft("→"). 57 | WithCenter("↔"). 58 | WithMidRight("←"). 59 | WithBottomLeft("↘"). 60 | WithBottomMid("↓"). 61 | WithBottomRight("↙"), 62 | 63 | // start style 64 | tw.NewSymbolCustom("Starry"). 65 | WithRow("★"). 66 | WithColumn("☆"). 67 | WithTopLeft("✧"). 68 | WithTopMid("✯"). 69 | WithTopRight("✧"). 70 | WithMidLeft("✦"). 71 | WithCenter("✶"). 72 | WithMidRight("✦"). 73 | WithBottomLeft("✧"). 74 | WithBottomMid("✯"). 75 | WithBottomRight("✧"), 76 | 77 | tw.NewSymbolCustom("Hearts"). 78 | WithRow("♥"). 79 | WithColumn("❤"). 80 | WithTopLeft("❥"). 81 | WithTopMid("♡"). 82 | WithTopRight("❥"). 83 | WithMidLeft("❣"). 84 | WithCenter("✚"). 85 | WithMidRight("❣"). 86 | WithBottomLeft("❦"). 87 | WithBottomMid("♡"). 88 | WithBottomRight("❦"), 89 | 90 | tw.NewSymbolCustom("Tech"). 91 | WithRow("="). 92 | WithColumn("||"). 93 | WithTopLeft("/*"). 94 | WithTopMid("##"). 95 | WithTopRight("*/"). 96 | WithMidLeft("//"). 97 | WithCenter("<>"). 98 | WithMidRight("\\"). 99 | WithBottomLeft("\\*"). 100 | WithBottomMid("##"). 101 | WithBottomRight("*/"), 102 | 103 | tw.NewSymbolCustom("Nature"). 104 | WithRow("~"). 105 | WithColumn("|"). 106 | WithTopLeft("🌱"). 107 | WithTopMid("🌿"). 108 | WithTopRight("🌱"). 109 | WithMidLeft("🍃"). 110 | WithCenter("❀"). 111 | WithMidRight("🍃"). 112 | WithBottomLeft("🌻"). 113 | WithBottomMid("🌾"). 114 | WithBottomRight("🌻"), 115 | 116 | tw.NewSymbolCustom("Artistic"). 117 | WithRow("▬"). 118 | WithColumn("▐"). 119 | WithTopLeft("◈"). 120 | WithTopMid("◊"). 121 | WithTopRight("◈"). 122 | WithMidLeft("◀"). 123 | WithCenter("⬔"). 124 | WithMidRight("▶"). 125 | WithBottomLeft("◭"). 126 | WithBottomMid("▣"). 127 | WithBottomRight("◮"), 128 | 129 | tw.NewSymbolCustom("8-Bit"). 130 | WithRow("■"). 131 | WithColumn("█"). 132 | WithTopLeft("╔"). 133 | WithTopMid("▲"). 134 | WithTopRight("╗"). 135 | WithMidLeft("◄"). 136 | WithCenter("♦"). 137 | WithMidRight("►"). 138 | WithBottomLeft("╚"). 139 | WithBottomMid("▼"). 140 | WithBottomRight("╝"), 141 | 142 | tw.NewSymbolCustom("Chaos"). 143 | WithRow("≈"). 144 | WithColumn("§"). 145 | WithTopLeft("⌘"). 146 | WithTopMid("∞"). 147 | WithTopRight("⌥"). 148 | WithMidLeft("⚡"). 149 | WithCenter("☯"). 150 | WithMidRight("♞"). 151 | WithBottomLeft("⌂"). 152 | WithBottomMid("∆"). 153 | WithBottomRight("◊"), 154 | 155 | tw.NewSymbolCustom("Dots"). 156 | WithRow("·"). 157 | WithColumn(" "). // Invisible column lines 158 | WithTopLeft("·"). 159 | WithTopMid("·"). 160 | WithTopRight("·"). 161 | WithMidLeft(" "). 162 | WithCenter("·"). 163 | WithMidRight(" "). 164 | WithBottomLeft("·"). 165 | WithBottomMid("·"). 166 | WithBottomRight("·"), 167 | 168 | tw.NewSymbolCustom("Blocks"). 169 | WithRow("▀"). 170 | WithColumn("█"). 171 | WithTopLeft("▛"). 172 | WithTopMid("▀"). 173 | WithTopRight("▜"). 174 | WithMidLeft("▌"). 175 | WithCenter("█"). 176 | WithMidRight("▐"). 177 | WithBottomLeft("▙"). 178 | WithBottomMid("▄"). 179 | WithBottomRight("▟"), 180 | 181 | tw.NewSymbolCustom("Zen"). 182 | WithRow("~"). 183 | WithColumn(" "). 184 | WithTopLeft(" "). 185 | WithTopMid("♨"). 186 | WithTopRight(" "). 187 | WithMidLeft(" "). 188 | WithCenter("☯"). 189 | WithMidRight(" "). 190 | WithBottomLeft(" "). 191 | WithBottomMid("♨"). 192 | WithBottomRight(" "), 193 | } 194 | 195 | var table *tablewriter.Table 196 | for _, style := range DottedStyle { 197 | ll.Info(style.Name() + " style") 198 | table = tablewriter.NewTable(os.Stdout, 199 | tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{Symbols: style})), 200 | tablewriter.WithConfig(cnf), 201 | ) 202 | table.Header([]string{"Department", "Division", "Team", "Lead"}) 203 | table.Bulk(data) 204 | table.Render() 205 | 206 | fmt.Println() 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /_example/symbols/out.txt: -------------------------------------------------------------------------------- 1 | INFO: Dotted style 2 | .··················································. 3 | : DEPARTMENT : DIVISION : TEAM : LEAD : 4 | :·············+··········+···············+·········: 5 | : Engineering : Backend : API Team : Alice : 6 | : : : Database Team : Bob : 7 | : : Frontend : UI Team : Charlie : 8 | : Marketing : Digital : SEO Team : Dave : 9 | : : : Content Team : Eve : 10 | '··················································' 11 | 12 | INFO: Arrow style 13 | ↗→→→→→→→→→→→→→↑→→→→→→→→→→↑→→→→→→→→→→→→→→→↑→→→→→→→→→↖ 14 | ↓ DEPARTMENT ↓ DIVISION ↓ TEAM ↓ LEAD ↓ 15 | →→→→→→→→→→→→→→↔→→→→→→→→→→↔→→→→→→→→→→→→→→→↔→→→→→→→→→← 16 | ↓ Engineering ↓ Backend ↓ API Team ↓ Alice ↓ 17 | ↓ ↓ ↓ Database Team ↓ Bob ↓ 18 | ↓ ↓ Frontend ↓ UI Team ↓ Charlie ↓ 19 | ↓ Marketing ↓ Digital ↓ SEO Team ↓ Dave ↓ 20 | ↓ ↓ ↓ Content Team ↓ Eve ↓ 21 | ↘→→→→→→→→→→→→→↓→→→→→→→→→→↓→→→→→→→→→→→→→→→↓→→→→→→→→→↙ 22 | 23 | INFO: Starry style 24 | ✧★★★★★★★★★★★★★✯★★★★★★★★★★✯★★★★★★★★★★★★★★★✯★★★★★★★★★✧ 25 | ☆ DEPARTMENT ☆ DIVISION ☆ TEAM ☆ LEAD ☆ 26 | ✦★★★★★★★★★★★★★✶★★★★★★★★★★✶★★★★★★★★★★★★★★★✶★★★★★★★★★✦ 27 | ☆ Engineering ☆ Backend ☆ API Team ☆ Alice ☆ 28 | ☆ ☆ ☆ Database Team ☆ Bob ☆ 29 | ☆ ☆ Frontend ☆ UI Team ☆ Charlie ☆ 30 | ☆ Marketing ☆ Digital ☆ SEO Team ☆ Dave ☆ 31 | ☆ ☆ ☆ Content Team ☆ Eve ☆ 32 | ✧★★★★★★★★★★★★★✯★★★★★★★★★★✯★★★★★★★★★★★★★★★✯★★★★★★★★★✧ 33 | 34 | INFO: Hearts style 35 | ❥♥♥♥♥♥♥♥♥♥♥♥♥♥♡♥♥♥♥♥♥♥♥♥♥♡♥♥♥♥♥♥♥♥♥♥♥♥♥♥♥♡♥♥♥♥♥♥♥♥♥❥ 36 | ❤ DEPARTMENT ❤ DIVISION ❤ TEAM ❤ LEAD ❤ 37 | ❣♥♥♥♥♥♥♥♥♥♥♥♥♥✚♥♥♥♥♥♥♥♥♥♥✚♥♥♥♥♥♥♥♥♥♥♥♥♥♥♥✚♥♥♥♥♥♥♥♥♥❣ 38 | ❤ Engineering ❤ Backend ❤ API Team ❤ Alice ❤ 39 | ❤ ❤ ❤ Database Team ❤ Bob ❤ 40 | ❤ ❤ Frontend ❤ UI Team ❤ Charlie ❤ 41 | ❤ Marketing ❤ Digital ❤ SEO Team ❤ Dave ❤ 42 | ❤ ❤ ❤ Content Team ❤ Eve ❤ 43 | ❦♥♥♥♥♥♥♥♥♥♥♥♥♥♡♥♥♥♥♥♥♥♥♥♥♡♥♥♥♥♥♥♥♥♥♥♥♥♥♥♥♡♥♥♥♥♥♥♥♥♥❦ 44 | 45 | INFO: Tech style 46 | /*=============##==========##===============##=========*/ 47 | || DEPARTMENT || DIVISION || TEAM || LEAD || 48 | //=============<>==========<>===============<>==========\ 49 | || Engineering || Backend || API Team || Alice || 50 | || || || Database Team || Bob || 51 | || || Frontend || UI Team || Charlie || 52 | || Marketing || Digital || SEO Team || Dave || 53 | || || || Content Team || Eve || 54 | \*=============##==========##===============##=========*/ 55 | 56 | INFO: Nature style 57 | 🌱~~~~~~~~~~~🌿~~~~~~~~~🌿~~~~~~~~~~~~~~🌿~~~~~~~~🌱 58 | | DEPARTMENT | DIVISION | TEAM | LEAD | 59 | 🍃~~~~~~~~~~~~❀~~~~~~~~~~❀~~~~~~~~~~~~~~~❀~~~~~~~~🍃 60 | | Engineering | Backend | API Team | Alice | 61 | | | | Database Team | Bob | 62 | | | Frontend | UI Team | Charlie | 63 | | Marketing | Digital | SEO Team | Dave | 64 | | | | Content Team | Eve | 65 | 🌻~~~~~~~~~~~🌾~~~~~~~~~🌾~~~~~~~~~~~~~~🌾~~~~~~~~🌻 66 | 67 | INFO: Artistic style 68 | ◈▬▬▬▬▬▬▬▬▬▬▬▬▬◊▬▬▬▬▬▬▬▬▬▬◊▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬◊▬▬▬▬▬▬▬▬▬◈ 69 | ▐ DEPARTMENT ▐ DIVISION ▐ TEAM ▐ LEAD ▐ 70 | ◀▬▬▬▬▬▬▬▬▬▬▬▬▬⬔▬▬▬▬▬▬▬▬▬▬⬔▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬⬔▬▬▬▬▬▬▬▬▬▶ 71 | ▐ Engineering ▐ Backend ▐ API Team ▐ Alice ▐ 72 | ▐ ▐ ▐ Database Team ▐ Bob ▐ 73 | ▐ ▐ Frontend ▐ UI Team ▐ Charlie ▐ 74 | ▐ Marketing ▐ Digital ▐ SEO Team ▐ Dave ▐ 75 | ▐ ▐ ▐ Content Team ▐ Eve ▐ 76 | ◭▬▬▬▬▬▬▬▬▬▬▬▬▬▣▬▬▬▬▬▬▬▬▬▬▣▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▣▬▬▬▬▬▬▬▬▬◮ 77 | 78 | INFO: 8-Bit style 79 | ╔■■■■■■■■■■■■■▲■■■■■■■■■■▲■■■■■■■■■■■■■■■▲■■■■■■■■■╗ 80 | █ DEPARTMENT █ DIVISION █ TEAM █ LEAD █ 81 | ◄■■■■■■■■■■■■■♦■■■■■■■■■■♦■■■■■■■■■■■■■■■♦■■■■■■■■■► 82 | █ Engineering █ Backend █ API Team █ Alice █ 83 | █ █ █ Database Team █ Bob █ 84 | █ █ Frontend █ UI Team █ Charlie █ 85 | █ Marketing █ Digital █ SEO Team █ Dave █ 86 | █ █ █ Content Team █ Eve █ 87 | ╚■■■■■■■■■■■■■▼■■■■■■■■■■▼■■■■■■■■■■■■■■■▼■■■■■■■■■╝ 88 | 89 | INFO: Chaos style 90 | ⌘≈≈≈≈≈≈≈≈≈≈≈≈≈∞≈≈≈≈≈≈≈≈≈≈∞≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈∞≈≈≈≈≈≈≈≈≈⌥ 91 | § DEPARTMENT § DIVISION § TEAM § LEAD § 92 | ⚡≈≈≈≈≈≈≈≈≈≈≈≈☯≈≈≈≈≈≈≈≈≈≈☯≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈☯≈≈≈≈≈≈≈≈≈♞ 93 | § Engineering § Backend § API Team § Alice § 94 | § § § Database Team § Bob § 95 | § § Frontend § UI Team § Charlie § 96 | § Marketing § Digital § SEO Team § Dave § 97 | § § § Content Team § Eve § 98 | ⌂≈≈≈≈≈≈≈≈≈≈≈≈≈∆≈≈≈≈≈≈≈≈≈≈∆≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈∆≈≈≈≈≈≈≈≈≈◊ 99 | 100 | INFO: Dots style 101 | ···················································· 102 | DEPARTMENT DIVISION TEAM LEAD 103 | ·················································· 104 | Engineering Backend API Team Alice 105 | Database Team Bob 106 | Frontend UI Team Charlie 107 | Marketing Digital SEO Team Dave 108 | Content Team Eve 109 | ···················································· 110 | 111 | INFO: Blocks style 112 | ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ 113 | █ DEPARTMENT █ DIVISION █ TEAM █ LEAD █ 114 | ▌▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀▀▀▀▀▀▀▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀▀▀▀▀▀▀▀▐ 115 | █ Engineering █ Backend █ API Team █ Alice █ 116 | █ █ █ Database Team █ Bob █ 117 | █ █ Frontend █ UI Team █ Charlie █ 118 | █ Marketing █ Digital █ SEO Team █ Dave █ 119 | █ █ █ Content Team █ Eve █ 120 | ▙▀▀▀▀▀▀▀▀▀▀▀▀▀▄▀▀▀▀▀▀▀▀▀▀▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▀▀▀▀▀▀▀▀▀▟ 121 | 122 | INFO: Zen style 123 | ~~~~~~~~~~~~~♨~~~~~~~~~~♨~~~~~~~~~~~~~~~♨~~~~~~~~~ 124 | DEPARTMENT DIVISION TEAM LEAD 125 | ~~~~~~~~~~~~~☯~~~~~~~~~~☯~~~~~~~~~~~~~~~☯~~~~~~~~~ 126 | Engineering Backend API Team Alice 127 | Database Team Bob 128 | Frontend UI Team Charlie 129 | Marketing Digital SEO Team Dave 130 | Content Team Eve 131 | ~~~~~~~~~~~~~♨~~~~~~~~~~♨~~~~~~~~~~~~~~~♨~~~~~~~~~ 132 | 133 | -------------------------------------------------------------------------------- /_readme/color_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olekukonko/tablewriter/fbb970f655f4eaff4aea6ed7830a4c6ad99b1ae7/_readme/color_1.png -------------------------------------------------------------------------------- /cmd/csv2table/README.md: -------------------------------------------------------------------------------- 1 | ASCII Table Writer Tool 2 | ========= 3 | 4 | Generate ASCII table on the fly via command line ... Installation is simple as 5 | 6 | #### Get Tool 7 | 8 | go get github.com/olekukonko/tablewriter/csv2table 9 | 10 | #### Install Tool 11 | 12 | go install github.com/olekukonko/tablewriter/csv2table 13 | 14 | 15 | #### Usage 16 | 17 | csv2table -f test.csv 18 | 19 | #### Support for Piping 20 | 21 | cat test.csv | csv2table -p=true 22 | 23 | #### Output 24 | 25 | ``` 26 | +------------+-----------+---------+ 27 | | FIRST NAME | LAST NAME | SSN | 28 | +------------+-----------+---------+ 29 | | John | Barry | 123456 | 30 | | Kathy | Smith | 687987 | 31 | | Bob | McCornick | 3979870 | 32 | +------------+-----------+---------+ 33 | ``` 34 | 35 | #### Another Piping with Header set to `false` 36 | 37 | echo dance,with,me | csv2table -p=true -h=false 38 | 39 | #### Output 40 | 41 | +-------+------+-----+ 42 | | dance | with | me | 43 | +-------+------+-----+ 44 | -------------------------------------------------------------------------------- /cmd/csv2table/_data/test.csv: -------------------------------------------------------------------------------- 1 | first_name,last_name,ssn 2 | John,Barry,123456 3 | Kathy,Smith,687987 4 | Bob,McCornick,3979870 -------------------------------------------------------------------------------- /cmd/csv2table/_data/test_info.csv: -------------------------------------------------------------------------------- 1 | Field,Type,Null,Key,Default,Extra 2 | user_id,smallint(5),NO,PRI,NULL,auto_increment 3 | username,varchar(10),NO,,NULL, 4 | password,varchar(100),NO,,NULL, -------------------------------------------------------------------------------- /csv.go: -------------------------------------------------------------------------------- 1 | package tablewriter 2 | 3 | import ( 4 | "encoding/csv" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // NewCSV Start A new table by importing from a CSV file 10 | // Takes io.Writer and csv File name 11 | func NewCSV(writer io.Writer, fileName string, hasHeader bool, opts ...Option) (*Table, error) { 12 | // Open the CSV file 13 | file, err := os.Open(fileName) 14 | if err != nil { 15 | // Log implicitly handled by NewTable if logger is configured via opts 16 | return nil, err // Return nil *Table on error 17 | } 18 | defer file.Close() // Ensure file is closed 19 | 20 | // Create a CSV reader 21 | csvReader := csv.NewReader(file) 22 | 23 | // Delegate to NewCSVReader, passing through the options 24 | return NewCSVReader(writer, csvReader, hasHeader, opts...) 25 | } 26 | 27 | // NewCSVReader Start a New Table Writer with csv.Reader 28 | // This enables customisation such as reader.Comma = ';' 29 | // See http://golang.org/src/pkg/encoding/csv/reader.go?s=3213:3671#L94 30 | func NewCSVReader(writer io.Writer, csvReader *csv.Reader, hasHeader bool, opts ...Option) (*Table, error) { 31 | // Create a new table instance using the modern API and provided options. 32 | // Options configure the table's appearance and behavior (renderer, borders, etc.). 33 | t := NewTable(writer, opts...) // Logger setup happens here if WithLogger/WithDebug is passed 34 | 35 | // Process header row if specified 36 | if hasHeader { 37 | headers, err := csvReader.Read() 38 | if err != nil { 39 | // Handle EOF specifically: means the CSV was empty or contained only an empty header line. 40 | if err == io.EOF { 41 | t.logger.Debug("NewCSVReader: CSV empty or only header found (EOF after header read attempt).") 42 | // Return the table configured by opts, but without data/header. 43 | // It's ready for Render() which will likely output nothing or just borders if configured. 44 | return t, nil 45 | } 46 | // Log other read errors 47 | t.logger.Errorf("NewCSVReader: Error reading CSV header: %v", err) 48 | return nil, err // Return nil *Table on critical read error 49 | } 50 | 51 | // Check if the read header is genuinely empty (e.g., a blank line in the CSV) 52 | isEmptyHeader := true 53 | for _, h := range headers { 54 | if h != "" { 55 | isEmptyHeader = false 56 | break 57 | } 58 | } 59 | 60 | if !isEmptyHeader { 61 | t.Header(headers) // Use the Table method to set the header data 62 | t.logger.Debugf("NewCSVReader: Header set from CSV: %v", headers) 63 | } else { 64 | t.logger.Debug("NewCSVReader: Read an empty header line, skipping setting table header.") 65 | } 66 | } 67 | 68 | // Process data rows 69 | rowCount := 0 70 | for { 71 | record, err := csvReader.Read() 72 | if err == io.EOF { 73 | break // Reached the end of the CSV data 74 | } 75 | if err != nil { 76 | // Log other read errors during data processing 77 | t.logger.Errorf("NewCSVReader: Error reading CSV record: %v", err) 78 | return nil, err // Return nil *Table on critical read error 79 | } 80 | 81 | // Append the record to the table's internal buffer (for batch rendering). 82 | // The Table.Append method handles conversion and storage. 83 | if appendErr := t.Append(record); appendErr != nil { 84 | t.logger.Errorf("NewCSVReader: Error appending record #%d: %v", rowCount+1, appendErr) 85 | // Decide if append error is fatal. For now, let's treat it as fatal. 86 | return nil, appendErr 87 | } 88 | rowCount++ 89 | } 90 | t.logger.Debugf("NewCSVReader: Finished reading CSV. Appended %d data rows.", rowCount) 91 | 92 | // Return the configured and populated table instance, ready for Render() call. 93 | return t, nil 94 | } 95 | -------------------------------------------------------------------------------- /deprecated.go: -------------------------------------------------------------------------------- 1 | package tablewriter 2 | 3 | import "github.com/olekukonko/tablewriter/tw" 4 | 5 | // WithBorders configures the table's border settings by updating the renderer's border configuration. 6 | // This function is deprecated and will be removed in a future version. 7 | // 8 | // Deprecated: Use [WithRendition] to configure border settings for renderers that support 9 | // [tw.Renditioning], or update the renderer's [tw.RenderConfig] directly via its Config() method. 10 | // This function has no effect if no renderer is set on the table. 11 | // 12 | // Example migration: 13 | // 14 | // // Old (deprecated) 15 | // table.Options(WithBorders(tw.Border{Top: true, Bottom: true})) 16 | // // New (recommended) 17 | // table.Options(WithRendition(tw.Rendition{Borders: tw.Border{Top: true, Bottom: true}})) 18 | // 19 | // Parameters: 20 | // - borders: The [tw.Border] configuration to apply to the renderer's borders. 21 | // 22 | // Returns: 23 | // 24 | // An [Option] that updates the renderer's border settings if a renderer is set. 25 | // Logs a debug message if debugging is enabled and a renderer is present. 26 | func WithBorders(borders tw.Border) Option { 27 | return func(target *Table) { 28 | if target.renderer != nil { 29 | cfg := target.renderer.Config() 30 | cfg.Borders = borders 31 | if target.logger != nil { 32 | target.logger.Debugf("Option: WithBorders applied to Table: %+v", borders) 33 | } 34 | } 35 | } 36 | } 37 | 38 | // Behavior is an alias for [tw.Behavior] to configure table behavior settings. 39 | // This type is deprecated and will be removed in a future version. 40 | // 41 | // Deprecated: Use [tw.Behavior] directly to configure settings such as auto-hiding empty 42 | // columns, trimming spaces, or controlling header/footer visibility. 43 | // 44 | // Example migration: 45 | // 46 | // // Old (deprecated) 47 | // var b tablewriter.Behavior = tablewriter.Behavior{AutoHide: tw.On} 48 | // // New (recommended) 49 | // var b tw.Behavior = tw.Behavior{AutoHide: tw.On} 50 | type Behavior tw.Behavior 51 | 52 | // Settings is an alias for [tw.Settings] to configure renderer settings. 53 | // This type is deprecated and will be removed in a future version. 54 | // 55 | // Deprecated: Use [tw.Settings] directly to configure renderer settings, such as 56 | // separators and line styles. 57 | // 58 | // Example migration: 59 | // 60 | // // Old (deprecated) 61 | // var s tablewriter.Settings = tablewriter.Settings{Separator: "|"} 62 | // // New (recommended) 63 | // var s tw.Settings = tw.Settings{Separator: "|"} 64 | type Settings tw.Settings 65 | 66 | // WithRendererSettings updates the renderer's settings, such as separators and line styles. 67 | // This function is deprecated and will be removed in a future version. 68 | // 69 | // Deprecated: Use [WithRendition] to update renderer settings for renderers that implement 70 | // [tw.Renditioning], or configure the renderer's [tw.Settings] directly via its 71 | // [tw.Renderer.Config] method. This function has no effect if no renderer is set. 72 | // 73 | // Example migration: 74 | // 75 | // // Old (deprecated) 76 | // table.Options(WithRendererSettings(tw.Settings{Separator: "|"})) 77 | // // New (recommended) 78 | // table.Options(WithRendition(tw.Rendition{Settings: tw.Settings{Separator: "|"}})) 79 | // 80 | // Parameters: 81 | // - settings: The [tw.Settings] configuration to apply to the renderer. 82 | // 83 | // Returns: 84 | // 85 | // An [Option] that updates the renderer's settings if a renderer is set. 86 | // Logs a debug message if debugging is enabled and a renderer is present. 87 | func WithRendererSettings(settings tw.Settings) Option { 88 | return func(target *Table) { 89 | if target.renderer != nil { 90 | cfg := target.renderer.Config() 91 | cfg.Settings = settings 92 | if target.logger != nil { 93 | target.logger.Debugf("Option: WithRendererSettings applied to Table: %+v", settings) 94 | } 95 | } 96 | } 97 | } 98 | 99 | // WithAlignment sets the text alignment for footer cells within the formatting configuration. 100 | // This method is deprecated and will be removed in the next version. 101 | // 102 | // Deprecated: Use [FooterConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal] 103 | // or [AlignmentConfigBuilder.WithPerColumn] to configure footer alignments. 104 | // Alternatively, apply a complete [tw.CellAlignment] configuration using 105 | // [WithFooterAlignmentConfig]. 106 | // 107 | // Example migration: 108 | // 109 | // // Old (deprecated) 110 | // builder.Footer().Formatting().WithAlignment(tw.AlignRight) 111 | // // New (recommended) 112 | // builder.Footer().Alignment().WithGlobal(tw.AlignRight) 113 | // // Or 114 | // table.Options(WithFooterAlignmentConfig(tw.CellAlignment{Global: tw.AlignRight})) 115 | // 116 | // Parameters: 117 | // - align: The [tw.Align] value to set for footer cells. Valid values are 118 | // [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone]. 119 | // Invalid alignments are ignored. 120 | // 121 | // Returns: 122 | // 123 | // The [FooterFormattingBuilder] instance for method chaining. 124 | func (ff *FooterFormattingBuilder) WithAlignment(align tw.Align) *FooterFormattingBuilder { 125 | if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone { 126 | return ff 127 | } 128 | ff.config.Alignment = align 129 | return ff 130 | } 131 | 132 | // WithAlignment sets the text alignment for header cells within the formatting configuration. 133 | // This method is deprecated and will be removed in the next version. 134 | // 135 | // Deprecated: Use [HeaderConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal] 136 | // or [AlignmentConfigBuilder.WithPerColumn] to configure header alignments. 137 | // Alternatively, apply a complete [tw.CellAlignment] configuration using 138 | // [WithHeaderAlignmentConfig]. 139 | // 140 | // Example migration: 141 | // 142 | // // Old (deprecated) 143 | // builder.Header().Formatting().WithAlignment(tw.AlignCenter) 144 | // // New (recommended) 145 | // builder.Header().Alignment().WithGlobal(tw.AlignCenter) 146 | // // Or 147 | // table.Options(WithHeaderAlignmentConfig(tw.CellAlignment{Global: tw.AlignCenter})) 148 | // 149 | // Parameters: 150 | // - align: The [tw.Align] value to set for header cells. Valid values are 151 | // [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone]. 152 | // Invalid alignments are ignored. 153 | // 154 | // Returns: 155 | // 156 | // The [HeaderFormattingBuilder] instance for method chaining. 157 | func (hf *HeaderFormattingBuilder) WithAlignment(align tw.Align) *HeaderFormattingBuilder { 158 | if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone { 159 | return hf 160 | } 161 | hf.config.Alignment = align 162 | return hf 163 | } 164 | 165 | // WithAlignment sets the text alignment for row cells within the formatting configuration. 166 | // This method is deprecated and will be removed in the next version. 167 | // 168 | // Deprecated: Use [RowConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal] 169 | // or [AlignmentConfigBuilder.WithPerColumn] to configure row alignments. 170 | // Alternatively, apply a complete [tw.CellAlignment] configuration using 171 | // [WithRowAlignmentConfig]. 172 | // 173 | // Example migration: 174 | // 175 | // // Old (deprecated) 176 | // builder.Row().Formatting().WithAlignment(tw.AlignLeft) 177 | // // New (recommended) 178 | // builder.Row().Alignment().WithGlobal(tw.AlignLeft) 179 | // // Or 180 | // table.Options(WithRowAlignmentConfig(tw.CellAlignment{Global: tw.AlignLeft})) 181 | // 182 | // Parameters: 183 | // - align: The [tw.Align] value to set for row cells. Valid values are 184 | // [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone]. 185 | // Invalid alignments are ignored. 186 | // 187 | // Returns: 188 | // 189 | // The [RowFormattingBuilder] instance for method chaining. 190 | func (rf *RowFormattingBuilder) WithAlignment(align tw.Align) *RowFormattingBuilder { 191 | if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone { 192 | return rf 193 | } 194 | rf.config.Alignment = align 195 | return rf 196 | } 197 | 198 | // WithTableMax sets the maximum width of the entire table in characters. 199 | // Negative values are ignored, and the change is logged if debugging is enabled. 200 | // The width constrains the table's rendering, potentially causing text wrapping or truncation 201 | // based on the configuration's wrapping settings (e.g., tw.WrapTruncate). 202 | // If debug logging is enabled via WithDebug(true), the applied width is logged. 203 | // 204 | // Deprecated: Use WithMaxWidth instead, which provides the same functionality with a clearer name 205 | // and consistent naming across the package. For example: 206 | // 207 | // tablewriter.NewTable(os.Stdout, tablewriter.WithMaxWidth(80)) 208 | func WithTableMax(width int) Option { 209 | return func(target *Table) { 210 | if width < 0 { 211 | return 212 | } 213 | target.config.MaxWidth = width 214 | if target.logger != nil { 215 | target.logger.Debugf("Option: WithTableMax applied to Table: %v", width) 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/olekukonko/tablewriter 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/fatih/color v1.15.0 7 | github.com/mattn/go-runewidth v0.0.16 8 | github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 9 | ) 10 | 11 | require ( 12 | github.com/mattn/go-colorable v0.1.13 // indirect 13 | github.com/mattn/go-isatty v0.0.19 // indirect 14 | github.com/olekukonko/ll v0.0.8 // indirect 15 | github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 // indirect 16 | github.com/rivo/uniseg v0.2.0 // indirect 17 | golang.org/x/sys v0.12.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 2 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 3 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 4 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 5 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 6 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 7 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 8 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 9 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 10 | github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= 11 | github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= 12 | github.com/olekukonko/ll v0.0.0-20250507172320-ea747e008e73 h1:Gqgp4clii9OWN5xgc8OwaD57qHx+zM4fXPIxB4QC3Zk= 13 | github.com/olekukonko/ll v0.0.0-20250507172320-ea747e008e73/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 14 | github.com/olekukonko/ll v0.0.0-20250509084555-6eba43302332 h1:n5y0nbsA071TLIuVUFjz1nPBPi0RoWe/xelq4jsbvaU= 15 | github.com/olekukonko/ll v0.0.0-20250509084555-6eba43302332/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 16 | github.com/olekukonko/ll v0.0.0-20250509110120-b54a09ca9f96 h1:za5DfBlQMgVn0XJUN34kzssuW0MUzIVHXLaR/PDZwyw= 17 | github.com/olekukonko/ll v0.0.0-20250509110120-b54a09ca9f96/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 18 | github.com/olekukonko/ll v0.0.0-20250510115240-15382ceb1b19 h1:KS8mPZS4LMxG83NIMVka44XDsM7UpTTKIqGWMnRe7Xc= 19 | github.com/olekukonko/ll v0.0.0-20250510115240-15382ceb1b19/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 20 | github.com/olekukonko/ll v0.0.0-20250510133129-b58cd384cb8f h1:dOY4AnSWDfF3gLxwxnbNUAF7EEKPUmLRlqXPakmdyn4= 21 | github.com/olekukonko/ll v0.0.0-20250510133129-b58cd384cb8f/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 22 | github.com/olekukonko/ll v0.0.0-20250510142951-0783ae1ab997 h1:chuSUEauzoEqa7MIGefWSARCurGBbwohTPQYK1zSync= 23 | github.com/olekukonko/ll v0.0.0-20250510142951-0783ae1ab997/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 24 | github.com/olekukonko/ll v0.0.0-20250510164409-ceb6a13d91b7 h1:N4G1sMmKuHIZ6aZEhjosieSKhOUvKUN2xdotI+wqoVU= 25 | github.com/olekukonko/ll v0.0.0-20250510164409-ceb6a13d91b7/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 26 | github.com/olekukonko/ll v0.0.6-0.20250511102614-9564773e9d27 h1:LgDwLQDELPB6wMOx1x4DSXnH2pjQNDKFgqv2inJuiAU= 27 | github.com/olekukonko/ll v0.0.6-0.20250511102614-9564773e9d27/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 28 | github.com/olekukonko/ll v0.0.6-0.20250513000243-3b679d30b046 h1:LHNAHSvMNNAYbh5Rj+RcR2BJrQ3PH3KKc3Db+ktanO4= 29 | github.com/olekukonko/ll v0.0.6-0.20250513000243-3b679d30b046/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 30 | github.com/olekukonko/ll v0.0.6-0.20250513000539-a956e85bdb0e h1:MkLJt4HgQw7HAWjzPdA8bBgpIKjGxIGjpy2316gN1eU= 31 | github.com/olekukonko/ll v0.0.6-0.20250513000539-a956e85bdb0e/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 32 | github.com/olekukonko/ll v0.0.6-0.20250513001635-5dbed4661872 h1:44lc4ASGSoHddTt0yClHvlqWm50vzXPE1LkkPQxj8kc= 33 | github.com/olekukonko/ll v0.0.6-0.20250513001635-5dbed4661872/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 34 | github.com/olekukonko/ll v0.0.6-0.20250513034024-62c5c2714b38 h1:Lnuzuijce7bnRJnlns11a2mUTW9lMF5kUW+ZriZh/hI= 35 | github.com/olekukonko/ll v0.0.6-0.20250513034024-62c5c2714b38/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 36 | github.com/olekukonko/ll v0.0.7 h1:K66xcUlG2qWRhPoLw/cidmbv4pDDJtZuvJGsR5QTzXo= 37 | github.com/olekukonko/ll v0.0.7/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 38 | github.com/olekukonko/ll v0.0.8-0.20250516000150-d68806778337 h1:UyQ9e528yarbqDlJ2k3LHdOrjbLJD4n930urUM6t5u4= 39 | github.com/olekukonko/ll v0.0.8-0.20250516000150-d68806778337/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 40 | github.com/olekukonko/ll v0.0.8-0.20250516001244-1c9e058e90b6 h1:0rbVA8or3kK/eRrNdHcshP5aC6WvvUjcZawf08QlqR4= 41 | github.com/olekukonko/ll v0.0.8-0.20250516001244-1c9e058e90b6/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 42 | github.com/olekukonko/ll v0.0.8-0.20250516010636-22ea57d81985 h1:V2wKiwjwAfRJRtUP6pC7wt4opeF14enO0du2dRV6Llo= 43 | github.com/olekukonko/ll v0.0.8-0.20250516010636-22ea57d81985/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 44 | github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= 45 | github.com/olekukonko/ll v0.0.8/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 46 | github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 h1:LiZB1h0GIcudcDci2bxbqI6DXV8bF8POAnArqvRrIyw= 47 | github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= 48 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 49 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 53 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | -------------------------------------------------------------------------------- /pkg/twwarp/_data/long-text-wrapped.txt: -------------------------------------------------------------------------------- 1 | Я к вам пишу — чего же боле? Что я могу еще сказать? Теперь, я знаю, в вашей 2 | воле Меня презреньем наказать. Но вы, к моей несчастной доле Хоть каплю жалости 3 | храня, Вы не оставите меня. Сначала я молчать хотела; Поверьте: моего стыда Вы 4 | не узнали б никогда, Когда б надежду я имела Хоть редко, хоть в неделю раз В 5 | деревне нашей видеть вас, Чтоб только слышать ваши речи, Вам слово молвить, и 6 | потом Все думать, думать об одном И день и ночь до новой встречи. Но, говорят, 7 | вы нелюдим; В глуши, в деревне всё вам скучно, А мы… ничем мы не блестим, 8 | Хоть вам и рады простодушно. Зачем вы посетили нас? В глуши забытого селенья 9 | Я никогда не знала б вас, Не знала б горького мученья. Души неопытной волненья 10 | Смирив со временем (как знать?), По сердцу я нашла бы друга, Была бы верная 11 | супруга И добродетельная мать. Другой!.. Нет, никому на свете Не отдала бы 12 | сердца я! То в вышнем суждено совете… То воля неба: я твоя; Вся жизнь моя была 13 | залогом Свиданья верного с тобой; Я знаю, ты мне послан богом, До гроба ты 14 | хранитель мой… Ты в сновиденьях мне являлся, Незримый, ты мне был уж мил, Твой 15 | чудный взгляд меня томил, В душе твой голос раздавался Давно… нет, это был не 16 | сон! Ты чуть вошел, я вмиг узнала, Вся обомлела, запылала И в мыслях молвила: 17 | вот он! Не правда ль? Я тебя слыхала: Ты говорил со мной в тиши, Когда я бедным 18 | помогала Или молитвой услаждала Тоску волнуемой души? И в это самое мгновенье Не 19 | ты ли, милое виденье, В прозрачной темноте мелькнул, Приникнул тихо к изголовью? 20 | Не ты ль, с отрадой и любовью, Слова надежды мне шепнул? Кто ты, мой ангел ли 21 | хранитель, Или коварный искуситель: Мои сомненья разреши. Быть может, это все 22 | пустое, Обман неопытной души! И суждено совсем иное… Но так и быть! Судьбу мою 23 | Отныне я тебе вручаю, Перед тобою слезы лью, Твоей защиты умоляю… Вообрази: я 24 | здесь одна, Никто меня не понимает, Рассудок мой изнемогает, И молча гибнуть я 25 | должна. Я жду тебя: единым взором Надежды сердца оживи Иль сон тяжелый перерви, 26 | Увы, заслуженным укором! Кончаю! Страшно перечесть… Стыдом и страхом замираю… Но 27 | мне порукой ваша честь, И смело ей себя вверяю… -------------------------------------------------------------------------------- /pkg/twwarp/_data/long-text.txt: -------------------------------------------------------------------------------- 1 | Я к вам пишу — чего же боле? 2 | Что я могу еще сказать? 3 | Теперь, я знаю, в вашей воле 4 | Меня презреньем наказать. 5 | Но вы, к моей несчастной доле 6 | Хоть каплю жалости храня, 7 | Вы не оставите меня. 8 | Сначала я молчать хотела; 9 | Поверьте: моего стыда 10 | Вы не узнали б никогда, 11 | Когда б надежду я имела 12 | Хоть редко, хоть в неделю раз 13 | В деревне нашей видеть вас, 14 | Чтоб только слышать ваши речи, 15 | Вам слово молвить, и потом 16 | Все думать, думать об одном 17 | И день и ночь до новой встречи. 18 | Но, говорят, вы нелюдим; 19 | В глуши, в деревне всё вам скучно, 20 | А мы… ничем мы не блестим, 21 | Хоть вам и рады простодушно. 22 | 23 | Зачем вы посетили нас? 24 | В глуши забытого селенья 25 | Я никогда не знала б вас, 26 | Не знала б горького мученья. 27 | Души неопытной волненья 28 | Смирив со временем (как знать?), 29 | По сердцу я нашла бы друга, 30 | Была бы верная супруга 31 | И добродетельная мать. 32 | 33 | Другой!.. Нет, никому на свете 34 | Не отдала бы сердца я! 35 | То в вышнем суждено совете… 36 | То воля неба: я твоя; 37 | Вся жизнь моя была залогом 38 | Свиданья верного с тобой; 39 | Я знаю, ты мне послан богом, 40 | До гроба ты хранитель мой… 41 | Ты в сновиденьях мне являлся, 42 | Незримый, ты мне был уж мил, 43 | Твой чудный взгляд меня томил, 44 | В душе твой голос раздавался 45 | Давно… нет, это был не сон! 46 | Ты чуть вошел, я вмиг узнала, 47 | Вся обомлела, запылала 48 | И в мыслях молвила: вот он! 49 | Не правда ль? Я тебя слыхала: 50 | Ты говорил со мной в тиши, 51 | Когда я бедным помогала 52 | Или молитвой услаждала 53 | Тоску волнуемой души? 54 | И в это самое мгновенье 55 | Не ты ли, милое виденье, 56 | В прозрачной темноте мелькнул, 57 | Приникнул тихо к изголовью? 58 | Не ты ль, с отрадой и любовью, 59 | Слова надежды мне шепнул? 60 | Кто ты, мой ангел ли хранитель, 61 | Или коварный искуситель: 62 | Мои сомненья разреши. 63 | Быть может, это все пустое, 64 | Обман неопытной души! 65 | И суждено совсем иное… 66 | Но так и быть! Судьбу мою 67 | Отныне я тебе вручаю, 68 | Перед тобою слезы лью, 69 | Твоей защиты умоляю… 70 | Вообрази: я здесь одна, 71 | Никто меня не понимает, 72 | Рассудок мой изнемогает, 73 | И молча гибнуть я должна. 74 | Я жду тебя: единым взором 75 | Надежды сердца оживи 76 | Иль сон тяжелый перерви, 77 | Увы, заслуженным укором! 78 | 79 | Кончаю! Страшно перечесть… 80 | Стыдом и страхом замираю… 81 | Но мне порукой ваша честь, 82 | И смело ей себя вверяю… 83 | -------------------------------------------------------------------------------- /pkg/twwarp/wrap.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Oleku Konko All rights reserved. 2 | // Use of this source code is governed by a MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | // This module is a Table Writer API for the Go Programming Language. 6 | // The protocols were written in pure Go and works on windows and unix systems 7 | 8 | package twwarp 9 | 10 | import ( 11 | "github.com/rivo/uniseg" 12 | "math" 13 | "strings" 14 | "unicode" 15 | 16 | "github.com/mattn/go-runewidth" 17 | ) 18 | 19 | const ( 20 | nl = "\n" 21 | sp = " " 22 | ) 23 | 24 | const defaultPenalty = 1e5 25 | 26 | func SplitWords(s string) []string { 27 | words := make([]string, 0, len(s)/5) 28 | var wordBegin int 29 | wordPending := false 30 | for i, c := range s { 31 | if unicode.IsSpace(c) { 32 | if wordPending { 33 | words = append(words, s[wordBegin:i]) 34 | wordPending = false 35 | } 36 | continue 37 | } 38 | if !wordPending { 39 | wordBegin = i 40 | wordPending = true 41 | } 42 | } 43 | if wordPending { 44 | words = append(words, s[wordBegin:]) 45 | } 46 | return words 47 | } 48 | 49 | // WrapString wraps s into a paragraph of lines of length lim, with minimal 50 | // raggedness. 51 | func WrapString(s string, lim int) ([]string, int) { 52 | if s == sp { 53 | return []string{sp}, lim 54 | } 55 | words := SplitWords(s) 56 | if len(words) == 0 { 57 | return []string{""}, lim 58 | } 59 | var lines []string 60 | max := 0 61 | for _, v := range words { 62 | max = runewidth.StringWidth(v) 63 | if max > lim { 64 | lim = max 65 | } 66 | } 67 | for _, line := range WrapWords(words, 1, lim, defaultPenalty) { 68 | lines = append(lines, strings.Join(line, sp)) 69 | } 70 | return lines, lim 71 | } 72 | 73 | // WrapStringWithSpaces wraps a string into lines of a specified display width while preserving 74 | // leading and trailing spaces. It splits the input string into words, condenses internal multiple 75 | // spaces to a single space, and wraps the content to fit within the given width limit, measured 76 | // using Unicode-aware display width. The function is used in the logging library to format log 77 | // messages for consistent output. It returns the wrapped lines as a slice of strings and the 78 | // adjusted width limit, which may increase if a single word exceeds the input limit. Thread-safe 79 | // as it does not modify shared state. 80 | func WrapStringWithSpaces(s string, lim int) ([]string, int) { 81 | if len(s) == 0 { 82 | return []string{""}, lim 83 | } 84 | if strings.TrimSpace(s) == "" { // All spaces 85 | if runewidth.StringWidth(s) <= lim { 86 | return []string{s}, runewidth.StringWidth(s) 87 | } 88 | // For very long all-space strings, "wrap" by truncating to the limit. 89 | if lim > 0 { 90 | // Use our new helper function to get a substring of the correct display width 91 | substring, _ := stringToDisplayWidth(s, lim) 92 | return []string{substring}, lim 93 | } 94 | return []string{""}, lim 95 | } 96 | 97 | var leadingSpaces, trailingSpaces, coreContent string 98 | firstNonSpace := strings.IndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) }) 99 | // firstNonSpace will not be -1 due to TrimSpace check above. 100 | leadingSpaces = s[:firstNonSpace] 101 | lastNonSpace := strings.LastIndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) }) 102 | trailingSpaces = s[lastNonSpace+1:] 103 | coreContent = s[firstNonSpace : lastNonSpace+1] 104 | 105 | if coreContent == "" { 106 | return []string{leadingSpaces + trailingSpaces}, lim 107 | } 108 | 109 | words := SplitWords(coreContent) 110 | if len(words) == 0 { 111 | return []string{leadingSpaces + trailingSpaces}, lim 112 | } 113 | 114 | var lines []string 115 | currentLim := lim 116 | 117 | maxCoreWordWidth := 0 118 | for _, v := range words { 119 | w := runewidth.StringWidth(v) 120 | if w > maxCoreWordWidth { 121 | maxCoreWordWidth = w 122 | } 123 | } 124 | 125 | if maxCoreWordWidth > currentLim { 126 | currentLim = maxCoreWordWidth 127 | } 128 | 129 | wrappedWordLines := WrapWords(words, 1, currentLim, defaultPenalty) 130 | 131 | for i, lineWords := range wrappedWordLines { 132 | joinedLine := strings.Join(lineWords, sp) 133 | finalLine := leadingSpaces + joinedLine 134 | if i == len(wrappedWordLines)-1 { // Last line 135 | finalLine += trailingSpaces 136 | } 137 | lines = append(lines, finalLine) 138 | } 139 | return lines, currentLim 140 | } 141 | 142 | // stringToDisplayWidth returns a substring of s that has a display width 143 | // as close as possible to, but not exceeding, targetWidth. 144 | // It returns the substring and its actual display width. 145 | func stringToDisplayWidth(s string, targetWidth int) (substring string, actualWidth int) { 146 | if targetWidth <= 0 { 147 | return "", 0 148 | } 149 | 150 | var currentWidth int 151 | var endIndex int // Tracks the byte index in the original string 152 | 153 | g := uniseg.NewGraphemes(s) 154 | for g.Next() { 155 | grapheme := g.Str() 156 | graphemeWidth := runewidth.StringWidth(grapheme) // Get width of the current grapheme cluster 157 | 158 | if currentWidth+graphemeWidth > targetWidth { 159 | // Adding this grapheme would exceed the target width 160 | break 161 | } 162 | 163 | currentWidth += graphemeWidth 164 | // Get the end byte position of the current grapheme cluster 165 | _, e := g.Positions() 166 | endIndex = e 167 | } 168 | return s[:endIndex], currentWidth 169 | } 170 | 171 | // WrapWords is the low-level line-breaking algorithm, useful if you need more 172 | // control over the details of the text wrapping process. For most uses, 173 | // WrapString will be sufficient and more convenient. 174 | // 175 | // WrapWords splits a list of words into lines with minimal "raggedness", 176 | // treating each rune as one unit, accounting for spc units between adjacent 177 | // words on each line, and attempting to limit lines to lim units. Raggedness 178 | // is the total error over all lines, where error is the square of the 179 | // difference of the length of the line and lim. Too-long lines (which only 180 | // happen when a single word is longer than lim units) have pen penalty units 181 | // added to the error. 182 | func WrapWords(words []string, spc, lim, pen int) [][]string { 183 | n := len(words) 184 | if n == 0 { 185 | return nil 186 | } 187 | lengths := make([]int, n) 188 | for i := 0; i < n; i++ { 189 | lengths[i] = runewidth.StringWidth(words[i]) 190 | } 191 | nbrk := make([]int, n) 192 | cost := make([]int, n) 193 | for i := range cost { 194 | cost[i] = math.MaxInt32 195 | } 196 | remainderLen := lengths[n-1] 197 | for i := n - 1; i >= 0; i-- { 198 | if i < n-1 { 199 | remainderLen += spc + lengths[i] 200 | } 201 | if remainderLen <= lim { 202 | cost[i] = 0 203 | nbrk[i] = n 204 | continue 205 | } 206 | phraseLen := lengths[i] 207 | for j := i + 1; j < n; j++ { 208 | if j > i+1 { 209 | phraseLen += spc + lengths[j-1] 210 | } 211 | d := lim - phraseLen 212 | c := d*d + cost[j] 213 | if phraseLen > lim { 214 | c += pen // too-long lines get a worse penalty 215 | } 216 | if c < cost[i] { 217 | cost[i] = c 218 | nbrk[i] = j 219 | } 220 | } 221 | } 222 | var lines [][]string 223 | i := 0 224 | for i < n { 225 | lines = append(lines, words[i:nbrk[i]]) 226 | i = nbrk[i] 227 | } 228 | return lines 229 | } 230 | 231 | // getLines decomposes a multiline string into a slice of strings. 232 | func getLines(s string) []string { 233 | return strings.Split(s, nl) 234 | } 235 | -------------------------------------------------------------------------------- /pkg/twwarp/wrap_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Oleku Konko All rights reserved. 2 | // Use of this source code is governed by a MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | // This module is a Table Writer API for the Go Programming Language. 6 | // The protocols were written in pure Go and works on windows and unix systems 7 | 8 | package twwarp 9 | 10 | import ( 11 | "bytes" 12 | "fmt" 13 | "github.com/olekukonko/tablewriter/tw" 14 | "os" 15 | "reflect" 16 | "runtime" 17 | "strings" 18 | "testing" 19 | 20 | "github.com/mattn/go-runewidth" 21 | ) 22 | 23 | var ( 24 | text = "The quick brown fox jumps over the lazy dog." 25 | testDir = "./_data" 26 | ) 27 | 28 | // checkEqual compares two values and fails the test if they are not equal 29 | func checkEqual(t *testing.T, got, want interface{}, msgs ...interface{}) { 30 | t.Helper() 31 | if !reflect.DeepEqual(got, want) { 32 | var buf bytes.Buffer 33 | buf.WriteString(fmt.Sprintf("got:\n[%v]\nwant:\n[%v]\n", got, want)) 34 | for _, v := range msgs { 35 | buf.WriteString(fmt.Sprint(v)) 36 | } 37 | t.Errorf(buf.String()) 38 | } 39 | } 40 | 41 | func TestWrap(t *testing.T) { 42 | exp := []string{ 43 | "The", "quick", "brown", "fox", 44 | "jumps", "over", "the", "lazy", "dog."} 45 | 46 | got, _ := WrapString(text, 6) 47 | checkEqual(t, len(got), len(exp)) 48 | } 49 | 50 | func TestWrapOneLine(t *testing.T) { 51 | exp := "The quick brown fox jumps over the lazy dog." 52 | words, _ := WrapString(text, 500) 53 | checkEqual(t, strings.Join(words, string(tw.Space)), exp) 54 | 55 | } 56 | 57 | func TestUnicode(t *testing.T) { 58 | input := "Česká řeřicha" 59 | var wordsUnicode []string 60 | if runewidth.IsEastAsian() { 61 | wordsUnicode, _ = WrapString(input, 14) 62 | } else { 63 | wordsUnicode, _ = WrapString(input, 13) 64 | } 65 | // input contains 13 (or 14 for CJK) runes, so it fits on one line. 66 | checkEqual(t, len(wordsUnicode), 1) 67 | } 68 | 69 | func TestDisplayWidth(t *testing.T) { 70 | input := "Česká řeřicha" 71 | want := 13 72 | if runewidth.IsEastAsian() { 73 | want = 14 74 | } 75 | if n := tw.DisplayWidth(input); n != want { 76 | t.Errorf("Wants: %d Got: %d", want, n) 77 | } 78 | input = "\033[43;30m" + input + "\033[00m" 79 | checkEqual(t, tw.DisplayWidth(input), want) 80 | 81 | input = "\033]8;;idea://open/?file=/path/somefile.php&line=12\033\\some URL\033]8;;\033\\" 82 | checkEqual(t, tw.DisplayWidth(input), 8) 83 | 84 | } 85 | 86 | // WrapString was extremely memory greedy, it performed insane number of 87 | // allocations for what it was doing. See BenchmarkWrapString for details. 88 | func TestWrapStringAllocation(t *testing.T) { 89 | originalTextBytes, err := os.ReadFile(testDir + "/long-text.txt") 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | originalText := string(originalTextBytes) 94 | 95 | wantWrappedBytes, err := os.ReadFile(testDir + "/long-text-wrapped.txt") 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | wantWrappedText := string(wantWrappedBytes) 100 | 101 | var ms runtime.MemStats 102 | runtime.ReadMemStats(&ms) 103 | heapAllocBefore := int64(ms.HeapAlloc / 1024 / 1024) 104 | 105 | // When 106 | gotLines, gotLim := WrapString(originalText, 80) 107 | 108 | // Then 109 | wantLim := 80 110 | if gotLim != wantLim { 111 | t.Errorf("Invalid limit: want=%d, got=%d", wantLim, gotLim) 112 | } 113 | 114 | gotWrappedText := strings.Join(gotLines, "\n") 115 | if gotWrappedText != wantWrappedText { 116 | t.Errorf("Invalid lines: want=\n%s\n got=\n%s", wantWrappedText, gotWrappedText) 117 | } 118 | 119 | runtime.ReadMemStats(&ms) 120 | heapAllocAfter := int64(ms.HeapAlloc / 1024 / 1024) 121 | heapAllocDelta := heapAllocAfter - heapAllocBefore 122 | if heapAllocDelta > 1 { 123 | t.Fatalf("heap allocation should not be greater than 1Mb, got=%dMb", heapAllocDelta) 124 | } 125 | } 126 | 127 | // Before optimization: 128 | // BenchmarkWrapString-16 1 2490331031 ns/op 2535184104 B/op 50905550 allocs/op 129 | // After optimization: 130 | // BenchmarkWrapString-16 1652 658098 ns/op 230223 B/op 5176 allocs/op 131 | func BenchmarkWrapString(b *testing.B) { 132 | d, err := os.ReadFile(testDir + "/long-text.txt") 133 | if err != nil { 134 | b.Fatal(err) 135 | } 136 | for i := 0; i < b.N; i++ { 137 | WrapString(string(d), 128) 138 | } 139 | } 140 | 141 | func TestSplitWords(t *testing.T) { 142 | for _, tt := range []struct { 143 | in string 144 | out []string 145 | }{{ 146 | in: "", 147 | out: []string{}, 148 | }, { 149 | in: "a", 150 | out: []string{"a"}, 151 | }, { 152 | in: "a b", 153 | out: []string{"a", "b"}, 154 | }, { 155 | in: " a b ", 156 | out: []string{"a", "b"}, 157 | }, { 158 | in: "\r\na\t\t \r\t b\r\n ", 159 | out: []string{"a", "b"}, 160 | }} { 161 | t.Run(tt.in, func(t *testing.T) { 162 | got := SplitWords(tt.in) 163 | if !reflect.DeepEqual(tt.out, got) { 164 | t.Errorf("want=%s, got=%s", tt.out, got) 165 | } 166 | }) 167 | } 168 | } 169 | 170 | func TestWrapString(t *testing.T) { 171 | want := []string{"ああああああああああああああああああああああああ", "あああああああ"} 172 | got, _ := WrapString("ああああああああああああああああああああああああ あああああああ", 55) 173 | checkEqual(t, got, want) 174 | } 175 | -------------------------------------------------------------------------------- /renderer/fn.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "github.com/fatih/color" 6 | "github.com/olekukonko/tablewriter/tw" 7 | ) 8 | 9 | // defaultBlueprint returns a default Rendition for ASCII table rendering with borders and light symbols. 10 | func defaultBlueprint() tw.Rendition { 11 | return tw.Rendition{ 12 | Borders: tw.Border{ 13 | Left: tw.On, 14 | Right: tw.On, 15 | Top: tw.On, 16 | Bottom: tw.On, 17 | }, 18 | Settings: tw.Settings{ 19 | Separators: tw.Separators{ 20 | ShowHeader: tw.On, 21 | ShowFooter: tw.On, 22 | BetweenRows: tw.Off, 23 | BetweenColumns: tw.On, 24 | }, 25 | Lines: tw.Lines{ 26 | ShowTop: tw.On, 27 | ShowBottom: tw.On, 28 | ShowHeaderLine: tw.On, 29 | ShowFooterLine: tw.On, 30 | }, 31 | CompactMode: tw.Off, 32 | // Cushion: tw.On, 33 | }, 34 | Symbols: tw.NewSymbols(tw.StyleLight), 35 | Streaming: true, 36 | } 37 | } 38 | 39 | // defaultColorized returns a default ColorizedConfig optimized for dark terminal backgrounds with colored headers, rows, and borders. 40 | func defaultColorized() ColorizedConfig { 41 | return ColorizedConfig{ 42 | Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On}, 43 | Settings: tw.Settings{ 44 | Separators: tw.Separators{ 45 | ShowHeader: tw.On, 46 | ShowFooter: tw.On, 47 | BetweenRows: tw.Off, 48 | BetweenColumns: tw.On, 49 | }, 50 | Lines: tw.Lines{ 51 | ShowTop: tw.On, 52 | ShowBottom: tw.On, 53 | ShowHeaderLine: tw.On, 54 | ShowFooterLine: tw.On, 55 | }, 56 | 57 | CompactMode: tw.Off, 58 | }, 59 | Header: Tint{ 60 | FG: Colors{color.FgWhite, color.Bold}, 61 | BG: Colors{color.BgBlack}, 62 | }, 63 | Column: Tint{ 64 | FG: Colors{color.FgCyan}, 65 | BG: Colors{color.BgBlack}, 66 | }, 67 | Footer: Tint{ 68 | FG: Colors{color.FgYellow}, 69 | BG: Colors{color.BgBlack}, 70 | }, 71 | Border: Tint{ 72 | FG: Colors{color.FgWhite}, 73 | BG: Colors{color.BgBlack}, 74 | }, 75 | Separator: Tint{ 76 | FG: Colors{color.FgWhite}, 77 | BG: Colors{color.BgBlack}, 78 | }, 79 | Symbols: tw.NewSymbols(tw.StyleLight), 80 | } 81 | } 82 | 83 | // defaultOceanRendererConfig returns a base tw.Rendition for the Ocean renderer. 84 | func defaultOceanRendererConfig() tw.Rendition { 85 | 86 | return tw.Rendition{ 87 | Borders: tw.Border{ 88 | Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On, 89 | }, 90 | Settings: tw.Settings{ 91 | Separators: tw.Separators{ 92 | ShowHeader: tw.On, 93 | ShowFooter: tw.Off, 94 | BetweenRows: tw.Off, 95 | BetweenColumns: tw.On, 96 | }, 97 | Lines: tw.Lines{ 98 | ShowTop: tw.On, 99 | ShowBottom: tw.On, 100 | ShowHeaderLine: tw.On, 101 | ShowFooterLine: tw.Off, 102 | }, 103 | 104 | CompactMode: tw.Off, 105 | }, 106 | Symbols: tw.NewSymbols(tw.StyleDefault), 107 | Streaming: true, 108 | } 109 | } 110 | 111 | // getHTMLStyle remains the same 112 | func getHTMLStyle(align tw.Align) string { 113 | styleContent := tw.Empty 114 | switch align { 115 | case tw.AlignRight: 116 | styleContent = "text-align: right;" 117 | case tw.AlignCenter: 118 | styleContent = "text-align: center;" 119 | case tw.AlignLeft: 120 | styleContent = "text-align: left;" 121 | } 122 | if styleContent != tw.Empty { 123 | return fmt.Sprintf(` style="%s"`, styleContent) 124 | } 125 | return tw.Empty 126 | } 127 | 128 | // mergeLines combines default and override line settings, preserving defaults for unset (zero) overrides. 129 | func mergeLines(defaults, overrides tw.Lines) tw.Lines { 130 | if overrides.ShowTop != 0 { 131 | defaults.ShowTop = overrides.ShowTop 132 | } 133 | if overrides.ShowBottom != 0 { 134 | defaults.ShowBottom = overrides.ShowBottom 135 | } 136 | if overrides.ShowHeaderLine != 0 { 137 | defaults.ShowHeaderLine = overrides.ShowHeaderLine 138 | } 139 | if overrides.ShowFooterLine != 0 { 140 | defaults.ShowFooterLine = overrides.ShowFooterLine 141 | } 142 | return defaults 143 | } 144 | 145 | // mergeSeparators combines default and override separator settings, preserving defaults for unset (zero) overrides. 146 | func mergeSeparators(defaults, overrides tw.Separators) tw.Separators { 147 | if overrides.ShowHeader != 0 { 148 | defaults.ShowHeader = overrides.ShowHeader 149 | } 150 | if overrides.ShowFooter != 0 { 151 | defaults.ShowFooter = overrides.ShowFooter 152 | } 153 | if overrides.BetweenRows != 0 { 154 | defaults.BetweenRows = overrides.BetweenRows 155 | } 156 | if overrides.BetweenColumns != 0 { 157 | defaults.BetweenColumns = overrides.BetweenColumns 158 | } 159 | return defaults 160 | } 161 | 162 | // mergeSettings combines default and override settings, preserving defaults for unset (zero) overrides. 163 | func mergeSettings(defaults, overrides tw.Settings) tw.Settings { 164 | if overrides.Separators.ShowHeader != tw.Unknown { 165 | defaults.Separators.ShowHeader = overrides.Separators.ShowHeader 166 | } 167 | if overrides.Separators.ShowFooter != tw.Unknown { 168 | defaults.Separators.ShowFooter = overrides.Separators.ShowFooter 169 | } 170 | if overrides.Separators.BetweenRows != tw.Unknown { 171 | defaults.Separators.BetweenRows = overrides.Separators.BetweenRows 172 | } 173 | if overrides.Separators.BetweenColumns != tw.Unknown { 174 | defaults.Separators.BetweenColumns = overrides.Separators.BetweenColumns 175 | } 176 | if overrides.Lines.ShowTop != tw.Unknown { 177 | defaults.Lines.ShowTop = overrides.Lines.ShowTop 178 | } 179 | if overrides.Lines.ShowBottom != tw.Unknown { 180 | defaults.Lines.ShowBottom = overrides.Lines.ShowBottom 181 | } 182 | if overrides.Lines.ShowHeaderLine != tw.Unknown { 183 | defaults.Lines.ShowHeaderLine = overrides.Lines.ShowHeaderLine 184 | } 185 | if overrides.Lines.ShowFooterLine != tw.Unknown { 186 | defaults.Lines.ShowFooterLine = overrides.Lines.ShowFooterLine 187 | } 188 | 189 | if overrides.CompactMode != tw.Unknown { 190 | defaults.CompactMode = overrides.CompactMode 191 | } 192 | 193 | //if overrides.Cushion != tw.Unknown { 194 | // defaults.Cushion = overrides.Cushion 195 | //} 196 | 197 | return defaults 198 | } 199 | 200 | // MergeRendition merges the 'override' rendition into the 'current' rendition. 201 | // It only updates fields in 'current' if they are explicitly set (non-zero/non-nil) in 'override'. 202 | // This allows for partial updates to a renderer's configuration. 203 | func mergeRendition(current, override tw.Rendition) tw.Rendition { 204 | // Merge Borders: Only update if override border states are explicitly set (not 0). 205 | // A tw.State's zero value is 0, which is distinct from tw.On (1) or tw.Off (-1). 206 | // So, if override.Borders.Left is 0, it means "not specified", so we keep current. 207 | if override.Borders.Left != 0 { 208 | current.Borders.Left = override.Borders.Left 209 | } 210 | if override.Borders.Right != 0 { 211 | current.Borders.Right = override.Borders.Right 212 | } 213 | if override.Borders.Top != 0 { 214 | current.Borders.Top = override.Borders.Top 215 | } 216 | if override.Borders.Bottom != 0 { 217 | current.Borders.Bottom = override.Borders.Bottom 218 | } 219 | 220 | // Merge Symbols: Only update if override.Symbols is not nil. 221 | if override.Symbols != nil { 222 | current.Symbols = override.Symbols 223 | } 224 | 225 | // Merge Settings: Use the existing mergeSettings for granular control. 226 | // mergeSettings already handles preserving defaults for unset (zero) overrides. 227 | current.Settings = mergeSettings(current.Settings, override.Settings) 228 | 229 | // Streaming flag: typically set at renderer creation, but can be overridden if needed. 230 | // For now, let's assume it's not commonly changed post-creation by a generic rendition merge. 231 | // If override provides a different streaming capability, it might indicate a fundamental 232 | // change that a simple merge shouldn't handle without more context. 233 | // current.Streaming = override.Streaming // Or keep current.Streaming 234 | 235 | return current 236 | } 237 | -------------------------------------------------------------------------------- /renderer/html.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/olekukonko/ll" 7 | "html" 8 | "io" 9 | "strings" 10 | 11 | "github.com/olekukonko/tablewriter/tw" 12 | ) 13 | 14 | // HTMLConfig defines settings for the HTML table renderer. 15 | type HTMLConfig struct { 16 | EscapeContent bool // Whether to escape cell content 17 | AddLinesTag bool // Whether to wrap multiline content in tags 18 | TableClass string // CSS class for 19 | HeaderClass string // CSS class for 20 | BodyClass string // CSS class for 21 | FooterClass string // CSS class for 22 | RowClass string // CSS class for in body 23 | HeaderRowClass string // CSS class for in header 24 | FooterRowClass string // CSS class for in footer 25 | } 26 | 27 | // HTML renders tables in HTML format with customizable classes and content handling. 28 | type HTML struct { 29 | config HTMLConfig // Renderer configuration 30 | w io.Writer // Output w 31 | trace []string // Debug trace messages 32 | debug bool // Enables debug logging 33 | tableStarted bool // Tracks if
tag is open 34 | tbodyStarted bool // Tracks if tag is open 35 | tfootStarted bool // Tracks if tag is open 36 | vMergeTrack map[int]int // Tracks vertical merge spans by column index 37 | logger *ll.Logger 38 | } 39 | 40 | // NewHTML initializes an HTML renderer with the given w, debug setting, and optional configuration. 41 | // It panics if the w is nil and applies defaults for unset config fields. 42 | // Update: see https://github.com/olekukonko/tablewriter/issues/258 43 | func NewHTML(configs ...HTMLConfig) *HTML { 44 | cfg := HTMLConfig{ 45 | EscapeContent: true, 46 | AddLinesTag: false, 47 | } 48 | if len(configs) > 0 { 49 | userCfg := configs[0] 50 | cfg.EscapeContent = userCfg.EscapeContent 51 | cfg.AddLinesTag = userCfg.AddLinesTag 52 | cfg.TableClass = userCfg.TableClass 53 | cfg.HeaderClass = userCfg.HeaderClass 54 | cfg.BodyClass = userCfg.BodyClass 55 | cfg.FooterClass = userCfg.FooterClass 56 | cfg.RowClass = userCfg.RowClass 57 | cfg.HeaderRowClass = userCfg.HeaderRowClass 58 | cfg.FooterRowClass = userCfg.FooterRowClass 59 | } 60 | return &HTML{ 61 | config: cfg, 62 | vMergeTrack: make(map[int]int), 63 | tableStarted: false, 64 | tbodyStarted: false, 65 | tfootStarted: false, 66 | } 67 | } 68 | 69 | func (h *HTML) Logger(logger *ll.Logger) { 70 | h.logger = logger 71 | } 72 | 73 | // Config returns a Rendition representation of the current configuration. 74 | func (h *HTML) Config() tw.Rendition { 75 | return tw.Rendition{ 76 | Borders: tw.BorderNone, 77 | Symbols: tw.NewSymbols(tw.StyleNone), 78 | Settings: tw.Settings{}, 79 | Streaming: false, 80 | } 81 | } 82 | 83 | // debugLog appends a formatted message to the debug trace if debugging is enabled. 84 | //func (h *HTML) debugLog(format string, a ...interface{}) { 85 | // if h.debug { 86 | // msg := fmt.Sprintf(format, a...) 87 | // h.trace = append(h.trace, fmt.Sprintf("[HTML] %s", msg)) 88 | // } 89 | //} 90 | 91 | // Debug returns the accumulated debug trace messages. 92 | func (h *HTML) Debug() []string { 93 | return h.trace 94 | } 95 | 96 | // Start begins the HTML table rendering by opening the
tag. 97 | func (h *HTML) Start(w io.Writer) error { 98 | h.w = w 99 | h.Reset() 100 | h.logger.Debug("HTML.Start() called.") 101 | 102 | classAttr := tw.Empty 103 | if h.config.TableClass != tw.Empty { 104 | classAttr = fmt.Sprintf(` class="%s"`, h.config.TableClass) 105 | } 106 | h.logger.Debugf("Writing opening tag", classAttr) 107 | _, err := fmt.Fprintf(h.w, "\n", classAttr) 108 | if err != nil { 109 | return err 110 | } 111 | h.tableStarted = true 112 | return nil 113 | } 114 | 115 | // closePreviousSection closes any open or sections. 116 | func (h *HTML) closePreviousSection() { 117 | if h.tbodyStarted { 118 | h.logger.Debug("Closing tag") 119 | fmt.Fprintln(h.w, "") 120 | h.tbodyStarted = false 121 | } 122 | if h.tfootStarted { 123 | h.logger.Debug("Closing tag") 124 | fmt.Fprintln(h.w, "") 125 | h.tfootStarted = false 126 | } 127 | } 128 | 129 | // Header renders the section with header rows, supporting horizontal merges. 130 | func (h *HTML) Header(headers [][]string, ctx tw.Formatting) { 131 | if !h.tableStarted { 132 | h.logger.Debug("WARN: Header called before Start") 133 | return 134 | } 135 | if len(headers) == 0 || len(headers[0]) == 0 { 136 | h.logger.Debug("Header: No headers") 137 | return 138 | } 139 | 140 | h.closePreviousSection() 141 | classAttr := tw.Empty 142 | if h.config.HeaderClass != tw.Empty { 143 | classAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderClass) 144 | } 145 | fmt.Fprintf(h.w, "\n", classAttr) 146 | 147 | headerRow := headers[0] 148 | numCols := 0 149 | if len(ctx.Row.Current) > 0 { 150 | maxKey := -1 151 | for k := range ctx.Row.Current { 152 | if k > maxKey { 153 | maxKey = k 154 | } 155 | } 156 | numCols = maxKey + 1 157 | } else if len(headerRow) > 0 { 158 | numCols = len(headerRow) 159 | } 160 | 161 | indent := " " 162 | rowClassAttr := tw.Empty 163 | if h.config.HeaderRowClass != tw.Empty { 164 | rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderRowClass) 165 | } 166 | fmt.Fprintf(h.w, "%s", indent, rowClassAttr) 167 | 168 | renderedCols := 0 169 | for colIdx := 0; renderedCols < numCols && colIdx < numCols; { 170 | // Skip columns consumed by vertical merges 171 | if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 { 172 | h.logger.Debugf("Header: Skipping col %d due to vmerge", colIdx) 173 | h.vMergeTrack[colIdx]-- 174 | if h.vMergeTrack[colIdx] <= 1 { 175 | delete(h.vMergeTrack, colIdx) 176 | } 177 | colIdx++ 178 | continue 179 | } 180 | 181 | // Render cell 182 | cellCtx, ok := ctx.Row.Current[colIdx] 183 | if !ok { 184 | cellCtx = tw.CellContext{Align: tw.AlignCenter} 185 | } 186 | originalContent := tw.Empty 187 | if colIdx < len(headerRow) { 188 | originalContent = headerRow[colIdx] 189 | } 190 | 191 | tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, true, colIdx) 192 | fmt.Fprintf(h.w, "<%s%s>%s", tag, attributes, processedContent, tag) 193 | renderedCols++ 194 | 195 | // Handle horizontal merges 196 | hSpan := 1 197 | if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start { 198 | hSpan = cellCtx.Merge.Horizontal.Span 199 | renderedCols += (hSpan - 1) 200 | } 201 | colIdx += hSpan 202 | } 203 | fmt.Fprintf(h.w, "\n") 204 | fmt.Fprintln(h.w, "") 205 | } 206 | 207 | // Row renders a element within , supporting horizontal and vertical merges. 208 | func (h *HTML) Row(row []string, ctx tw.Formatting) { 209 | if !h.tableStarted { 210 | h.logger.Debug("WARN: Row called before Start") 211 | return 212 | } 213 | 214 | if !h.tbodyStarted { 215 | h.closePreviousSection() 216 | classAttr := tw.Empty 217 | if h.config.BodyClass != tw.Empty { 218 | classAttr = fmt.Sprintf(` class="%s"`, h.config.BodyClass) 219 | } 220 | h.logger.Debugf("Writing opening tag", classAttr) 221 | fmt.Fprintf(h.w, "\n", classAttr) 222 | h.tbodyStarted = true 223 | } 224 | 225 | h.logger.Debugf("Rendering row data: %v", row) 226 | numCols := 0 227 | if len(ctx.Row.Current) > 0 { 228 | maxKey := -1 229 | for k := range ctx.Row.Current { 230 | if k > maxKey { 231 | maxKey = k 232 | } 233 | } 234 | numCols = maxKey + 1 235 | } else if len(row) > 0 { 236 | numCols = len(row) 237 | } 238 | 239 | indent := " " 240 | rowClassAttr := tw.Empty 241 | if h.config.RowClass != tw.Empty { 242 | rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.RowClass) 243 | } 244 | fmt.Fprintf(h.w, "%s", indent, rowClassAttr) 245 | 246 | renderedCols := 0 247 | for colIdx := 0; renderedCols < numCols && colIdx < numCols; { 248 | // Skip columns consumed by vertical merges 249 | if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 { 250 | h.logger.Debugf("Row: Skipping render for col %d due to vertical merge (remaining %d)", colIdx, remainingSpan-1) 251 | h.vMergeTrack[colIdx]-- 252 | if h.vMergeTrack[colIdx] <= 1 { 253 | delete(h.vMergeTrack, colIdx) 254 | } 255 | colIdx++ 256 | continue 257 | } 258 | 259 | // Render cell 260 | cellCtx, ok := ctx.Row.Current[colIdx] 261 | if !ok { 262 | cellCtx = tw.CellContext{Align: tw.AlignLeft} 263 | } 264 | originalContent := tw.Empty 265 | if colIdx < len(row) { 266 | originalContent = row[colIdx] 267 | } 268 | 269 | tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx) 270 | fmt.Fprintf(h.w, "<%s%s>%s", tag, attributes, processedContent, tag) 271 | renderedCols++ 272 | 273 | // Handle horizontal merges 274 | hSpan := 1 275 | if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start { 276 | hSpan = cellCtx.Merge.Horizontal.Span 277 | renderedCols += (hSpan - 1) 278 | } 279 | colIdx += hSpan 280 | } 281 | fmt.Fprintf(h.w, "\n") 282 | } 283 | 284 | // Footer renders the section with footer rows, supporting horizontal merges. 285 | func (h *HTML) Footer(footers [][]string, ctx tw.Formatting) { 286 | if !h.tableStarted { 287 | h.logger.Debug("WARN: Footer called before Start") 288 | return 289 | } 290 | if len(footers) == 0 || len(footers[0]) == 0 { 291 | h.logger.Debug("Footer: No footers") 292 | return 293 | } 294 | 295 | h.closePreviousSection() 296 | classAttr := tw.Empty 297 | if h.config.FooterClass != tw.Empty { 298 | classAttr = fmt.Sprintf(` class="%s"`, h.config.FooterClass) 299 | } 300 | fmt.Fprintf(h.w, "\n", classAttr) 301 | h.tfootStarted = true 302 | 303 | footerRow := footers[0] 304 | numCols := 0 305 | if len(ctx.Row.Current) > 0 { 306 | maxKey := -1 307 | for k := range ctx.Row.Current { 308 | if k > maxKey { 309 | maxKey = k 310 | } 311 | } 312 | numCols = maxKey + 1 313 | } else if len(footerRow) > 0 { 314 | numCols = len(footerRow) 315 | } 316 | 317 | indent := " " 318 | rowClassAttr := tw.Empty 319 | if h.config.FooterRowClass != tw.Empty { 320 | rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.FooterRowClass) 321 | } 322 | fmt.Fprintf(h.w, "%s", indent, rowClassAttr) 323 | 324 | renderedCols := 0 325 | for colIdx := 0; renderedCols < numCols && colIdx < numCols; { 326 | cellCtx, ok := ctx.Row.Current[colIdx] 327 | if !ok { 328 | cellCtx = tw.CellContext{Align: tw.AlignRight} 329 | } 330 | originalContent := tw.Empty 331 | if colIdx < len(footerRow) { 332 | originalContent = footerRow[colIdx] 333 | } 334 | 335 | tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx) 336 | fmt.Fprintf(h.w, "<%s%s>%s", tag, attributes, processedContent, tag) 337 | renderedCols++ 338 | 339 | hSpan := 1 340 | if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start { 341 | hSpan = cellCtx.Merge.Horizontal.Span 342 | renderedCols += (hSpan - 1) 343 | } 344 | colIdx += hSpan 345 | } 346 | fmt.Fprintf(h.w, "\n") 347 | fmt.Fprintln(h.w, "") 348 | h.tfootStarted = false 349 | } 350 | 351 | // renderRowCell generates HTML for a single cell, handling content escaping, merges, and alignment. 352 | func (h *HTML) renderRowCell(originalContent string, cellCtx tw.CellContext, isHeader bool, colIdx int) (tag, attributes, processedContent string) { 353 | tag = "td" 354 | if isHeader { 355 | tag = "th" 356 | } 357 | 358 | // Process content 359 | processedContent = originalContent 360 | containsNewline := strings.Contains(originalContent, "\n") 361 | 362 | if h.config.EscapeContent { 363 | if containsNewline { 364 | const newlinePlaceholder = "[[--HTML_RENDERER_BR_PLACEHOLDER--]]" 365 | tempContent := strings.ReplaceAll(originalContent, "\n", newlinePlaceholder) 366 | escapedContent := html.EscapeString(tempContent) 367 | processedContent = strings.ReplaceAll(escapedContent, newlinePlaceholder, "
") 368 | } else { 369 | processedContent = html.EscapeString(originalContent) 370 | } 371 | } else if containsNewline { 372 | processedContent = strings.ReplaceAll(originalContent, "\n", "
") 373 | } 374 | 375 | if containsNewline && h.config.AddLinesTag { 376 | processedContent = "" + processedContent + "" 377 | } 378 | 379 | // Build attributes 380 | var attrBuilder strings.Builder 381 | merge := cellCtx.Merge 382 | 383 | if merge.Horizontal.Present && merge.Horizontal.Start && merge.Horizontal.Span > 1 { 384 | fmt.Fprintf(&attrBuilder, ` colspan="%d"`, merge.Horizontal.Span) 385 | } 386 | 387 | vSpan := 0 388 | if !isHeader { 389 | if merge.Vertical.Present && merge.Vertical.Start { 390 | vSpan = merge.Vertical.Span 391 | } else if merge.Hierarchical.Present && merge.Hierarchical.Start { 392 | vSpan = merge.Hierarchical.Span 393 | } 394 | if vSpan > 1 { 395 | fmt.Fprintf(&attrBuilder, ` rowspan="%d"`, vSpan) 396 | h.vMergeTrack[colIdx] = vSpan 397 | h.logger.Debugf("renderRowCell: Tracking rowspan=%d for col %d", vSpan, colIdx) 398 | } 399 | } 400 | 401 | if style := getHTMLStyle(cellCtx.Align); style != tw.Empty { 402 | attrBuilder.WriteString(style) 403 | } 404 | attributes = attrBuilder.String() 405 | return 406 | } 407 | 408 | // Line is a no-op for HTML rendering, as structural lines are handled by tags. 409 | func (h *HTML) Line(ctx tw.Formatting) {} 410 | 411 | // Reset clears the renderer's internal state, including debug traces and merge tracking. 412 | func (h *HTML) Reset() { 413 | h.logger.Debug("HTML.Reset() called.") 414 | h.tableStarted = false 415 | h.tbodyStarted = false 416 | h.tfootStarted = false 417 | h.vMergeTrack = make(map[int]int) 418 | h.trace = nil 419 | } 420 | 421 | // Close ensures all open HTML tags (
, , ) are properly closed. 422 | func (h *HTML) Close() error { 423 | if h.w == nil { 424 | return errors.New("HTML Renderer Close called on nil internal w") 425 | } 426 | 427 | if h.tableStarted { 428 | h.logger.Debug("HTML.Close() called.") 429 | h.closePreviousSection() 430 | h.logger.Debug("Closing
tag.") 431 | _, err := fmt.Fprintln(h.w, "
") 432 | h.tableStarted = false 433 | h.tbodyStarted = false 434 | h.tfootStarted = false 435 | h.vMergeTrack = make(map[int]int) 436 | return err 437 | } 438 | h.logger.Debug("HTML.Close() called, but table was not started (no-op).") 439 | return nil 440 | } 441 | -------------------------------------------------------------------------------- /renderer/junction.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "github.com/olekukonko/ll" 5 | "github.com/olekukonko/tablewriter/tw" 6 | ) 7 | 8 | // Junction handles rendering of table junction points (corners, intersections) with color support. 9 | type Junction struct { 10 | sym tw.Symbols // Symbols used for rendering junctions and lines 11 | ctx tw.Formatting // Current table formatting context 12 | colIdx int // Index of the column being processed 13 | debugging bool // Enables debug logging 14 | borderTint Tint // Colors for border symbols 15 | separatorTint Tint // Colors for separator symbols 16 | logger *ll.Logger 17 | } 18 | 19 | type JunctionContext struct { 20 | Symbols tw.Symbols 21 | Ctx tw.Formatting 22 | ColIdx int 23 | Logger *ll.Logger 24 | BorderTint Tint 25 | SeparatorTint Tint 26 | } 27 | 28 | // NewJunction initializes a Junction with the given symbols, context, and tints. 29 | // If debug is nil, a no-op debug function is used. 30 | func NewJunction(ctx JunctionContext) *Junction { 31 | return &Junction{ 32 | sym: ctx.Symbols, 33 | ctx: ctx.Ctx, 34 | colIdx: ctx.ColIdx, 35 | logger: ctx.Logger.Namespace("junction"), 36 | borderTint: ctx.BorderTint, 37 | separatorTint: ctx.SeparatorTint, 38 | } 39 | } 40 | 41 | // getMergeState retrieves the merge state for a specific column in a row, returning an empty state if not found. 42 | func (jr *Junction) getMergeState(row map[int]tw.CellContext, colIdx int) tw.MergeState { 43 | if row == nil || colIdx < 0 { 44 | return tw.MergeState{} 45 | } 46 | return row[colIdx].Merge 47 | } 48 | 49 | // GetSegment determines whether to render a colored horizontal line or an empty space based on merge states. 50 | func (jr *Junction) GetSegment() string { 51 | currentMerge := jr.getMergeState(jr.ctx.Row.Current, jr.colIdx) 52 | nextMerge := jr.getMergeState(jr.ctx.Row.Next, jr.colIdx) 53 | 54 | vPassThruStrict := (currentMerge.Vertical.Present && nextMerge.Vertical.Present && !currentMerge.Vertical.End && !nextMerge.Vertical.Start) || 55 | (currentMerge.Hierarchical.Present && nextMerge.Hierarchical.Present && !currentMerge.Hierarchical.End && !nextMerge.Hierarchical.Start) 56 | 57 | if vPassThruStrict { 58 | jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Empty segment", jr.colIdx, vPassThruStrict) 59 | return tw.Empty 60 | } 61 | symbol := jr.sym.Row() 62 | coloredSymbol := jr.borderTint.Apply(symbol) 63 | jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Colored row symbol '%s'", jr.colIdx, vPassThruStrict, coloredSymbol) 64 | return coloredSymbol 65 | } 66 | 67 | // RenderLeft selects and colors the leftmost junction symbol for the current row line based on position and merges. 68 | func (jr *Junction) RenderLeft() string { 69 | mergeAbove := jr.getMergeState(jr.ctx.Row.Current, 0) 70 | mergeBelow := jr.getMergeState(jr.ctx.Row.Next, 0) 71 | 72 | jr.logger.Debugf("RenderLeft: Level=%v, Location=%v, Previous=%v", jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous) 73 | 74 | isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) || 75 | (jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil) 76 | if isTopBorder { 77 | symbol := jr.sym.TopLeft() 78 | return jr.borderTint.Apply(symbol) 79 | } 80 | 81 | isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter 82 | isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd 83 | if isBottom || isFooter { 84 | symbol := jr.sym.BottomLeft() 85 | return jr.borderTint.Apply(symbol) 86 | } 87 | 88 | isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) || 89 | (mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start) 90 | if isVPassThruStrict { 91 | symbol := jr.sym.Column() 92 | return jr.separatorTint.Apply(symbol) 93 | } 94 | 95 | symbol := jr.sym.MidLeft() 96 | return jr.borderTint.Apply(symbol) 97 | } 98 | 99 | // RenderRight selects and colors the rightmost junction symbol for the row line based on position, merges, and last column index. 100 | func (jr *Junction) RenderRight(lastColIdx int) string { 101 | jr.logger.Debugf("RenderRight: lastColIdx=%d, Level=%v, Location=%v, Previous=%v", lastColIdx, jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous) 102 | 103 | if lastColIdx < 0 { 104 | switch jr.ctx.Level { 105 | case tw.LevelHeader: 106 | symbol := jr.sym.TopRight() 107 | return jr.borderTint.Apply(symbol) 108 | case tw.LevelFooter: 109 | symbol := jr.sym.BottomRight() 110 | return jr.borderTint.Apply(symbol) 111 | default: 112 | if jr.ctx.Row.Location == tw.LocationFirst { 113 | symbol := jr.sym.TopRight() 114 | return jr.borderTint.Apply(symbol) 115 | } 116 | if jr.ctx.Row.Location == tw.LocationEnd { 117 | symbol := jr.sym.BottomRight() 118 | return jr.borderTint.Apply(symbol) 119 | } 120 | symbol := jr.sym.MidRight() 121 | return jr.borderTint.Apply(symbol) 122 | } 123 | } 124 | 125 | mergeAbove := jr.getMergeState(jr.ctx.Row.Current, lastColIdx) 126 | mergeBelow := jr.getMergeState(jr.ctx.Row.Next, lastColIdx) 127 | 128 | isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) || 129 | (jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil) 130 | if isTopBorder { 131 | symbol := jr.sym.TopRight() 132 | return jr.borderTint.Apply(symbol) 133 | } 134 | 135 | isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter 136 | isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd 137 | if isBottom || isFooter { 138 | symbol := jr.sym.BottomRight() 139 | return jr.borderTint.Apply(symbol) 140 | } 141 | 142 | isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) || 143 | (mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start) 144 | if isVPassThruStrict { 145 | symbol := jr.sym.Column() 146 | return jr.separatorTint.Apply(symbol) 147 | } 148 | 149 | symbol := jr.sym.MidRight() 150 | return jr.borderTint.Apply(symbol) 151 | } 152 | 153 | // RenderJunction selects and colors the junction symbol between two adjacent columns based on merge states and table position. 154 | func (jr *Junction) RenderJunction(leftColIdx, rightColIdx int) string { 155 | mergeCurrentL := jr.getMergeState(jr.ctx.Row.Current, leftColIdx) 156 | mergeCurrentR := jr.getMergeState(jr.ctx.Row.Current, rightColIdx) 157 | mergeNextL := jr.getMergeState(jr.ctx.Row.Next, leftColIdx) 158 | mergeNextR := jr.getMergeState(jr.ctx.Row.Next, rightColIdx) 159 | 160 | isSpannedCurrent := mergeCurrentL.Horizontal.Present && !mergeCurrentL.Horizontal.End 161 | isSpannedNext := mergeNextL.Horizontal.Present && !mergeNextL.Horizontal.End 162 | 163 | vPassThruLStrict := (mergeCurrentL.Vertical.Present && mergeNextL.Vertical.Present && !mergeCurrentL.Vertical.End && !mergeNextL.Vertical.Start) || 164 | (mergeCurrentL.Hierarchical.Present && mergeNextL.Hierarchical.Present && !mergeCurrentL.Hierarchical.End && !mergeNextL.Hierarchical.Start) 165 | vPassThruRStrict := (mergeCurrentR.Vertical.Present && mergeNextR.Vertical.Present && !mergeCurrentR.Vertical.End && !mergeNextR.Vertical.Start) || 166 | (mergeCurrentR.Hierarchical.Present && mergeNextR.Hierarchical.Present && !mergeCurrentR.Hierarchical.End && !mergeNextR.Hierarchical.Start) 167 | 168 | isTop := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) || 169 | (jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && len(jr.ctx.Row.Previous) == 0) 170 | isBottom := (jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd) || 171 | (jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter) 172 | isPreFooter := jr.ctx.Level == tw.LevelFooter && (jr.ctx.Row.Position == tw.Row || jr.ctx.Row.Position == tw.Header) 173 | 174 | if isTop { 175 | if isSpannedNext { 176 | symbol := jr.sym.Row() 177 | return jr.borderTint.Apply(symbol) 178 | } 179 | symbol := jr.sym.TopMid() 180 | return jr.borderTint.Apply(symbol) 181 | } 182 | 183 | if isBottom { 184 | if vPassThruLStrict && vPassThruRStrict { 185 | symbol := jr.sym.Column() 186 | return jr.separatorTint.Apply(symbol) 187 | } 188 | if vPassThruLStrict { 189 | symbol := jr.sym.MidLeft() 190 | return jr.borderTint.Apply(symbol) 191 | } 192 | if vPassThruRStrict { 193 | symbol := jr.sym.MidRight() 194 | return jr.borderTint.Apply(symbol) 195 | } 196 | if isSpannedCurrent { 197 | symbol := jr.sym.Row() 198 | return jr.borderTint.Apply(symbol) 199 | } 200 | symbol := jr.sym.BottomMid() 201 | return jr.borderTint.Apply(symbol) 202 | } 203 | 204 | if isPreFooter { 205 | if vPassThruLStrict && vPassThruRStrict { 206 | symbol := jr.sym.Column() 207 | return jr.separatorTint.Apply(symbol) 208 | } 209 | if vPassThruLStrict { 210 | symbol := jr.sym.MidLeft() 211 | return jr.borderTint.Apply(symbol) 212 | } 213 | if vPassThruRStrict { 214 | symbol := jr.sym.MidRight() 215 | return jr.borderTint.Apply(symbol) 216 | } 217 | if mergeCurrentL.Horizontal.Present { 218 | if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && !mergeCurrentR.Horizontal.End { 219 | jr.logger.Debugf("Footer separator: H-merge continues from col %d to %d (mid-span), using BottomMid", leftColIdx, rightColIdx) 220 | symbol := jr.sym.BottomMid() 221 | return jr.borderTint.Apply(symbol) 222 | } 223 | if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && mergeCurrentR.Horizontal.End { 224 | jr.logger.Debugf("Footer separator: H-merge ends at col %d, using BottomMid", rightColIdx) 225 | symbol := jr.sym.BottomMid() 226 | return jr.borderTint.Apply(symbol) 227 | } 228 | if mergeCurrentL.Horizontal.End && !mergeCurrentR.Horizontal.Present { 229 | jr.logger.Debugf("Footer separator: H-merge ends at col %d, next col %d not merged, using Center", leftColIdx, rightColIdx) 230 | symbol := jr.sym.Center() 231 | return jr.borderTint.Apply(symbol) 232 | } 233 | } 234 | if isSpannedNext { 235 | symbol := jr.sym.BottomMid() 236 | return jr.borderTint.Apply(symbol) 237 | } 238 | if isSpannedCurrent { 239 | symbol := jr.sym.TopMid() 240 | return jr.borderTint.Apply(symbol) 241 | } 242 | symbol := jr.sym.Center() 243 | return jr.borderTint.Apply(symbol) 244 | } 245 | 246 | if vPassThruLStrict && vPassThruRStrict { 247 | symbol := jr.sym.Column() 248 | return jr.separatorTint.Apply(symbol) 249 | } 250 | if vPassThruLStrict { 251 | symbol := jr.sym.MidLeft() 252 | return jr.borderTint.Apply(symbol) 253 | } 254 | if vPassThruRStrict { 255 | symbol := jr.sym.MidRight() 256 | return jr.borderTint.Apply(symbol) 257 | } 258 | if isSpannedCurrent && isSpannedNext { 259 | symbol := jr.sym.Row() 260 | return jr.borderTint.Apply(symbol) 261 | } 262 | if isSpannedCurrent { 263 | symbol := jr.sym.TopMid() 264 | return jr.borderTint.Apply(symbol) 265 | } 266 | if isSpannedNext { 267 | symbol := jr.sym.BottomMid() 268 | return jr.borderTint.Apply(symbol) 269 | } 270 | 271 | symbol := jr.sym.Center() 272 | return jr.borderTint.Apply(symbol) 273 | } 274 | -------------------------------------------------------------------------------- /tests/blueprint_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "github.com/olekukonko/tablewriter/renderer" 5 | "github.com/olekukonko/tablewriter/tw" 6 | "testing" 7 | ) 8 | 9 | func TestDefaultConfigMerging(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | config tw.Rendition 13 | expected tw.Rendition 14 | }{ 15 | { 16 | name: "EmptyConfig", 17 | config: tw.Rendition{}, 18 | expected: tw.Rendition{ 19 | Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On}, 20 | Settings: tw.Settings{ 21 | Separators: tw.Separators{ 22 | ShowHeader: tw.On, 23 | ShowFooter: tw.On, 24 | BetweenRows: tw.Off, 25 | BetweenColumns: tw.On, 26 | }, 27 | Lines: tw.Lines{ 28 | ShowTop: tw.On, 29 | ShowBottom: tw.On, 30 | ShowHeaderLine: tw.On, 31 | ShowFooterLine: tw.On, 32 | }, 33 | // TrimWhitespace: tw.On, 34 | CompactMode: tw.Off, 35 | }, 36 | Symbols: tw.NewSymbols(tw.StyleLight), 37 | }, 38 | }, 39 | { 40 | name: "PartialBorders", 41 | config: tw.Rendition{ 42 | Borders: tw.Border{Top: tw.Off}, 43 | }, 44 | expected: tw.Rendition{ 45 | Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.Off, Bottom: tw.On}, 46 | Settings: tw.Settings{ 47 | Separators: tw.Separators{ 48 | ShowHeader: tw.On, 49 | ShowFooter: tw.On, 50 | BetweenRows: tw.Off, 51 | BetweenColumns: tw.On, 52 | }, 53 | Lines: tw.Lines{ 54 | ShowTop: tw.On, 55 | ShowBottom: tw.On, 56 | ShowHeaderLine: tw.On, 57 | ShowFooterLine: tw.On, 58 | }, 59 | // TrimWhitespace: tw.On, 60 | CompactMode: tw.Off, 61 | }, 62 | Symbols: tw.NewSymbols(tw.StyleLight), 63 | }, 64 | }, 65 | { 66 | name: "PartialSettingsLines", 67 | config: tw.Rendition{ 68 | Settings: tw.Settings{ 69 | Lines: tw.Lines{ShowFooterLine: tw.Off}, 70 | }, 71 | }, 72 | expected: tw.Rendition{ 73 | Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On}, 74 | Settings: tw.Settings{ 75 | Separators: tw.Separators{ 76 | ShowHeader: tw.On, 77 | ShowFooter: tw.On, 78 | BetweenRows: tw.Off, 79 | BetweenColumns: tw.On, 80 | }, 81 | Lines: tw.Lines{ 82 | ShowTop: tw.On, 83 | ShowBottom: tw.On, 84 | ShowHeaderLine: tw.On, 85 | ShowFooterLine: tw.Off, 86 | }, 87 | // TrimWhitespace: tw.On, 88 | CompactMode: tw.Off, 89 | }, 90 | Symbols: tw.NewSymbols(tw.StyleLight), 91 | }, 92 | }, 93 | } 94 | 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | r := renderer.NewBlueprint(tt.config) 98 | got := r.Config() 99 | 100 | // Compare Borders 101 | if got.Borders != tt.expected.Borders { 102 | t.Errorf("%s: Borders mismatch - expected %+v, got %+v", tt.name, tt.expected.Borders, got.Borders) 103 | } 104 | // Compare Settings.Lines 105 | if got.Settings.Lines != tt.expected.Settings.Lines { 106 | t.Errorf("%s: Settings.Lines mismatch - expected %+v, got %+v", tt.name, tt.expected.Settings.Lines, got.Settings.Lines) 107 | } 108 | // Compare Settings.Separators 109 | if got.Settings.Separators != tt.expected.Settings.Separators { 110 | t.Errorf("%s: Settings.Separators mismatch - expected %+v, got %+v", tt.name, tt.expected.Settings.Separators, got.Settings.Separators) 111 | } 112 | // Check Symbols (basic presence check) 113 | if (tt.expected.Symbols == nil) != (got.Symbols == nil) { 114 | t.Errorf("%s: Symbols mismatch - expected nil: %v, got nil: %v", tt.name, tt.expected.Symbols == nil, got.Symbols == nil) 115 | } 116 | }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/bug_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/olekukonko/tablewriter/renderer" 8 | "github.com/olekukonko/tablewriter/tw" 9 | "testing" 10 | ) 11 | 12 | type Cleaner string 13 | 14 | // Note: Format() overrides String() if both exist. 15 | func (c Cleaner) Format() string { 16 | return clean(string(c)) 17 | } 18 | 19 | type Age int 20 | 21 | // Age int will be ignore and string will be used 22 | func (a Age) String() string { 23 | return fmt.Sprintf("%dyrs", a) 24 | } 25 | 26 | type Person struct { 27 | Name string 28 | Age int 29 | City string 30 | } 31 | 32 | type Profile struct { 33 | Name Cleaner 34 | Age Age 35 | City string 36 | } 37 | 38 | func TestBug252(t *testing.T) { 39 | var buf bytes.Buffer 40 | type Person struct { 41 | Name string 42 | Age int 43 | City string 44 | } 45 | 46 | header := []string{"Name", "Age", "City"} 47 | alice := Person{Name: "Alice", Age: 25, City: "New York"} 48 | bob := Profile{Name: Cleaner("Bo b"), Age: Age(30), City: "Boston"} 49 | 50 | t.Run("Normal", func(t *testing.T) { 51 | buf.Reset() 52 | table := tablewriter.NewTable(&buf, tablewriter.WithDebug(true)) 53 | table.Header(header) 54 | table.Append("Alice", "25", "New York") 55 | table.Append("Bob", "30", "Boston") 56 | table.Render() 57 | 58 | expected := ` 59 | ┌───────┬─────┬──────────┐ 60 | │ NAME │ AGE │ CITY │ 61 | ├───────┼─────┼──────────┤ 62 | │ Alice │ 25 │ New York │ 63 | │ Bob │ 30 │ Boston │ 64 | └───────┴─────┴──────────┘ 65 | ` 66 | debug := visualCheck(t, "TestBug252-Normal", buf.String(), expected) 67 | if !debug { 68 | t.Error(table.Debug()) 69 | } 70 | 71 | }) 72 | 73 | t.Run("Mixed", func(t *testing.T) { 74 | buf.Reset() 75 | table := tablewriter.NewTable(&buf, tablewriter.WithDebug(true)) 76 | table.Header(header) 77 | table.Append(alice) 78 | table.Append("Bob", "30", "Boston") 79 | table.Render() 80 | 81 | expected := ` 82 | ┌───────┬─────┬──────────┐ 83 | │ NAME │ AGE │ CITY │ 84 | ├───────┼─────┼──────────┤ 85 | │ Alice │ 25 │ New York │ 86 | │ Bob │ 30 │ Boston │ 87 | └───────┴─────┴──────────┘ 88 | ` 89 | debug := visualCheck(t, "TestBug252-Mixed", buf.String(), expected) 90 | if !debug { 91 | // t.Error(table.Debug()) 92 | } 93 | 94 | }) 95 | 96 | t.Run("Profile", func(t *testing.T) { 97 | buf.Reset() 98 | table := tablewriter.NewTable(&buf, tablewriter.WithDebug(true)) 99 | table.Header(header) 100 | table.Append(Cleaner("A lice"), Cleaner("2 5 yrs"), "New York") 101 | table.Append(bob) 102 | table.Render() 103 | 104 | expected := ` 105 | ┌───────┬───────┬──────────┐ 106 | │ NAME │ AGE │ CITY │ 107 | ├───────┼───────┼──────────┤ 108 | │ Alice │ 25yrs │ New York │ 109 | │ Bob │ 30yrs │ Boston │ 110 | └───────┴───────┴──────────┘ 111 | 112 | ` 113 | debug := visualCheck(t, "TestBasicTableDefault", buf.String(), expected) 114 | if !debug { 115 | // t.Error(table.Debug()) 116 | } 117 | }) 118 | 119 | type Override struct { 120 | Fish string `json:"name"` 121 | Name string `json:"-"` 122 | Age Age 123 | City string 124 | } 125 | t.Run("Override", func(t *testing.T) { 126 | buf.Reset() 127 | table := tablewriter.NewTable(&buf, tablewriter.WithDebug(true)) 128 | table.Header(header) 129 | table.Append(Cleaner("A lice"), Cleaner("2 5 yrs"), "New York") 130 | table.Append(Override{ 131 | Fish: "Bob", 132 | Name: "Skip", 133 | Age: Age(25), 134 | City: "Boston", 135 | }) 136 | table.Render() 137 | 138 | expected := ` 139 | ┌───────┬───────┬──────────┐ 140 | │ NAME │ AGE │ CITY │ 141 | ├───────┼───────┼──────────┤ 142 | │ Alice │ 25yrs │ New York │ 143 | │ Bob │ 25yrs │ Boston │ 144 | └───────┴───────┴──────────┘ 145 | 146 | ` 147 | debug := visualCheck(t, "TestBug252-Override", buf.String(), expected) 148 | if !debug { 149 | // t.Error(table.Debug()) 150 | } 151 | }) 152 | 153 | t.Run("Override-Streaming", func(t *testing.T) { 154 | buf.Reset() 155 | table := tablewriter.NewTable(&buf, tablewriter.WithDebug(true), tablewriter.WithStreaming(tw.StreamConfig{Enable: true})) 156 | 157 | err := table.Start() 158 | if err != nil { 159 | t.Error(err) 160 | } 161 | 162 | table.Header(header) 163 | table.Append(Cleaner("A lice"), Cleaner("2 5 yrs"), "New York") 164 | table.Append(Override{ 165 | Fish: "Bob", 166 | Name: "Skip", 167 | Age: Age(25), 168 | City: "Boston", 169 | }) 170 | expected := ` 171 | ┌────────┬────────┬────────┐ 172 | │ NAME │ AGE │ CITY │ 173 | ├────────┼────────┼────────┤ 174 | │ Alice │ 25yrs │ New │ 175 | │ │ │ York │ 176 | │ Bob │ 25yrs │ Boston │ 177 | └────────┴────────┴────────┘ 178 | 179 | 180 | ` 181 | err = table.Close() 182 | if err != nil { 183 | t.Error(err) 184 | } 185 | debug := visualCheck(t, "TestBug252-Override-Streaming", buf.String(), expected) 186 | if !debug { 187 | // t.Error(table.Debug()) 188 | } 189 | }) 190 | 191 | } 192 | 193 | func TestBug254(t *testing.T) { 194 | var buf bytes.Buffer 195 | data := [][]string{ 196 | {" LEFT", "RIGHT ", " BOTH "}, 197 | } 198 | t.Run("Normal", func(t *testing.T) { 199 | buf.Reset() 200 | table := tablewriter.NewTable(&buf, 201 | tablewriter.WithRowMaxWidth(20), 202 | tablewriter.WithTrimSpace(tw.On), 203 | tablewriter.WithAlignment(tw.Alignment{tw.AlignCenter, tw.AlignCenter, tw.AlignCenter}), 204 | ) 205 | table.Bulk(data) 206 | table.Render() 207 | 208 | expected := ` 209 | ┌──────┬───────┬──────┐ 210 | │ LEFT │ RIGHT │ BOTH │ 211 | └──────┴───────┴──────┘ 212 | 213 | ` 214 | debug := visualCheck(t, "TestBug252-Normal", buf.String(), expected) 215 | if !debug { 216 | t.Error(table.Debug()) 217 | } 218 | 219 | }) 220 | 221 | t.Run("Mixed", func(t *testing.T) { 222 | buf.Reset() 223 | table := tablewriter.NewTable(&buf, 224 | tablewriter.WithRowMaxWidth(20), 225 | tablewriter.WithTrimSpace(tw.Off), 226 | tablewriter.WithAlignment(tw.Alignment{tw.AlignCenter, tw.AlignCenter, tw.AlignCenter}), 227 | ) 228 | table.Bulk(data) 229 | table.Render() 230 | 231 | expected := ` 232 | ┌────────┬─────────┬──────────┐ 233 | │ LEFT │ RIGHT │ BOTH │ 234 | └────────┴─────────┴──────────┘ 235 | ` 236 | debug := visualCheck(t, "TestBug252-Mixed", buf.String(), expected) 237 | if !debug { 238 | t.Error(table.Debug()) 239 | } 240 | 241 | }) 242 | 243 | } 244 | 245 | func TestBug267(t *testing.T) { 246 | var buf bytes.Buffer 247 | data := [][]string{ 248 | {"a", "b", "c"}, 249 | {"aa", "bb", "cc"}, 250 | } 251 | t.Run("WithoutMaxWith", func(t *testing.T) { 252 | buf.Reset() 253 | table := tablewriter.NewTable(&buf, 254 | tablewriter.WithTrimSpace(tw.On), 255 | tablewriter.WithConfig(tablewriter.Config{Row: tw.CellConfig{Padding: tw.CellPadding{ 256 | Global: tw.PaddingNone, 257 | }}}), 258 | ) 259 | table.Bulk(data) 260 | table.Render() 261 | 262 | expected := ` 263 | ┌──┬──┬──┐ 264 | │a │b │c │ 265 | │aa│bb│cc│ 266 | └──┴──┴──┘ 267 | 268 | ` 269 | debug := visualCheck(t, "TestBug252-Normal", buf.String(), expected) 270 | if !debug { 271 | t.Error(table.Debug()) 272 | } 273 | 274 | }) 275 | 276 | t.Run("WithMaxWidth", func(t *testing.T) { 277 | buf.Reset() 278 | table := tablewriter.NewTable(&buf, 279 | tablewriter.WithRowMaxWidth(20), 280 | tablewriter.WithTrimSpace(tw.Off), 281 | tablewriter.WithAlignment(tw.Alignment{tw.AlignCenter, tw.AlignCenter, tw.AlignCenter}), 282 | tablewriter.WithDebug(false), 283 | tablewriter.WithConfig(tablewriter.Config{Row: tw.CellConfig{Padding: tw.CellPadding{ 284 | Global: tw.PaddingNone, 285 | }}}), 286 | ) 287 | table.Bulk(data) 288 | table.Render() 289 | 290 | expected := ` 291 | ┌──┬──┬──┐ 292 | │a │b │c │ 293 | │aa│bb│cc│ 294 | └──┴──┴──┘ 295 | ` 296 | debug := visualCheck(t, "TestBug252-Mixed", buf.String(), expected) 297 | if !debug { 298 | t.Error(table.Debug()) 299 | } 300 | 301 | }) 302 | 303 | } 304 | 305 | func TestBug271(t *testing.T) { 306 | var buf bytes.Buffer 307 | table := tablewriter.NewTable(&buf, tablewriter.WithConfig(tablewriter.Config{ 308 | Header: tw.CellConfig{ 309 | Formatting: tw.CellFormatting{ 310 | MergeMode: tw.MergeHorizontal, 311 | }, 312 | }, 313 | Footer: tw.CellConfig{ 314 | Formatting: tw.CellFormatting{ 315 | MergeMode: tw.MergeHorizontal, 316 | }, 317 | Alignment: tw.CellAlignment{PerColumn: []tw.Align{tw.Skip, tw.Skip, tw.AlignRight, tw.AlignLeft}}, 318 | }, 319 | }), 320 | tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ 321 | Settings: tw.Settings{ 322 | Separators: tw.Separators{ 323 | BetweenRows: tw.On, 324 | }, 325 | }, 326 | })), 327 | ) 328 | table.Header([]string{"Info", "Info", "Info", "Info"}) 329 | table.Append([]string{"1/1/2014", "Domain name", "Successful", "Successful"}) 330 | table.Footer([]string{"", "", "TOTAL", "$145.93"}) // Fixed from Append 331 | table.Render() 332 | 333 | expected := ` 334 | ┌──────────────────────────────────────────────────┐ 335 | │ INFO │ 336 | ├──────────┬─────────────┬────────────┬────────────┤ 337 | │ 1/1/2014 │ Domain name │ Successful │ Successful │ 338 | ├──────────┴─────────────┴────────────┼────────────┤ 339 | │ TOTAL │ $145.93 │ 340 | └─────────────────────────────────────┴────────────┘ 341 | 342 | ` 343 | check := visualCheck(t, "HorizontalMergeAlignFooter", buf.String(), expected) 344 | if !check { 345 | t.Error(table.Debug()) 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /tests/caption_test.go: -------------------------------------------------------------------------------- 1 | // File: tests/caption_test.go 2 | package tests 3 | 4 | import ( 5 | "bytes" 6 | "github.com/olekukonko/tablewriter/renderer" 7 | "testing" 8 | 9 | "github.com/olekukonko/tablewriter" 10 | "github.com/olekukonko/tablewriter/tw" // Assuming your tw types (CaptionPosition, Align) are here 11 | ) 12 | 13 | // TestTableCaptions comprehensive tests for caption functionality 14 | func TestTableCaptions(t *testing.T) { 15 | data := [][]string{ 16 | {"Alice", "30", "New York"}, 17 | {"Bob", "24", "San Francisco"}, 18 | {"Charlie", "35", "London"}, 19 | } 20 | headers := []string{"Name", "Age", "City"} 21 | shortCaption := "User Data" 22 | longCaption := "This is a detailed caption for the user data table, intended to demonstrate text wrapping and alignment features." 23 | 24 | baseTableSetup := func(buf *bytes.Buffer) *tablewriter.Table { 25 | table := tablewriter.NewTable(buf, 26 | tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{Symbols: tw.NewSymbols(tw.StyleASCII)})), 27 | tablewriter.WithDebug(true), 28 | ) 29 | table.Header(headers) 30 | for _, v := range data { 31 | table.Append(v) 32 | } 33 | return table 34 | } 35 | 36 | t.Run("NoCaption", func(t *testing.T) { 37 | var buf bytes.Buffer 38 | table := baseTableSetup(&buf) 39 | table.Render() 40 | expected := ` 41 | +---------+-----+---------------+ 42 | | NAME | AGE | CITY | 43 | +---------+-----+---------------+ 44 | | Alice | 30 | New York | 45 | | Bob | 24 | San Francisco | 46 | | Charlie | 35 | London | 47 | +---------+-----+---------------+ 48 | ` 49 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 50 | t.Log(table.Debug().String()) 51 | } 52 | }) 53 | 54 | t.Run("LegacySetCaption_BottomCenter", func(t *testing.T) { 55 | var buf bytes.Buffer 56 | table := baseTableSetup(&buf) 57 | table.Caption(tw.Caption{Text: shortCaption}) // Legacy, defaults to BottomCenter, auto width 58 | table.Render() 59 | // Width of table: 7+3+15 + 4 borders/separators = 29. "User Data" is 9. 60 | // (29-9)/2 = 10 padding left. 61 | expected := ` 62 | +---------+-----+---------------+ 63 | | NAME | AGE | CITY | 64 | +---------+-----+---------------+ 65 | | Alice | 30 | New York | 66 | | Bob | 24 | San Francisco | 67 | | Charlie | 35 | London | 68 | +---------+-----+---------------+ 69 | User Data 70 | ` 71 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 72 | t.Log(table.Debug().String()) 73 | } 74 | }) 75 | 76 | t.Run("CaptionBottomCenter_AutoWidthBottom", func(t *testing.T) { 77 | var buf bytes.Buffer 78 | table := baseTableSetup(&buf) 79 | table.Caption(tw.Caption{Text: shortCaption, Spot: tw.SpotBottomCenter, Align: tw.AlignCenter}) 80 | table.Render() 81 | expected := ` 82 | +---------+-----+---------------+ 83 | | NAME | AGE | CITY | 84 | +---------+-----+---------------+ 85 | | Alice | 30 | New York | 86 | | Bob | 24 | San Francisco | 87 | | Charlie | 35 | London | 88 | +---------+-----+---------------+ 89 | User Data 90 | ` 91 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 92 | t.Log(table.Debug().String()) 93 | } 94 | }) 95 | 96 | t.Run("CaptionTopCenter_AutoWidthTop", func(t *testing.T) { 97 | var buf bytes.Buffer 98 | table := baseTableSetup(&buf) 99 | table.Caption(tw.Caption{Text: shortCaption, Spot: tw.SpotTopCenter, Align: tw.AlignCenter}) 100 | table.Render() 101 | expected := ` 102 | User Data 103 | +---------+-----+---------------+ 104 | | NAME | AGE | CITY | 105 | +---------+-----+---------------+ 106 | | Alice | 30 | New York | 107 | | Bob | 24 | San Francisco | 108 | | Charlie | 35 | London | 109 | +---------+-----+---------------+ 110 | ` 111 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 112 | t.Log(table.Debug().String()) 113 | } 114 | }) 115 | 116 | t.Run("CaptionBottomLeft_AutoWidth", func(t *testing.T) { 117 | var buf bytes.Buffer 118 | table := baseTableSetup(&buf) 119 | table.Caption(tw.Caption{Text: shortCaption, Spot: tw.SpotBottomLeft, Align: tw.AlignLeft}) 120 | table.Render() 121 | expected := ` 122 | +---------+-----+---------------+ 123 | | NAME | AGE | CITY | 124 | +---------+-----+---------------+ 125 | | Alice | 30 | New York | 126 | | Bob | 24 | San Francisco | 127 | | Charlie | 35 | London | 128 | +---------+-----+---------------+ 129 | User Data 130 | ` 131 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 132 | t.Log(table.Debug().String()) 133 | } 134 | }) 135 | 136 | t.Run("CaptionTopRight_AutoWidth", func(t *testing.T) { 137 | var buf bytes.Buffer 138 | table := baseTableSetup(&buf) 139 | table.Caption(tw.Caption{Text: shortCaption, Spot: tw.SpotTopRight, Align: tw.AlignRight}) 140 | table.Render() 141 | expected := ` 142 | User Data 143 | +---------+-----+---------------+ 144 | | NAME | AGE | CITY | 145 | +---------+-----+---------------+ 146 | | Alice | 30 | New York | 147 | | Bob | 24 | San Francisco | 148 | | Charlie | 35 | London | 149 | +---------+-----+---------------+ 150 | ` 151 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 152 | t.Log(table.Debug().String()) 153 | } 154 | }) 155 | 156 | t.Run("CaptionBottomCenter_LongCaption_AutoWidth", func(t *testing.T) { 157 | var buf bytes.Buffer 158 | table := baseTableSetup(&buf) 159 | table.Caption(tw.Caption{Text: longCaption, Spot: tw.SpotBottomCenter, Align: tw.AlignCenter}) 160 | table.Render() 161 | // Table width is 29. Long caption will wrap to this. 162 | // "This is a detailed caption for" (29) 163 | // "the user data table, intended" (29) 164 | // "to demonstrate text wrapping" (28) 165 | // "and alignment features." (25) 166 | expected := ` 167 | +---------+-----+---------------+ 168 | | NAME | AGE | CITY | 169 | +---------+-----+---------------+ 170 | | Alice | 30 | New York | 171 | | Bob | 24 | San Francisco | 172 | | Charlie | 35 | London | 173 | +---------+-----+---------------+ 174 | This is a detailed caption for 175 | the user data table, intended 176 | to demonstrate text wrapping and 177 | alignment features. 178 | ` 179 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 180 | t.Log(table.Debug().String()) 181 | } 182 | }) 183 | 184 | t.Run("CaptionTopLeft_LongCaption_MaxWidth20", func(t *testing.T) { 185 | var buf bytes.Buffer 186 | table := baseTableSetup(&buf) 187 | // captionMaxWidth 20, table width is 29. Caption wraps to 20. Padded to 29 (table width) for alignment. 188 | table.Caption(tw.Caption{Text: longCaption, Spot: tw.SpotTopLeft, Align: tw.AlignLeft, Width: 20}) 189 | table.Render() 190 | 191 | // The visual check normalizes spaces, so the alignment padding to table width is tricky to test visually for left/right aligned captions. 192 | // It's more about the wrapping width for the caption text itself. 193 | // The printTopBottomCaption will align the *block* of wrapped text. 194 | // Let's adjust the expected output: the lines are padded to actualTableWidth. 195 | // The caption lines themselves are max 20 wide. 196 | expectedAdjusted := ` 197 | This is a detailed 198 | caption for the user 199 | data table, intended 200 | to demonstrate 201 | text wrapping and 202 | alignment features. 203 | +---------+-----+---------------+ 204 | | NAME | AGE | CITY | 205 | +---------+-----+---------------+ 206 | | Alice | 30 | New York | 207 | | Bob | 24 | San Francisco | 208 | | Charlie | 35 | London | 209 | +---------+-----+---------------+ 210 | ` 211 | if !visualCheckCaption(t, t.Name(), buf.String(), expectedAdjusted) { 212 | t.Log(table.Debug().String()) 213 | } 214 | }) 215 | 216 | t.Run("CaptionBottomCenter_EmptyTable", func(t *testing.T) { 217 | var buf bytes.Buffer 218 | table := tablewriter.NewWriter(&buf) 219 | // No header, no data 220 | table.Caption(tw.Caption{Text: shortCaption, Spot: tw.SpotBottomCenter, Align: tw.AlignCenter}) 221 | table.Render() 222 | // Expected: table is empty box, caption centered to its own width or a default. 223 | // Empty table with default borders prints: 224 | // +--+ 225 | // +--+ 226 | // If actualTableWidth is 0, captionWrapWidth becomes natural width of caption (9 for "User Data") 227 | // Then paddingTargetWidth also becomes 9. 228 | expected := ` 229 | +--+ 230 | +--+ 231 | User Data 232 | ` 233 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 234 | t.Log(table.Debug().String()) 235 | } 236 | }) 237 | 238 | t.Run("CaptionTopLeft_EmptyTable_MaxWidth10", func(t *testing.T) { 239 | var buf bytes.Buffer 240 | table := tablewriter.NewWriter(&buf) 241 | table.Caption(tw.Caption{Text: "A very long caption text.", Spot: tw.SpotTopLeft, Align: tw.AlignLeft, Width: 10}) 242 | table.Render() 243 | // Table is empty, captionMaxWidth is 10. 244 | // "A very" 245 | // "long" 246 | // "caption" 247 | // "text." 248 | // Each line left-aligned within width 10. 249 | expected := ` 250 | A very 251 | long 252 | caption 253 | text. 254 | +--+ 255 | +--+ 256 | ` 257 | if !visualCheckCaption(t, t.Name(), buf.String(), expected) { 258 | t.Log(table.Debug().String()) 259 | } 260 | }) 261 | } 262 | -------------------------------------------------------------------------------- /tests/colorized_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/fatih/color" 8 | "github.com/olekukonko/tablewriter" 9 | "github.com/olekukonko/tablewriter/renderer" 10 | "github.com/olekukonko/tablewriter/tw" 11 | ) 12 | 13 | func TestColorizedBasicTable(t *testing.T) { 14 | var buf bytes.Buffer 15 | table := tablewriter.NewTable(&buf, 16 | tablewriter.WithRenderer(renderer.NewColorized()), 17 | ) 18 | table.Header([]string{"Name", "Age", "City"}) 19 | table.Append([]string{"Alice", "25", "New York"}) 20 | table.Append([]string{"Bob", "30", "Boston"}) 21 | table.Render() 22 | 23 | // Expected colors: Headers (white/bold on black), Rows (cyan on black), Borders/Separators (white on black) 24 | expected := ` 25 | ┌───────┬─────┬──────────┐ 26 | │ NAME │ AGE │ CITY │ 27 | ├───────┼─────┼──────────┤ 28 | │ Alice │ 25 │ New York │ 29 | │ Bob │ 30 │ Boston │ 30 | └───────┴─────┴──────────┘ 31 | ` 32 | visualCheck(t, "ColorizedBasicTable", buf.String(), expected) 33 | 34 | } 35 | 36 | func TestColorizedNoBorders(t *testing.T) { 37 | var buf bytes.Buffer 38 | table := tablewriter.NewTable(&buf, 39 | tablewriter.WithRenderer(renderer.NewColorized(renderer.ColorizedConfig{ 40 | Borders: tw.Border{Left: tw.Off, Right: tw.Off, Top: tw.Off, Bottom: tw.Off}, 41 | })), 42 | ) 43 | table.Header([]string{"Name", "Age", "City"}) 44 | table.Append([]string{"Alice", "25", "New York"}) 45 | table.Append([]string{"Bob", "30", "Boston"}) 46 | table.Render() 47 | 48 | // Expected colors: Headers (white/bold on black), Rows (cyan on black), Separators (white on black) 49 | expected := ` 50 | NAME │ AGE │ CITY 51 | ───────┼─────┼────────── 52 | Alice │ 25 │ New York 53 | Bob │ 30 │ Boston 54 | ` 55 | visualCheck(t, "ColorizedNoBorders", buf.String(), expected) 56 | } 57 | 58 | func TestColorizedCustomColors(t *testing.T) { 59 | var buf bytes.Buffer 60 | table := tablewriter.NewTable(&buf, 61 | tablewriter.WithRenderer(renderer.NewColorized(renderer.ColorizedConfig{ 62 | Header: renderer.Tint{ 63 | FG: renderer.Colors{color.FgGreen, color.Bold}, 64 | BG: renderer.Colors{color.BgBlue}, 65 | Columns: []renderer.Tint{ 66 | {FG: renderer.Colors{color.FgRed}, BG: renderer.Colors{color.BgBlue}}, 67 | {FG: renderer.Colors{color.FgYellow}, BG: renderer.Colors{color.BgBlue}}, 68 | }, 69 | }, 70 | Column: renderer.Tint{ 71 | FG: renderer.Colors{color.FgBlue}, 72 | BG: renderer.Colors{color.BgBlack}, 73 | Columns: []renderer.Tint{ 74 | {FG: renderer.Colors{color.FgMagenta}, BG: renderer.Colors{color.BgBlack}}, 75 | }, 76 | }, 77 | Footer: renderer.Tint{ 78 | FG: renderer.Colors{color.FgYellow}, 79 | BG: renderer.Colors{color.BgBlue}, 80 | }, 81 | Border: renderer.Tint{ 82 | FG: renderer.Colors{color.FgWhite}, 83 | BG: renderer.Colors{color.BgBlue}, 84 | }, 85 | Separator: renderer.Tint{ 86 | FG: renderer.Colors{color.FgWhite}, 87 | BG: renderer.Colors{color.BgBlue}, 88 | }, 89 | })), 90 | tablewriter.WithFooterConfig(tw.CellConfig{ 91 | Alignment: tw.CellAlignment{PerColumn: []tw.Align{tw.AlignRight, tw.AlignCenter}}, 92 | }), 93 | ) 94 | table.Header([]string{"Name", "Age"}) 95 | table.Append([]string{"Alice", "25"}) 96 | table.Footer([]string{"Total", "1"}) 97 | table.Render() 98 | 99 | // Expected colors: Headers (red, yellow on blue), Rows (magenta, blue on black), Footers (yellow on blue), Borders/Separators (white on blue) 100 | expected := ` 101 | ┌───────┬─────┐ 102 | │ NAME │ AGE │ 103 | ├───────┼─────┤ 104 | │ Alice │ 25 │ 105 | ├───────┼─────┤ 106 | │ Total │ 1 │ 107 | └───────┴─────┘ 108 | ` 109 | if !visualCheck(t, "ColorizedCustomColors", buf.String(), expected) { 110 | t.Error(table.Debug()) 111 | } 112 | } 113 | 114 | func TestColorizedLongValues(t *testing.T) { 115 | var buf bytes.Buffer 116 | c := tablewriter.Config{ 117 | Row: tw.CellConfig{ 118 | Formatting: tw.CellFormatting{ 119 | AutoWrap: tw.WrapNormal, 120 | }, 121 | Alignment: tw.CellAlignment{Global: tw.AlignLeft}, 122 | ColMaxWidths: tw.CellWidth{Global: 20}, 123 | }, 124 | } 125 | table := tablewriter.NewTable(&buf, 126 | tablewriter.WithConfig(c), 127 | tablewriter.WithRenderer(renderer.NewColorized()), 128 | ) 129 | table.Header([]string{"No", "Description", "Note"}) 130 | table.Append([]string{"1", "This is a very long description that should wrap", "Short"}) 131 | table.Append([]string{"2", "Short desc", "Another note"}) 132 | table.Render() 133 | 134 | // Expected colors: Headers (white/bold on black), Rows (cyan on black), Borders/Separators (white on black) 135 | expected := ` 136 | ┌────┬──────────────────┬──────────────┐ 137 | │ NO │ DESCRIPTION │ NOTE │ 138 | ├────┼──────────────────┼──────────────┤ 139 | │ 1 │ This is a very │ Short │ 140 | │ │ long description │ │ 141 | │ │ that should wrap │ │ 142 | │ 2 │ Short desc │ Another note │ 143 | └────┴──────────────────┴──────────────┘ 144 | ` 145 | visualCheck(t, "ColorizedLongValues", buf.String(), expected) 146 | } 147 | 148 | func TestColorizedHorizontalMerge(t *testing.T) { 149 | var buf bytes.Buffer 150 | c := tablewriter.Config{ 151 | Header: tw.CellConfig{ 152 | Formatting: tw.CellFormatting{ 153 | MergeMode: tw.MergeHorizontal, 154 | }, 155 | }, 156 | Row: tw.CellConfig{ 157 | Formatting: tw.CellFormatting{ 158 | MergeMode: tw.MergeHorizontal, 159 | }, 160 | }, 161 | } 162 | table := tablewriter.NewTable(&buf, 163 | tablewriter.WithConfig(c), 164 | tablewriter.WithRenderer(renderer.NewColorized()), 165 | ) 166 | table.Header([]string{"Merged", "Merged", "Normal"}) 167 | table.Append([]string{"Same", "Same", "Unique"}) 168 | table.Render() 169 | 170 | // Expected colors: Headers (white/bold on black), Rows (cyan on black), Borders/Separators (white on black) 171 | expected := ` 172 | ┌─────────────────┬────────┐ 173 | │ MERGED │ NORMAL │ 174 | ├─────────────────┼────────┤ 175 | │ Same │ Unique │ 176 | └─────────────────┴────────┘ 177 | ` 178 | if !visualCheck(t, "ColorizedHorizontalMerge", buf.String(), expected) { 179 | t.Error(table.Debug()) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/csv_test.go: -------------------------------------------------------------------------------- 1 | package tests // Use _test package to test as a user 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/olekukonko/tablewriter" 10 | "github.com/olekukonko/tablewriter/renderer" // For direct renderer use if needed 11 | "github.com/olekukonko/tablewriter/tw" 12 | ) 13 | 14 | const csvTestData = `Name,Department,Salary 15 | Alice,Engineering,120000 16 | Bob,Marketing,85000 17 | Charlie,Engineering,135000 18 | Diana,HR,70000 19 | ` 20 | 21 | func getCSVReaderFromString(data string) *csv.Reader { 22 | stringReader := strings.NewReader(data) 23 | return csv.NewReader(stringReader) 24 | } 25 | 26 | func TestTable_Configure_Basic(t *testing.T) { 27 | var buf bytes.Buffer 28 | csvReader := getCSVReaderFromString(csvTestData) 29 | 30 | table, err := tablewriter.NewCSVReader(&buf, csvReader, true) 31 | if err != nil { 32 | t.Fatalf("NewCSVReader failed: %v", err) 33 | } 34 | 35 | // Check initial default config values (examples) 36 | if table.Config().Header.Alignment.Global != tw.AlignCenter { 37 | t.Errorf("Expected initial header alignment to be Center, got %s", table.Config().Header.Alignment.Global) 38 | } 39 | if table.Config().Behavior.TrimSpace != tw.On { // Default from defaultConfig() 40 | t.Errorf("Expected initial TrimSpace to be On, got %s", table.Config().Behavior.TrimSpace) 41 | } 42 | 43 | table.Configure(func(cfg *tablewriter.Config) { 44 | cfg.Header.Alignment.Global = tw.AlignLeft 45 | cfg.Row.Alignment.Global = tw.AlignRight 46 | cfg.Behavior.TrimSpace = tw.Off 47 | cfg.Debug = true // This should enable the logger 48 | }) 49 | 50 | // Check that Table.config was updated 51 | if table.Config().Header.Alignment.Global != tw.AlignLeft { 52 | t.Errorf("Expected configured header alignment to be Left, got %s", table.Config().Header.Alignment.Global) 53 | } 54 | if table.Config().Row.Alignment.Global != tw.AlignRight { 55 | t.Errorf("Expected configured row alignment to be Right, got %s", table.Config().Row.Alignment.Global) 56 | } 57 | if table.Config().Behavior.TrimSpace != tw.Off { 58 | t.Errorf("Expected configured TrimSpace to be Off, got %s", table.Config().Behavior.TrimSpace) 59 | } 60 | if !table.Config().Debug { 61 | t.Errorf("Expected configured Debug to be true") 62 | } 63 | 64 | // Render and check output (visual check will confirm alignment and trimming) 65 | err = table.Render() 66 | if err != nil { 67 | t.Fatalf("Render failed: %v", err) 68 | } 69 | 70 | // What visualCheck will see (assuming Blueprint respects CellContext.Align passed from table.config): 71 | expectedAfterConfigure := ` 72 | ┌─────────┬─────────────┬────────┐ 73 | │ NAME │ DEPARTMENT │ SALARY │ 74 | ├─────────┼─────────────┼────────┤ 75 | │ Alice │ Engineering │ 120000 │ 76 | │ Bob │ Marketing │ 85000 │ 77 | │ Charlie │ Engineering │ 135000 │ 78 | │ Diana │ HR │ 70000 │ 79 | └─────────┴─────────────┴────────┘ 80 | ` 81 | if !visualCheck(t, "TestTable_Configure_Basic", buf.String(), expectedAfterConfigure) { 82 | t.Logf("Debug trace from table:\n%s", table.Debug().String()) 83 | } 84 | } 85 | 86 | func TestTable_Options_WithRendition_Borderless(t *testing.T) { 87 | var buf bytes.Buffer 88 | csvReader := getCSVReaderFromString(csvTestData) // Ensure csvTestData is defined 89 | 90 | table, err := tablewriter.NewCSVReader(&buf, csvReader, true, tablewriter.WithDebug(true)) 91 | if err != nil { 92 | t.Fatalf("NewCSVReader failed: %v", err) 93 | } 94 | 95 | // Initially, it should have default borders 96 | table.Render() 97 | initialOutputWithBorders := buf.String() 98 | buf.Reset() // Clear buffer for next render 99 | 100 | if !strings.Contains(initialOutputWithBorders, "┌───") { // Basic check for default border 101 | t.Errorf("Expected initial render to have borders, but got:\n%s", initialOutputWithBorders) 102 | } 103 | 104 | // Define a TRULY borderless and line-less rendition 105 | borderlessRendition := tw.Rendition{ 106 | Borders: tw.Border{ // Explicitly set all borders to Off 107 | Left: tw.Off, 108 | Right: tw.Off, 109 | Top: tw.Off, 110 | Bottom: tw.Off, 111 | }, 112 | // Using StyleNone for symbols means no visible characters for borders/lines if they were on. 113 | // For a "markdown-like but no lines" look, you might use StyleMarkdown and then turn off lines/separators. 114 | // For true "no visual structure", StyleNone is good. 115 | Symbols: tw.NewSymbols(tw.StyleNone), 116 | Settings: tw.Settings{ 117 | Lines: tw.Lines{ // Explicitly set all line drawing to Off 118 | ShowTop: tw.Off, 119 | ShowBottom: tw.Off, 120 | ShowHeaderLine: tw.Off, 121 | ShowFooterLine: tw.Off, 122 | }, 123 | Separators: tw.Separators{ // Explicitly set all separators to Off 124 | ShowHeader: tw.Off, 125 | ShowFooter: tw.Off, 126 | BetweenRows: tw.Off, 127 | BetweenColumns: tw.Off, 128 | }, 129 | }, 130 | } 131 | 132 | table.Options( 133 | tablewriter.WithRendition(borderlessRendition), 134 | ) 135 | 136 | // Render again 137 | err = table.Render() 138 | if err != nil { 139 | t.Fatalf("Render after WithRendition failed: %v", err) 140 | } 141 | 142 | // Expected output: Plain text, no borders, no lines, no separators. 143 | // Content alignment will be default (Header:Center, Row:Left) because 144 | // Table.config was not modified for alignments in this test. 145 | expectedOutputBorderless := ` 146 | NAME DEPARTMENT SALARY 147 | Alice Engineering 120000 148 | Bob Marketing 85000 149 | Charlie Engineering 135000 150 | Diana HR 70000 151 | ` 152 | if !visualCheck(t, "TestTable_Options_WithRendition_Borderless", buf.String(), expectedOutputBorderless) { 153 | t.Logf("Initial output with borders was:\n%s", initialOutputWithBorders) // For context 154 | t.Logf("Debug trace from table after borderless rendition:\n%s", table.Debug().String()) 155 | } 156 | 157 | // Verify renderer's internal config was changed 158 | if bp, ok := table.Renderer().(*renderer.Blueprint); ok { 159 | currentRendererCfg := bp.Config() 160 | 161 | if currentRendererCfg.Borders.Left.Enabled() { 162 | t.Errorf("Blueprint Borders.Left should be OFF, but is ON") 163 | } 164 | if currentRendererCfg.Borders.Right.Enabled() { 165 | t.Errorf("Blueprint Borders.Right should be OFF, but is ON") 166 | } 167 | if currentRendererCfg.Borders.Top.Enabled() { 168 | t.Errorf("Blueprint Borders.Top should be OFF, but is ON") 169 | } 170 | if currentRendererCfg.Borders.Bottom.Enabled() { 171 | t.Errorf("Blueprint Borders.Bottom should be OFF, but is ON") 172 | } 173 | 174 | if currentRendererCfg.Settings.Lines.ShowHeaderLine.Enabled() { 175 | t.Errorf("Blueprint Settings.Lines.ShowHeaderLine should be OFF, but is ON") 176 | } 177 | if currentRendererCfg.Settings.Lines.ShowTop.Enabled() { 178 | t.Errorf("Blueprint Settings.Lines.ShowTop should be OFF, but is ON") 179 | } 180 | if currentRendererCfg.Settings.Separators.BetweenColumns.Enabled() { 181 | t.Errorf("Blueprint Settings.Separators.BetweenColumns should be OFF, but is ON") 182 | } 183 | 184 | // Check symbols if relevant (StyleNone should have empty symbols) 185 | if currentRendererCfg.Symbols.Column() != "" { 186 | t.Errorf("Blueprint Symbols.Column should be empty for StyleNone, got '%s'", currentRendererCfg.Symbols.Column()) 187 | } 188 | } else { 189 | t.Logf("Renderer is not *renderer.Blueprint, skipping detailed internal config check. Type is %T", table.Renderer()) 190 | } 191 | } 192 | 193 | // Assume csvTestData and getCSVReaderFromString are defined as in previous examples: 194 | const csvTestDataForPartial = `Name,Department,Salary 195 | Alice,Engineering,120000 196 | Bob,Marketing,85000 197 | ` 198 | 199 | func getCSVReaderFromStringForPartial(data string) *csv.Reader { 200 | stringReader := strings.NewReader(data) 201 | return csv.NewReader(stringReader) 202 | } 203 | 204 | // Assume csvTestDataForPartial and getCSVReaderFromStringForPartial are defined 205 | const csvTestDataForPartialUpdate = `Name,Department,Salary 206 | Alice,Engineering,120000 207 | Bob,Marketing,85000 208 | ` 209 | 210 | func getCSVReaderFromStringForPartialUpdate(data string) *csv.Reader { 211 | stringReader := strings.NewReader(data) 212 | return csv.NewReader(stringReader) 213 | } 214 | 215 | func TestTable_Options_WithRendition_PartialUpdate(t *testing.T) { 216 | var buf bytes.Buffer 217 | csvReader := getCSVReaderFromStringForPartialUpdate(csvTestDataForPartialUpdate) 218 | 219 | // 1. Define an explicitly borderless and line-less initial rendition 220 | initiallyAllOffRendition := tw.Rendition{ 221 | Borders: tw.Border{ 222 | Left: tw.Off, 223 | Right: tw.Off, 224 | Top: tw.Off, 225 | Bottom: tw.Off, 226 | }, 227 | Symbols: tw.NewSymbols(tw.StyleNone), // StyleNone should render no visible symbols 228 | Settings: tw.Settings{ 229 | Lines: tw.Lines{ 230 | ShowTop: tw.Off, 231 | ShowBottom: tw.Off, 232 | ShowHeaderLine: tw.Off, 233 | ShowFooterLine: tw.Off, 234 | }, 235 | Separators: tw.Separators{ 236 | ShowHeader: tw.Off, 237 | ShowFooter: tw.Off, 238 | BetweenRows: tw.Off, 239 | BetweenColumns: tw.Off, 240 | }, 241 | }, 242 | } 243 | 244 | // Create table with this explicitly "all off" rendition 245 | table, err := tablewriter.NewCSVReader(&buf, csvReader, true, 246 | tablewriter.WithDebug(true), 247 | tablewriter.WithRenderer(renderer.NewBlueprint(initiallyAllOffRendition)), 248 | ) 249 | if err != nil { 250 | t.Fatalf("NewCSVReader with initial 'all off' rendition failed: %v", err) 251 | } 252 | 253 | // Render to confirm initial state (should be very plain) 254 | table.Render() 255 | outputAfterInitialAllOff := buf.String() 256 | buf.Reset() // Clear buffer for the next render 257 | 258 | // Check the initial plain output (content only, no borders/lines) 259 | expectedInitialPlainOutput := ` 260 | NAME DEPARTMENT SALARY 261 | Alice Engineering 120000 262 | Bob Marketing 85000 263 | ` 264 | if !visualCheck(t, "TestTable_Options_WithRendition_PartialUpdate_InitialState", outputAfterInitialAllOff, expectedInitialPlainOutput) { 265 | t.Errorf("Initial render was not plain as expected.") 266 | t.Logf("Initial 'all off' output was:\n%s", outputAfterInitialAllOff) 267 | } 268 | 269 | partialRenditionUpdate := tw.Rendition{ 270 | Borders: tw.Border{Top: tw.On, Bottom: tw.On}, // Left/Right are 0 (unspecified in this struct literal) 271 | Symbols: tw.NewSymbols(tw.StyleHeavy), 272 | Settings: tw.Settings{ 273 | Lines: tw.Lines{ShowTop: tw.On, ShowBottom: tw.On}, // Enable drawing of these lines 274 | // Separators are zero-value, so they will remain Off from 'initiallyAllOffRendition' 275 | }, 276 | } 277 | 278 | // Apply the partial update using Options 279 | table.Options( 280 | tablewriter.WithRendition(partialRenditionUpdate), 281 | ) 282 | 283 | // Render again 284 | err = table.Render() 285 | if err != nil { 286 | t.Fatalf("Render after partial WithRendition failed: %v", err) 287 | } 288 | outputAfterPartialUpdate := buf.String() 289 | 290 | expectedOutputPartialBorders := ` 291 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 292 | NAME DEPARTMENT SALARY 293 | Alice Engineering 120000 294 | Bob Marketing 85000 295 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 296 | ` 297 | 298 | if !visualCheck(t, "TestTable_Options_WithRendition_PartialUpdate_FinalState", outputAfterPartialUpdate, expectedOutputPartialBorders) { 299 | t.Logf("Initial 'all off' output was:\n%s", outputAfterInitialAllOff) // For context 300 | t.Logf("Debug trace from table after partial update:\n%s", table.Debug().String()) 301 | } 302 | 303 | // 3. Verify the renderer's internal configuration reflects the partial update correctly. 304 | if bp, ok := table.Renderer().(*renderer.Blueprint); ok { 305 | currentRendererCfg := bp.Config() 306 | 307 | if !currentRendererCfg.Borders.Top.Enabled() { 308 | t.Errorf("Blueprint Borders.Top should be ON, but is OFF") 309 | } 310 | if !currentRendererCfg.Borders.Bottom.Enabled() { 311 | t.Errorf("Blueprint Borders.Bottom should be ON, but is OFF") 312 | } 313 | if currentRendererCfg.Borders.Left.Enabled() { 314 | t.Errorf("Blueprint Borders.Left should remain OFF, but is ON") 315 | } 316 | if currentRendererCfg.Borders.Right.Enabled() { 317 | t.Errorf("Blueprint Borders.Right should remain OFF, but is ON") 318 | } 319 | 320 | if currentRendererCfg.Symbols.Row() != "━" { // From StyleHeavy 321 | t.Errorf("Blueprint Symbols.Row is not '━' (Heavy), got '%s'", currentRendererCfg.Symbols.Row()) 322 | } 323 | // Column symbol check might be less relevant if BetweenColumns is Off, but good for completeness. 324 | if currentRendererCfg.Symbols.Column() != "┃" { // From StyleHeavy 325 | t.Errorf("Blueprint Symbols.Column is not '┃' (Heavy), got '%s'", currentRendererCfg.Symbols.Column()) 326 | } 327 | 328 | // Check Settings.Lines 329 | if !currentRendererCfg.Settings.Lines.ShowTop.Enabled() { 330 | t.Errorf("Blueprint Settings.Lines.ShowTop should be ON, but is OFF") 331 | } 332 | if !currentRendererCfg.Settings.Lines.ShowBottom.Enabled() { 333 | t.Errorf("Blueprint Settings.Lines.ShowBottom should be ON, but is OFF") 334 | } 335 | if currentRendererCfg.Settings.Lines.ShowHeaderLine.Enabled() { 336 | t.Errorf("Blueprint Settings.Lines.ShowHeaderLine should remain OFF, but is ON") 337 | } 338 | 339 | // Check Settings.Separators 340 | if currentRendererCfg.Settings.Separators.BetweenColumns.Enabled() { 341 | t.Errorf("Blueprint Settings.Separators.BetweenColumns should remain OFF, but is ON") 342 | } 343 | } else { 344 | t.Logf("Renderer is not *renderer.Blueprint, skipping detailed internal config check. Type is %T", table.Renderer()) 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /tests/feature_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "github.com/olekukonko/tablewriter" 6 | "github.com/olekukonko/tablewriter/renderer" 7 | "github.com/olekukonko/tablewriter/tw" 8 | "testing" 9 | ) 10 | 11 | func TestBug260(t *testing.T) { 12 | var buf bytes.Buffer 13 | 14 | tableRendition := tw.Rendition{ 15 | Borders: tw.BorderNone, 16 | Settings: tw.Settings{ 17 | Separators: tw.Separators{ 18 | ShowHeader: tw.Off, 19 | ShowFooter: tw.Off, 20 | BetweenRows: tw.Off, 21 | BetweenColumns: tw.Off, 22 | }, 23 | }, 24 | Symbols: tw.NewSymbols(tw.StyleNone), 25 | } 26 | 27 | t.Run("Normal", func(t *testing.T) { 28 | buf.Reset() 29 | tableRenderer := renderer.NewBlueprint(tableRendition) 30 | table := tablewriter.NewTable( 31 | &buf, 32 | tablewriter.WithRenderer(tableRenderer), 33 | tablewriter.WithTableMax(120), 34 | tablewriter.WithTrimSpace(tw.Off), 35 | tablewriter.WithDebug(true), 36 | tablewriter.WithPadding(tw.PaddingNone), 37 | ) 38 | 39 | table.Append([]string{"INFO:", 40 | "The original machine had a base-plate of prefabulated aluminite, surmounted by a malleable logarithmic casing in such a way that the two main spurving bearings were in a direct line with the pentametric fan.", 41 | }) 42 | 43 | table.Append("INFO:", 44 | "The original machine had a base-plate of prefabulated aluminite, surmounted by a malleable logarithmic casing in such a way that the two main spurving bearings were in a direct line with the pentametric fan.", 45 | ) 46 | 47 | table.Render() 48 | 49 | expected := ` 50 | INFO:The original machine had a base-plate of prefabulated 51 | aluminite, surmounted by a malleable logarithmic casing in 52 | such a way that the two main spurving bearings were in a 53 | direct line with the pentametric fan. 54 | INFO:The original machine had a base-plate of prefabulated 55 | aluminite, surmounted by a malleable logarithmic casing in 56 | such a way that the two main spurving bearings were in a 57 | direct line with the pentametric fan. 58 | ` 59 | debug := visualCheck(t, "TestBug252-Mixed", buf.String(), expected) 60 | if !debug { 61 | t.Error(table.Debug()) 62 | } 63 | 64 | }) 65 | 66 | t.Run("Mixed", func(t *testing.T) { 67 | buf.Reset() 68 | tableRenderer := renderer.NewBlueprint(tableRendition) 69 | table := tablewriter.NewTable( 70 | &buf, 71 | tablewriter.WithRenderer(tableRenderer), 72 | tablewriter.WithTableMax(120), 73 | tablewriter.WithTrimSpace(tw.Off), 74 | tablewriter.WithDebug(true), 75 | tablewriter.WithPadding(tw.PaddingNone), 76 | ) 77 | 78 | table.Append([]string{"INFO:", 79 | "The original machine had a base-plate of prefabulated aluminite, surmounted by a malleable logarithmic casing in such a way that the two main spurving bearings were in a direct line with the pentametric fan.", 80 | }) 81 | 82 | table.Append("INFO: ", 83 | "The original machine had a base-plate of prefabulated aluminite, surmounted by a malleable logarithmic casing in such a way that the two main spurving bearings were in a direct line with the pentametric fan.", 84 | ) 85 | 86 | table.Render() 87 | 88 | expected := ` 89 | INFO: The original machine had a base-plate of prefabulated 90 | aluminite, surmounted by a malleable logarithmic casing in 91 | such a way that the two main spurving bearings were in a 92 | direct line with the pentametric fan. 93 | INFO: The original machine had a base-plate of prefabulated 94 | aluminite, surmounted by a malleable logarithmic casing in 95 | such a way that the two main spurving bearings were in a 96 | direct line with the pentametric fan. 97 | ` 98 | debug := visualCheck(t, "TestBug252-Mixed", buf.String(), expected) 99 | if !debug { 100 | t.Error(table.Debug()) 101 | } 102 | 103 | }) 104 | 105 | } 106 | 107 | func TestBatchPerColumnWidths(t *testing.T) { 108 | var buf bytes.Buffer 109 | table := tablewriter.NewTable(&buf, tablewriter.WithConfig(tablewriter.Config{ 110 | Widths: tw.CellWidth{ 111 | PerColumn: tw.NewMapper[int, int]().Set(0, 8).Set(1, 10).Set(2, 15), // Total widths: 8, 5, 15 112 | }, 113 | Row: tw.CellConfig{ 114 | Formatting: tw.CellFormatting{ 115 | AutoWrap: tw.WrapTruncate, // Truncate content to fit 116 | }, 117 | }, 118 | }), tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ 119 | Settings: tw.Settings{ 120 | Separators: tw.Separators{ 121 | BetweenColumns: tw.On, // Separator width = 1 122 | }, 123 | }, 124 | }))) 125 | 126 | table.Header([]string{"Name", "Age", "City"}) 127 | table.Append([]string{"Alice Smith", "25", "New York City"}) 128 | table.Append([]string{"Bob Johnson", "30", "Boston"}) 129 | table.Render() 130 | 131 | // Expected widths: 132 | // Col 0: 8 (content=6, pad=1+1, sep=1 for next column) 133 | // Col 1: 5 (content=3, pad=1+1, sep=1 for next column) 134 | // Col 2: 15 (content=13, pad=1+1, no sep at end) 135 | expected := ` 136 | ┌────────┬──────────┬───────────────┐ 137 | │ NAME │ AGE │ CITY │ 138 | ├────────┼──────────┼───────────────┤ 139 | │ Alic… │ 25 │ New York City │ 140 | │ Bob … │ 30 │ Boston │ 141 | └────────┴──────────┴───────────────┘ 142 | 143 | ` 144 | if !visualCheck(t, "BatchPerColumnWidths", buf.String(), expected) { 145 | t.Error(table.Debug()) 146 | } 147 | } 148 | 149 | func TestBatchGlobalWidthScaling(t *testing.T) { 150 | var buf bytes.Buffer 151 | table := tablewriter.NewTable(&buf, tablewriter.WithConfig(tablewriter.Config{ 152 | Widths: tw.CellWidth{ 153 | Global: 20, // Total table width, including padding and separators 154 | }, 155 | Row: tw.CellConfig{ 156 | Formatting: tw.CellFormatting{ 157 | AutoWrap: tw.WrapNormal, 158 | }, 159 | }, 160 | }), tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ 161 | Settings: tw.Settings{ 162 | Separators: tw.Separators{ 163 | BetweenColumns: tw.On, // Separator width = 1 164 | }, 165 | }, 166 | }))) 167 | 168 | table.Header([]string{"Name", "Age", "City"}) 169 | table.Append([]string{"Alice Smith", "25", "New York City"}) 170 | table.Append([]string{"Bob Johnson", "30", "Boston"}) 171 | table.Render() 172 | 173 | // Expected widths: 174 | // Total width = 20, with 2 separators (2x1 = 2) 175 | // Available for columns = 20 - 2 = 18 176 | // 3 columns, so each ~6 (18/3), adjusted for padding and separators 177 | // Col 0: 6 (content=4, pad=1+1, sep=1) 178 | // Col 1: 6 (content=4, pad=1+1, sep=1) 179 | // Col 2: 6 (content=4, pad=1+1) 180 | expected := ` 181 | ┌──────┬─────┬───────┐ 182 | │ NAME │ AGE │ CITY │ 183 | ├──────┼─────┼───────┤ 184 | │ Alic │ 25 │ New Y │ 185 | │ Bob │ 30 │ Bosto │ 186 | └──────┴─────┴───────┘ 187 | ` 188 | if !visualCheck(t, "BatchGlobalWidthScaling", buf.String(), expected) { 189 | t.Error(table.Debug()) 190 | } 191 | } 192 | 193 | func TestBatchWidthsWithHorizontalMerge(t *testing.T) { 194 | var buf bytes.Buffer 195 | table := tablewriter.NewTable(&buf, tablewriter.WithConfig(tablewriter.Config{ 196 | Widths: tw.CellWidth{ 197 | PerColumn: tw.NewMapper[int, int]().Set(0, 10).Set(1, 8).Set(2, 8), // Total widths: 10, 8, 8 198 | }, 199 | Row: tw.CellConfig{ 200 | Formatting: tw.CellFormatting{ 201 | MergeMode: tw.MergeHorizontal, 202 | AutoWrap: tw.WrapTruncate, 203 | }, 204 | }, 205 | }), tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ 206 | Settings: tw.Settings{ 207 | Separators: tw.Separators{ 208 | BetweenColumns: tw.On, // Separator width = 1 209 | BetweenRows: tw.On, 210 | }, 211 | }, 212 | }))) 213 | 214 | table.Header([]string{"Name", "Status", "Status"}) 215 | table.Append([]string{"Alice", "Active", "Active"}) // Should merge Status columns 216 | table.Append([]string{"Bob", "Inactive", "Pending"}) // No merge 217 | table.Render() 218 | 219 | // Expected widths: 220 | // Col 0: 10 (content=8, pad=1+1, sep=1) 221 | // Col 1: 8 (content=6, pad=1+1, sep=1) 222 | // Col 2: 8 (content=6, pad=1+1) 223 | // Merged Col 1+2: 8 + 8 - 1 (no separator between) = 15 (content=13, pad=1+1) 224 | expected := ` 225 | ┌──────────┬────────┬────────┐ 226 | │ NAME │ STATUS │ STATUS │ 227 | ├──────────┼────────┴────────┤ 228 | │ Alice │ Active │ 229 | ├──────────┼────────┬────────┤ 230 | │ Bob │ Inac… │ Pend… │ 231 | └──────────┴────────┴────────┘ 232 | ` 233 | if !visualCheck(t, "BatchWidthsWithHorizontalMerge", buf.String(), expected) { 234 | t.Error(table.Debug()) 235 | } 236 | } 237 | 238 | func TestWrapBreakWithConstrainedWidthsNoRightPadding(t *testing.T) { 239 | var buf bytes.Buffer 240 | table := tablewriter.NewTable(&buf, 241 | tablewriter.WithTrimSpace(tw.Off), 242 | tablewriter.WithHeaderAutoFormat(tw.Off), 243 | tablewriter.WithConfig(tablewriter.Config{ 244 | Widths: tw.CellWidth{ 245 | PerColumn: tw.NewMapper[int, int]().Set(0, 4).Set(1, 4).Set(2, 6).Set(3, 7), 246 | }, 247 | Row: tw.CellConfig{ 248 | Formatting: tw.CellFormatting{ 249 | AutoWrap: tw.WrapBreak, 250 | }, 251 | Padding: tw.CellPadding{ 252 | Global: tw.PaddingNone, 253 | }, 254 | }, 255 | }), 256 | ) 257 | 258 | headers := []string{"a", "b", "c", "d"} 259 | table.Header(headers) 260 | 261 | data := [][]string{ 262 | {"aa", "bb", "cc", "dd"}, 263 | {"aaa", "bbb", "ccc", "ddd"}, 264 | {"aaaa", "bbbb", "cccc", "dddd"}, 265 | {"aaaaa", "bbbbb", "ccccc", "ddddd"}, 266 | } 267 | table.Bulk(data) 268 | 269 | table.Render() 270 | 271 | expected := ` 272 | ┌────┬────┬──────┬───────┐ 273 | │ A │ B │ C │ D │ 274 | ├────┼────┼──────┼───────┤ 275 | │aa │bb │cc │dd │ 276 | │aaa │bbb │ccc │ddd │ 277 | │aaaa│bbbb│cccc │dddd │ 278 | │aaa↩│bbb↩│ccccc │ddddd │ 279 | │aa │bb │ │ │ 280 | └────┴────┴──────┴───────┘ 281 | ` 282 | if !visualCheck(t, "WrapBreakWithConstrainedWidthsNoRightPadding", buf.String(), expected) { 283 | t.Error(table.Debug()) 284 | } 285 | } 286 | 287 | func TestCompatMode(t *testing.T) { 288 | var buf bytes.Buffer 289 | table := tablewriter.NewTable(&buf, tablewriter.WithConfig(tablewriter.Config{ 290 | Header: tw.CellConfig{Formatting: tw.CellFormatting{MergeMode: tw.MergeHorizontal}}, 291 | Behavior: tw.Behavior{Compact: tw.Compact{Merge: tw.On}}, 292 | Debug: true, 293 | })) 294 | 295 | x := "This is a long header that makes the table look too wide" 296 | table.Header([]string{x, x}) 297 | table.Append([]string{"Key", "Value"}) 298 | table.Render() 299 | 300 | expected := ` 301 | ┌──────────────────────────────────────────────────────────┐ 302 | │ THIS IS A LONG HEADER THAT MAKES THE TABLE LOOK TOO WIDE │ 303 | ├────────────────────────────┬─────────────────────────────┤ 304 | │ Key │ Value │ 305 | └────────────────────────────┴─────────────────────────────┘ 306 | ` 307 | if !visualCheck(t, "TestCompatMode", buf.String(), expected) { 308 | t.Error(table.Debug()) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /tests/fn.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | "testing" 10 | "unicode" 11 | ) 12 | 13 | // mismatch represents a discrepancy between expected and actual output lines in a test. 14 | type mismatch struct { 15 | Line int `json:"line"` // Line number (1-based) 16 | Expected string `json:"expected"` // Expected line content and length 17 | Got string `json:"got"` // Actual line content and length 18 | } 19 | 20 | // MaskEmail masks email addresses in a slice of strings, replacing all but the first character of the local part with asterisks. 21 | func MaskEmail(cells []string) []string { 22 | for i, cell := range cells { 23 | if strings.Contains(cell, "@") { 24 | parts := strings.Split(cell, "@") 25 | if len(parts) == 2 { 26 | masked := parts[0][:1] + strings.Repeat("*", len(parts[0])-1) + "@" + parts[1] 27 | cells[i] = masked 28 | } 29 | } 30 | } 31 | return cells 32 | } 33 | 34 | // MaskPassword masks strings that resemble passwords (containing "pass" or 8+ characters) with asterisks. 35 | func MaskPassword(cells []string) []string { 36 | for i, cell := range cells { 37 | if len(cell) > 0 && (strings.Contains(strings.ToLower(cell), "pass") || len(cell) >= 8) { 38 | cells[i] = strings.Repeat("*", len(cell)) 39 | } 40 | } 41 | return cells 42 | } 43 | 44 | // MaskCard masks credit card-like numbers, keeping only the last four digits visible. 45 | func MaskCard(cells []string) []string { 46 | for i, cell := range cells { 47 | // Check for card-like numbers (12+ digits, with or without dashes/spaces) 48 | if len(cell) >= 12 && (strings.Contains(cell, "-") || len(strings.ReplaceAll(cell, " ", "")) >= 12) { 49 | parts := strings.FieldsFunc(cell, func(r rune) bool { return r == '-' || r == ' ' }) 50 | masked := "" 51 | for j, part := range parts { 52 | if j < len(parts)-1 { 53 | masked += strings.Repeat("*", len(part)) 54 | } else { 55 | masked += part // Keep last 4 digits visible 56 | } 57 | if j < len(parts)-1 { 58 | masked += "-" 59 | } 60 | } 61 | cells[i] = masked 62 | } 63 | } 64 | return cells 65 | } 66 | 67 | // visualCheck compares rendered output against expected lines, reporting mismatches in a test. 68 | // It normalizes line endings, strips ANSI colors, and trims empty lines before comparison. 69 | func visualCheck(t *testing.T, name string, output string, expected string) bool { 70 | t.Helper() 71 | 72 | // Normalize line endings and split into lines 73 | normalize := func(s string) []string { 74 | s = strings.ReplaceAll(s, "\r\n", "\n") 75 | s = StripColors(s) 76 | return strings.Split(s, "\n") 77 | } 78 | 79 | expectedLines := normalize(expected) 80 | outputLines := normalize(output) 81 | 82 | // Trim empty lines from start and end 83 | trimEmpty := func(lines []string) []string { 84 | start, end := 0, len(lines) 85 | for start < end && strings.TrimSpace(lines[start]) == "" { 86 | start++ 87 | } 88 | for end > start && strings.TrimSpace(lines[end-1]) == "" { 89 | end-- 90 | } 91 | return lines[start:end] 92 | } 93 | 94 | expectedLines = trimEmpty(expectedLines) 95 | outputLines = trimEmpty(outputLines) 96 | 97 | // Check line counts 98 | if len(outputLines) != len(expectedLines) { 99 | ex := strings.Join(expectedLines, "\n") 100 | ot := strings.Join(outputLines, "\n") 101 | t.Errorf("%s: line count mismatch - expected %d, got %d", name, len(expectedLines), len(outputLines)) 102 | t.Errorf("Expected:\n%s\n", ex) 103 | t.Errorf("Got:\n%s\n", ot) 104 | return false 105 | } 106 | 107 | var mismatches []mismatch 108 | for i := 0; i < len(expectedLines) && i < len(outputLines); i++ { 109 | exp := strings.TrimSpace(expectedLines[i]) 110 | got := strings.TrimSpace(outputLines[i]) 111 | if exp != got { 112 | mismatches = append(mismatches, mismatch{ 113 | Line: i + 1, 114 | Expected: fmt.Sprintf("%s (%d)", exp, len(exp)), 115 | Got: fmt.Sprintf("%s (%d)", got, len(got)), 116 | }) 117 | } 118 | } 119 | 120 | // Report mismatches 121 | if len(mismatches) > 0 { 122 | diff, _ := json.MarshalIndent(mismatches, "", " ") 123 | t.Errorf("%s: %d mismatches found:\n%s", name, len(mismatches), diff) 124 | t.Errorf("Full expected output:\n%s", expected) 125 | t.Errorf("Full actual output:\n%s", output) 126 | return false 127 | } 128 | 129 | return true 130 | } 131 | 132 | // visualCheckHTML compares rendered HTML output against expected lines, 133 | // trimming whitespace per line and ignoring blank lines. 134 | func visualCheckHTML(t *testing.T, name string, output string, expected string) bool { 135 | t.Helper() 136 | 137 | normalizeHTML := func(s string) []string { 138 | s = strings.ReplaceAll(s, "\r\n", "\n") // Normalize line endings 139 | lines := strings.Split(s, "\n") 140 | trimmedLines := make([]string, 0, len(lines)) 141 | for _, line := range lines { 142 | trimmed := strings.TrimSpace(line) 143 | if trimmed != "" { // Only keep non-blank lines 144 | trimmedLines = append(trimmedLines, trimmed) 145 | } 146 | } 147 | return trimmedLines 148 | } 149 | 150 | expectedLines := normalizeHTML(expected) 151 | outputLines := normalizeHTML(output) 152 | 153 | // Compare line counts 154 | if len(outputLines) != len(expectedLines) { 155 | t.Errorf("%s: line count mismatch - expected %d, got %d", name, len(expectedLines), len(outputLines)) 156 | t.Errorf("Expected (trimmed):\n%s", strings.Join(expectedLines, "\n")) 157 | t.Errorf("Got (trimmed):\n%s", strings.Join(outputLines, "\n")) 158 | // Optionally print full untrimmed for debugging exact whitespace 159 | // t.Errorf("Full Expected:\n%s", expected) 160 | // t.Errorf("Full Got:\n%s", output) 161 | return false 162 | } 163 | 164 | // Compare each line 165 | mismatches := []mismatch{} // Use mismatch struct from fn.go 166 | for i := 0; i < len(expectedLines); i++ { 167 | if expectedLines[i] != outputLines[i] { 168 | mismatches = append(mismatches, mismatch{ 169 | Line: i + 1, 170 | Expected: expectedLines[i], 171 | Got: outputLines[i], 172 | }) 173 | } 174 | } 175 | 176 | if len(mismatches) > 0 { 177 | t.Errorf("%s: %d mismatches found:", name, len(mismatches)) 178 | for _, mm := range mismatches { 179 | t.Errorf(" Line %d:\n Expected: %s\n Got: %s", mm.Line, mm.Expected, mm.Got) 180 | } 181 | // Optionally print full outputs again on mismatch 182 | // t.Errorf("Full Expected:\n%s", expected) 183 | // t.Errorf("Full Got:\n%s", output) 184 | return false 185 | } 186 | 187 | return true 188 | } 189 | 190 | // ansiColorRegex matches ANSI color escape sequences. 191 | var ansiColorRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) 192 | 193 | // StripColors removes ANSI color codes from a string. 194 | func StripColors(s string) string { 195 | return ansiColorRegex.ReplaceAllString(s, "") 196 | } 197 | 198 | // Regex to remove leading/trailing whitespace from lines AND blank lines for HTML comparison 199 | var htmlWhitespaceRegex = regexp.MustCompile(`(?m)^\s+|\s+$`) 200 | var blankLineRegex = regexp.MustCompile(`(?m)^\s*\n`) 201 | 202 | func normalizeHTMLStrict(s string) string { 203 | s = strings.ReplaceAll(s, "\r\n", "\n") 204 | s = strings.ReplaceAll(s, "\n", "") // Remove all newlines 205 | s = strings.ReplaceAll(s, "\t", "") // Remove tabs 206 | 207 | // Remove spaces after > and before <, effectively compacting tags 208 | s = regexp.MustCompile(`>\s+`).ReplaceAllString(s, ">") 209 | s = regexp.MustCompile(`\s+<`).ReplaceAllString(s, "<") 210 | 211 | // Trim overall leading/trailing space that might be left. 212 | return strings.TrimSpace(s) 213 | } 214 | 215 | // visualCheckCaption (helper function, potentially shared or adapted from your existing visualCheck) 216 | // Ensure this helper normalizes expected and got strings for reliable comparison 217 | // (e.g., trim spaces from each line, normalize newlines) 218 | func visualCheckCaption(t *testing.T, testName, got, expected string) bool { 219 | t.Helper() 220 | normalize := func(s string) string { 221 | s = strings.ReplaceAll(s, "\r\n", "\n") // Normalize newlines 222 | lines := strings.Split(s, "\n") 223 | var trimmedLines []string 224 | for _, l := range lines { 225 | trimmedLines = append(trimmedLines, strings.TrimSpace(l)) 226 | } 227 | // Join, then trim overall to handle cases where expected might have leading/trailing blank lines 228 | // but individual lines should keep their relative structure. 229 | return strings.TrimSpace(strings.Join(trimmedLines, "\n")) 230 | } 231 | 232 | gotNormalized := normalize(got) 233 | expectedNormalized := normalize(expected) 234 | 235 | if gotNormalized != expectedNormalized { 236 | // Use a more detailed diff output if available, or just print both. 237 | t.Errorf("%s: outputs do not match.\nExpected:\n```\n%s\n```\nGot:\n```\n%s\n```\n---Diff---\n%s", 238 | testName, expected, got, getDiff(expectedNormalized, gotNormalized)) // You might need a diff utility 239 | return false 240 | } 241 | return true 242 | } 243 | 244 | // A simple diff helper (replace with a proper library if needed) 245 | func getDiff(expected, actual string) string { 246 | expectedLines := strings.Split(expected, "\n") 247 | actualLines := strings.Split(actual, "\n") 248 | maxLen := len(expectedLines) 249 | if len(actualLines) > maxLen { 250 | maxLen = len(actualLines) 251 | } 252 | var diff strings.Builder 253 | diff.WriteString("Line | Expected | Actual\n") 254 | diff.WriteString("-----|----------------------------------|----------------------------------\n") 255 | for i := 0; i < maxLen; i++ { 256 | eLine := "" 257 | if i < len(expectedLines) { 258 | eLine = expectedLines[i] 259 | } 260 | aLine := "" 261 | if i < len(actualLines) { 262 | aLine = actualLines[i] 263 | } 264 | marker := " " 265 | if eLine != aLine { 266 | marker = "!" 267 | } 268 | diff.WriteString(fmt.Sprintf("%4d %s| %-32s | %-32s\n", i+1, marker, eLine, aLine)) 269 | } 270 | return diff.String() 271 | } 272 | 273 | func getLastContentLine(buf *bytes.Buffer) string { 274 | content := buf.String() 275 | lines := strings.Split(content, "\n") 276 | 277 | // Search backwards for first non-border, non-empty line 278 | for i := len(lines) - 1; i >= 0; i-- { 279 | line := strings.TrimSpace(lines[i]) 280 | if line == "" || strings.Contains(line, "─") || 281 | strings.Contains(line, "┌") || strings.Contains(line, "└") { 282 | continue 283 | } 284 | return line 285 | } 286 | return "" 287 | } 288 | 289 | type Name struct { 290 | First string 291 | Last string 292 | } 293 | 294 | // this will be ignored since Format() is present 295 | func (n Name) String() string { 296 | return fmt.Sprintf("%s %s", n.First, n.Last) 297 | } 298 | 299 | // Note: Format() overrides String() if both exist. 300 | func (n Name) Format() string { 301 | return fmt.Sprintf("%s %s", clean(n.First), clean(n.Last)) 302 | } 303 | 304 | // clean ensures the first letter is capitalized and the rest are lowercase 305 | func clean(s string) string { 306 | s = strings.TrimSpace(strings.ToLower(s)) 307 | words := strings.Fields(s) 308 | s = strings.Join(words, "") 309 | 310 | if s == "" { 311 | return s 312 | } 313 | // Capitalize the first letter 314 | runes := []rune(s) 315 | runes[0] = unicode.ToUpper(runes[0]) 316 | return string(runes) 317 | } 318 | -------------------------------------------------------------------------------- /tests/markdown_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/olekukonko/tablewriter" 8 | "github.com/olekukonko/tablewriter/renderer" 9 | "github.com/olekukonko/tablewriter/tw" 10 | ) 11 | 12 | func TestMarkdownBasicTable(t *testing.T) { 13 | var buf bytes.Buffer 14 | table := tablewriter.NewTable(&buf, 15 | tablewriter.WithRenderer(renderer.NewMarkdown()), 16 | ) 17 | table.Header([]string{"Name", "Age", "City"}) 18 | table.Append([]string{"Alice", "25", "New York"}) 19 | table.Append([]string{"Bob", "30", "Boston"}) 20 | table.Render() 21 | 22 | expected := ` 23 | | NAME | AGE | CITY | 24 | |:-----:|:---:|:--------:| 25 | | Alice | 25 | New York | 26 | | Bob | 30 | Boston | 27 | ` 28 | if !visualCheck(t, "MarkdownBasicTable", buf.String(), expected) { 29 | t.Error(table.Debug()) 30 | } 31 | } 32 | 33 | func TestMarkdownAlignment(t *testing.T) { 34 | var buf bytes.Buffer 35 | table := tablewriter.NewTable(&buf, 36 | tablewriter.WithRenderer(renderer.NewMarkdown()), 37 | tablewriter.WithConfig(tablewriter.Config{Header: tw.CellConfig{ 38 | Alignment: tw.CellAlignment{PerColumn: []tw.Align{tw.AlignLeft, tw.AlignRight, tw.AlignRight, tw.AlignCenter}}}, 39 | }), 40 | ) 41 | table.Header([]string{"Name", "Age", "City", "Status"}) 42 | table.Append([]string{"Alice", "25", "New York", "OK"}) 43 | table.Append([]string{"Bob", "30", "Boston", "ERROR"}) 44 | table.Render() 45 | 46 | expected := ` 47 | | NAME | AGE | CITY | STATUS | 48 | |:------|----:|---------:|:------:| 49 | | Alice | 25 | New York | OK | 50 | | Bob | 30 | Boston | ERROR | 51 | ` 52 | if !visualCheck(t, "MarkdownBasicTable", buf.String(), expected) { 53 | t.Error(table.Debug()) 54 | } 55 | } 56 | 57 | func TestMarkdownNoBorders(t *testing.T) { 58 | var buf bytes.Buffer 59 | table := tablewriter.NewTable(&buf, 60 | tablewriter.WithRenderer(renderer.NewMarkdown(tw.Rendition{ 61 | Borders: tw.Border{Left: tw.Off, Right: tw.Off, Top: tw.Off, Bottom: tw.Off}, 62 | })), 63 | tablewriter.WithConfig(tablewriter.Config{Header: tw.CellConfig{ 64 | Alignment: tw.CellAlignment{PerColumn: []tw.Align{tw.AlignLeft}}}, 65 | }), 66 | ) 67 | 68 | table.Header([]string{"Name", "Age", "City"}) 69 | table.Append([]string{"Alice", "25", "New York"}) 70 | table.Append([]string{"Bob", "30", "Boston"}) 71 | table.Render() 72 | 73 | expected := ` 74 | NAME | AGE | CITY 75 | :------|:---:|:--------: 76 | Alice | 25 | New York 77 | Bob | 30 | Boston 78 | ` 79 | visualCheck(t, "MarkdownNoBorders", buf.String(), expected) 80 | } 81 | 82 | func TestMarkdownUnicode(t *testing.T) { 83 | var buf bytes.Buffer 84 | table := tablewriter.NewTable(&buf, 85 | tablewriter.WithRenderer(renderer.NewMarkdown()), 86 | ) 87 | table.Header([]string{"Name", "Age", "City"}) 88 | table.Append([]string{"Bøb", "30", "Tōkyō"}) 89 | table.Append([]string{"José", "28", "México"}) 90 | table.Append([]string{"张三", "35", "北京"}) 91 | table.Render() 92 | 93 | expected := ` 94 | | NAME | AGE | CITY | 95 | |:----:|:---:|:------:| 96 | | Bøb | 30 | Tōkyō | 97 | | José | 28 | México | 98 | | 张三 | 35 | 北京 | 99 | ` 100 | visualCheck(t, "MarkdownUnicode", buf.String(), expected) 101 | } 102 | 103 | func TestMarkdownLongHeaders(t *testing.T) { 104 | var buf bytes.Buffer 105 | c := tablewriter.Config{ 106 | Header: tw.CellConfig{ 107 | Formatting: tw.CellFormatting{ 108 | AutoWrap: tw.WrapTruncate, 109 | }, 110 | ColMaxWidths: tw.CellWidth{Global: 20}, 111 | }, 112 | } 113 | table := tablewriter.NewTable(&buf, 114 | tablewriter.WithConfig(c), 115 | tablewriter.WithRenderer(renderer.NewMarkdown()), 116 | tablewriter.WithAlignment(tw.MakeAlign(3, tw.AlignLeft)), 117 | ) 118 | table.Header([]string{"Name", "Age", "Very Long Header That Needs Truncation"}) 119 | table.Append([]string{"Alice", "25", "New York"}) 120 | table.Append([]string{"Bob", "30", "Boston"}) 121 | table.Render() 122 | 123 | expected := ` 124 | | NAME | AGE | VERY LONG HEADER… | 125 | |:------|:----|:------------------| 126 | | Alice | 25 | New York | 127 | | Bob | 30 | Boston | 128 | ` 129 | visualCheck(t, "MarkdownLongHeaders", buf.String(), expected) 130 | } 131 | 132 | func TestMarkdownLongValues(t *testing.T) { 133 | var buf bytes.Buffer 134 | c := tablewriter.Config{ 135 | Row: tw.CellConfig{ 136 | Formatting: tw.CellFormatting{ 137 | AutoWrap: tw.WrapNormal, 138 | }, 139 | Alignment: tw.CellAlignment{Global: tw.AlignLeft}, 140 | ColMaxWidths: tw.CellWidth{Global: 20}, 141 | }, 142 | } 143 | table := tablewriter.NewTable(&buf, 144 | tablewriter.WithConfig(c), 145 | tablewriter.WithRenderer(renderer.NewMarkdown()), 146 | tablewriter.WithAlignment(tw.MakeAlign(3, tw.AlignLeft)), 147 | ) 148 | table.Header([]string{"No", "Description", "Note"}) 149 | table.Append([]string{"1", "This is a very long description that should wrap", "Short"}) 150 | table.Append([]string{"2", "Short desc", "Another note"}) 151 | table.Render() 152 | 153 | expected := ` 154 | | NO | DESCRIPTION | NOTE | 155 | |:---|:-----------------|:-------------| 156 | | 1 | This is a very | Short | 157 | | | long description | | 158 | | | that should wrap | | 159 | | 2 | Short desc | Another note | 160 | ` 161 | visualCheck(t, "MarkdownLongValues", buf.String(), expected) 162 | } 163 | 164 | func TestMarkdownCustomPadding(t *testing.T) { 165 | var buf bytes.Buffer 166 | c := tablewriter.Config{ 167 | Header: tw.CellConfig{ 168 | Padding: tw.CellPadding{ 169 | Global: tw.Padding{Left: "*", Right: "*", Top: "", Bottom: ""}, 170 | }, 171 | }, 172 | Row: tw.CellConfig{ 173 | Padding: tw.CellPadding{ 174 | Global: tw.Padding{Left: ">", Right: "<", Top: "", Bottom: ""}, 175 | }, 176 | }, 177 | } 178 | table := tablewriter.NewTable(&buf, 179 | tablewriter.WithConfig(c), 180 | tablewriter.WithRenderer(renderer.NewMarkdown()), 181 | ) 182 | table.Header([]string{"Name", "Age", "City"}) 183 | table.Append([]string{"Alice", "25", "New York"}) 184 | table.Append([]string{"Bob", "30", "Boston"}) 185 | table.Render() 186 | 187 | expected := ` 188 | |*NAME**|*AGE*|***CITY***| 189 | |:-----:|:---:|:--------:| 190 | |>Alice<|>25<<|>New York<| 191 | |>>Bob<<|>30<<|>>Boston<<| 192 | ` 193 | visualCheck(t, "MarkdownCustomPadding", buf.String(), expected) 194 | } 195 | 196 | func TestMarkdownHorizontalMerge(t *testing.T) { 197 | var buf bytes.Buffer 198 | c := tablewriter.Config{ 199 | Header: tw.CellConfig{ 200 | Formatting: tw.CellFormatting{ 201 | MergeMode: tw.MergeHorizontal, 202 | }, 203 | }, 204 | Row: tw.CellConfig{ 205 | Formatting: tw.CellFormatting{ 206 | MergeMode: tw.MergeHorizontal, 207 | }, 208 | }, 209 | } 210 | table := tablewriter.NewTable(&buf, 211 | tablewriter.WithConfig(c), 212 | tablewriter.WithRenderer(renderer.NewMarkdown()), 213 | ) 214 | table.Header([]string{"Merged", "Merged", "Normal"}) 215 | table.Append([]string{"Same", "Same", "Unique"}) 216 | table.Render() 217 | 218 | expected := ` 219 | | MERGED | NORMAL | 220 | |:---------------:|:------:| 221 | | Same | Unique | 222 | ` 223 | visualCheck(t, "MarkdownHorizontalMerge", buf.String(), expected) 224 | } 225 | 226 | func TestMarkdownEmptyTable(t *testing.T) { 227 | var buf bytes.Buffer 228 | table := tablewriter.NewTable(&buf, 229 | tablewriter.WithRenderer(renderer.NewMarkdown()), 230 | ) 231 | table.Render() 232 | 233 | expected := "" 234 | visualCheck(t, "MarkdownEmptyTable", buf.String(), expected) 235 | } 236 | 237 | func TestMarkdownWithFooter(t *testing.T) { 238 | var buf bytes.Buffer 239 | c := tablewriter.Config{ 240 | Footer: tw.CellConfig{ 241 | Alignment: tw.CellAlignment{Global: tw.AlignRight}, 242 | }, 243 | } 244 | table := tablewriter.NewTable(&buf, 245 | tablewriter.WithConfig(c), 246 | tablewriter.WithRenderer(renderer.NewMarkdown()), 247 | ) 248 | table.Header([]string{"Name", "Age", "City"}) 249 | table.Append([]string{"Alice", "25", "New York"}) 250 | table.Append([]string{"Bob", "30", "Boston"}) 251 | table.Footer([]string{"Total", "2", ""}) 252 | table.Render() 253 | 254 | expected := ` 255 | | NAME | AGE | CITY | 256 | |:-----:|:---:|:--------:| 257 | | Alice | 25 | New York | 258 | | Bob | 30 | Boston | 259 | | Total | 2 | | 260 | ` 261 | visualCheck(t, "MarkdownWithFooter", buf.String(), expected) 262 | } 263 | 264 | func TestMarkdownAlignmentNone(t *testing.T) { 265 | t.Run("AlignNone", func(t *testing.T) { 266 | var buf bytes.Buffer 267 | table := tablewriter.NewTable(&buf, tablewriter.WithRenderer(renderer.NewMarkdown())) 268 | table.Configure(func(cfg *tablewriter.Config) { 269 | cfg.Header.Alignment.PerColumn = []tw.Align{tw.AlignNone} 270 | cfg.Row.Alignment.PerColumn = []tw.Align{tw.AlignNone} 271 | cfg.Debug = true 272 | }) 273 | table.Header([]string{"Header"}) 274 | table.Append([]string{"Data"}) 275 | table.Render() 276 | 277 | expected := ` 278 | | HEADER | 279 | |--------| 280 | | Data | 281 | 282 | 283 | ` 284 | if !visualCheck(t, "AlignNone", buf.String(), expected) { 285 | t.Fatal(table.Debug()) 286 | } 287 | }) 288 | } 289 | -------------------------------------------------------------------------------- /tests/struct_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/olekukonko/tablewriter/renderer" 8 | "github.com/olekukonko/tablewriter/tw" 9 | "testing" 10 | ) 11 | 12 | // Employee represents a struct for employee data, simulating a database record. 13 | type Employee struct { 14 | ID int 15 | Name string 16 | Age int 17 | Department string 18 | Salary float64 19 | } 20 | 21 | // dummyDatabase simulates a database with employee records. 22 | type dummyDatabase struct { 23 | records []Employee 24 | } 25 | 26 | // fetchEmployees simulates fetching data from a database. 27 | func (db *dummyDatabase) fetchEmployees() []Employee { 28 | return db.records 29 | } 30 | 31 | // employeeStringer converts an Employee struct to a slice of strings for table rendering. 32 | func employeeStringer(e interface{}) []string { 33 | emp, ok := e.(Employee) 34 | if !ok { 35 | return []string{"Error: Invalid type"} 36 | } 37 | return []string{ 38 | fmt.Sprintf("%d", emp.ID), 39 | emp.Name, 40 | fmt.Sprintf("%d", emp.Age), 41 | emp.Department, 42 | fmt.Sprintf("%.2f", emp.Salary), 43 | } 44 | } 45 | 46 | // TestStructTableWithDB tests rendering a table from struct data fetched from a dummy database. 47 | func TestStructTableWithDB(t *testing.T) { 48 | // Initialize dummy database with sample data 49 | db := &dummyDatabase{ 50 | records: []Employee{ 51 | {ID: 1, Name: "Alice Smith", Age: 28, Department: "Engineering", Salary: 75000.50}, 52 | {ID: 2, Name: "Bob Johnson", Age: 34, Department: "Marketing", Salary: 62000.00}, 53 | {ID: 3, Name: "Charlie Brown", Age: 45, Department: "HR", Salary: 80000.75}, 54 | }, 55 | } 56 | 57 | // Configure table with custom settings 58 | config := tablewriter.Config{ 59 | Header: tw.CellConfig{ 60 | Formatting: tw.CellFormatting{ 61 | AutoFormat: tw.On, 62 | }, 63 | Alignment: tw.CellAlignment{Global: tw.AlignCenter}, 64 | }, 65 | Row: tw.CellConfig{ 66 | Alignment: tw.CellAlignment{Global: tw.AlignLeft}, 67 | }, 68 | Footer: tw.CellConfig{ 69 | Alignment: tw.CellAlignment{Global: tw.AlignRight}, 70 | }, 71 | } 72 | 73 | // Create table with buffer and custom renderer 74 | var buf bytes.Buffer 75 | table := tablewriter.NewTable(&buf, 76 | tablewriter.WithConfig(config), 77 | tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ 78 | Symbols: tw.NewSymbols(tw.StyleRounded), // Use rounded Unicode style 79 | Settings: tw.Settings{ 80 | Separators: tw.Separators{ 81 | BetweenColumns: tw.On, 82 | BetweenRows: tw.Off, 83 | }, 84 | Lines: tw.Lines{ 85 | ShowHeaderLine: tw.On, 86 | }, 87 | }, 88 | })), 89 | tablewriter.WithStringer(employeeStringer), 90 | ) 91 | 92 | // Set the stringer for converting Employee structs 93 | 94 | // Set header 95 | table.Header([]string{"ID", "Name", "Age", "Department", "Salary"}) 96 | 97 | // Fetch data from "database" and append to table 98 | employees := db.fetchEmployees() 99 | for _, emp := range employees { 100 | if err := table.Append(emp); err != nil { 101 | t.Fatalf("Failed to append employee: %v", err) 102 | } 103 | } 104 | 105 | // Add a footer with a total salary 106 | totalSalary := 0.0 107 | for _, emp := range employees { 108 | totalSalary += emp.Salary 109 | } 110 | table.Footer([]string{"", "", "", "Total", fmt.Sprintf("%.2f", totalSalary)}) 111 | 112 | // Render the table 113 | if err := table.Render(); err != nil { 114 | t.Fatalf("Failed to render table: %v", err) 115 | } 116 | 117 | // Expected output 118 | expected := ` 119 | ╭────┬───────────────┬─────┬─────────────┬───────────╮ 120 | │ ID │ NAME │ AGE │ DEPARTMENT │ SALARY │ 121 | ├────┼───────────────┼─────┼─────────────┼───────────┤ 122 | │ 1 │ Alice Smith │ 28 │ Engineering │ 75000.50 │ 123 | │ 2 │ Bob Johnson │ 34 │ Marketing │ 62000.00 │ 124 | │ 3 │ Charlie Brown │ 45 │ HR │ 80000.75 │ 125 | ├────┼───────────────┼─────┼─────────────┼───────────┤ 126 | │ │ │ │ Total │ 217001.25 │ 127 | ╰────┴───────────────┴─────┴─────────────┴───────────╯ 128 | ` 129 | 130 | // Visual check 131 | if !visualCheck(t, "StructTableWithDB", buf.String(), expected) { 132 | t.Log(table.Debug()) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/table_bench_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "github.com/olekukonko/tablewriter" 5 | "github.com/olekukonko/tablewriter/renderer" 6 | "github.com/olekukonko/tablewriter/tw" 7 | "io" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | type Country string 13 | 14 | func (c Country) String() string { return strings.ToUpper(string(c)) } 15 | 16 | func BenchmarkBlueprint(b *testing.B) { 17 | table := tablewriter.NewTable(io.Discard, tablewriter.WithRenderer(renderer.NewBlueprint())) 18 | table.Header([]string{"Name", "Age", "City"}) 19 | for i := 0; i < b.N; i++ { 20 | table.Append([]any{"Alice", Age(25), Country("New York")}) 21 | table.Append([]string{"Bob", "30", "Boston"}) 22 | table.Render() 23 | } 24 | } 25 | 26 | func BenchmarkOcean(b *testing.B) { 27 | table := tablewriter.NewTable(io.Discard, tablewriter.WithRenderer(renderer.NewOcean())) 28 | table.Header([]string{"Name", "Age", "City"}) 29 | for i := 0; i < b.N; i++ { 30 | table.Append([]any{"Alice", Age(25), Country("New York")}) 31 | table.Append([]string{"Bob", "30", "Boston"}) 32 | table.Render() 33 | } 34 | } 35 | 36 | func BenchmarkMarkdown(b *testing.B) { 37 | table := tablewriter.NewTable(io.Discard, tablewriter.WithRenderer(renderer.NewMarkdown())) 38 | table.Header([]string{"Name", "Age", "City"}) 39 | for i := 0; i < b.N; i++ { 40 | table.Append([]any{"Alice", Age(25), Country("New York")}) 41 | table.Append([]string{"Bob", "30", "Boston"}) 42 | table.Render() 43 | } 44 | } 45 | 46 | func BenchmarkColorized(b *testing.B) { 47 | table := tablewriter.NewTable(io.Discard, tablewriter.WithRenderer(renderer.NewColorized())) 48 | table.Header([]string{"Name", "Age", "City"}) 49 | for i := 0; i < b.N; i++ { 50 | table.Append([]any{"Alice", Age(25), Country("New York")}) 51 | table.Append([]string{"Bob", "30", "Boston"}) 52 | table.Render() 53 | } 54 | } 55 | 56 | func BenchmarkStreamBlueprint(b *testing.B) { 57 | table := tablewriter.NewTable(io.Discard, 58 | tablewriter.WithRenderer(renderer.NewBlueprint()), 59 | tablewriter.WithStreaming(tw.StreamConfig{Enable: true})) 60 | 61 | err := table.Start() 62 | if err != nil { 63 | b.Fatal(err) 64 | } 65 | table.Header([]string{"Name", "Age", "City"}) 66 | for i := 0; i < b.N; i++ { 67 | table.Append([]any{"Alice", Age(25), Country("New York")}) 68 | table.Append([]string{"Bob", "30", "Boston"}) 69 | 70 | } 71 | 72 | err = table.Close() 73 | if err != nil { 74 | b.Fatal(err) 75 | } 76 | } 77 | 78 | func BenchmarkStreamOcean(b *testing.B) { 79 | table := tablewriter.NewTable(io.Discard, 80 | tablewriter.WithRenderer(renderer.NewOcean()), 81 | tablewriter.WithStreaming(tw.StreamConfig{Enable: true})) 82 | 83 | err := table.Start() 84 | if err != nil { 85 | b.Fatal(err) 86 | } 87 | table.Header([]string{"Name", "Age", "City"}) 88 | for i := 0; i < b.N; i++ { 89 | table.Append([]any{"Alice", Age(25), Country("New York")}) 90 | table.Append([]string{"Bob", "30", "Boston"}) 91 | 92 | } 93 | 94 | err = table.Close() 95 | if err != nil { 96 | b.Fatal(err) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tw/cell.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | // CellFormatting holds formatting options for table cells. 4 | type CellFormatting struct { 5 | AutoWrap int // Wrapping behavior (e.g., WrapTruncate, WrapNormal) 6 | MergeMode int // Bitmask for merge behavior (e.g., MergeHorizontal, MergeVertical) 7 | 8 | // Changed form bool to State 9 | // See https://github.com/olekukonko/tablewriter/issues/261 10 | AutoFormat State // Enables automatic formatting (e.g., title case for headers) 11 | 12 | // Deprecated: kept for compatibility 13 | // will be removed soon 14 | Alignment Align // Text alignment within the cell (e.g., Left, Right, Center) 15 | 16 | } 17 | 18 | // CellPadding defines padding settings for table cells. 19 | type CellPadding struct { 20 | Global Padding // Default padding applied to all cells 21 | PerColumn []Padding // Column-specific padding overrides 22 | } 23 | 24 | // CellFilter defines filtering functions for cell content. 25 | type CellFilter struct { 26 | Global func([]string) []string // Processes the entire row 27 | PerColumn []func(string) string // Processes individual cells by column 28 | } 29 | 30 | // CellCallbacks holds callback functions for cell processing. 31 | // Note: These are currently placeholders and not fully implemented. 32 | type CellCallbacks struct { 33 | Global func() // Global callback applied to all cells 34 | PerColumn []func() // Column-specific callbacks 35 | } 36 | 37 | // CellAlignment defines alignment settings for table cells. 38 | type CellAlignment struct { 39 | Global Align // Default alignment applied to all cells 40 | PerColumn []Align // Column-specific alignment overrides 41 | } 42 | 43 | // CellConfig combines formatting, padding, and callback settings for a table section. 44 | type CellConfig struct { 45 | Formatting CellFormatting // Cell formatting options 46 | Padding CellPadding // Padding configuration 47 | Callbacks CellCallbacks // Callback functions (unused) 48 | Filter CellFilter // Function to filter cell content (renamed from Filter Filter) 49 | Alignment CellAlignment // Alignment configuration for cells 50 | ColMaxWidths CellWidth // Per-column maximum width overrides 51 | 52 | // Deprecated: use Alignment.PerColumn instead. Will be removed in a future version. 53 | // will be removed soon 54 | ColumnAligns []Align // Per-column alignment overrides 55 | } 56 | 57 | type CellWidth struct { 58 | Global int 59 | PerColumn Mapper[int, int] 60 | } 61 | 62 | func (c CellWidth) Constrained() bool { 63 | return c.Global > 0 || c.PerColumn.Len() > 0 64 | } 65 | -------------------------------------------------------------------------------- /tw/deprecated.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | // Deprecated: SymbolASCII is deprecated; use Glyphs with StyleASCII instead. 4 | // this will be removed soon 5 | type SymbolASCII struct{} 6 | 7 | // SymbolASCII symbol methods 8 | func (s *SymbolASCII) Name() string { return StyleNameASCII.String() } 9 | func (s *SymbolASCII) Center() string { return "+" } 10 | func (s *SymbolASCII) Row() string { return "-" } 11 | func (s *SymbolASCII) Column() string { return "|" } 12 | func (s *SymbolASCII) TopLeft() string { return "+" } 13 | func (s *SymbolASCII) TopMid() string { return "+" } 14 | func (s *SymbolASCII) TopRight() string { return "+" } 15 | func (s *SymbolASCII) MidLeft() string { return "+" } 16 | func (s *SymbolASCII) MidRight() string { return "+" } 17 | func (s *SymbolASCII) BottomLeft() string { return "+" } 18 | func (s *SymbolASCII) BottomMid() string { return "+" } 19 | func (s *SymbolASCII) BottomRight() string { return "+" } 20 | func (s *SymbolASCII) HeaderLeft() string { return "+" } 21 | func (s *SymbolASCII) HeaderMid() string { return "+" } 22 | func (s *SymbolASCII) HeaderRight() string { return "+" } 23 | 24 | // Deprecated: SymbolUnicode is deprecated; use Glyphs with appropriate styles (e.g., StyleLight, StyleHeavy) instead. 25 | // this will be removed soon 26 | type SymbolUnicode struct { 27 | row string 28 | column string 29 | center string 30 | corners [9]string // [topLeft, topMid, topRight, midLeft, center, midRight, bottomLeft, bottomMid, bottomRight] 31 | } 32 | 33 | // SymbolUnicode symbol methods 34 | func (s *SymbolUnicode) Name() string { return "unicode" } 35 | func (s *SymbolUnicode) Center() string { return s.center } 36 | func (s *SymbolUnicode) Row() string { return s.row } 37 | func (s *SymbolUnicode) Column() string { return s.column } 38 | func (s *SymbolUnicode) TopLeft() string { return s.corners[0] } 39 | func (s *SymbolUnicode) TopMid() string { return s.corners[1] } 40 | func (s *SymbolUnicode) TopRight() string { return s.corners[2] } 41 | func (s *SymbolUnicode) MidLeft() string { return s.corners[3] } 42 | func (s *SymbolUnicode) MidRight() string { return s.corners[5] } 43 | func (s *SymbolUnicode) BottomLeft() string { return s.corners[6] } 44 | func (s *SymbolUnicode) BottomMid() string { return s.corners[7] } 45 | func (s *SymbolUnicode) BottomRight() string { return s.corners[8] } 46 | func (s *SymbolUnicode) HeaderLeft() string { return s.MidLeft() } 47 | func (s *SymbolUnicode) HeaderMid() string { return s.Center() } 48 | func (s *SymbolUnicode) HeaderRight() string { return s.MidRight() } 49 | 50 | // Deprecated: SymbolMarkdown is deprecated; use Glyphs with StyleMarkdown instead. 51 | // this will be removed soon 52 | type SymbolMarkdown struct{} 53 | 54 | // SymbolMarkdown symbol methods 55 | func (s *SymbolMarkdown) Name() string { return StyleNameMarkdown.String() } 56 | func (s *SymbolMarkdown) Center() string { return "|" } 57 | func (s *SymbolMarkdown) Row() string { return "-" } 58 | func (s *SymbolMarkdown) Column() string { return "|" } 59 | func (s *SymbolMarkdown) TopLeft() string { return "" } 60 | func (s *SymbolMarkdown) TopMid() string { return "" } 61 | func (s *SymbolMarkdown) TopRight() string { return "" } 62 | func (s *SymbolMarkdown) MidLeft() string { return "|" } 63 | func (s *SymbolMarkdown) MidRight() string { return "|" } 64 | func (s *SymbolMarkdown) BottomLeft() string { return "" } 65 | func (s *SymbolMarkdown) BottomMid() string { return "" } 66 | func (s *SymbolMarkdown) BottomRight() string { return "" } 67 | func (s *SymbolMarkdown) HeaderLeft() string { return "|" } 68 | func (s *SymbolMarkdown) HeaderMid() string { return "|" } 69 | func (s *SymbolMarkdown) HeaderRight() string { return "|" } 70 | 71 | // Deprecated: SymbolNothing is deprecated; use Glyphs with StyleNone instead. 72 | // this will be removed soon 73 | type SymbolNothing struct{} 74 | 75 | // SymbolNothing symbol methods 76 | func (s *SymbolNothing) Name() string { return StyleNameNothing.String() } 77 | func (s *SymbolNothing) Center() string { return "" } 78 | func (s *SymbolNothing) Row() string { return "" } 79 | func (s *SymbolNothing) Column() string { return "" } 80 | func (s *SymbolNothing) TopLeft() string { return "" } 81 | func (s *SymbolNothing) TopMid() string { return "" } 82 | func (s *SymbolNothing) TopRight() string { return "" } 83 | func (s *SymbolNothing) MidLeft() string { return "" } 84 | func (s *SymbolNothing) MidRight() string { return "" } 85 | func (s *SymbolNothing) BottomLeft() string { return "" } 86 | func (s *SymbolNothing) BottomMid() string { return "" } 87 | func (s *SymbolNothing) BottomRight() string { return "" } 88 | func (s *SymbolNothing) HeaderLeft() string { return "" } 89 | func (s *SymbolNothing) HeaderMid() string { return "" } 90 | func (s *SymbolNothing) HeaderRight() string { return "" } 91 | 92 | // Deprecated: SymbolGraphical is deprecated; use Glyphs with StyleGraphical instead. 93 | // this will be removed soon 94 | type SymbolGraphical struct{} 95 | 96 | // SymbolGraphical symbol methods 97 | func (s *SymbolGraphical) Name() string { return StyleNameGraphical.String() } 98 | func (s *SymbolGraphical) Center() string { return "🟧" } // Orange square (matches mid junctions) 99 | func (s *SymbolGraphical) Row() string { return "🟥" } // Red square (matches corners) 100 | func (s *SymbolGraphical) Column() string { return "🟦" } // Blue square (vertical line) 101 | func (s *SymbolGraphical) TopLeft() string { return "🟥" } // Top-left corner 102 | func (s *SymbolGraphical) TopMid() string { return "🔳" } // Top junction 103 | func (s *SymbolGraphical) TopRight() string { return "🟥" } // Top-right corner 104 | func (s *SymbolGraphical) MidLeft() string { return "🟧" } // Left junction 105 | func (s *SymbolGraphical) MidRight() string { return "🟧" } // Right junction 106 | func (s *SymbolGraphical) BottomLeft() string { return "🟥" } // Bottom-left corner 107 | func (s *SymbolGraphical) BottomMid() string { return "🔳" } // Bottom junction 108 | func (s *SymbolGraphical) BottomRight() string { return "🟥" } // Bottom-right corner 109 | func (s *SymbolGraphical) HeaderLeft() string { return "🟧" } // Header left (matches mid junctions) 110 | func (s *SymbolGraphical) HeaderMid() string { return "🟧" } // Header middle (matches mid junctions) 111 | func (s *SymbolGraphical) HeaderRight() string { return "🟧" } // Header right (matches mid junctions) 112 | 113 | // Deprecated: SymbolMerger is deprecated; use Glyphs with StyleMerger instead. 114 | // this will be removed soon 115 | type SymbolMerger struct { 116 | row string 117 | column string 118 | center string 119 | corners [9]string // [TL, TM, TR, ML, CenterIdx(unused), MR, BL, BM, BR] 120 | } 121 | 122 | // SymbolMerger symbol methods 123 | func (s *SymbolMerger) Name() string { return StyleNameMerger.String() } 124 | func (s *SymbolMerger) Center() string { return s.center } // Main crossing symbol 125 | func (s *SymbolMerger) Row() string { return s.row } 126 | func (s *SymbolMerger) Column() string { return s.column } 127 | func (s *SymbolMerger) TopLeft() string { return s.corners[0] } 128 | func (s *SymbolMerger) TopMid() string { return s.corners[1] } // LevelHeader junction 129 | func (s *SymbolMerger) TopRight() string { return s.corners[2] } 130 | func (s *SymbolMerger) MidLeft() string { return s.corners[3] } // Left junction 131 | func (s *SymbolMerger) MidRight() string { return s.corners[5] } // Right junction 132 | func (s *SymbolMerger) BottomLeft() string { return s.corners[6] } 133 | func (s *SymbolMerger) BottomMid() string { return s.corners[7] } // LevelFooter junction 134 | func (s *SymbolMerger) BottomRight() string { return s.corners[8] } 135 | func (s *SymbolMerger) HeaderLeft() string { return s.MidLeft() } 136 | func (s *SymbolMerger) HeaderMid() string { return s.Center() } 137 | func (s *SymbolMerger) HeaderRight() string { return s.MidRight() } 138 | -------------------------------------------------------------------------------- /tw/fn.go: -------------------------------------------------------------------------------- 1 | // Package tw provides utility functions for text formatting, width calculation, and string manipulation 2 | // specifically tailored for table rendering, including handling ANSI escape codes and Unicode text. 3 | package tw 4 | 5 | import ( 6 | "bytes" // For buffering string output 7 | "github.com/mattn/go-runewidth" // For calculating display width of Unicode characters 8 | "math" // For mathematical operations like ceiling 9 | "regexp" // For regular expression handling of ANSI codes 10 | "strconv" // For string-to-number conversions 11 | "strings" // For string manipulation utilities 12 | "unicode" // For Unicode character classification 13 | "unicode/utf8" // For UTF-8 rune handling 14 | ) 15 | 16 | // ansi is a compiled regex pattern used to strip ANSI escape codes. 17 | // These codes are used in terminal output for styling and are invisible in rendered text. 18 | var ansi = CompileANSIFilter() 19 | 20 | // CompileANSIFilter constructs and compiles a regex for matching ANSI sequences. 21 | // It supports both control sequences (CSI) and operating system commands (OSC) like hyperlinks. 22 | func CompileANSIFilter() *regexp.Regexp { 23 | var regESC = "\x1b" // ASCII escape character 24 | var regBEL = "\x07" // ASCII bell character 25 | 26 | // ANSI string terminator: either ESC+\ or BEL 27 | var regST = "(" + regexp.QuoteMeta(regESC+"\\") + "|" + regexp.QuoteMeta(regBEL) + ")" 28 | // Control Sequence Introducer (CSI): ESC[ followed by parameters and a final byte 29 | var regCSI = regexp.QuoteMeta(regESC+"[") + "[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]" 30 | // Operating System Command (OSC): ESC] followed by arbitrary content until a terminator 31 | var regOSC = regexp.QuoteMeta(regESC+"]") + ".*?" + regST 32 | 33 | // Combine CSI and OSC patterns into a single regex 34 | return regexp.MustCompile("(" + regCSI + "|" + regOSC + ")") 35 | } 36 | 37 | // DisplayWidth calculates the visual width of a string, excluding ANSI escape sequences. 38 | // It uses go-runewidth to handle Unicode characters correctly. 39 | func DisplayWidth(str string) int { 40 | // Strip ANSI codes before calculating width to avoid counting invisible characters 41 | return runewidth.StringWidth(ansi.ReplaceAllLiteralString(str, "")) 42 | } 43 | 44 | // TruncateString shortens a string to a specified maximum display width while preserving ANSI color codes. 45 | // An optional suffix (e.g., "...") is appended if truncation occurs. 46 | func TruncateString(s string, maxWidth int, suffix ...string) string { 47 | // If maxWidth is 0 or negative, return an empty string 48 | if maxWidth <= 0 { 49 | return "" 50 | } 51 | 52 | // Join suffix slices into a single string and calculate its display width 53 | suffixStr := strings.Join(suffix, " ") 54 | suffixDisplayWidth := 0 55 | if len(suffixStr) > 0 { 56 | // Strip ANSI from suffix for accurate width calculation 57 | suffixDisplayWidth = runewidth.StringWidth(ansi.ReplaceAllLiteralString(suffixStr, "")) 58 | } 59 | 60 | // Check if the string (without ANSI) plus suffix fits within maxWidth 61 | strippedS := ansi.ReplaceAllLiteralString(s, "") 62 | if runewidth.StringWidth(strippedS)+suffixDisplayWidth <= maxWidth { 63 | // If it fits, return the original string (with ANSI) plus suffix 64 | return s + suffixStr 65 | } 66 | 67 | // Handle edge case: maxWidth is too small for even the suffix 68 | if maxWidth < suffixDisplayWidth { 69 | // Try truncating the string without suffix 70 | return TruncateString(s, maxWidth) // Recursive call without suffix 71 | } 72 | // Handle edge case: maxWidth exactly equals suffix width 73 | if maxWidth == suffixDisplayWidth { 74 | if runewidth.StringWidth(strippedS) > 0 { 75 | // If there's content, it's fully truncated; return suffix 76 | return suffixStr 77 | } 78 | return "" // No content and no space for content; return empty string 79 | } 80 | 81 | // Calculate the maximum width available for the content (excluding suffix) 82 | targetContentDisplayWidth := maxWidth - suffixDisplayWidth 83 | 84 | var contentBuf bytes.Buffer // Buffer for building truncated content 85 | var currentContentDisplayWidth int // Tracks display width of content 86 | var ansiSeqBuf bytes.Buffer // Buffer for collecting ANSI sequences 87 | inAnsiSequence := false // Tracks if we're inside an ANSI sequence 88 | 89 | // Iterate over runes to build content while respecting maxWidth 90 | for _, r := range s { 91 | if r == '\x1b' { // Start of ANSI escape sequence 92 | if inAnsiSequence { 93 | // Unexpected new ESC; flush existing sequence 94 | contentBuf.Write(ansiSeqBuf.Bytes()) 95 | ansiSeqBuf.Reset() 96 | } 97 | inAnsiSequence = true 98 | ansiSeqBuf.WriteRune(r) 99 | } else if inAnsiSequence { 100 | ansiSeqBuf.WriteRune(r) 101 | // Detect end of common ANSI sequences (e.g., SGR 'm' or CSI terminators) 102 | if r == 'm' || (ansiSeqBuf.Len() > 2 && ansiSeqBuf.Bytes()[1] == '[' && r >= '@' && r <= '~') { 103 | inAnsiSequence = false 104 | contentBuf.Write(ansiSeqBuf.Bytes()) // Append completed sequence 105 | ansiSeqBuf.Reset() 106 | } else if ansiSeqBuf.Len() > 128 { // Prevent buffer overflow for malformed sequences 107 | inAnsiSequence = false 108 | contentBuf.Write(ansiSeqBuf.Bytes()) 109 | ansiSeqBuf.Reset() 110 | } 111 | } else { 112 | // Handle displayable characters 113 | runeDisplayWidth := runewidth.RuneWidth(r) 114 | if currentContentDisplayWidth+runeDisplayWidth > targetContentDisplayWidth { 115 | // Adding this rune would exceed the content width; stop here 116 | break 117 | } 118 | contentBuf.WriteRune(r) 119 | currentContentDisplayWidth += runeDisplayWidth 120 | } 121 | } 122 | 123 | // Append any unterminated ANSI sequence 124 | if ansiSeqBuf.Len() > 0 { 125 | contentBuf.Write(ansiSeqBuf.Bytes()) 126 | } 127 | 128 | finalContent := contentBuf.String() 129 | 130 | // Append suffix if content was truncated or if suffix is provided and content exists 131 | if runewidth.StringWidth(ansi.ReplaceAllLiteralString(finalContent, "")) < runewidth.StringWidth(strippedS) { 132 | // Content was truncated; append suffix 133 | return finalContent + suffixStr 134 | } else if len(suffixStr) > 0 && len(finalContent) > 0 { 135 | // No truncation but suffix exists; append it 136 | return finalContent + suffixStr 137 | } else if len(suffixStr) > 0 && len(strippedS) == 0 { 138 | // Original string was empty; return suffix 139 | return suffixStr 140 | } 141 | 142 | // Return content as is (with preserved ANSI codes) 143 | return finalContent 144 | } 145 | 146 | // Title normalizes and uppercases a label string for use in headers. 147 | // It replaces underscores and certain dots with spaces and trims whitespace. 148 | func Title(name string) string { 149 | origLen := len(name) 150 | rs := []rune(name) 151 | for i, r := range rs { 152 | switch r { 153 | case '_': 154 | rs[i] = ' ' // Replace underscores with spaces 155 | case '.': 156 | // Replace dots with spaces unless they are between numeric or space characters 157 | if (i != 0 && !IsIsNumericOrSpace(rs[i-1])) || (i != len(rs)-1 && !IsIsNumericOrSpace(rs[i+1])) { 158 | rs[i] = ' ' 159 | } 160 | } 161 | } 162 | name = string(rs) 163 | name = strings.TrimSpace(name) 164 | // If the input was non-empty but trimmed to empty, return a single space 165 | if len(name) == 0 && origLen > 0 { 166 | name = " " 167 | } 168 | // Convert to uppercase for header formatting 169 | return strings.ToUpper(name) 170 | } 171 | 172 | // PadCenter centers a string within a specified width using a padding character. 173 | // Extra padding is split between left and right, with slight preference to left if uneven. 174 | func PadCenter(s, pad string, width int) string { 175 | gap := width - DisplayWidth(s) 176 | if gap > 0 { 177 | // Calculate left and right padding; ceil ensures left gets extra if gap is odd 178 | gapLeft := int(math.Ceil(float64(gap) / 2)) 179 | gapRight := gap - gapLeft 180 | return strings.Repeat(pad, gapLeft) + s + strings.Repeat(pad, gapRight) 181 | } 182 | // If no padding needed or string is too wide, return as is 183 | return s 184 | } 185 | 186 | // PadRight left-aligns a string within a specified width, filling remaining space on the right with padding. 187 | func PadRight(s, pad string, width int) string { 188 | gap := width - DisplayWidth(s) 189 | if gap > 0 { 190 | // Append padding to the right 191 | return s + strings.Repeat(pad, gap) 192 | } 193 | // If no padding needed or string is too wide, return as is 194 | return s 195 | } 196 | 197 | // PadLeft right-aligns a string within a specified width, filling remaining space on the left with padding. 198 | func PadLeft(s, pad string, width int) string { 199 | gap := width - DisplayWidth(s) 200 | if gap > 0 { 201 | // Prepend padding to the left 202 | return strings.Repeat(pad, gap) + s 203 | } 204 | // If no padding needed or string is too wide, return as is 205 | return s 206 | } 207 | 208 | // Pad aligns a string within a specified width using a padding character. 209 | // It truncates if the string is wider than the target width. 210 | func Pad(s string, padChar string, totalWidth int, alignment Align) string { 211 | sDisplayWidth := DisplayWidth(s) 212 | if sDisplayWidth > totalWidth { 213 | return TruncateString(s, totalWidth) // Only truncate if necessary 214 | } 215 | switch alignment { 216 | case AlignLeft: 217 | return PadRight(s, padChar, totalWidth) 218 | case AlignRight: 219 | return PadLeft(s, padChar, totalWidth) 220 | case AlignCenter: 221 | return PadCenter(s, padChar, totalWidth) 222 | default: 223 | return PadRight(s, padChar, totalWidth) 224 | } 225 | } 226 | 227 | // IsIsNumericOrSpace checks if a rune is a digit or space character. 228 | // Used in formatting logic to determine safe character replacements. 229 | func IsIsNumericOrSpace(r rune) bool { 230 | return ('0' <= r && r <= '9') || r == ' ' 231 | } 232 | 233 | // IsNumeric checks if a string represents a valid integer or floating-point number. 234 | func IsNumeric(s string) bool { 235 | s = strings.TrimSpace(s) 236 | if s == "" { 237 | return false 238 | } 239 | // Try parsing as integer first 240 | if _, err := strconv.Atoi(s); err == nil { 241 | return true 242 | } 243 | // Then try parsing as float 244 | _, err := strconv.ParseFloat(s, 64) 245 | return err == nil 246 | } 247 | 248 | // SplitCamelCase splits a camelCase or PascalCase or snake_case string into separate words. 249 | // It detects transitions between uppercase, lowercase, digits, and other characters. 250 | func SplitCamelCase(src string) (entries []string) { 251 | // Validate UTF-8 input; return as single entry if invalid 252 | if !utf8.ValidString(src) { 253 | return []string{src} 254 | } 255 | entries = []string{} 256 | var runes [][]rune 257 | lastClass := 0 258 | class := 0 259 | // Classify each rune into categories: lowercase (1), uppercase (2), digit (3), other (4) 260 | for _, r := range src { 261 | switch { 262 | case unicode.IsLower(r): 263 | class = 1 264 | case unicode.IsUpper(r): 265 | class = 2 266 | case unicode.IsDigit(r): 267 | class = 3 268 | default: 269 | class = 4 270 | } 271 | // Group consecutive runes of the same class together 272 | if class == lastClass { 273 | runes[len(runes)-1] = append(runes[len(runes)-1], r) 274 | } else { 275 | runes = append(runes, []rune{r}) 276 | } 277 | lastClass = class 278 | } 279 | // Adjust for cases where an uppercase letter is followed by lowercase (e.g., CamelCase) 280 | for i := 0; i < len(runes)-1; i++ { 281 | if unicode.IsUpper(runes[i][0]) && unicode.IsLower(runes[i+1][0]) { 282 | // Move the last uppercase rune to the next group for proper word splitting 283 | runes[i+1] = append([]rune{runes[i][len(runes[i])-1]}, runes[i+1]...) 284 | runes[i] = runes[i][:len(runes[i])-1] 285 | } 286 | } 287 | // Convert rune groups to strings, excluding empty, underscore or whitespace-only groups 288 | for _, s := range runes { 289 | str := string(s) 290 | if len(s) > 0 && strings.TrimSpace(str) != "" && str != "_" { 291 | entries = append(entries, str) 292 | } 293 | } 294 | return 295 | } 296 | 297 | // Or provides a ternary-like operation for strings, returning 'valid' if cond is true, else 'inValid'. 298 | func Or(cond bool, valid, inValid string) string { 299 | if cond { 300 | return valid 301 | } 302 | return inValid 303 | } 304 | 305 | // Max returns the greater of two integers. 306 | func Max(a, b int) int { 307 | if a > b { 308 | return a 309 | } 310 | return b 311 | } 312 | 313 | // Min returns the smaller of two integers. 314 | func Min(a, b int) int { 315 | if a < b { 316 | return a 317 | } 318 | return b 319 | } 320 | 321 | // BreakPoint finds the rune index where the display width of a string first exceeds the specified limit. 322 | // It returns the number of runes if the entire string fits, or 0 if nothing fits. 323 | func BreakPoint(s string, limit int) int { 324 | // If limit is 0 or negative, nothing can fit 325 | if limit <= 0 { 326 | return 0 327 | } 328 | // Empty string has a breakpoint of 0 329 | if s == "" { 330 | return 0 331 | } 332 | 333 | currentWidth := 0 334 | runeCount := 0 335 | // Iterate over runes, accumulating display width 336 | for _, r := range s { 337 | runeWidth := DisplayWidth(string(r)) // Calculate width of individual rune 338 | if currentWidth+runeWidth > limit { 339 | // Adding this rune would exceed the limit; breakpoint is before this rune 340 | if currentWidth == 0 { 341 | // First rune is too wide; allow breaking after it if limit > 0 342 | if runeWidth > limit && limit > 0 { 343 | return 1 344 | } 345 | return 0 346 | } 347 | return runeCount 348 | } 349 | currentWidth += runeWidth 350 | runeCount++ 351 | } 352 | 353 | // Entire string fits within the limit 354 | return runeCount 355 | } 356 | 357 | func MakeAlign(l int, align Align) Alignment { 358 | aa := make(Alignment, l) 359 | for i := 0; i < l; i++ { 360 | aa[i] = align 361 | } 362 | return aa 363 | } 364 | -------------------------------------------------------------------------------- /tw/fn_test.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestSplitCase(t *testing.T) { 9 | tests := []struct { 10 | input string 11 | expected []string 12 | }{ 13 | { 14 | input: "", 15 | expected: []string{}, 16 | }, 17 | { 18 | input: "snake_Case", 19 | expected: []string{"snake", "Case"}, 20 | }, 21 | { 22 | input: "PascalCase", 23 | expected: []string{"Pascal", "Case"}, 24 | }, 25 | { 26 | input: "camelCase", 27 | expected: []string{"camel", "Case"}, 28 | }, 29 | { 30 | input: "_snake_CasePascalCase_camelCase123", 31 | expected: []string{"snake", "Case", "Pascal", "Case", "camel", "Case", "123"}, 32 | }, 33 | { 34 | input: "ㅤ", 35 | expected: []string{"ㅤ"}, 36 | }, 37 | { 38 | input: " \r\n\t", 39 | expected: []string{}, 40 | }, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.input, func(t *testing.T) { 44 | if output := SplitCamelCase(tt.input); !reflect.DeepEqual(output, tt.expected) { 45 | t.Errorf("SplitCamelCase(%q) = %v, want %v", tt.input, output, tt.expected) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tw/mapper.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | ) 7 | 8 | // KeyValuePair represents a single key-value pair from a Mapper. 9 | type KeyValuePair[K comparable, V any] struct { 10 | Key K 11 | Value V 12 | } 13 | 14 | // Mapper is a generic map type with comparable keys and any value type. 15 | // It provides type-safe operations on maps with additional convenience methods. 16 | type Mapper[K comparable, V any] map[K]V 17 | 18 | // NewMapper creates and returns a new initialized Mapper. 19 | func NewMapper[K comparable, V any]() Mapper[K, V] { 20 | return make(Mapper[K, V]) 21 | } 22 | 23 | // Get returns the value associated with the key. 24 | // If the key doesn't exist or the map is nil, it returns the zero value for the value type. 25 | func (m Mapper[K, V]) Get(key K) V { 26 | if m == nil { 27 | var zero V 28 | return zero 29 | } 30 | return m[key] 31 | } 32 | 33 | // OK returns the value associated with the key and a boolean indicating whether the key exists. 34 | func (m Mapper[K, V]) OK(key K) (V, bool) { 35 | if m == nil { 36 | var zero V 37 | return zero, false 38 | } 39 | val, ok := m[key] 40 | return val, ok 41 | } 42 | 43 | // Set sets the value for the specified key. 44 | // Does nothing if the map is nil. 45 | func (m Mapper[K, V]) Set(key K, value V) Mapper[K, V] { 46 | if m != nil { 47 | m[key] = value 48 | } 49 | return m 50 | } 51 | 52 | // Delete removes the specified key from the map. 53 | // Does nothing if the key doesn't exist or the map is nil. 54 | func (m Mapper[K, V]) Delete(key K) Mapper[K, V] { 55 | if m != nil { 56 | delete(m, key) 57 | } 58 | return m 59 | } 60 | 61 | // Has returns true if the key exists in the map, false otherwise. 62 | func (m Mapper[K, V]) Has(key K) bool { 63 | if m == nil { 64 | return false 65 | } 66 | _, exists := m[key] 67 | return exists 68 | } 69 | 70 | // Len returns the number of elements in the map. 71 | // Returns 0 if the map is nil. 72 | func (m Mapper[K, V]) Len() int { 73 | if m == nil { 74 | return 0 75 | } 76 | return len(m) 77 | } 78 | 79 | // Keys returns a slice containing all keys in the map. 80 | // Returns nil if the map is nil or empty. 81 | func (m Mapper[K, V]) Keys() []K { 82 | if m == nil { 83 | return nil 84 | } 85 | keys := make([]K, 0, len(m)) 86 | for k := range m { 87 | keys = append(keys, k) 88 | } 89 | return keys 90 | } 91 | 92 | func (m Mapper[K, V]) Clear() { 93 | if m == nil { 94 | return 95 | } 96 | for k := range m { 97 | delete(m, k) 98 | } 99 | } 100 | 101 | // Values returns a slice containing all values in the map. 102 | // Returns nil if the map is nil or empty. 103 | func (m Mapper[K, V]) Values() []V { 104 | if m == nil { 105 | return nil 106 | } 107 | values := make([]V, 0, len(m)) 108 | for _, v := range m { 109 | values = append(values, v) 110 | } 111 | return values 112 | } 113 | 114 | // Each iterates over each key-value pair in the map and calls the provided function. 115 | // Does nothing if the map is nil. 116 | func (m Mapper[K, V]) Each(fn func(K, V)) { 117 | if m != nil { 118 | for k, v := range m { 119 | fn(k, v) 120 | } 121 | } 122 | } 123 | 124 | // Filter returns a new Mapper containing only the key-value pairs that satisfy the predicate. 125 | func (m Mapper[K, V]) Filter(fn func(K, V) bool) Mapper[K, V] { 126 | result := NewMapper[K, V]() 127 | if m != nil { 128 | for k, v := range m { 129 | if fn(k, v) { 130 | result[k] = v 131 | } 132 | } 133 | } 134 | return result 135 | } 136 | 137 | // MapValues returns a new Mapper with the same keys but values transformed by the provided function. 138 | func (m Mapper[K, V]) MapValues(fn func(V) V) Mapper[K, V] { 139 | result := NewMapper[K, V]() 140 | if m != nil { 141 | for k, v := range m { 142 | result[k] = fn(v) 143 | } 144 | } 145 | return result 146 | } 147 | 148 | // Clone returns a shallow copy of the Mapper. 149 | func (m Mapper[K, V]) Clone() Mapper[K, V] { 150 | result := NewMapper[K, V]() 151 | if m != nil { 152 | for k, v := range m { 153 | result[k] = v 154 | } 155 | } 156 | return result 157 | } 158 | 159 | // Slicer converts the Mapper to a Slicer of key-value pairs. 160 | func (m Mapper[K, V]) Slicer() Slicer[KeyValuePair[K, V]] { 161 | if m == nil { 162 | return nil 163 | } 164 | result := make(Slicer[KeyValuePair[K, V]], 0, len(m)) 165 | for k, v := range m { 166 | result = append(result, KeyValuePair[K, V]{Key: k, Value: v}) 167 | } 168 | return result 169 | } 170 | 171 | func (m Mapper[K, V]) SortedKeys() []K { 172 | keys := make([]K, 0, len(m)) 173 | for k := range m { 174 | keys = append(keys, k) 175 | } 176 | 177 | sort.Slice(keys, func(i, j int) bool { 178 | a, b := any(keys[i]), any(keys[j]) 179 | 180 | switch va := a.(type) { 181 | case int: 182 | if vb, ok := b.(int); ok { 183 | return va < vb 184 | } 185 | case int32: 186 | if vb, ok := b.(int32); ok { 187 | return va < vb 188 | } 189 | case int64: 190 | if vb, ok := b.(int64); ok { 191 | return va < vb 192 | } 193 | case uint: 194 | if vb, ok := b.(uint); ok { 195 | return va < vb 196 | } 197 | case uint64: 198 | if vb, ok := b.(uint64); ok { 199 | return va < vb 200 | } 201 | case float32: 202 | if vb, ok := b.(float32); ok { 203 | return va < vb 204 | } 205 | case float64: 206 | if vb, ok := b.(float64); ok { 207 | return va < vb 208 | } 209 | case string: 210 | if vb, ok := b.(string); ok { 211 | return va < vb 212 | } 213 | } 214 | 215 | // fallback to string comparison 216 | return fmt.Sprintf("%v", a) < fmt.Sprintf("%v", b) 217 | }) 218 | 219 | return keys 220 | } 221 | -------------------------------------------------------------------------------- /tw/preset.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | // BorderNone defines a border configuration with all sides disabled. 4 | var ( 5 | // PaddingNone represents explicitly empty padding (no spacing on any side) 6 | // Equivalent to Padding{Overwrite: true} 7 | PaddingNone = Padding{Left: Empty, Right: Empty, Top: Empty, Bottom: Empty, Overwrite: true} 8 | BorderNone = Border{Left: Off, Right: Off, Top: Off, Bottom: Off} 9 | LinesNone = Lines{ShowTop: Off, ShowBottom: Off, ShowHeaderLine: Off, ShowFooterLine: Off} 10 | SeparatorsNone = Separators{ShowHeader: Off, ShowFooter: Off, BetweenRows: Off, BetweenColumns: Off} 11 | ) 12 | 13 | var ( 14 | 15 | // PaddingDefault represents standard single-space padding on left/right 16 | // Equivalent to Padding{Left: " ", Right: " ", Overwrite: true} 17 | PaddingDefault = Padding{Left: " ", Right: " ", Overwrite: true} 18 | ) 19 | -------------------------------------------------------------------------------- /tw/renderer.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "github.com/olekukonko/ll" 5 | "io" 6 | ) 7 | 8 | // Renderer defines the interface for rendering tables to an io.Writer. 9 | // Implementations must handle headers, rows, footers, and separator lines. 10 | type Renderer interface { 11 | Start(w io.Writer) error 12 | Header(headers [][]string, ctx Formatting) // Renders table header 13 | Row(row []string, ctx Formatting) // Renders a single row 14 | Footer(footers [][]string, ctx Formatting) // Renders table footer 15 | Line(ctx Formatting) // Renders separator line 16 | Config() Rendition // Returns renderer config 17 | Close() error // Gets Rendition form Blueprint 18 | Logger(logger *ll.Logger) // send logger to renderers 19 | } 20 | 21 | // Rendition holds the configuration for the default renderer. 22 | type Rendition struct { 23 | Borders Border // Border visibility settings 24 | Symbols Symbols // Symbols used for table drawing 25 | Settings Settings // Rendering behavior settings 26 | Streaming bool 27 | } 28 | 29 | // Renditioning has a method to update its rendition. 30 | // Let's define an optional interface for this. 31 | type Renditioning interface { 32 | Rendition(r Rendition) 33 | } 34 | 35 | // Formatting encapsulates the complete formatting context for a table row. 36 | // It provides all necessary information to render a row correctly within the table structure. 37 | type Formatting struct { 38 | Row RowContext // Detailed configuration for the row and its cells 39 | Level Level // Hierarchical level (Header, Body, Footer) affecting line drawing 40 | HasFooter bool // Indicates if the table includes a footer section 41 | IsSubRow bool // Marks this as a continuation or padding line in multi-line rows 42 | NormalizedWidths Mapper[int, int] 43 | } 44 | 45 | // CellContext defines the properties and formatting state of an individual table cell. 46 | type CellContext struct { 47 | Data string // Content to be displayed in the cell, provided by the caller 48 | Align Align // Text alignment within the cell (Left, Right, Center, Skip) 49 | Padding Padding // Padding characters surrounding the cell content 50 | Width int // Suggested width (often overridden by Row.Widths) 51 | Merge MergeState // Details about cell spanning across rows or columns 52 | } 53 | 54 | // MergeState captures how a cell merges across different directions. 55 | type MergeState struct { 56 | Vertical MergeStateOption // Properties for vertical merging (across rows) 57 | Horizontal MergeStateOption // Properties for horizontal merging (across columns) 58 | Hierarchical MergeStateOption // Properties for nested/hierarchical merging 59 | } 60 | 61 | // MergeStateOption represents common attributes for merging in a specific direction. 62 | type MergeStateOption struct { 63 | Present bool // True if this merge direction is active 64 | Span int // Number of cells this merge spans 65 | Start bool // True if this cell is the starting point of the merge 66 | End bool // True if this cell is the ending point of the merge 67 | } 68 | 69 | // RowContext manages layout properties and relationships for a row and its columns. 70 | // It maintains state about the current row and its neighbors for proper rendering. 71 | type RowContext struct { 72 | Position Position // Section of the table (Header, Row, Footer) 73 | Location Location // Boundary position (First, Middle, End) 74 | Current map[int]CellContext // Cells in this row, indexed by column 75 | Previous map[int]CellContext // Cells from the row above; nil if none 76 | Next map[int]CellContext // Cells from the row below; nil if none 77 | Widths Mapper[int, int] // Computed widths for each column 78 | ColMaxWidths CellWidth // Maximum allowed width per column 79 | } 80 | 81 | func (r RowContext) GetCell(col int) CellContext { 82 | return r.Current[col] 83 | } 84 | 85 | // Separators controls the visibility of separators in the table. 86 | type Separators struct { 87 | ShowHeader State // Controls header separator visibility 88 | ShowFooter State // Controls footer separator visibility 89 | BetweenRows State // Determines if lines appear between rows 90 | BetweenColumns State // Determines if separators appear between columns 91 | } 92 | 93 | // Lines manages the visibility of table boundary lines. 94 | type Lines struct { 95 | ShowTop State // Top border visibility 96 | ShowBottom State // Bottom border visibility 97 | ShowHeaderLine State // Header separator line visibility 98 | ShowFooterLine State // Footer separator line visibility 99 | } 100 | 101 | // Settings holds configuration preferences for rendering behavior. 102 | type Settings struct { 103 | Separators Separators // Separator visibility settings 104 | Lines Lines // Line visibility settings 105 | CompactMode State // Reserved for future compact rendering (unused) 106 | // Cushion State 107 | } 108 | 109 | // Border defines the visibility states of table borders. 110 | type Border struct { 111 | Left State // Left border visibility 112 | Right State // Right border visibility 113 | Top State // Top border visibility 114 | Bottom State // Bottom border visibility 115 | Overwrite bool 116 | } 117 | 118 | type StreamConfig struct { 119 | Enable bool 120 | 121 | // Deprecated: Use top-level Config.Widths for streaming width control. 122 | // This field will be removed in a future version. It will be respected if 123 | // Config.Widths is not set and this field is. 124 | Widths CellWidth 125 | } 126 | -------------------------------------------------------------------------------- /tw/slicer.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | // Slicer is a generic slice type that provides additional methods for slice manipulation. 4 | type Slicer[T any] []T 5 | 6 | // NewSlicer creates and returns a new initialized Slicer. 7 | func NewSlicer[T any]() Slicer[T] { 8 | return make(Slicer[T], 0) 9 | } 10 | 11 | // Get returns the element at the specified index. 12 | // Returns the zero value if the index is out of bounds or the slice is nil. 13 | func (s Slicer[T]) Get(index int) T { 14 | if s == nil || index < 0 || index >= len(s) { 15 | var zero T 16 | return zero 17 | } 18 | return s[index] 19 | } 20 | 21 | // GetOK returns the element at the specified index and a boolean indicating whether the index was valid. 22 | func (s Slicer[T]) GetOK(index int) (T, bool) { 23 | if s == nil || index < 0 || index >= len(s) { 24 | var zero T 25 | return zero, false 26 | } 27 | return s[index], true 28 | } 29 | 30 | // Append appends elements to the slice and returns the new slice. 31 | func (s Slicer[T]) Append(elements ...T) Slicer[T] { 32 | return append(s, elements...) 33 | } 34 | 35 | // Prepend adds elements to the beginning of the slice and returns the new slice. 36 | func (s Slicer[T]) Prepend(elements ...T) Slicer[T] { 37 | return append(elements, s...) 38 | } 39 | 40 | // Len returns the number of elements in the slice. 41 | // Returns 0 if the slice is nil. 42 | func (s Slicer[T]) Len() int { 43 | if s == nil { 44 | return 0 45 | } 46 | return len(s) 47 | } 48 | 49 | // IsEmpty returns true if the slice is nil or has zero elements. 50 | func (s Slicer[T]) IsEmpty() bool { 51 | return s.Len() == 0 52 | } 53 | 54 | // Has returns true if the index exists in the slice. 55 | func (s Slicer[T]) Has(index int) bool { 56 | return index >= 0 && index < s.Len() 57 | } 58 | 59 | // First returns the first element of the slice, or the zero value if empty. 60 | func (s Slicer[T]) First() T { 61 | return s.Get(0) 62 | } 63 | 64 | // Last returns the last element of the slice, or the zero value if empty. 65 | func (s Slicer[T]) Last() T { 66 | return s.Get(s.Len() - 1) 67 | } 68 | 69 | // Each iterates over each element in the slice and calls the provided function. 70 | // Does nothing if the slice is nil. 71 | func (s Slicer[T]) Each(fn func(T)) { 72 | if s != nil { 73 | for _, v := range s { 74 | fn(v) 75 | } 76 | } 77 | } 78 | 79 | // Filter returns a new Slicer containing only elements that satisfy the predicate. 80 | func (s Slicer[T]) Filter(fn func(T) bool) Slicer[T] { 81 | result := NewSlicer[T]() 82 | if s != nil { 83 | for _, v := range s { 84 | if fn(v) { 85 | result = result.Append(v) 86 | } 87 | } 88 | } 89 | return result 90 | } 91 | 92 | // Map returns a new Slicer with each element transformed by the provided function. 93 | func (s Slicer[T]) Map(fn func(T) T) Slicer[T] { 94 | result := NewSlicer[T]() 95 | if s != nil { 96 | for _, v := range s { 97 | result = result.Append(fn(v)) 98 | } 99 | } 100 | return result 101 | } 102 | 103 | // Contains returns true if the slice contains an element that satisfies the predicate. 104 | func (s Slicer[T]) Contains(fn func(T) bool) bool { 105 | if s != nil { 106 | for _, v := range s { 107 | if fn(v) { 108 | return true 109 | } 110 | } 111 | } 112 | return false 113 | } 114 | 115 | // Find returns the first element that satisfies the predicate, along with a boolean indicating if it was found. 116 | func (s Slicer[T]) Find(fn func(T) bool) (T, bool) { 117 | if s != nil { 118 | for _, v := range s { 119 | if fn(v) { 120 | return v, true 121 | } 122 | } 123 | } 124 | var zero T 125 | return zero, false 126 | } 127 | 128 | // Clone returns a shallow copy of the Slicer. 129 | func (s Slicer[T]) Clone() Slicer[T] { 130 | result := NewSlicer[T]() 131 | if s != nil { 132 | result = append(result, s...) 133 | } 134 | return result 135 | } 136 | 137 | // SlicerToMapper converts a Slicer of KeyValuePair to a Mapper. 138 | func SlicerToMapper[K comparable, V any](s Slicer[KeyValuePair[K, V]]) Mapper[K, V] { 139 | result := make(Mapper[K, V]) 140 | if s == nil { 141 | return result 142 | } 143 | for _, pair := range s { 144 | result[pair.Key] = pair.Value 145 | } 146 | return result 147 | } 148 | -------------------------------------------------------------------------------- /tw/state.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | // State represents an on/off state 4 | type State int 5 | 6 | // Public: Methods for State type 7 | 8 | // Enabled checks if the state is on 9 | func (o State) Enabled() bool { return o == Success } 10 | 11 | // Default checks if the state is unknown 12 | func (o State) Default() bool { return o == Unknown } 13 | 14 | // Disabled checks if the state is off 15 | func (o State) Disabled() bool { return o == Fail } 16 | 17 | // Toggle switches the state between on and off 18 | func (o State) Toggle() State { 19 | if o == Fail { 20 | return Success 21 | } 22 | return Fail 23 | } 24 | 25 | // Cond executes a condition if the state is enabled 26 | func (o State) Cond(c func() bool) bool { 27 | if o.Enabled() { 28 | return c() 29 | } 30 | return false 31 | } 32 | 33 | // Or returns this state if enabled, else the provided state 34 | func (o State) Or(c State) State { 35 | if o.Enabled() { 36 | return o 37 | } 38 | return c 39 | } 40 | 41 | // String returns the string representation of the state 42 | func (o State) String() string { 43 | if o.Enabled() { 44 | return "on" 45 | } 46 | 47 | if o.Disabled() { 48 | return "off" 49 | } 50 | return "undefined" 51 | } 52 | -------------------------------------------------------------------------------- /tw/tw.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | // Operation Status Constants 4 | // Used to indicate the success or failure of operations 5 | const ( 6 | Pending = 0 // Operation failed 7 | Fail = -1 // Operation failed 8 | Success = 1 // Operation succeeded 9 | 10 | MinimumColumnWidth = 8 11 | ) 12 | 13 | const ( 14 | Empty = "" 15 | Skip = "" 16 | Space = " " 17 | NewLine = "\n" 18 | Column = ":" 19 | Dash = "-" 20 | ) 21 | 22 | // Feature State Constants 23 | // Represents enabled/disabled states for features 24 | const ( 25 | Unknown State = Pending // Feature is enabled 26 | On State = Success // Feature is enabled 27 | Off State = Fail // Feature is disabled 28 | ) 29 | 30 | // Table Alignment Constants 31 | // Defines text alignment options for table content 32 | const ( 33 | AlignNone Align = "none" // Center-aligned text 34 | AlignCenter Align = "center" // Center-aligned text 35 | AlignRight Align = "right" // Right-aligned text 36 | AlignLeft Align = "left" // Left-aligned text 37 | AlignDefault = AlignLeft // Left-aligned text 38 | ) 39 | 40 | const ( 41 | Header Position = "header" // Table header section 42 | Row Position = "row" // Table row section 43 | Footer Position = "footer" // Table footer section 44 | ) 45 | 46 | const ( 47 | LevelHeader Level = iota // Topmost line position 48 | LevelBody // LevelBody line position 49 | LevelFooter // LevelFooter line position 50 | ) 51 | 52 | const ( 53 | LocationFirst Location = "first" // Topmost line position 54 | LocationMiddle Location = "middle" // LevelBody line position 55 | LocationEnd Location = "end" // LevelFooter line position 56 | ) 57 | 58 | const ( 59 | SectionHeader = "heder" 60 | SectionRow = "row" 61 | SectionFooter = "footer" 62 | ) 63 | 64 | // Text Wrapping Constants 65 | // Defines text wrapping behavior in table cells 66 | const ( 67 | WrapNone = iota // No wrapping 68 | WrapNormal // Standard word wrapping 69 | WrapTruncate // Truncate text with ellipsis 70 | WrapBreak // Break words to fit 71 | ) 72 | 73 | // Cell Merge Constants 74 | // Specifies cell merging behavior in tables 75 | 76 | const ( 77 | MergeNone = iota // No merging 78 | MergeVertical // Merge cells vertically 79 | MergeHorizontal // Merge cells horizontally 80 | MergeBoth // Merge both vertically and horizontally 81 | MergeHierarchical // Hierarchical merging 82 | ) 83 | 84 | // Special Character Constants 85 | // Defines special characters used in formatting 86 | const ( 87 | CharEllipsis = "…" // Ellipsis character for truncation 88 | CharBreak = "↩" // Break character for wrapping 89 | ) 90 | 91 | type Spot int 92 | 93 | const ( 94 | SpotNone Spot = iota 95 | SpotTopLeft 96 | SpotTopCenter 97 | SpotTopRight 98 | SpotBottomLeft 99 | SpotBottomCenter // Default for legacy SetCaption 100 | SpotBottomRight 101 | SpotLeftTop 102 | SpotLeftCenter 103 | SpotLeftBottom 104 | SpotRightTop 105 | SpotRightCenter 106 | SpotRIghtBottom 107 | ) 108 | -------------------------------------------------------------------------------- /tw/types.go: -------------------------------------------------------------------------------- 1 | // Package tw defines types and constants for table formatting and configuration, 2 | // including validation logic for various table properties. 3 | package tw 4 | 5 | import ( 6 | "fmt" 7 | "github.com/olekukonko/errors" 8 | "strings" 9 | ) // Custom error handling library 10 | 11 | // Position defines where formatting applies in the table (e.g., header, footer, or rows). 12 | type Position string 13 | 14 | // Validate checks if the Position is one of the allowed values: Header, Footer, or Row. 15 | func (pos Position) Validate() error { 16 | switch pos { 17 | case Header, Footer, Row: 18 | return nil // Valid position 19 | } 20 | // Return an error for any unrecognized position 21 | return errors.New("invalid position") 22 | } 23 | 24 | // Filter defines a function type for processing cell content. 25 | // It takes a slice of strings (representing cell data) and returns a processed slice. 26 | type Filter func([]string) []string 27 | 28 | // Formatter defines an interface for types that can format themselves into a string. 29 | // Used for custom formatting of table cell content. 30 | type Formatter interface { 31 | Format() string // Returns the formatted string representation 32 | } 33 | 34 | // Align specifies the text alignment within a table cell. 35 | type Align string 36 | 37 | // Validate checks if the Align is one of the allowed values: None, Center, Left, or Right. 38 | func (a Align) Validate() error { 39 | switch a { 40 | case AlignNone, AlignCenter, AlignLeft, AlignRight: 41 | return nil // Valid alignment 42 | } 43 | // Return an error for any unrecognized alignment 44 | return errors.New("invalid align") 45 | } 46 | 47 | type Alignment []Align 48 | 49 | func (a Alignment) String() string { 50 | var str strings.Builder 51 | for i, a := range a { 52 | if i > 0 { 53 | str.WriteString("; ") 54 | } 55 | str.WriteString(fmt.Sprint(i)) 56 | str.WriteString("=") 57 | str.WriteString(string(a)) 58 | } 59 | return str.String() 60 | } 61 | 62 | func (a Alignment) Add(aligns ...Align) Alignment { 63 | aa := make(Alignment, len(aligns)) 64 | copy(aa, aligns) 65 | return aa 66 | } 67 | 68 | func (a Alignment) Set(col int, align Align) Alignment { 69 | if col >= 0 && col < len(a) { 70 | a[col] = align 71 | } 72 | return a 73 | } 74 | 75 | // Copy creates a new independent copy of the Alignment 76 | func (a Alignment) Copy() Alignment { 77 | aa := make(Alignment, len(a)) 78 | copy(aa, a) 79 | return aa 80 | } 81 | 82 | // Level indicates the vertical position of a line in the table (e.g., header, body, or footer). 83 | type Level int 84 | 85 | // Validate checks if the Level is one of the allowed values: Header, Body, or Footer. 86 | func (l Level) Validate() error { 87 | switch l { 88 | case LevelHeader, LevelBody, LevelFooter: 89 | return nil // Valid level 90 | } 91 | // Return an error for any unrecognized level 92 | return errors.New("invalid level") 93 | } 94 | 95 | // Location specifies the horizontal position of a cell or column within a table row. 96 | type Location string 97 | 98 | // Validate checks if the Location is one of the allowed values: First, Middle, or End. 99 | func (l Location) Validate() error { 100 | switch l { 101 | case LocationFirst, LocationMiddle, LocationEnd: 102 | return nil // Valid location 103 | } 104 | // Return an error for any unrecognized location 105 | return errors.New("invalid location") 106 | } 107 | 108 | type Caption struct { 109 | Text string 110 | Spot Spot 111 | Align Align 112 | Width int 113 | } 114 | 115 | func (c Caption) WithText(text string) Caption { 116 | c.Text = text 117 | return c 118 | } 119 | 120 | func (c Caption) WithSpot(spot Spot) Caption { 121 | c.Spot = spot 122 | return c 123 | } 124 | 125 | func (c Caption) WithAlign(align Align) Caption { 126 | c.Align = align 127 | return c 128 | } 129 | 130 | func (c Caption) WithWidth(width int) Caption { 131 | c.Width = width 132 | return c 133 | } 134 | 135 | type Control struct { 136 | Hide State 137 | } 138 | 139 | // Compact configures compact width optimization for merged cells. 140 | type Compact struct { 141 | Merge State // Merge enables compact width calculation during cell merging, optimizing space allocation. 142 | } 143 | 144 | // Behavior defines settings that control table rendering behaviors, such as column visibility and content formatting. 145 | type Behavior struct { 146 | AutoHide State // AutoHide determines whether empty columns are hidden. Ignored in streaming mode. 147 | TrimSpace State // TrimSpace enables trimming of leading and trailing spaces from cell content. 148 | 149 | Header Control // Header specifies control settings for the table header. 150 | Footer Control // Footer specifies control settings for the table footer. 151 | 152 | // Compact enables optimized width calculation for merged cells, such as in horizontal merges, 153 | // by systematically determining the most efficient width instead of scaling by the number of columns. 154 | Compact Compact 155 | } 156 | 157 | // Padding defines the spacing characters around cell content in all four directions. 158 | // A zero-value Padding struct will use the table's default padding unless Overwrite is true. 159 | type Padding struct { 160 | Left string 161 | Right string 162 | Top string 163 | Bottom string 164 | 165 | // Overwrite forces tablewriter to use this padding configuration exactly as specified, 166 | // even when empty. When false (default), empty Padding fields will inherit defaults. 167 | // 168 | // For explicit no-padding, use the PaddingNone constant instead of setting Overwrite. 169 | Overwrite bool 170 | } 171 | 172 | // Common padding configurations for convenience 173 | 174 | // Equals reports whether two Padding configurations are identical in all fields. 175 | // This includes comparing the Overwrite flag as part of the equality check. 176 | func (p Padding) Equals(padding Padding) bool { 177 | return p.Left == padding.Left && 178 | p.Right == padding.Right && 179 | p.Top == padding.Top && 180 | p.Bottom == padding.Bottom && 181 | p.Overwrite == padding.Overwrite 182 | } 183 | 184 | // Empty reports whether all padding strings are empty (all fields == ""). 185 | // Note that an Empty padding may still take effect if Overwrite is true. 186 | func (p Padding) Empty() bool { 187 | return p.Left == "" && p.Right == "" && p.Top == "" && p.Bottom == "" 188 | } 189 | 190 | // Paddable reports whether this Padding configuration should override existing padding. 191 | // Returns true if either: 192 | // - Any padding string is non-empty (!p.Empty()) 193 | // - Overwrite flag is true (even with all strings empty) 194 | // 195 | // This is used internally during configuration merging to determine whether to 196 | // apply the padding settings. 197 | func (p Padding) Paddable() bool { 198 | return !p.Empty() || p.Overwrite 199 | } 200 | --------------------------------------------------------------------------------