How to convert ANSI terminal content to HTML

11 min read

Most programming tutorials published on the web reproduce source code excerpts inside preformatted text blocks. When they involve terminal user interfaces and colored outputs, they may include a capture from a terminal. The simple approach is to take a screenshot, but it comes with the disadvantages of raster images: it doesn't scale up, the fonts lose legibility, you cannot copy the content or edit it, and it consumes more space. This article explains how to capture the content of a terminal in ANSI text format, how to convert it to HTML, and how to make it responsive like an image.

§
Capture

The first step is to capture the content of the terminal with tmux, including the visible text and the hidden formatting elements composed of ANSI escape sequences.

§
ANSI escape sequences

Terminals communicate with an application through a stream of bytes. Some of them represent visible text, while others are hidden control characters such as backspace, carriage return, or interrupt, which form an in-band communication mechanism. Escape sequences start with the control character ESC, followed by the Control Sequence Introducer CSI. They provide functions such as moving the cursor, changing the style of the text, or clearing the screen. The general format is ESC CSI B... F, where:

  • ESC is the escape control character (byte 0x1b or 0o33 in octal notation, often represented as ^[).
  • CSI is the Control Sequence Introducer (byte 0x9b or [ in ASCII).
  • B... corresponds to zero or more bytes. There is a distinction between parameter bytes in the range 0x30-0x3f (including numbers and ;) and intermediate bytes in the range 0x20-0x2f, but this article only deals with the former.
  • F is the final byte that terminates the sequence, in the range 0x40-0x7e.

For example:

  • ESC [ m clears the screen (the final byte is m).
  • ESC [ 31 m sets the foreground color to red (31 is the first parameter byte, coding for the foreground color red).
  • ESC [ 3 ; 43 H moves the cursor to the 3rd row and 43rd column (the arguments are separated by ; and the final byte is H).

Initially, ANSI terminals only supported 8 colors: Black, Red, Green, Yellow, Blue, Magenta, Cyan, White. Later, their bright variants were standardized, for a total of 16, identified by an index from 0 to 15. These colors, in addition to the default foreground and background, can be configured with a terminal color scheme. Further improvements brought new colors for a total of 256, and modern terminal emulators support 24-bit colors, defined by their RGB components.

For more information, see:

  • ANSI escape code on Wikipedia.
  • urxvt(7), section "Rxvt-unicode Technical Reference" for a concise list of the common control codes.
  • ECMA-48 for the technical specification.

§
Hard copy

The GNOME Terminal provides an option to export its content to HTML. If you want the true representation that you can view inside a terminal, you may prefer saving the content in ANSI text format first. Then, you can convert to HTML or any other format (including taking a screenshot).

Multiplexers such as tmux or screen provide a terminal agnostic solution to make a "hard copy" of their content. This article demonstrates how to do it with tmux.

Use the command resize-window to resize the window containing the pane with id 1 to a standard size suitable for publication, for instance, 80 columns by 24 rows:

$ tmux resize-window -x 80 -y 24 -t %1

Then, run capture-pane with the options -e to include the escape sequences, -p to print the output to stdout, -t %1 to target the pane with id 1, and redirect the output to the file demo.ans:

$ tmux capture-pane -e -p -t %1 > demo.ans

You can run clear; cat demo.ans in any terminal to display the captured content and you can also edit it with your preferred text editor.

Note that this capture doesn't have any intrinsic size. If all the lines are narrower than the terminal window, the trailing whitespaces are not reproduced in the output (unless you use the option -N). On the contrary, if you display it in a smaller terminal, longer lines may get wrapped. The capture doesn't indicate the position of the cursor either, as it is usually drawn by the terminal itself, not by the application.

§
Metadata

You can add extra escape sequences in the output file to provide additional information, such as the terminal size and the cursor position. The ANSI standard defines ESC [ r ; c R to return the cursor coordinates, where r and c are the 1-based row and column indices.

There are other escape sequences reserved for private use, identified by their final byte. For instance, Xterm uses ESC [ 9; h ; w t to return the size of the terminal, where h and w are the numbers of rows and columns.

From a shell script, you can start an escape sequence by calling printf with the argument \033, the octal representation of the ESC control character. With the help of the list-panes command, retrieve the pane size to complete the escape sequence and save it to demo.ans:

capture.sh
tmux list-panes -F "#{pane_height} #{pane_width}" -t %1 | {
	read r c
	printf '\033[9;%u;%ut' "$r" "$c" > demo.ans
}

Similarly, append the 1-based cursor coordinates:

capture.sh
tmux list-panes -F "#{cursor_y} #{cursor_x}" -t %1 | {
	read r c
	printf '\033[%u;%uR' "$((r + 1))" "$((c + 1))" >> demo.ans
}

Finally, capture the pane content:

capture.sh
tmux capture-pane -e -p -t %1 >> demo.ans

The capture file now contains the information to faithfully reproduce the view of the original terminal.

See scripts/tmux-capture.sh for the full capture script.

§
Rendering

This section explains how to take the raw ANSI text format, convert it to HTML, and make it responsive.

§
Conversion

To use the additional information added in the last section, you have to make your own interpreter. Thanks to the Rust terminal emulator project Alacritty, the crate vte provides all the logic needed to parse ANSI control codes. All that is left is to implement the Perform trait:

vte
trait Perform {
	// Output a visible character.
	fn print(&mut self, c: char);

	// Handle control characters (newlines).
	fn execute(&mut self, b: u8);

	// Handle escape sequences (changes in text attributes).
	fn csi_dispatch(
		&mut self,
		params: &Params, // Parameters separated by `;`.
		intermediates: &[u8],
		_ignore: bool,
		action: char     // Final byte.
	);
}

At the minimum, you should track the basic styles: foreground and background colors, bold, italic, underline, reverse, and the reset sequences. The text is rendered inside an HTML preformatted text block pre with consecutive span elements:

<pre class="ansi">Hello, <span class="f3 i">World</span>!</pre>

csi_dispatch is called for each escape sequence. Note that a single sequence can set multiple attributes at once. For instance, ESC [ 1 ; 4 m sets both the bold and underline properties. Anything that you don't handle will just get stripped from the output.

print is called for each visible character. If the current style differs from the last, you can close the current span of text, and start a new one with these new attributes. If the cursor is at the current position, it wraps the character inside a cursor span element.

execute allows you to handle control characters, especially newlines. If the cursor is past the end of the line, you need to append blank characters until you reach the cursor position; otherwise you will never encounter any character under the cursor. Similarly, styles like the text background color stop at the end of the line, so you also need to extend them up to the terminal width. This is why knowing the size of the terminal (or the output) is required.

There are other considerations such as HTML escaping. For the full implementation, see src/content/ansi.rs.

§
Styling

The inline elements inside the preformatted text block have short class names matching the text attributes:

  • f0 to f15 for the 16 standard foreground colors.
  • b0 to b15 for the 16 standard background colors.
  • b, i, u, r, s for bold, italic, underline, reverse, strike.
  • c2 and c6 for block and bar cursor shapes.

Most of them map directly to a CSS property:

ansi.css
.ansi .b {
	font-weight: bold;
}

.ansi .i {
	font-style: italic;
}

CSS variables help configure the foreground, the background, and the 16 standard ANSI colors that you can reverse by adding rules for the combined classes, for instance f0.r and b0.r:

ansi.css
:root {
	--ansi-fg: #fff;
	--ansi-bg: #000;
	--ansi-0:  #111;
	/* ... */
}

.ansi {
	color: var(--ansi-fg);
	background-color: var(--ansi-bg);
}

.ansi .f0, .ansi .b0.r {
	color: var(--ansi-0);
}

.ansi .b0, .ansi .f0.r {
	background-color: var(--ansi-0);
}

The preformatted text block has the width of its longest line. Assuming the text was recorded with an 80-column terminal, you can set the width based on the number of columns:

<pre class="ansi" style="width: 80ch">...</pre>

The unit ch is relative to the width of the character 0 in the target font. Since pre elements use a monospace font by default, all the characters have the same width: 80ch corresponds to 80 columns of text.

For the full stylesheet, see static/blog/ansi.css.

§
Scaling

The issue is that preformatted text blocks don't scale: either you have a scrollbar, or it overflows its parent. To scale a preformatted text block, the only property under your control is the font size.

The width depends on the font size, but also on the width of the characters and the number of columns. The width of a character is specific to each font, and if you don't use web fonts, it depends on the font configured by the user.

For an initial font size of 1rem, let --parent-width be the width of the container and --element-width the width of the preformatted text block in pixels. Computing the appropriate font size in CSS looks like this:

.ansi {
	font-size: calc(var(--parent-width) * 1rem / var(--element-width));
}

The only way to assign --element-width is by using a length in ch units. Unfortunately, the function calc is limited:

  • In a multiplication, at least one of the factors must be unitless.
  • In a division, the divisor must be unitless.

Furthermore, as soon as the font size changes, --element-width would also change. That rules out a pure CSS solution, but it is still possible to partly implement this computation in CSS if you notice that the division 1rem / var(--element-width) corresponds to a constant width-to-font-size ratio. With JavaScript, you can compute this ratio for each ANSI text block and set it in the font-size property:

element {
	font-size: calc(var(--parent-width) * 0.0013054830287206266rem);
}

Assuming the ANSI text containers have the same --parent-width, you can update its value when the page gets resized, and the browser will take care of recomputing the font size for each ANSI text block (which is more efficient than in a JavaScript loop):

ansi.js
const els = document.getElementsByClassName('ansi');

if (!els.length) {
	return;
}

for (let el of els) {
	// After resizing, the child can be slightly larger than its parent.
	el.parentElement.style['overflow-x'] = 'hidden';

	// Set the font size to 1rem to compute the ratio.
	el.style.fontSize = '1rem';

	// Width to font size ratio for this element (rem / px).
	const ratio = 1 / el.clientWidth;

	// Set the font size from `--parent-width` and limit it to 1em.
	el.style.fontSize = `min(1em, calc(var(--parent-width) * ${ratio}rem))`
}

// Update `--parent-width` on resize.
function resize() {
	document.documentElement.style.setProperty(
		'--parent-width',
		els[0].parentElement.clientWidth
	);
}

window.addEventListener('resize', function() {
	// You could add a debounce timeout.
	resize();
});

resize();

The text may get very close to the right border at certain sizes, so you can add some horizontal padding relative to the font size (in em) to keep the text away from the edges. The code doesn't change, because el.clientWidth already takes the padding into account (but make sure .ansi has box-sizing: content-box).

The downside of relying on JavaScript is that if it is disabled, the elements have a scrollbar similar to a code snippet, though I think it is an acceptable trade-off. (Actually, it can even be a feature to render colored command output in code snippets.)

For the full implementation, see static/blog/ansi.js.

§
Conclusion

As a demonstration, I leave you with this example that I hope you can decode to find out the topic of an upcoming article:

  l   c 2 -   > I             !     w e 0 v   d   ' D / f   Y o   C     x
  e   x   {   u P             }   h Y f # A   i   ~ " [ v   : [   J
  F   G   1   m +         q   '   +   z Y +   z   e ^ * l   Z F   R
  i o T   F     -         >   0   H   ] 3 h   ;   < m & +   Q #   r
y i n ?   < u             H   H ' a   l ! v   4   / 5 R <   /     H
f   g {   T M       V     (   d M C   w u m   _   } x | S   6     6 }
E   G /   s ]       Q   R a   . b )   } 2     "   ' 0 Q %   7     M "
!   < #   O j       [   V '   ~ X     9 "     ;   Q ( y w   :     [ :
d   H u   . m     b H   , y x Q N     l %     C     t r k   o     ,
V   J     ~       k -   H - d   z     S g   c "     1 0 @   v     w
    ~ i   ,   5   +     x # $   8     \ ,   j r     E 5 q         E
    { B   f   (   5     g S 1   }     ( j   z +     p n }       C "       }
      w       L   S     [ = y   I     l )   7 ]     l J Z       x z   g   P
  a   e       h   F     i u     {     u 9   f s G N t ^ %       x I   g   N   h
  ;   Z       /   L     @ P     +       B   L w 2 0   e e       - B   <   [   $
  N   0       "   Q     -       F       I   ; A P Y     d       .     e   ~   \
  X   x       P   k     l                   b z 5 S     m C     d         w   ;
  \   C       b   `         r   2           i * ! f     i 2     2         )   H
  8   G       >   7   2     g   d           F 1 i     <   C     V     R t *   j
  !   r       z   $   d     y   ^           j         ?   p     i     0 B % \ /
  =   4       )   &   n     B   U           l         G   z   7 a     & 7 q # H
              @   O   >     ;   4           r         )   B   9 a     u u D T <
              3   P   `     "   7         - k         *   L m Q E l   B ' , Y
              "   B   ^     1   n   k     \ g         b   | [ I i Q   3 0   z
Resize your window to see how well this capture from unimatrix scales. (Source)