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 = "&amp;";
230     if (s == "<")
231       s = "&lt;";
232     if (s == ">")
233       s = "&gt;";
234     if (s == "\"")
235       s = "&quot;";
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 }