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
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) → strThe 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 erroransi_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) → strThe 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 errorThe 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.qweOr 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.logThen, in your program, all of it or only what you call:
use ansi_html.qweruse ansi_to_html, ansi_to_html_with from ansi_html // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// 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
//! 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, ¶ms); 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)}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)}Related
log_parse.qwer · 1.0.0Log lines into rows: nginx and Apache access logs, syslog, logfmt and JSON lines, the format found by itself, with counts by any field.7 views · 7 downloadsSQL formatting sql_format.qwer · 1.0.0SQL pretty-printed with indents and keywords in capitals, placeholders filled in for logs, and squeezed back onto one line.9 views · 5 downloadsSyslog syslog.qwer · 1.0.1Syslog messages built and read, RFC 5424 with structured data and the older BSD format, sent to a log server over UDP, TCP or TLS, and a receiver for what devices send.7 views · 10 downloadsThread 0 posts
On ANSI to HTML: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.