1 module tabletool; 2 3 import std.algorithm : canFind, max, min, map; 4 import std.array : array; 5 import std.conv : to; 6 import std.format : format; 7 import std.range : join, repeat, zip; 8 import std.traits : getUDAs, hasUDA; 9 import std.utf; 10 11 import eastasianwidth : displayWidth; 12 13 /// Option to specify the table style. 14 enum Style 15 { 16 simple, 17 markdown, 18 grid, 19 } 20 21 /// Option to specify the position of element in the cell. 22 enum Align 23 { 24 center, 25 left, 26 right, 27 } 28 29 /// Configurations for tabulate. 30 struct Config 31 { 32 Style style = Style.simple; 33 Align align_ = Align.center; 34 bool showHeader = true; 35 } 36 37 /// UDA to set display name of the struct member. 38 struct DisplayName 39 { 40 string name; 41 } 42 43 /// Detailed configurations to set table-wide appearance. 44 struct TableConfig 45 { 46 Style style = Style.simple; 47 string leftPadding = " "; 48 string rightPadding = " "; 49 bool showHeader = true; 50 } 51 52 /// Detailed configurations to set each column appearance. 53 struct ColumnConfig 54 { 55 size_t width; 56 string header = ""; 57 Align align_ = Align.center; 58 } 59 60 /** 61 * Tabulate array of array data. 62 * Params: 63 * data = An array of array of string compatible data 64 * headers = Headers for each columns 65 * config = A configuration to set appearance 66 * Returns: The table string 67 */ 68 string tabulate(T)(in T[][] data, in string[] headers, in Config config = Config()) 69 { 70 assert(data.length > 0); 71 assert(headers.length == 0 || data[0].length == headers.length); 72 73 auto actualHeaders = headers.length > 0 ? headers : "".repeat(data[0].length).array(); 74 auto widthes = calcWidthes(data, actualHeaders); 75 76 auto tableConfig = TableConfig(); 77 tableConfig.style = config.style; 78 tableConfig.showHeader = config.showHeader && headers.length > 0; 79 80 auto columnConfigs = zip(widthes, actualHeaders).map!(tup => ColumnConfig(tup[0], tup[1], config 81 .align_)).array(); 82 83 return tabulate(data, tableConfig, columnConfigs); 84 } 85 86 /// 87 unittest 88 { 89 const testdata = [ 90 ["D-man", "Programming Language"], 91 ["D言語くん", "プログラミング言語"], 92 ]; 93 const headers = ["マスコットキャラクタ", "about"]; 94 const reference = 95 " マスコットキャラクタ about \n" ~ 96 "---------------------- ----------------------\n" ~ 97 " D-man Programming Language \n" ~ 98 " D言語くん プログラミング言語 "; 99 assert(tabulate(testdata, headers, Config(Style.simple, Align.center, true)) == reference); 100 } 101 102 /** 103 * Tabulate array of array data (headerless version). 104 * 105 * In this version, config.showHeader will be ignored and header section of the 106 * table will be invisible. 107 * 108 * Params: 109 * data = An array of array of string compatible data 110 * config = A configuration to set appearance 111 * Returns: The table string 112 */ 113 string tabulate(T)(in T[][] data, in Config config = Config()) 114 { 115 return tabulate(data, [], config); 116 } 117 118 /// 119 unittest 120 { 121 const testdata = [ 122 ["D-man", "Programming Language"], 123 ["D言語くん", "プログラミング言語"], 124 ]; 125 const reference = 126 " D-man Programming Language \n" ~ 127 " D言語くん プログラミング言語 "; 128 assert(tabulate(testdata, Config(Style.simple, Align.center, true)) == reference); 129 } 130 131 /** 132 * Tabulate array of strut data. 133 * 134 * This version consume an array of struct. The headers will be extrated from 135 * members' name and each member should be able to convert to string. If some 136 * of members need to be re-named, an UDA DisplayName can be used. 137 * 138 * Params: 139 * data = An array of struct data 140 * config = A configuration to set appearance 141 * Returns: The table string 142 */ 143 string tabulate(T)(in T[] data, in Config config = Config()) if (is(T == struct)) 144 { 145 string[][] stringData; 146 string[] headers; 147 148 foreach (member; __traits(allMembers, T)) 149 { 150 static if (hasUDA!(__traits(getMember, T, member), DisplayName)) 151 { 152 enum displayName = getUDAs!(__traits(getMember, T, member), DisplayName)[0]; 153 headers ~= displayName.name; 154 } 155 else 156 { 157 headers ~= member; 158 } 159 } 160 foreach (d; data) 161 { 162 string[] line; 163 foreach (member; __traits(allMembers, T)) 164 { 165 line ~= __traits(getMember, d, member).to!string; 166 } 167 stringData ~= line; 168 } 169 return tabulate(stringData, headers, config); 170 } 171 172 /// 173 unittest 174 { 175 struct TestData 176 { 177 @DisplayName("マスコットキャラクタ") 178 string name; 179 string about; 180 } 181 182 const testdata = [ 183 TestData("D-man", "Programming Language"), 184 TestData("D言語くん", "プログラミング言語"), 185 ]; 186 const reference = 187 " マスコットキャラクタ about \n" ~ 188 "---------------------- ----------------------\n" ~ 189 " D-man Programming Language \n" ~ 190 " D言語くん プログラミング言語 "; 191 192 assert(tabulate(testdata, Config(Style.simple, Align.center, true)) == reference); 193 } 194 195 /** 196 * Tabulate an array of associative array. 197 * 198 * This version tabulates an array of associative array. The keys will be used 199 * as headers and there is no need to align each keys of each array elements. 200 * If some missing key exists in the one line, that cell will be empty. 201 * 202 * Params: 203 * data = An array of associative array data 204 * config = A configuration to set appearance 205 * Returns: The table string 206 */ 207 string tabulate(Key, Value)(in Value[Key][] data, in Config config = Config()) 208 { 209 string[][] stringData; 210 Key[] headers; 211 foreach (line; data) 212 { 213 foreach (key; line.byKey()) 214 { 215 if (!headers.canFind(key)) 216 { 217 headers ~= key; 218 } 219 } 220 } 221 foreach (line; data) 222 { 223 string toStr(Key h) 224 { 225 if (h in line) 226 { 227 return line[h].to!string; 228 } 229 else 230 { 231 return ""; 232 } 233 } 234 235 stringData ~= headers.map!(h => toStr(h)).array(); 236 } 237 string[] stringHeaders = headers.map!(h => h.to!string).array(); 238 return tabulate(stringData, stringHeaders, config); 239 } 240 241 /// 242 unittest 243 { 244 const testdata = [ 245 [ 246 "マスコットキャラクタ": "D-man", 247 "about": "Programming Language" 248 ], 249 [ 250 "マスコットキャラクタ": "D言語くん", 251 "about": "プログラミング言語" 252 ], 253 ]; 254 const reference = 255 " マスコットキャラクタ about \n" ~ 256 "---------------------- ----------------------\n" ~ 257 " D-man Programming Language \n" ~ 258 " D言語くん プログラミング言語 "; 259 assert(tabulate(testdata, Config(Style.simple, Align.center, true)) == reference); 260 } 261 262 /** 263 * Tabulate an array of array data with detailed configurations. 264 * 265 * This version uses TableConfig and an array of ColumnConfig instead of Config. 266 * TableConfig affects the whole table appearance and ColumnConfigs affect each 267 * columns' appearance. This can be used if you want to configure (e.g.) 268 * columns one-by-one. 269 * 270 * Params: 271 * data = An array of array data 272 * tableConfig = A table-wide configuration 273 * columnConfigs = Configurations for each columns (The length should match with data) 274 * Returns: The table string 275 */ 276 string tabulate(T)(in T[][] data, in TableConfig tableConfig, in ColumnConfig[] columnConfigs) 277 { 278 assert(data.length > 0); 279 assert(data[0].length == columnConfigs.length); 280 281 const ruler = Ruler(tableConfig.style); 282 const widthes = columnConfigs.map!(c => c.width).array(); 283 const aligns = columnConfigs.map!(c => c.align_).array(); 284 const widthForRuler = widthes.map!(w => w + displayWidth( 285 tableConfig.leftPadding) + displayWidth(tableConfig.rightPadding)).array(); 286 287 string[] lines; 288 289 if (auto top = ruler.top(widthForRuler)) 290 { 291 lines ~= top; 292 } 293 294 if (tableConfig.showHeader) 295 { 296 const headers = columnConfigs.map!(c => c.header).array(); 297 lines ~= makeItemLine( 298 headers, 299 widthes, 300 aligns, 301 ruler, 302 tableConfig.leftPadding, 303 tableConfig.rightPadding 304 ); 305 if (auto sep = ruler.headerItemSeperator(widthForRuler)) 306 { 307 lines ~= sep; 308 } 309 } 310 foreach (i, line; data) 311 { 312 lines ~= makeItemLine( 313 line, 314 widthes, 315 aligns, 316 ruler, 317 tableConfig.leftPadding, 318 tableConfig.rightPadding, 319 ); 320 if ((i + 1) != data.length) 321 { 322 if (auto sep = ruler.horizontalItemSeperator(widthForRuler)) 323 { 324 lines ~= sep; 325 } 326 } 327 } 328 if (auto bottom = ruler.bottom(widthForRuler)) 329 { 330 lines ~= bottom; 331 } 332 333 return lines.join("\n"); 334 } 335 336 /// 337 unittest 338 { 339 const testdata = [ 340 ["D-man", "Programming Language"], 341 ["D言語くん", "プログラミング言語"], 342 ]; 343 const tableConfig = TableConfig(Style.simple, " ", " ", true); 344 const columnConfigs = [ 345 ColumnConfig(20, "マスコットキャラクタ", Align.center), 346 ColumnConfig(10, "about", Align.center) 347 ]; 348 const reference = 349 " マスコットキャラクタ about \n" ~ 350 "---------------------- ------------\n" ~ 351 " D-man ..ming L.. \n" ~ 352 " D言語くん ..ラミン.. "; 353 assert(tabulate(testdata, tableConfig, columnConfigs) == reference); 354 } 355 356 private string alignment(string text, Align align_, size_t width) 357 { 358 static immutable dotTable = ["", ".", ".."]; 359 if (width == 0) 360 { 361 return ""; 362 } 363 const textWidth = displayWidth(text); 364 if (textWidth > width) 365 { 366 with (Align) final switch (align_) 367 { 368 case left: 369 return cutRight(text, width.to!int - 2) ~ dotTable[min(width, 2)]; 370 case right: 371 return dotTable[min(width, 2)] ~ cutLeft(text, width.to!int - 2); 372 case center: 373 const c = cutBoth(text, width.to!int - 4); 374 const l = min(width / 2, 2); 375 const r = min(width / 2 + width % 2, 2); 376 return dotTable[l] ~ c ~ dotTable[r]; 377 } 378 } 379 else 380 { 381 with (Align) final switch (align_) 382 { 383 case left: 384 return format!"%-s%-s"(text, ' '.repeat(width - textWidth)); 385 case right: 386 return format!"%-s%-s"(' '.repeat(width - textWidth), text); 387 case center: 388 const l = (width - textWidth) / 2; 389 const r = (width - textWidth) / 2 + (width - textWidth) % 2; 390 return format!"%-s%-s%-s"(' '.repeat(l), text, ' '.repeat(r)); 391 } 392 } 393 } 394 395 private string cutRight(string text, int width) 396 { 397 if (width <= 0) 398 { 399 return ""; 400 } 401 for (int c = count(text).to!int - 1; displayWidth(text) > width; c--) 402 { 403 text = text[0 .. toUTFindex(text, c)]; 404 } 405 return width > displayWidth(text) ? text ~ "." : text; 406 } 407 408 private string cutLeft(string text, int width) 409 { 410 if (width <= 0) 411 { 412 return ""; 413 } 414 while (displayWidth(text) > width) 415 { 416 text = text[toUTFindex(text, 1) .. $]; 417 } 418 return width > displayWidth(text) ? "." ~ text : text; 419 } 420 421 private string cutBoth(string text, int width) 422 { 423 if (width <= 0) 424 { 425 return ""; 426 } 427 bool cutLeftSide = false; 428 while (displayWidth(text) > width) 429 { 430 if (cutLeftSide) 431 { 432 text = text[toUTFindex(text, 1) .. $]; 433 } 434 else 435 { 436 text = text[0 .. toUTFindex(text, count(text).to!int - 1)]; 437 } 438 cutLeftSide = !cutLeftSide; 439 } 440 return width > displayWidth(text) ? text ~ "." : text; 441 } 442 443 unittest 444 { 445 string a = "こんにちは"; 446 447 assert(alignment(a, Align.left, 12) == "こんにちは "); 448 assert(alignment(a, Align.left, 11) == "こんにちは "); 449 assert(alignment(a, Align.left, 10) == "こんにちは"); 450 assert(alignment(a, Align.left, 9) == "こんに..."); 451 assert(alignment(a, Align.left, 8) == "こんに.."); 452 assert(alignment(a, Align.left, 3) == "..."); 453 assert(alignment(a, Align.left, 2) == ".."); 454 assert(alignment(a, Align.left, 1) == "."); 455 assert(alignment(a, Align.left, 0) == ""); 456 457 assert(alignment(a, Align.center, 12) == " こんにちは "); 458 assert(alignment(a, Align.center, 11) == "こんにちは "); 459 assert(alignment(a, Align.center, 10) == "こんにちは"); 460 assert(alignment(a, Align.center, 9) == "..んに..."); 461 assert(alignment(a, Align.center, 8) == "..んに.."); 462 assert(alignment(a, Align.center, 5) == "....."); 463 assert(alignment(a, Align.center, 4) == "...."); 464 assert(alignment(a, Align.center, 3) == "..."); 465 assert(alignment(a, Align.center, 2) == ".."); 466 assert(alignment(a, Align.center, 1) == "."); 467 assert(alignment(a, Align.center, 0) == ""); 468 469 assert(alignment(a, Align.right, 12) == " こんにちは"); 470 assert(alignment(a, Align.right, 11) == " こんにちは"); 471 assert(alignment(a, Align.right, 10) == "こんにちは"); 472 assert(alignment(a, Align.right, 9) == "...にちは"); 473 assert(alignment(a, Align.right, 8) == "..にちは"); 474 assert(alignment(a, Align.right, 3) == "..."); 475 assert(alignment(a, Align.right, 2) == ".."); 476 assert(alignment(a, Align.right, 1) == "."); 477 assert(alignment(a, Align.right, 0) == ""); 478 } 479 480 private string makeItemLine(T)( 481 in T[] line, 482 in size_t[] widthes, 483 in Align[] aligns, 484 in Ruler ruler, 485 in string leftPadding, 486 in string rightPadding, 487 ) 488 { 489 return ruler.left() 490 ~ zip(line, aligns, widthes) 491 .map!(tup => leftPadding ~ alignment(tup[0].to!string, tup[1], tup[2]) ~ rightPadding) 492 .join(ruler.vertical()) 493 ~ ruler.right(); 494 } 495 496 unittest 497 { 498 string[] line = ["a", "ab", "abc", "abcd", "abcde"]; 499 size_t[] widthes = [6, 5, 4, 3, 2]; 500 Ruler ruler = Ruler(Style.markdown); 501 string leftPadding = "*"; 502 string rightPadding = "^^"; 503 Align[] aligns = [ 504 Align.left, Align.right, Align.center, Align.left, Align.right 505 ]; 506 assert(makeItemLine(line, widthes, aligns, ruler, leftPadding, rightPadding) 507 == "|*a ^^|* ab^^|*abc ^^|*a..^^|*..^^|"); 508 } 509 510 private size_t[] calcWidthes(T)(in T[][] data, in string[] headers) 511 { 512 assert(data.length > 0); 513 assert(data[0].length == headers.length); 514 515 auto widthes = headers.map!(h => displayWidth(h)).array(); 516 517 foreach (line; data) 518 { 519 assert(line.length == widthes.length); 520 foreach (i; 0 .. widthes.length) 521 { 522 widthes[i] = max(widthes[i], displayWidth(line[i])); 523 } 524 } 525 return widthes; 526 } 527 528 /// Nethack(vi) style function naming 529 private struct Ruler 530 { 531 Style style; 532 533 enum Index 534 { 535 HL, // ─ 536 JK, // │ 537 JL, // ┌ 538 HJ, // ┐ 539 HK, // ┘ 540 KL, // └ 541 JKL, // ├ 542 HJL, // ┬ 543 HJK, // ┤ 544 HKL, // ┴ 545 HJKL, // ┼ 546 } 547 548 private static immutable string[] simpleLiterals = [ 549 "-", " ", "", "", "", "", "", "", "", "", " " 550 ]; 551 private static immutable string[] markdownLiterals = [ 552 "-", "|", "", "", "", "", "|", "", "|", "", "|" 553 ]; 554 private static immutable string[] gridLiterals = [ 555 "─", "│", "┌", "┐", "┘", "└", "├", "┬", "┤", "┴", 556 "┼" 557 ]; 558 559 private immutable(string)[] select() const @nogc nothrow pure 560 { 561 with (Style) final switch (style) 562 { 563 case simple: 564 return simpleLiterals; 565 case markdown: 566 return markdownLiterals; 567 case grid: 568 return gridLiterals; 569 } 570 } 571 572 string get(Index index) const 573 { 574 const target = select(); 575 return target[index.to!int]; 576 } 577 578 string horizontalItemSeperator(const size_t[] widthes) const 579 { 580 with (Style) final switch (style) 581 { 582 case simple, markdown: 583 return null; 584 case grid: 585 return makeHorizontal(widthes, get(Index.HL), get(Index.HJKL), get(Index.JKL), get( 586 Index.HJK)); 587 } 588 } 589 590 string headerItemSeperator(const size_t[] widthes) const 591 { 592 return makeHorizontal(widthes, get(Index.HL), get(Index.HJKL), get(Index.JKL), get( 593 Index.HJK)); 594 } 595 596 string left() const 597 { 598 with (Style) final switch (style) 599 { 600 case simple: 601 return ""; 602 case markdown, grid: 603 return get(Index.JK); 604 } 605 } 606 607 string right() const 608 { 609 with (Style) final switch (style) 610 { 611 case simple: 612 return ""; 613 case markdown, grid: 614 return get(Index.JK); 615 } 616 } 617 618 string vertical() const 619 { 620 return get(Index.JK); 621 } 622 623 string top(const size_t[] widthes) const 624 { 625 with (Style) final switch (style) 626 { 627 case simple, markdown: 628 return null; 629 case grid: 630 return makeHorizontal(widthes, get(Index.HL), get(Index.HJL), get(Index.JL), get( 631 Index.HJ)); 632 } 633 } 634 635 string bottom(const size_t[] widthes) const 636 { 637 with (Style) final switch (style) 638 { 639 case simple, markdown: 640 return null; 641 case grid: 642 return makeHorizontal(widthes, get(Index.HL), get(Index.HKL), get(Index.KL), get( 643 Index.HK)); 644 } 645 } 646 647 private static string makeHorizontal(const size_t[] widthes, string h, string p, string l, string r) 648 { 649 return format!"%-s%-s%-s"(l, widthes.map!(w => h.repeat(w).join()).join(p), r); 650 } 651 } 652 653 unittest 654 { 655 const ruler = Ruler(Style.simple); 656 size_t[] widthes = [1, 2, 3]; 657 658 assert(ruler.horizontalItemSeperator(widthes) is null); 659 assert(ruler.headerItemSeperator(widthes) == "- -- ---"); 660 assert(ruler.top(widthes) is null); 661 assert(ruler.bottom(widthes) is null); 662 assert(ruler.left() == ""); 663 assert(ruler.right() == ""); 664 assert(ruler.vertical() == " "); 665 } 666 667 unittest 668 { 669 const ruler = Ruler(Style.markdown); 670 size_t[] widthes = [1, 2, 3]; 671 672 assert(ruler.horizontalItemSeperator(widthes) is null); 673 assert(ruler.headerItemSeperator(widthes) == "|-|--|---|"); 674 assert(ruler.top(widthes) is null); 675 assert(ruler.bottom(widthes) is null); 676 assert(ruler.left() == "|"); 677 assert(ruler.right() == "|"); 678 assert(ruler.vertical() == "|"); 679 } 680 681 unittest 682 { 683 const ruler = Ruler(Style.grid); 684 size_t[] widthes = [1, 2, 3]; 685 686 assert(ruler.horizontalItemSeperator(widthes) == "├─┼──┼───┤"); 687 assert(ruler.headerItemSeperator(widthes) == "├─┼──┼───┤"); 688 assert(ruler.top(widthes) == "┌─┬──┬───┐"); 689 assert(ruler.bottom(widthes) == "└─┴──┴───┘"); 690 assert(ruler.left() == "│"); 691 assert(ruler.right() == "│"); 692 assert(ruler.vertical() == "│"); 693 }