1 /* 2 This file is part of ath (ansi to html), ported from aha 3 4 Copyright (C) 2022 Malte Jürgens <maltejur@dismail.de> 5 Copyright (C) 2012-2021 Alexander Matthes (Ziz) , ziz_at_mailbox.org 6 7 ath is free software: you can redistribute it and/or modify it under the 8 terms of the GNU Lesser General Public License as published by the Free 9 Software Foundation, either version 3 of the License, or (at your option) any 10 later version. 11 12 ath is distributed in the hope that it will be useful, but WITHOUT ANY 13 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A 14 PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15 details. 16 17 You should have received a copy of the GNU Lesser General Public License along 18 with ath. If not, see <https://www.gnu.org/licenses/>. 19 */ 20 21 /// ath is a library for converting text containing ANSI escape sequences to HTML. 22 module ath; 23 24 import std.format : format; 25 import std.stdio : File, stdout, stderr, writef, writefln; 26 import std.process : Pipe, pipe; 27 import std.array : join; 28 import std.ascii : newline; 29 import std.algorithm : max, min; 30 import std.algorithm.mutation : swap; 31 import core.stdc.stdio : fgetc, EOF, FILE; 32 33 class AthException : Exception 34 { 35 this( 36 string msg, 37 string file = __FILE__, 38 size_t line = __LINE__, 39 Throwable nextInChain = null 40 ) pure nothrow @nogc @safe 41 { 42 super(msg, file, line, nextInChain); 43 } 44 } 45 46 /// Options for the ath library. 47 struct AthOptions 48 { 49 /** 50 By default ath keeps the input stream in a buffer, allowing for cursor sequences 51 to be used. Use this option to disable that behaviour and directly pass the 52 input stream through. May improve performance and memory consumption. Only 53 really makes sense if you use the piped version of the ansi_to_html function. 54 */ 55 bool noBuffer = false; 56 57 /** 58 Use a dark color scheme. 59 */ 60 bool dark = false; 61 62 /** 63 Generate a whole HTML document instead of just the <pre>...</pre> tag. 64 */ 65 bool document = false; 66 67 /** 68 Leave the <pre> tags out of the output. 69 */ 70 bool noPre = false; 71 } 72 73 private const string[] palleteDark = [ 74 "dimgray", "red", "lime", "yellow", "#3333FF", "fuchsia", "aqua", "white", 75 "black", "white" 76 ]; 77 private const string[] palleteLight = [ 78 "dimgray", "red", "green", "olive", "blue", "purple", "teal", "gray", "white", 79 "black" 80 ]; 81 82 private enum ColorMode 83 { 84 MODE_3BIT, 85 MODE_8BIT, 86 MODE_24BIT 87 } 88 89 private struct State 90 { 91 int fc; 92 int bc; 93 bool bold; 94 bool faint; 95 bool italic; 96 bool underline; 97 bool blink; 98 bool crossedout; 99 bool inverted; 100 bool hidden; 101 bool overlined; 102 ColorMode fc_colormode; 103 ColorMode bc_colormode; 104 bool fc_highlighted; 105 bool bc_highlighted; 106 } 107 108 private struct Cell 109 { 110 char value; 111 State state; 112 bool empty = true; 113 114 this(char value, State state) 115 { 116 this.value = value; 117 this.state = state; 118 this.empty = false; 119 } 120 } 121 122 private const State defaultState = { 123 fc: -1, 124 bc: -1, 125 bold: false, 126 faint: false, 127 italic: false, 128 underline: false, 129 blink: false, 130 crossedout: false, 131 inverted: false, 132 hidden: false, 133 overlined: false, 134 fc_colormode: ColorMode.MODE_3BIT, 135 bc_colormode: ColorMode.MODE_3BIT, 136 fc_highlighted: false, 137 bc_highlighted: false 138 }; 139 140 private int getNextChar(FILE* fp) 141 { 142 143 int c; 144 if ((c = fgetc(fp)) != EOF) 145 { 146 // stderr.write(cast(char)(c)); 147 return c; 148 } 149 150 throw new AthException("Unexpected EOF"); 151 } 152 153 private string make_rgb(int color_id) 154 { 155 if (color_id < 16 || color_id > 255) 156 return "#000000"; 157 if (color_id >= 232) 158 { 159 int index = color_id - 232; 160 int grey = index * 256 / 24; 161 return format("#%02x%02x%02x", grey, grey, grey); 162 } 163 164 int index_R = (color_id - 16) / 36; 165 int rgb_R; 166 if (index_R > 0) 167 rgb_R = 55 + index_R * 40; 168 else 169 rgb_R = 0; 170 171 int index_G = ((color_id - 16) % 36) / 6; 172 int rgb_G; 173 if (index_G > 0) 174 rgb_G = 55 + index_G * 40; 175 else 176 rgb_G = 0; 177 178 int index_B = ((color_id - 16) % 6); 179 int rgb_B; 180 if (index_B > 0) 181 rgb_B = 55 + index_B * 40; 182 else 183 rgb_B = 0; 184 185 return format("#%02x%02x%02x", rgb_R, rgb_G, rgb_B); 186 } 187 188 private void swapColors(State* state) 189 { 190 state.inverted = !state.inverted; 191 192 if (state.bc_colormode == ColorMode.MODE_3BIT && state.bc == -1) 193 state.bc = -2; 194 if (state.fc_colormode == ColorMode.MODE_3BIT && state.fc == -1) 195 state.fc = -2; 196 197 swap(state.fc, state.bc); 198 swap(state.fc_colormode, state.bc_colormode); 199 swap(state.fc_highlighted, state.bc_highlighted); 200 } 201 202 /** 203 Takes the contents of the passed input `File`, converts the 204 contained ANSI to HTML and writes the result to the passed 205 output `File`. 206 */ 207 void ansi_to_html(File input, File output, AthOptions options = AthOptions()) 208 { 209 Cell[][] buf; 210 int c; 211 int line = 0; 212 int saved_line = 0; 213 int col = 0; 214 int saved_col = 0; 215 State state = defaultState; 216 State oldState = defaultState; 217 218 void ensureBuffer() 219 { 220 buf.length = max(buf.length, line + 1); 221 buf[line].length = max(buf[line].length, col + 1); 222 } 223 224 string escapeHtml(char c) 225 { 226 string s = [c]; 227 228 if (s == "&") 229 s = "&"; 230 if (s == "<") 231 s = "<"; 232 if (s == ">") 233 s = ">"; 234 if (s == "\"") 235 s = """; 236 237 return s; 238 } 239 240 void write(char c) 241 { 242 if (options.noBuffer) 243 output.write(escapeHtml(c)); 244 else 245 { 246 ensureBuffer(); 247 buf[line][col] = Cell(c, state); 248 } 249 } 250 251 void handleNewState(State oldState, State newState) 252 { 253 //Checking the differences 254 if (newState != oldState) 255 { 256 257 // If old state was different than the default one, close the current <span> 258 if (oldState != defaultState) 259 output.write("</span>"); 260 261 // Open new <span> if current state differs from the default one 262 if (newState != defaultState) 263 { 264 output.write("<span style=\""); 265 266 if (newState.bold) 267 output.write("font-weight:bold;"); 268 else if (newState.faint) 269 output.write("font-weight:lighter;"); 270 271 if (newState.italic) 272 output.write("font-style:italic;"); 273 274 if (newState.underline || newState.blink || newState.crossedout || newState.overlined) 275 { 276 output.write("text-decoration:"); 277 278 if (newState.underline) 279 output.write(" underline"); 280 if (newState.blink) 281 output.write(" blink"); 282 if (newState.crossedout) 283 output.write(" line-through"); 284 if (newState.overlined) 285 output.write(" overline"); 286 287 output.write(";"); 288 } 289 290 if (newState.hidden) 291 output.write("opacity:0;"); 292 293 if (newState.fc_highlighted || newState.bc_highlighted) 294 output.write("filter: contrast(70%) brightness(190%);"); 295 296 const string default_fc_color = options.dark ? "lightgray" : "black"; 297 string fc_color = default_fc_color; 298 final switch (newState.fc_colormode) 299 { 300 case ColorMode.MODE_3BIT: 301 if (newState.fc >= 0 && newState.fc <= 9) 302 fc_color = options.dark ? palleteDark[newState.fc] : palleteLight[newState.fc]; 303 break; 304 305 case ColorMode.MODE_8BIT: 306 if (newState.fc >= 0 && newState.fc <= 7) 307 fc_color = options.dark ? palleteDark[newState.fc] : palleteLight[newState.fc]; 308 else 309 fc_color = make_rgb(newState.fc); 310 break; 311 312 case ColorMode.MODE_24BIT: 313 fc_color = format("#%06x", newState.fc); 314 break; 315 } 316 317 const string default_bc_color = options.dark ? "black" : "lightgray"; 318 string bc_color = default_bc_color; 319 final switch (newState.bc_colormode) 320 { 321 case ColorMode.MODE_3BIT: 322 if (newState.bc >= 0 && newState.bc <= 9) 323 bc_color = options.dark ? palleteDark[newState.bc] : palleteLight[newState.bc]; 324 break; 325 326 case ColorMode.MODE_8BIT: 327 if (newState.bc >= 0 && newState.bc <= 7) 328 bc_color = options.dark ? palleteDark[newState.bc] : palleteLight[newState.bc]; 329 else 330 bc_color = make_rgb(newState.bc); 331 break; 332 333 case ColorMode.MODE_24BIT: 334 bc_color = format("#%06x", newState.bc); 335 break; 336 } 337 338 if (newState.inverted) 339 swap(fc_color, bc_color); 340 341 if (fc_color != default_fc_color) 342 output.write(format("color:%s;", fc_color)); 343 if (bc_color != default_bc_color) 344 output.write(format("background-color:%s;", bc_color)); 345 346 output.write("\">"); 347 } 348 } 349 } 350 351 /// Make sure new state is still picked up when jumping to new cursor position 352 void saveStateEmpty() 353 { 354 ensureBuffer(); // buf[line] is accessed, gotta make sure it exists 355 if (col + 1 >= buf[line].length || buf[line][col + 1].empty) 356 { 357 col += 1; 358 ensureBuffer(); 359 buf[line][col] = Cell(' ', state); 360 col -= 1; 361 } 362 } 363 364 void newline() 365 { 366 if (options.noBuffer) 367 output.write("\n"); 368 else 369 saveStateEmpty(); 370 line += 1; 371 col = 0; 372 } 373 374 void carriageReturn() 375 { 376 if (options.noBuffer) 377 return; 378 saveStateEmpty(); 379 col = 0; 380 } 381 382 void moveCursor(int deltaX, int deltaY) 383 { 384 if (options.noBuffer) 385 return; 386 387 saveStateEmpty(); 388 389 // Ensure bounds 390 line = max(line + deltaY, 0); 391 col = max(col + deltaX, 0); 392 } 393 394 FILE* fp = input.getFP(); 395 396 if (options.document) 397 { 398 output.writeln("<!DOCTYPE html> 399 <head> 400 <meta charset=\"UTF-8\"> 401 <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"> 402 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"> 403 <title>ath</title>"); 404 if (options.dark) 405 output.writeln("<style>body{background-color:black;color:lightgray;}</style>"); 406 output.writeln("</head> 407 <body> 408 <pre>"); 409 } 410 else if (!options.noPre) 411 { 412 if (options.dark) 413 output.write("<pre style=\"background-color:black;color:lightgray;\">"); 414 else 415 output.write("<pre>"); 416 } 417 418 while ((c = fgetc(fp)) != EOF) 419 { 420 // ESC 421 if (c == '\033') 422 { 423 // Searching the end (a letter) and safe the insert: 424 c = getNextChar(fp); 425 if (c == '[') // CSI code, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors 426 { 427 int[] elems; 428 int elem = 0; 429 int value = 0; 430 431 while (true) 432 { 433 c = getNextChar(fp); 434 435 if (((c >= 'A') && (c <= 'Z')) || ((c >= 'a') && (c <= 'z'))) 436 { 437 elems ~= value; 438 break; 439 } 440 441 if (c == ';' || c == ':' || c == 0) 442 { 443 elems ~= value; 444 value = 0; 445 continue; 446 } 447 448 value = (value * 10) + (c - '0'); 449 } 450 451 switch (c) 452 { 453 case 'A': // Cursor Up 454 int deltaY = elems[0] == 0 ? -1 : -elems[0]; 455 moveCursor(0, deltaY); 456 break; 457 458 case 'B': // Cursor Down 459 int deltaY = elems[0] == 0 ? 1 : elems[0]; 460 moveCursor(0, deltaY); 461 break; 462 463 case 'C': // Cursor Forward 464 int deltaX = elems[0] == 0 ? 1 : elems[0]; 465 moveCursor(deltaX, 0); 466 break; 467 468 case 'D': // Cursor Back 469 int deltaX = elems[0] == 0 ? -1 : -elems[0]; 470 moveCursor(deltaX, 0); 471 break; 472 473 case 'E': // Cursor Next Line 474 carriageReturn(); 475 int deltaY = elems[0] == 0 ? 1 : elems[0]; 476 moveCursor(0, deltaY); 477 break; 478 479 case 'F': // Cursor Previous Line 480 carriageReturn(); 481 int deltaY = elems[0] == 0 ? -1 : -elems[0]; 482 moveCursor(0, deltaY); 483 break; 484 485 case 'G': // Cursor Horizontal Absolute 486 int newCol = elems[0] == 0 ? 1 : elems[0]; 487 moveCursor(newCol - 1 - col, 0); 488 break; 489 490 case 'H': // Cursor Position 491 case 'f': // Horizontal Vertical Position 492 int newLine = elems[0] == 0 ? 1 : elems[0]; 493 int newCol = (elems.length == 1 || elems[1] == 0) ? 1 : elems[1]; 494 moveCursor(newCol - 1 - col, newLine - 1 - line); 495 break; 496 497 case 'J': // Erase in Display 498 switch (elems[0]) 499 { 500 case 0: // Clear from cursor to end of screen 501 buf.length = line + 1; 502 for (int i = col; i < buf[line].length; i += 1) 503 buf[line][col].empty = true; 504 break; 505 506 case 1: // Clear from cursor to beginning of the screen 507 ulong height = buf.length; 508 Cell[][] tmp = buf[line .. buf.length]; 509 buf.length = 0; 510 buf.length = height; 511 buf[line .. buf.length] = tmp; 512 for (int i = col; i >= 0; i -= 1) 513 buf[line][col].empty = true; 514 break; 515 516 case 2: 517 case 3: 518 buf.length = 0; 519 buf.length = line + 1; 520 break; 521 522 default: 523 break; 524 } 525 break; 526 527 case 'K': // Erase in Line 528 switch (elems[0]) 529 { 530 531 case 0: // Clear from cursor to the end of the line 532 for (int i = col; i < buf[line].length; i += 1) 533 buf[line][col].empty = true; 534 break; 535 536 case 1: // Clear from cursor to beginning of the line 537 for (int i = col; i >= 0; i -= 1) 538 buf[line][col].empty = true; 539 break; 540 541 case 2: 542 for (int i = 0; i < buf[line].length; i += 1) 543 buf[line][col].empty = true; 544 break; 545 546 default: 547 break; 548 } 549 break; 550 551 case 'm': 552 while (elem < elems.length) 553 { 554 switch (elems[elem]) 555 { 556 case 0: // 0 - Reset all 557 state = defaultState; 558 break; 559 560 case 1: // 1 - Enable Bold 561 state.bold = true; 562 break; 563 564 case 2: // 2 - Enable Faint 565 state.faint = true; 566 break; 567 568 case 3: // 3 - Enable Italic 569 state.italic = true; 570 break; 571 572 case 4: // 4 - Enable underline 573 state.underline = true; 574 break; 575 576 case 5: // 5 - Slow Blink 577 state.blink = true; 578 break; 579 580 case 7: // 7 - Inverse video 581 state.inverted = true; 582 break; 583 584 case 8: // 8 - Conceal or hide 585 state.hidden = true; 586 break; 587 588 case 9: // 9 - Enable hide 589 state.crossedout = true; 590 break; 591 592 case 21: // 21 - Reset bold 593 case 22: // 22 - Not bold, not "high intensity" color 594 state.bold = false; 595 state.faint = false; 596 break; 597 598 case 23: // 23 - Reset italic 599 state.italic = false; 600 break; 601 602 case 24: // 23 - Reset underline 603 state.underline = false; 604 break; 605 606 case 25: // 25 - Reset blink 607 state.blink = false; 608 break; 609 610 case 27: // 27 - Reset Inverted 611 state.inverted = false; 612 break; 613 614 case 28: // 28 - Reveal 615 state.hidden = false; 616 break; 617 618 case 29: // 29 - Reset crossed-out 619 state.crossedout = false; 620 break; 621 622 case 30: 623 case 31: 624 case 32: 625 case 33: 626 case 34: 627 case 35: 628 case 36: 629 case 37: // 30-37 - Set foreground color (3 bit) 630 state.fc_colormode = ColorMode.MODE_3BIT; 631 state.fc = elems[elem] - 30; 632 break; 633 634 case 38: 635 if (elems[elem + 1] == 5) // 38 - Set foreground color (8 bit) 636 { 637 state.fc_colormode = ColorMode.MODE_8BIT; 638 if (elems[elem + 2] >= 8 && elems[elem + 2] <= 15) 639 { 640 state.fc = elems[elem + 2] - 8; 641 state.fc_highlighted = true; 642 } 643 else 644 { 645 state.fc = elems[elem + 2]; 646 state.fc_highlighted = false; 647 } 648 elem += 2; 649 } 650 else if (elems[elem + 1] == 2) // 38 - Set foreground color (24 bit) 651 { 652 int r = elems[elem + 2]; 653 int g = elems[elem + 3]; 654 int b = elems[elem + 4]; 655 656 state.fc_colormode = ColorMode.MODE_24BIT; 657 state.fc = 658 (r & 255) * 65_536 + 659 (g & 255) * 256 + 660 (b & 255); 661 state.fc_highlighted = false; 662 663 elem += 4; 664 } 665 break; 666 667 case 39: // 39 - Default foreground color 668 state.fc_colormode = ColorMode.MODE_3BIT; 669 state.fc = -1; 670 state.fc_highlighted = false; 671 break; 672 673 case 40: 674 case 41: 675 case 42: 676 case 43: 677 case 44: 678 case 45: 679 case 46: 680 case 47: // 40-47 - Set background color (3 bit) 681 state.bc_colormode = ColorMode.MODE_3BIT; 682 state.bc = elems[elem] - 40; 683 break; 684 685 case 48: 686 if (elems[elem + 1] == 5) // 48 - Set background color (8 bit) 687 { 688 state.bc_colormode = ColorMode.MODE_8BIT; 689 if (elems[elem + 2] >= 8 && elems[elem + 2] <= 15) 690 { 691 state.bc = elems[elem + 2] - 8; 692 state.bc_highlighted = true; 693 } 694 else 695 { 696 state.bc = elems[elem + 2]; 697 state.bc_highlighted = false; 698 } 699 elem += 2; 700 } 701 else if (elems[elem + 1] == 2) // 48 - Set background color (24 bit) 702 { 703 int r = elems[elem + 2]; 704 int g = elems[elem + 3]; 705 int b = elems[elem + 4]; 706 707 state.bc_colormode = ColorMode.MODE_24BIT; 708 state.bc = 709 (r & 255) * 65_536 + 710 (g & 255) * 256 + 711 (b & 255); 712 state.bc_highlighted = false; 713 714 elem += 4; 715 } 716 break; 717 718 case 49: // 49 - Default background color 719 state.bc_colormode = ColorMode.MODE_3BIT; 720 state.bc = -1; 721 state.bc_highlighted = false; 722 break; 723 724 case 53: // 53 - Overlined 725 state.overlined = true; 726 break; 727 728 case 55: // 55 - Not overlined 729 state.overlined = false; 730 break; 731 732 default: 733 break; 734 } 735 elem += 1; 736 } 737 break; 738 739 case 's': 740 saved_line = line; 741 saved_col = col; 742 break; 743 744 case 'u': 745 line = saved_line; 746 col = saved_col; 747 buf.length = max(buf.length, line + 1); 748 break; 749 750 default: 751 break; 752 } 753 754 if (options.noBuffer) 755 handleNewState(oldState, state); 756 } 757 } 758 else if (c == '\n') 759 newline(); 760 else if (c == '\r') 761 carriageReturn(); 762 else 763 { 764 write(cast(char)(c)); 765 col += 1; 766 } 767 768 oldState = state; 769 } 770 771 if (!options.noBuffer) 772 foreach (size_t i, Cell[] lineContent; buf) 773 { 774 foreach (size_t y, Cell cell; lineContent) 775 if (!cell.empty) 776 { 777 handleNewState(oldState, cell.state); 778 output.write(escapeHtml(cell.value)); 779 oldState = cell.state; 780 } 781 else 782 { 783 handleNewState(oldState, oldState); 784 output.write(" "); 785 } 786 if (i + 1 != buf.length) 787 { 788 handleNewState(state, defaultState); 789 output.write("\n"); 790 } 791 } 792 793 handleNewState(state, defaultState); 794 795 if (options.document) 796 output.write("</pre> 797 </body> 798 </html>"); 799 else if (!options.noPre) 800 output.write("</pre>"); 801 802 output.close(); 803 } 804 805 /** 806 Takes the contents of the passed `File`, converts the 807 contained ANSI to HTML and returns a new `File` with the result. 808 */ 809 File ansi_to_html(File input, AthOptions options = AthOptions()) 810 { 811 Pipe output = pipe(); 812 ansi_to_html(input, output.writeEnd, options); 813 return output.readEnd; 814 } 815 816 /** 817 Converts a string containing ANSI escape sequences to HTML. 818 */ 819 string ansi_to_html(string ansi, AthOptions options = AthOptions()) 820 { 821 Pipe input = pipe(); 822 input.writeEnd.write(ansi); 823 input.writeEnd.close(); 824 File result = ansi_to_html(input.readEnd, options); 825 return cast(string)(result.byLine.join(newline)); 826 }