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 ism
).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 isH
).
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:
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
:
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
:
Similarly, append the 1-based cursor coordinates:
Finally, capture the pane content:
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:
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:
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
tof15
for the 16 standard foreground colors.b0
tob15
for the 16 standard background colors.b
,i
,u
,r
,s
for bold, italic, underline, reverse, strike.c2
andc6
for block and bar cursor shapes.
Most of them map directly to a CSS property:
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
:
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:
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:
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:
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):
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: