Developer tools

ANSI to HTML

Terminal output with ANSI colors made into HTML: build logs, test runs and CLI output on a page with their colors, bold, links and progress lines as the terminal showed them, or stripped to plain text.

Version 1.0.0 · 4 functions · 0 crates

#terminal#html#logs#devops

Functions

What use ansi_html.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use ansi_to_html, ansi_to_html_with from ansi_html (Quillo 0.6).

ansi_to_html(text: str) → str

The text as HTML with inline styles, for a dark background: colors (16, 256 and 24-bit), bold, dim, italic, underline, strike-through and inverse; OSC 8 links as <a> (http, https and mailto only); other escape codes dropped; a line redrawn after a carriage return (a progress bar) as it ended. Everything else is escaped, so it is safe inside a <pre>.

ansi_to_html_with(text: str, options: map) → stror an error

ansi_to_html with options: theme "dark" or "light" (the 16 colors' shades), classes true for class="ansi-fg-red ansi-bold" in place of inline styles (with ansi_css for the sheet; 256 and 24-bit colors stay inline), prefix for the class names ("ansi-"), pre true to wrap it all in <pre class="ansi">.

ansi_strip(text: str) → str

The text without escape codes, as it reads in the terminal (progress lines as they ended): for logs, search and emails.

ansi_css(theme: str, prefix: str) → stror an error

The style sheet for ansi_to_html_with's classes in theme ("dark" or "light"), with prefix ("ansi-"): the 16 colors as text and background, bold, dim, italic, underline and strike, and a pre block in the theme's colors.

Get it

$ quillo install ansi_html               # ansi_html.qwer into this folder$ quillo install ansi_html --example     # and its example, ansi_html.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/ansi_html.qwer$ curl -O https://quillolang.org/plugins/files/ansi_html.qwe$ curl -O https://quillolang.org/plugins/files/ansi_html_sample.log

Then, in your program, all of it or only what you call:

main.qwe
use ansi_html.qweruse ansi_to_html, ansi_to_html_with from ansi_html      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

ansi_html.qwe
// A CI page: a test run's colored output shown as the terminal showed it, with classes and a// style sheet, or as plain text for an email.////   quillo run ansi_html.qwe      then open http://localhost:8080/runuse "ansi_html.qwer"let LOG = read_file("ansi_html_sample.log")route GET /run = respond(200, `<!doctype html><title>Run 1042</title><style>body{margin:0;background:#1e1e1e}</style><pre style="color:#ccc;padding:16px;font:13px/1.45 monospace">${ansi_to_html(LOG)}</pre>`, { "content-type": "text/html; charset=utf-8" })route GET /classes = {    html: ansi_to_html_with(LOG, { classes: true, theme: "light", pre: true })?,    css: ansi_css("light", "ansi-")?,}route GET /plain = respond(200, ansi_strip(LOG), { "content-type": "text/plain; charset=utf-8" })
$ quillo run ansi_html.qwe$ curl 'localhost:8080/run'$ curl 'localhost:8080/classes'$ curl 'localhost:8080/plain'

Source

ansi_html.qwer
//! Terminal output with ANSI colors made into HTML: build logs, test runs and CLI output shown on a page with their colors, bold, links and progress lines as the terminal showed them.//! version: 1.0.0//!//! [dependencies]use serde_json::Value;const DARK: [&str; 16] = [    "#1e1e1e", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#cccccc",    "#666666", "#ff6b6b", "#5af78e", "#f4f99d", "#6ab0f3", "#ff92d0", "#9aedfe", "#ffffff",];const LIGHT: [&str; 16] = [    "#000000", "#cd3131", "#107c10", "#949800", "#0451a5", "#bc05bc", "#0598bc", "#555555",    "#666666", "#cd3131", "#14ce14", "#b5ba00", "#0451a5", "#bc05bc", "#0598bc", "#a5a5a5",];const NAMES: [&str; 8] = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"];#[derive(Clone, Default, PartialEq)]enum Color {    #[default]    None,    Basic(u8),    Rgb(u8, u8, u8),}#[derive(Clone, Default, PartialEq)]struct Style {    fg: Color,    bg: Color,    bold: bool,    dim: bool,    italic: bool,    underline: bool,    strike: bool,    inverse: bool,}fn xterm256(n: u8) -> Color {    match n {        0..=15 => Color::Basic(n),        16..=231 => {            let n = n - 16;            let c = |x: u8| if x == 0 { 0 } else { 55 + x * 40 };            Color::Rgb(c(n / 36), c((n / 6) % 6), c(n % 6))        }        _ => {            let g = 8 + (n - 232) * 10;            Color::Rgb(g, g, g)        }    }}fn sgr(st: &mut Style, params: &[u16]) {    let ps: Vec<u16> = if params.is_empty() { vec![0] } else { params.to_vec() };    let mut i = 0;    while i < ps.len() {        let p = ps[i];        match p {            0 => *st = Style::default(),            1 => st.bold = true,            2 => st.dim = true,            3 => st.italic = true,            4 => st.underline = true,            7 => st.inverse = true,            9 => st.strike = true,            21 | 22 => {                st.bold = false;                st.dim = false            }            23 => st.italic = false,            24 => st.underline = false,            27 => st.inverse = false,            29 => st.strike = false,            30..=37 => st.fg = Color::Basic((p - 30) as u8),            90..=97 => st.fg = Color::Basic((p - 90 + 8) as u8),            40..=47 => st.bg = Color::Basic((p - 40) as u8),            100..=107 => st.bg = Color::Basic((p - 100 + 8) as u8),            39 => st.fg = Color::None,            49 => st.bg = Color::None,            38 | 48 => {                let c = match ps.get(i + 1) {                    Some(5) => {                        i += 2;                        ps.get(i).map(|n| xterm256(*n as u8))                    }                    Some(2) => {                        i += 4;                        match (ps.get(i - 2), ps.get(i - 1), ps.get(i)) {                            (Some(r), Some(g), Some(b)) => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),                            _ => None,                        }                    }                    _ => None,                };                if let Some(c) = c {                    if p == 38 { st.fg = c } else { st.bg = c }                }            }            _ => {}        }        i += 1;    }}enum Piece {    Text(String),    Style(Style),    LinkStart(String),    LinkEnd,}/// Reads the text into pieces: text, style changes, OSC 8 links; other escape sequences are dropped; a carriage return (a progress/// bar redrawn) keeps only what was written after it on that line.fn scan(input: &str) -> Vec<Piece> {    let mut out = vec![];    let mut st = Style::default();    // Carriage returns first: each line keeps what follows its last \r (a trailing \r of \r\n is a line end).    let text: String = input        .split('\n')        .map(|l| {            let l = l.strip_suffix('\r').unwrap_or(l);            match l.rfind('\r') {                Some(i) => {                    // Styles set before the \r still apply: keep escape sequences from the overwritten part.                    let kept: String = escapes_only(&l[..i]);                    format!("{}{}", kept, &l[i + 1..])                }                None => l.to_string(),            }        })        .collect::<Vec<_>>()        .join("\n");    let b: Vec<char> = text.chars().collect();    let mut i = 0;    let mut buf = String::new();    while i < b.len() {        let c = b[i];        if c == '\x1b' && i + 1 < b.len() {            match b[i + 1] {                '[' => {                    let mut j = i + 2;                    while j < b.len() && !('\x40'..='\x7e').contains(&b[j]) {                        j += 1;                    }                    if j < b.len() && b[j] == 'm' {                        let params: Vec<u16> = b[i + 2..j].iter().collect::<String>().split([';', ':']).filter(|s| !s.is_empty()).map(|s| s.parse().unwrap_or(0)).collect();                        if !buf.is_empty() {                            out.push(Piece::Text(std::mem::take(&mut buf)));                        }                        sgr(&mut st, &params);                        out.push(Piece::Style(st.clone()));                    }                    i = j + 1;                    continue;                }                ']' => {                    // OSC: up to BEL or ESC \                    let mut j = i + 2;                    while j < b.len() && b[j] != '\x07' && !(b[j] == '\x1b' && b.get(j + 1) == Some(&'\\')) {                        j += 1;                    }                    let body: String = b[i + 2..j.min(b.len())].iter().collect();                    if let Some(rest) = body.strip_prefix("8;") {                        let url = rest.split_once(';').map(|x| x.1).unwrap_or("");                        if !buf.is_empty() {                            out.push(Piece::Text(std::mem::take(&mut buf)));                        }                        out.push(if url.is_empty() { Piece::LinkEnd } else { Piece::LinkStart(url.to_string()) });                    }                    i = if j < b.len() && b[j] == '\x1b' { j + 2 } else { j + 1 };                    continue;                }                _ => {                    i += 2;                    continue;                }            }        }        if c == '\x08' {            buf.pop();        } else if c == '\t' || c == '\n' || !c.is_control() {            buf.push(c);        }        i += 1;    }    if !buf.is_empty() {        out.push(Piece::Text(buf));    }    out}fn escapes_only(s: &str) -> String {    let mut out = String::new();    let mut it = s.char_indices().peekable();    while let Some((i, c)) = it.next() {        if c == '\x1b' && s[i + 1..].starts_with('[') {            if let Some(end) = s[i + 2..].find(|ch: char| ('\x40'..='\x7e').contains(&ch)) {                let seq = &s[i..i + 2 + end + 1];                if seq.ends_with('m') {                    out.push_str(seq);                }                for _ in 0..seq.chars().count() - 1 {                    it.next();                }            }        }    }    out}fn esc(s: &str) -> String {    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")}struct Opts {    classes: bool,    palette: &'static [&'static str; 16],    prefix: String,}fn css_color(c: &Color, o: &Opts) -> Option<String> {    match c {        Color::None => None,        Color::Basic(n) => Some(o.palette[*n as usize].to_string()),        Color::Rgb(r, g, b) => Some(format!("#{:02x}{:02x}{:02x}", r, g, b)),    }}fn open_tag(st: &Style, o: &Opts) -> Option<String> {    let (fg, bg) = if st.inverse { (&st.bg, &st.fg) } else { (&st.fg, &st.bg) };    let (fg, bg) = if st.inverse {        // Inverse with no colors set: the terminal's own background and text colors swapped.        let fg2 = if *fg == Color::None { Color::Basic(0) } else { fg.clone() };        let bg2 = if *bg == Color::None { Color::Basic(7) } else { bg.clone() };        (fg2, bg2)    } else {        (fg.clone(), bg.clone())    };    let mut classes = vec![];    let mut styles = vec![];    let basic_class = |c: &Color, kind: &str| -> Option<String> {        match c {            Color::Basic(n) => Some(format!("{}{}-{}{}", o.prefix, kind, if *n >= 8 { "bright-" } else { "" }, NAMES[(*n % 8) as usize])),            _ => None,        }    };    for (c, kind, prop) in [(&fg, "fg", "color"), (&bg, "bg", "background-color")] {        match (o.classes, basic_class(c, kind)) {            (true, Some(cl)) => classes.push(cl),            _ => {                if let Some(v) = css_color(c, o) {                    styles.push(format!("{}:{}", prop, v))                }            }        }    }    let flags = [        (st.bold, "bold", "font-weight:bold"),        (st.dim, "dim", "opacity:0.7"),        (st.italic, "italic", "font-style:italic"),        (st.underline, "underline", "text-decoration:underline"),        (st.strike, "strike", "text-decoration:line-through"),    ];    for (on, class, style) in flags {        if on {            if o.classes { classes.push(format!("{}{}", o.prefix, class)) } else { styles.push(style.to_string()) }        }    }    if st.underline && st.strike && !o.classes {        styles.retain(|s| !s.starts_with("text-decoration"));        styles.push("text-decoration:underline line-through".into());    }    if classes.is_empty() && styles.is_empty() {        return None;    }    let mut tag = String::from("<span");    if !classes.is_empty() {        tag.push_str(&format!(" class=\"{}\"", classes.join(" ")));    }    if !styles.is_empty() {        tag.push_str(&format!(" style=\"{}\"", styles.join(";")));    }    tag.push('>');    Some(tag)}fn render(text: &str, o: &Opts) -> String {    let mut html = String::new();    let mut open_span = false;    let mut cur = Style::default();    let mut in_link = false;    for p in scan(text) {        match p {            Piece::Text(t) => {                if !open_span {                    if let Some(tag) = open_tag(&cur, o) {                        html.push_str(&tag);                        open_span = true;                    }                }                html.push_str(&esc(&t));            }            Piece::Style(s) => {                if s != cur && open_span {                    html.push_str("</span>");                    open_span = false;                }                cur = s;            }            Piece::LinkStart(url) => {                if open_span {                    html.push_str("</span>");                    open_span = false;                }                if in_link {                    html.push_str("</a>");                }                let lower = url.to_lowercase();                if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:") {                    html.push_str(&format!("<a href=\"{}\">", esc(&url)));                    in_link = true;                } else {                    in_link = false;                }            }            Piece::LinkEnd => {                if open_span {                    html.push_str("</span>");                    open_span = false;                }                if in_link {                    html.push_str("</a>");                    in_link = false;                }            }        }    }    if open_span {        html.push_str("</span>");    }    if in_link {        html.push_str("</a>");    }    html}/// The text as HTML with inline styles, for a dark background: colors (16, 256 and 24-bit), bold, dim, italic, underline, strike-through/// and inverse; OSC 8 links as `<a>` (http, https and mailto only); other escape codes dropped; a line redrawn after a carriage return/// (a progress bar) as it ended. Everything else is escaped, so it is safe inside a `<pre>`.pub fn ansi_to_html(text: &str) -> String {    render(text, &Opts { classes: false, palette: &DARK, prefix: String::new() })}/// `ansi_to_html` with options: `theme` "dark" or "light" (the 16 colors' shades), `classes` true for `class="ansi-fg-red ansi-bold"`/// in place of inline styles (with `ansi_css` for the sheet; 256 and 24-bit colors stay inline), `prefix` for the class names ("ansi-"),/// `pre` true to wrap it all in `<pre class="ansi">`.pub fn ansi_to_html_with(text: &str, options: std::collections::HashMap<String, serde_json::Value>) -> Result<String, String> {    let theme = options.get("theme").and_then(Value::as_str).unwrap_or("dark");    let palette = match theme {        "dark" => &DARK,        "light" => &LIGHT,        other => return Err(format!("unknown theme `{}`: dark or light", other)),    };    let o = Opts {        classes: options.get("classes").and_then(Value::as_bool).unwrap_or(false),        palette,        prefix: options.get("prefix").and_then(Value::as_str).unwrap_or("ansi-").to_string(),    };    let html = render(text, &o);    Ok(if options.get("pre").and_then(Value::as_bool).unwrap_or(false) { format!("<pre class=\"{}pre\">{}</pre>", o.prefix, html) } else { html })}/// The text without escape codes, as it reads in the terminal (progress lines as they ended): for logs, search and emails.pub fn ansi_strip(text: &str) -> String {    scan(text).into_iter().filter_map(|p| if let Piece::Text(t) = p { Some(t) } else { None }).collect()}/// The style sheet for `ansi_to_html_with`'s classes in `theme` ("dark" or "light"), with `prefix` ("ansi-"): the 16 colors as text/// and background, bold, dim, italic, underline and strike, and a `pre` block in the theme's colors.pub fn ansi_css(theme: &str, prefix: &str) -> Result<String, String> {    let (p, bg, fg) = match theme {        "dark" => (&DARK, "#1e1e1e", "#cccccc"),        "light" => (&LIGHT, "#ffffff", "#333333"),        other => return Err(format!("unknown theme `{}`: dark or light", other)),    };    let mut css = format!(".{}pre {{ background: {}; color: {}; padding: 12px 16px; overflow-x: auto; font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }}\n", prefix, bg, fg);    for (i, c) in p.iter().enumerate() {        let name = format!("{}{}", if i >= 8 { "bright-" } else { "" }, NAMES[i % 8]);        css.push_str(&format!(".{0}fg-{1} {{ color: {2}; }}\n.{0}bg-{1} {{ background-color: {2}; }}\n", prefix, name, c));    }    css.push_str(&format!(        ".{0}bold {{ font-weight: bold; }}\n.{0}dim {{ opacity: 0.7; }}\n.{0}italic {{ font-style: italic; }}\n.{0}underline {{ text-decoration: underline; }}\n.{0}strike {{ text-decoration: line-through; }}\n",        prefix    ));    Ok(css)}
Thread
87Useful? Like it, or add to its thread below.

Thread 0 posts

On ANSI to HTML: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.